diff --git a/.gitattributes b/.gitattributes index 4689f6b54..2b1ede846 100644 --- a/.gitattributes +++ b/.gitattributes @@ -6,6 +6,14 @@ **/wwwroot/**/*.js text eol=lf **/wwwroot/**/*.css text eol=lf +# Shell scripts must keep LF endings. A CRLF checkout turns a script's shebang into `#!/bin/sh\r`, and a +# container that copies the script then fails to start because no interpreter by that name exists. +*.sh text eol=lf + +# Vendored specification cassettes are verified against the SHA-256 of the upstream file recorded in their +# manifest. Line ending normalization would rewrite the bytes and break that check on Windows checkouts. +tests/CrestApps.OrchardCore.Tests/Telephony/Cassettes/** -text + # Ensure binary files are never treated as text. *.webp binary *.png binary diff --git a/.github/contact-center/PRODUCTION-READINESS.md b/.github/contact-center/PRODUCTION-READINESS.md new file mode 100644 index 000000000..78f84cdb4 --- /dev/null +++ b/.github/contact-center/PRODUCTION-READINESS.md @@ -0,0 +1,207 @@ +# Contact Center — Production Readiness (Consolidated & Revised) + +**Status:** Retained record for this PR. This document **supersedes and consolidates** the earlier planning documents (`PLAN.md`, `PLAN-2-SINGLE-NODE-COMPLETION.md`, `PLAN-3-PRODUCTION-SINGLE-NODE-DISTRIBUTED.md`, `R0-BASELINE.md`), the pure release-tracking `*.v1.json` governance ledgers, and the `.playwright-mcp/` capture directory — all of which were planning scaffolding removed in the final cleanup task (§7) so the PR is merge-ready. It is now the sole surviving plan document. + +**Revision intent.** The prior plans were reviewed in depth from an Orchard Core / ASP.NET Core engineering standpoint, then cross-reviewed by two independent expert models (GPT-5.6 and Opus-5) who each verified the claims against the actual source. A large amount of genuinely valuable correctness, distribution, and data-lifecycle work was already completed and is retained (§3). A second, *open* set was found to be either (a) over-engineered relative to a first production release of an Orchard Core module, (b) reinventing capabilities Orchard Core already provides, or (c) exotic telephony features that belong to a later track — those are de-scoped with rationale (§5, §6). The remaining work (§4) is the minimum **honest** set that makes the module production-ready while **extending Orchard Core rather than working around it**. Where the reviewers found the code cannot honestly support a broad claim, we deliberately **narrow scope and advertise capabilities honestly** instead of building heavy machinery — that is the anti-over-engineering path *and* the production-honest one. + +--- + +## 1. Mission & scope + +Ship a **production-supported Contact Center on a single active application process running the full distributed contract**: PostgreSQL, Redis distributed locking, and the Redis SignalR backplane are mandatory, validated at startup, and probed by health checks. This is a **voice ACD** release (inbound voice, manual/preview dial, transfer, conference, supervision). + +**Single active process (deliberate scope decision).** Exactly **one** application process owns the ARI/telephony application at a time. The provider-application ownership and channel-ready coordination are process-local today (they do **not** use distributed locking), so *overlapping* deployments (two processes subscribed to the same ARI app simultaneously) are **not supported** and must be prohibited operationally. Deployment uses a **stop-old-then-start-new handover**; this is a **bounded maintenance interruption** for the telephony control path, **not** zero-downtime — during the handover window new inbound call admission depends on the upstream PBX/carrier's own retry/queueing (documented in D3), and we make no zero-downtime claim absent a tested external buffering contract. Growing to 2–4 *concurrently active* nodes requires distributed ARI ownership + cross-process channel-ready coordination, which is **Track B** (§6). The database/Redis/backplane layers are already distributed-correct on one node; this decision is only about the telephony-application singleton. + +**Recording scope (deliberate decision).** v1 supports **mono (mixed) bridge recording**, documented as mono with **no per-leg diarization or talk-over analytics**. Dual-channel/stereo recording is Track B (§6). Because recording is in scope, **recording/media erasure (C1) is release-blocking.** C1 delivers **recording/media erasure**, which is a *component* of GDPR Art. 17 / CCPA handling — it is **not** a general cross-entity subject-erasure feature (subject discovery/fan-out is Track B). Audio-dependent capabilities (recording, monitoring, whisper, barge) are advertised only per §4 item **G1** (prove-or-disable), and the base voice path itself is release-gated by G1. + +In scope: correctness of the provider-event authority, work-state and live-call topology, data lifecycle/retention + recording/media erasure, distribution correctness on one active process, observability with a minimum operational signal set (including functional Redis/lock/backplane probes), a credible minimum ACD metric set, an accessible agent desktop, security/compliance close-out, and honest operator documentation. + +Out of scope (see §6, Track B): concurrently-active multi-node, multi-region, chat/email/SMS channels, predictive dialing, WFM/QM, skill-proficiency/bullseye routing, and advanced media (stereo recording, MOS telemetry, codec negotiation, PBX failover, STIR/SHAKEN, DTMF/IVR, music-on-hold, call park). + +--- + +## 2. Guiding principles (extend Orchard Core; do not reinvent) + +These principles govern every remaining item and every code review in this PR: + +1. **Extend the framework, don't rebuild it.** Prefer Orchard Core and ASP.NET Core primitives over bespoke equivalents: + - **Config export → Deployment steps; config import → Recipes.** Never hand-roll an import/export format. (Done in W11.5 via the generic configuration-catalog kit; all future config must follow it. This also means the bespoke preview export/reset service is retired — §5.) + - **Health checks → ASP.NET Core `IHealthCheck` + `AddHealthChecks()`** (already used idiomatically). We do **not** add a bespoke aggregate endpoint and do **not** take a base dependency on `OrchardCore.HealthChecks` — see A2 for why a hard dependency would be a security/availability regression and why the aggregate route is documentation-only. + - **Background work → `IBackgroundTask`;** distributed coordination → `IDistributedLock` / `ILock`; caching → `ISignal` / Orchard cache; settings → site settings drivers; navigation → `INavigationProvider`; permissions → `IPermissionProvider`; tenant teardown → `IModularTenantEvents.RemovingAsync(ShellRemovingContext)`. + - **Data → YesSql sessions, indexes, and `IDataMigration`.** No raw ADO, no second ORM. Never parallelize operations over a single YesSql `ISession`; external/media I/O runs outside the YesSql transaction in fresh tenant scopes. +2. **Reuse in-repo patterns.** Catalog CRUD → `CatalogManager` / `ICatalogEntryHandler`; deferred external work → `ShellScope.AddDeferredTask`; durable side effects → the existing Contact Center outbox; scoped units of work → `IContactCenterScopeExecutor`; tenant-scoped blob cleanup → the `*BlobContainerTenantEvents` pattern (e.g. `AIDocumentBlobContainerTenantEvents`, `DncRegistryBlobContainerTenantEvents`). +3. **Right-sized rigor.** Every change ships with a focused regression test that fails if the behavior regresses. We do **not** require per-gate versioned "test specifications", ledger-evidence meta-tests, or governance JSON — that process overhead is removed (§5). +4. **Fail closed, degrade predictably.** Production topology is validated at startup; missing Redis/PostgreSQL is `Unhealthy` with an actionable message, not a silent semantic change. Checked-in development credentials are rejected in Production. +5. **Honest capability advertising.** A capability is enabled only when a provider implements it **and** it is verified (test or documented proof). Unimplemented surface is deleted, not advertised. Implemented capabilities whose only unproven dependency is the *deployment's* media path (Monitor/Whisper/Barge) stay advertised, but the deployment audio proof is a release gate (G1): in Production, unacknowledged audio verification **fails closed** (readiness withheld), and `Recording` — where the unproven-audio risk compounds with the C1 media-lifecycle risk — is **off by default** until proven. + +--- + +## 3. Retained foundation (already completed — do not redo) + +The following completed work is sound and is **kept**. It represents the bulk of the production-readiness effort and is only summarized here (full history lived in the superseded ledgers, which §7 removes): + +- **Truth & containment:** single-node-distributed topology profile + startup validation (fail-closed), CI gate repair, supply-chain (NuGet audit, SBOM, secret scan, license inventory, hermetic offline build), replacement of non-executing tests, and W0.7 containment fixes. +- **One provider-event authority:** `CrestApps.OrchardCore.Telephony.Core` provider-neutral ingress (lease, dedupe, ordering, retry); single ingest with two projections (Telephony history + Contact Center); one call-state vocabulary with real hangup causes; capability-split `ITelephonyProvider`; hardened Telephony store; provider-leakage removed from neutral contracts. +- **State authority & data lifecycle:** `ContactCenterWorkState` extracted off `OmnichannelActivity`; live call topology (legs/bridge/participants/consult); retention across all high-volume tables; predicate-led hot-path indexes with PostgreSQL `EXPLAIN` budgets; migration-safety (batched backfills, additive-only enforcement, N-1 rolling-upgrade harness, event/document upcasters). +- **Feature graph & Orchard idiom:** headless/admin feature split with a runtime closure proof; real `ContactCenter.Admin`; analytics capability degradation; feature-dependency audit; **recipes + deployment steps for all Contact Center/CRM configuration (generic catalog kit)**; bounded voice-webhook body; write-path validation moved out of display drivers into handlers; public-API baseline approval; agent-workspace N+1 removed. +- **Residual hygiene:** terminal-event teardown gating; hub cancellation convention; metric hot-row → append-and-rollup; agent-session heartbeat isolation; reservation critical-section reduction; **no Elasticsearch in any correctness path** (enforced). +- **Config validation (W18):** `IValidateOptions` + `ValidateOnStart` for Contact Center/Telephony/Asterisk options; production refuses invalid retention/health/topology configuration. *(The `KnownDevelopmentValues` digest register that shipped with W18 is **deleted** — see A1; the mandatory Production rejection of checked-in dev credentials is retained, inlined at both call sites.)* +- **Agent desktop accessibility (W6):** ARIA/live-region/keyboard/degraded-state work on the agent workspace. +- **Minimum ACD metrics (W9):** report timezone, interval buckets, service level %, hold time, occupancy, RONA auto-not-ready, supervisor force actions, ASA-from-queue-entry. + +--- + +## 4. Remaining work — checklist tracker + +Each item lists **why** it adds real value, the **Orchard Core-aligned approach**, and a testable **exit** criterion. Items are grouped, not strictly ordered; within a group they are independent unless noted. Every removal that touches a public type must **regenerate the W11.8 `PublicApiApprovalTests` baseline** as part of its exit. + +### Group A — De-overengineering & Orchard Core alignment (do first) + +- [x] **A1. Remove `KnownDevelopmentValues` entirely; inline mandatory Production credential rejection at both call sites.** *Value:* the class (SHA-256 digest register, placeholder tables, asset-rescanning test) is over-engineered machinery for a v1 module and the user mandated its removal — but the direct rejection of the repository-published ARI/SIP/TURN defaults in Production is a real runtime security control that must survive. *Approach:* **delete `KnownDevelopmentValues.cs` and `KnownDevelopmentValuesTests.cs`.** Inline a small, explicit rejection of the known checked-in dev secrets/placeholders **directly** at both consumers: `DefaultAsteriskOptionsValidator` (startup/options validation) **and** `AsteriskSoftPhoneRegistrationConfigContributor` (the runtime TURN-secret guard that degrades to STUN-only ICE — this covers **tenant-configured** settings that `IValidateOptions`/`ValidateOnStart` never sees). No shared static register, no digests, no rescan test. *Exit:* the class and its test are gone; both call sites still reject the published defaults in Production (covered by a focused test at each site); strict build clean; config-validation suite green. + +- [x] **A2. Keep the idiomatic health-check registration; add functional dependency probes; do not add bespoke aggregate plumbing.** *Value:* the module **already** registers all checks via `AddHealthChecks().AddCheck(name, tags)` — the idiomatic mechanism; there is no parallel subsystem to remove. One real gap: §1 promises live Redis/lock/backplane probing but the current registrations contain **no functional Redis connectivity or distributed-lock-acquisition probe**. A hard base dependency on `OrchardCore.HealthChecks` is also wrong: that module **unconditionally** maps its single, predicate-less aggregate route when enabled, so it cannot express the readiness-vs-dependency split and, with details on, would leak `DependencyTag` info anonymously; adding our own second `/health/aggregate` would just be the bespoke plumbing this principle forbids. *Approach:* + - **Add functional dependency checks:** a short-timeout Redis **connectivity** probe, a distributed-**lock acquire/release** probe, and a **backplane pub/sub round-trip** probe (publish→receive on a dedicated channel within a bounded timeout — Redis/lock connectivity alone does not prove SignalR pub/sub works), all registered with `DependencyTag` (alerting), **not** `ReadyTag` (readiness stays node-local) — this makes §1's live-probe claim true. + - **Keep** the tag-filtered node-readiness route, the authorized dependency-diagnostics route, host-level `UseContactCenterProcessLiveness`, and `SharedHealthCheckEndpointGuard`. + - **Do not** add a companion aggregate feature. If an operator separately enables `OrchardCore.HealthChecks`, **document** that its route is an aggregate diagnostic (guarded by `SharedHealthCheckEndpointGuard`), **not** a load-balancer probe. + *Exit:* base feature does **not** hard-depend on `OrchardCore.HealthChecks`; functional Redis connectivity, distributed-lock, **and backplane pub/sub round-trip** probes exist and are all `DependencyTag`; a test proves no dependency check participates in readiness; the "aggregate ≠ probe" note is in the docs; strict build clean. + +- [x] **A3. Remove the dead generic provider-webhook ingress (was W16.9).** *Value:* `IProviderVoiceWebhookAdapter` / `HmacProviderVoiceWebhookAdapterBase` / `ProviderVoiceWebhookProcessor` have **no** concrete implementation; both shipping PBXs ingest through the normalized `IProviderVoiceEventService` seam, so `ProcessAsync` returns `UnknownProvider` for every request — an authenticated public endpoint nothing can satisfy is a liability. *Approach:* delete the unused contract, base class, processor, endpoint mapping, and their test fakes. *Exit:* the endpoint and types are gone; no shipping ingress path is affected; activation/feature suites pass; **`PublicApiApprovalTests` baseline regenerated**. + +- [x] **A4. Delete the advertised-but-unimplemented supervisor take-over surface, and close the class with a test (was W12.2).** *Value:* `MonitorMode.TakeOver`, the `TakeOver` capability bit (`1 << 12`), and its `ContactCenterMonitoringService` entry advertise a feature no provider implements. `TakeOver` is the last `MonitorMode` member and the capability flag is an explicit value, so removal is ordinal-safe and does not disturb persisted bitmasks. *Approach:* remove the enum value, capability bit, and mode entry; add a **~30-line unit test asserting every bit in `ContactCenterVoiceProviderCapabilities` is either declared by an in-tree provider or explicitly listed as reserved** (this enforces the §2.5 honesty principle generically, replacing the removed governance gate). *Exit:* the symbol is gone; the capability-coverage test passes; monitoring tests updated; **`PublicApiApprovalTests` baseline regenerated**. + +- [x] **A5. Retire the bespoke preview export/reset service in favour of Deployment/Recipes (promoted from §5).** *Value:* `ContactCenterPreviewMaintenanceService` is not a reliable backup — it exports **before** quiescing, its "receipt" hashes row **counts** not content, it has **no import/restore**, and its config export duplicates Orchard **Deployment/Recipes**. Retiring it removes a false recoverability guarantee and a public API surface. This is real deletion + removal of a destructive admin action, so it is tracked here, not buried in §5. *Approach:* **delete the entire `Maintenance` feature** — the bespoke config-export path (Deployment steps + Recipes are the sanctioned mechanism) **and** the destructive preview reset (operational-data recovery is a database backup/restore, per E2); delete the public maintenance abstractions (`IContactCenterPreviewMaintenanceService`, `ContactCenterPreviewMaintenanceOptions`, `ContactCenterPreviewMaintenanceStatus`, and the whole `Abstractions/Maintenance` + `Core/Maintenance` surface), the controller/views/view-models, admin menu, permission, the service-collection extension, the Feature manifest block, and the dedicated tests. *Exit:* config export/import goes through Deployment/Recipes only; **the destructive preview reset is removed entirely** (no bespoke reset ships); public maintenance abstractions removed and **`PublicApiApprovalTests` baseline regenerated**; docs updated; strict build clean. + +### Group B — Correctness & distribution close-out + +- [x] **B1. Start the ARI listener after provider-local validation + ownership; gate Contact Center admission on the topology verdict (was W16.3).** *Value:* the base Asterisk realtime listener **also** serves standalone Telephony, so it must **not** depend on Contact Center readiness or an HTTP probe. Today it can start before the node is ready. *Approach:* start the listener after **provider-local** validation and ARI-application **ownership acquisition** (in `AsteriskRealtimeVoiceTenantEvents`); gate the Contact Center **admission / event bridges** on the topology verdict recorded by `ContactCenterTopologyValidator`. Readiness **observes** listener state; it does not control startup. *Exit:* a test proves the listener opens its WebSocket only after provider-local validation + ownership, and that Contact Center admission is withheld until the topology verdict is healthy. + +- [x] **B2. Telephony command settlement on shutdown, with correct per-path semantics (was W15.5).** *Value:* `DefaultTelephonyCommandExecutor` links `ApplicationStopping` into the operation token, which **cancels** the in-flight provider call — there is **no** command buffer/queue to "drain". Two distinct command paths exist and must be handled differently: the **durable** path via `ProviderCommandProcessor` (recoverable), and **synchronous** release-critical mutations that call `ITelephonyCommandExecutor` **directly** — recording (`ContactCenterRecordingService`), monitoring (`ContactCenterMonitoringService`), and transfer (`ContactCenterTransferService`) — which `ProviderCommandProcessor` can **not** durably mark `OutcomeUnknown`. *Approach:* on `ApplicationStopping`, **stop admitting new commands**. For the **durable** path, allow bounded settlement and let unresolved commands be marked `OutcomeUnknown` for `ProviderCommandRecoveryBackgroundTask`. For the **synchronous** path, either (preferred) route those release-critical mutations through the durable command state machine, **or** give them explicit synchronous-command semantics: bounded settlement, an explicit *unknown-result* surfaced to the caller (never a silent success), and provider-state reconciliation (D2) as the backstop. Do **not** claim exactly-once application for either path. *Exit:* a restart with in-flight commands leaves each command in a definite state (durable→`OutcomeUnknown`; synchronous→explicit unknown to caller) with no silent success; tests cover both paths and assert new commands are rejected during shutdown. + +- [x] **B3. Report-factory mutable-state — prove before refactoring (was W10.8).** *Value:* some scoped report providers hold mutable per-execution fields; a real cross-request/singleton leak would be a data-leak shape, but none was demonstrated. *Approach:* **first** write a concurrency test that drives two simultaneous report requests through the suspect provider(s). Only if it reproduces a leak, make the captured state per-invocation. If it does not reproduce, close the item with the passing test as evidence (no broad refactor). *Exit:* a two-request concurrency test exists and passes; any real leak it exposed is fixed. + +- [x] **B4. Provider-specific idempotency key (was W12.5).** *Value:* Asterisk `AsteriskRealtimeVoiceEventMapper.BuildIdempotencyKey` hashes the **raw payload**, so an upstream serialization change silently turns dedupe into double-processing. But ARI events carry **no `eventId`/sequence** — a naive `(provider, callId, eventType)` fallback would suppress legitimately repeated same-type events (hold/unhold, `ChannelVarset`, DTMF), converting dedupe into **event loss** (strictly worse). *Approach:* the key must be **per-provider and per-event-type**, enumerating discriminators that make two *distinct* same-type events on one call distinguishable (e.g. include the varset name/value, the target sub-state, or a monotonic field where ARI provides one); where ARI genuinely offers nothing better, pin a **stable serialization** for the payload hash rather than the raw wire bytes and document the assumption. *Exit:* dedupe survives a payload re-serialization in a test, **and** a test proves two legitimately distinct same-type events on one call are both processed. + +- [x] **B5. Historical transfer-string cleanup only — `MediaTopologyId` is already gone (narrowed from W10.9).** *Value:* verification showed the live `CallSession` already uses a typed active topology and the `MediaTopologyId` placeholder no longer exists; only historical transfer strings remain on `InteractionTransferHistoryEntry`. So this is **not** a release blocker. *Approach:* narrow to a compatibility-safe cleanup — ensure neutral consumers read typed transfer targets/outcomes and that historical string fields are clearly typed/labelled; no live-topology remodel. *Exit:* neutral consumers reference typed topology; historical strings are isolated to the history entry; architecture test passes. *(If verification finds nothing to change, close as already-correct with evidence.)* + +- [x] **B6. Confirm and harden the single-active-process telephony ownership (new — from the §1 scope decision).** *Value:* `AsteriskAriApplicationOwnershipRegistry` uses a **process-local** static dictionary (no distributed lock) and returns success for **blank** claim inputs; `AsteriskAgentChannelReadySignal` is tenant-local in-memory. These are correct **only** under the single-active-process scope. *Approach:* keep the process-local design (distributed ownership is Track B), but **deny + log** blank/invalid claim inputs at the claim path (`TryClaim` returns `false` so the listener does not start on an unconfigured provider — a denial, not a thrown exception during shell activation), and add a startup log/health note stating the single-active-process constraint. Document the non-overlapping deployment requirement in D3. *Exit:* blank claims are rejected by a test; the constraint is surfaced at startup and in the runbook. + +- [x] **B7. Background-task drain & lock-margin safety (new — reviewer finding).** *Value:* `ReservationExpiryBackgroundTask` holds a **60-second** lock expiration on a **one-minute** schedule and does not participate in the feature work-manager drain, so a slow run can overlap itself or a rolling handover. *Approach:* widen the lock margin relative to the schedule, and make the task participate in work admission/drain (W3.5-style) so it stops cleanly on shutdown. *Exit:* a test proves no self-overlap at the margin and that the task drains on `ApplicationStopping`. + +- [x] **B8. DialPad UTC parsing fix (new — reviewer finding).** *Value:* `DialPadTelephonyProvider` uses culture/time-zone-sensitive `DateTimeOffset.TryParse`, so provider timestamps can shift by the server's locale. *Approach:* parse as invariant/UTC (`DateTimeOffset.TryParse(..., CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, ...)` or the ISO-8601 exact form the provider emits). *Exit:* a test parses a provider timestamp identically regardless of ambient culture/time zone. + +### Group C — Data lifecycle & compliance + +- [x] **C1. Recording/media erasure & lifecycle (was W17) — release-blocking because recording is in scope.** *Scope note:* this delivers **recording/media erasure**, a component of GDPR Art. 17 / CCPA handling — **not** a general cross-entity subject-erasure feature (subject discovery/fan-out across CRM activities is Track B). All claims are worded as recording/media erasure. *Value:* recorded audio is currently **never** deleted by any path: `EraseAsync`/`RecordingErased` have no callers, `IRecordingMediaStore.DeleteAsync` is never invoked, a **second** `RecordingReference` lives on `CallSession` (not just `Interaction`), the interaction retention path deletes the row that carries `RecordingReference` on age alone without deleting the media, and the durable `AsteriskRecordingIngestService` can write media **after** an erasure request. So deletion is unimplementable and racy today. *Approach (extend Orchard Core):* + - Add an authorized erasure command/endpoint that calls `EraseAsync`. In **one YesSql unit of work**, atomically: clear **all** references (`Interaction` **and** `CallSession`), write the durable **erasure tombstone**, and enqueue durable media deletion on the **existing Contact Center outbox**. Pointer clears + tombstone + outbox message all commit together or not at all. + - `AsteriskRecordingIngestService` must consult the tombstone and **refuse/clean up** a late ingest so an event cannot resurrect the recording. + - Perform the actual provider/local/pluggable-store deletion **idempotently in a fresh scope outside the YesSql transaction** (external I/O never inside the tx). + - Extend the **existing retention background task**: the interaction retention path must **enqueue media deletion before deleting the row** and must **exclude records whose per-record `RecordingLegalHold` is set** (today `IsSubjectToLegalHold` only widens the time floor and ignores the per-record flag — a real bug that both orphans media and deletes held records). + - Handle **tenant decommission** via `IModularTenantEvents.RemovingAsync(ShellRemovingContext)` following the `*BlobContainerTenantEvents` pattern. Because a removed tenant's outbox/background task will not keep running, tenant removal must **fail closed on cleanup failure** (block/raise) **or** persist the cleanup in a host-level mechanism that survives shell deletion — never silently orphan media on tenant removal. + - Record a deletion **receipt in the Orchard audit trail** for human-visible confirmation — but audit is **not the sole durable receipt** (its category/retention can be disabled); the durable proof is the outbox completion + tombstone. + *Exit:* integration tests prove (1) after erasure the encrypted media bytes, transcript, both pointers, and any pending ingest job are gone, and the pointer clears + tombstone + outbox enqueue commit in a single atomic unit (crash-between test); (2) the retention path deletes expired media and **refuses** to delete records under `RecordingLegalHold`; (3) a late ingest after erasure is rejected by the tombstone; (4) tenant removal either completes media cleanup or fails closed (test both); (5) confirmed deletion (not merely request acceptance) is audited. No general "subject erasure / GDPR-complete" claim appears in code or docs. + +- [x] **C2. Column-size correction — treat as a real widening migration (from W16.7).** *Value:* a couple of index columns are mis-sized (`nvarchar(261)`, `nvarchar(128)`), risking truncation. **Correction:** widening an existing column is **not** an "additive-only" no-op — it alters an existing column and must be handled as a schema change with the N-1 rolling-upgrade harness. *Approach:* a migration that widens the columns, verified by the existing N-1 harness so old and new nodes agree during rollout. *Exit:* fresh and upgraded tenants produce identical, correctly-sized schemas; N-1 harness green. *Note:* moving the heartbeat timestamp out of the session document (the other half of W16.7) stays **deferred** (§6) — schema churn without a proven symptom now that the heartbeat write is isolated (W16.5). + +### Group D — Operability + +- [x] **D1. Bounded event channel with real backpressure + saturation metric (was W12.11; W12.13 folded in and de-scoped).** *Value:* the realtime channel uses a magic `1000`; `AsteriskRealtimeVoiceListener` declares `FullMode.Wait` but writes with `TryWrite`, so on saturation it **drops the connection and reconnects**. *Approach:* move the bound to a validated option; switch the write to **`await Writer.WriteAsync(...)` with a bounded wait** (real backpressure absorbed by the OS socket buffer); emit **one saturation metric/alert on the first wait** (not on drop); only if the wait times out, reconnect and run **coalesced reconciliation** after the old backlog drains. We do **not** build a separate durable pre-buffer event journal (W12.13). *Honest limitations to document:* reconciliation is **pointer-driven** (it reconciles calls we already know about, capped at 200/invocation, and no-ops when the reconciliation distributed lock is already held), so it restores **current state** but cannot reconstruct every intermediate hold/resume transition, and a dropped **`StasisStart`** (a call never learned about) is **not** recovered. *(Optional, cheap: adopt ARI channel-list on reconnect to recover unknown channels — this is W12.6's core idea; pull forward only if small.)* *Exit:* the bound is configuration-driven and validated; a saturation test asserts `WriteAsync` backpressure is applied and the metric increments on first wait; the documented limitation is written in §6/operator docs; no "zero loss" language remains. + +- [x] **D2. Reconciliation proves it reads live ARI (from W12.4).** *Value:* "reconciliation" that reads local state is not reconciliation. *Approach:* add a contract test proving the Asterisk `ITelephonyCallStateProvider` queries **live ARI** (using the recorded-cassette harness from W5.4), and that the explicit call-state transition table (from W1.3) rejects illegal edges. *Exit:* a cassette test fails if the provider stops querying ARI. *(The state-machine table itself is done; this is the reconciliation-liveness proof only.)* + +- [x] **D3. Minimum operational signals + handover/rollback runbook (was W15.4 + reviewer finding).** *Value:* `ContactCenterDiagnostics` currently exposes only two outbox counters; operators cannot see the health of the live system, and the deployment claim needs a written, rehearsed procedure. *Approach:* (a) add a **minimum operational signal set** to the existing telemetry: ARI connectivity/reconnect count, event-channel saturation (from D1), outbox age & dead-letter count, active/queued call counts, and the Redis/lock probe results (from A2) — reusing the observability seam already in place, no new framework. (b) Document the **stop-old-then-start-new** handover honestly as a **bounded maintenance interruption** of the telephony control path (not zero-downtime): state the single-active-process constraint (B6), that overlapping deployments are prohibited, that inbound admission during the window relies on the upstream PBX/carrier retry/queue behaviour, and the canary + rollback procedure for the single-node-distributed profile, referencing the drain and N-1 guarantees. *Exit:* the new metrics are emitted and covered by a test; the runbook exists, is linked from the deployment docs, states the non-overlapping-deployment requirement and the bounded-interruption reality (no zero-downtime claim), **explicitly states what happens to live calls during the handover gap** (channels sitting in ARI Stasis when the last subscriber disconnects — held, dropped, or dialplan fall-through), and the docs site builds. + +### Group E — Review & release + +- [x] **E1. Omnichannel/CRM layer review (was W13).** *Value:* Contact Center depends on the CRM layer for the universal work item, but that layer was reviewed less deeply. *Approach:* run a focused review (authorization boundaries, migration safety, concurrency/CAS, retention, PII, background schedulers, tenant isolation); also evaluate **signed origination markers (W12.12)** here — if the current channel-variable ownership inference is cheaply forgeable and the HMAC fix is small, pull it forward, else record Asterisk/dialplan as a documented trusted boundary. Fix or explicitly accept each finding with a dated rationale. *Exit:* review findings closed or accepted in writing; no open high-severity CRM finding remains. + +- [x] **E2. Operator documentation truthful & complete.** *Value:* README/architecture/deployment docs must match the shipped code. *Approach:* module READMEs, the single-node-distributed reference deployment (incl. the single-active-process constraint), the recording-is-mono statement, the emergency-calling scope statement + operator warning, and the data-residency statement — all in `src/CrestApps.Docs`, verified against code. *Exit:* docs build; internal links resolve; claims match code (covered by the §7 docs-review task). + +### Group F — Optional refactors (recommended, not release-blocking) + +These improve maintainability but are not required for a correct release. Implement if time allows; otherwise carry into normal maintenance. + +- [x] **F1. Decompose `VoiceContactCenterCallRouter`** (23 ctor deps) into an ordered, testable routing pipeline (was W10.3). +- [x] **F2. Collapse remaining catalog-CRUD controller duplication** onto the generic base introduced in W11.5 (was W10.4). +- [x] **F3. Decompose the two remaining mega-files** (`AsteriskContactCenterVoiceProvider`, `EnterpriseInteractionReportProvider`) (was W10.7). + +### Group G — Honest capability verification + +- [x] **G1. Prove the base voice path at deployment; default-disable only Recording (was W5 audio/restart/capacity).** *Value:* `AsteriskContactCenterVoiceProvider` advertises `Recording | Monitor | Whisper | Barge`, and the snoop/bridge **implementations** exist and are unit/cassette-tested — what is unproven is the **end-to-end WebRTC audio path** (certs/TURN/ICE), which is a property of a *deployment*, not of the capability code. §1 lists supervision as in-scope, so blanket-disabling Monitor/Whisper/Barge would **under-advertise implemented features** (the opposite honesty error). But inbound/outbound/transfer/conference **all** ride the same audio path, so it must be proven once. *Approach (right-sized, but a real gate):* + - **Base-voice deployment/release gate (fail-closed in Production):** a **successful reference execution** of the audio proof — **direct-ICE and forced-TURN** media established, plus a **restart/drain + dependency-failure** check and a measured **capacity floor** — must be performed against the reference topology and its **evidence recorded** before a deployment is declared production-supported. Live WebRTC E2E is not required *in CI*; use the existing recorded-cassette/signaling harness (W5.4) for the CI-provable portion and require the live media proof as a documented **deployment acceptance step** (D3/E2) with captured evidence. In **Production**, until the operator acknowledges the audio-verification step, the node **fails closed** (readiness withheld), not merely warns; outside Production a startup warning suffices. Do **not** hide implemented supervision capabilities. + - **Recording default-off only:** because the unproven-audio risk compounds with the C1 media-lifecycle/erasure risk, ship **`Recording` off by default**, enabled after the same proof passes for a deployment. **Monitor/Whisper/Barge remain advertised** (they are implemented and in-scope). + *Exit:* the CI-provable signaling portion is exercised by the cassette harness; the live direct-ICE + forced-TURN + restart/failure + capacity-floor proof procedure and its evidence template are in `src/CrestApps.Docs` and referenced as a base-voice deployment acceptance gate; **a successful reference execution against the reference topology has completed and its captured evidence is retained** (linked from the docs); in Production an unacknowledged audio verification withholds readiness (test proves fail-closed), non-Production emits a warning; `Recording` is off by default and a test asserts that; Monitor/Whisper/Barge stay advertised. + *Progress (code + docs landed; box stays open):* the fail-closed readiness check, `AudioVerificationAcknowledged ⇒ AudioVerificationEvidenceReference` config validator (`ValidateOnStart`), production/non-production startup warning, `RecordingEnabled` default flip to `false`, acceptance procedure + evidence template + validated-settings doc, and their tests are all landed and green (`gate:pr_ci.yml#build_test`). **The box remains unchecked because the one sub-criterion the CI cannot produce is the live reference execution itself** — direct-ICE + forced-TURN + restart/drain + capacity-floor against a real Asterisk/coturn/browser topology (the scaffolded `AsteriskBrowserAudioE2ETests.BrowserToAsteriskWebRtcAudio_WithDirectIceAndForcedTurn_VerifiesReceivedToneFrequencies` is `[Skip]` for exactly this reason). That run and its retained evidence are the outstanding **operator** step (the live audio proofs and the capacity certification), which stay open. + +--- + +## 5. De-scoped as over-engineering or process overhead (removed / not doing) + +Removed with rationale. These do not add real value proportional to their cost for a first production release of an Orchard Core module: + +- **Per-gate versioned test specifications** and the **`gate-ledger-evidence` meta-test** that parsed plan documents and enforced `gate:` annotations on every checkbox. Right-sized regression tests per change replace this; the generic capability-coverage test (A4) preserves the one behavioral rule worth keeping (no capability without an implementation). *(The plan-parsing governance tests and JSON ledgers were removed in §7 cleanup (T1).)* +- **`KnownDevelopmentValues` SHA-256 digest register + asset-rescanning test** — **deleted entirely** (A1); the mandatory Production rejection of checked-in credentials is inlined directly at both call sites with no shared static register. +- **Durable pre-buffer event journal with "zero lifecycle loss under saturation"** (old W12.13) — folded into D1 (bounded backpressure + coalesced reconciliation) with the loss limitation documented honestly. A separate durable journal is Track B rigor. +- **Bespoke Contact Center preview export/reset service** (`ContactCenterPreviewMaintenanceService`) — retired in favour of Orchard **Deployment/Recipes**; because this involves public-API removal and a behavior change to a destructive admin action, it is a **tracked checklist item (A5)**, not merely a de-scope. Rationale: it exports before quiescing, its "receipt" hashes row **counts** not content, it has **no import/restore**, and its config export duplicates Deployment/Recipes. Operational record/media backup is the datastore/provider's responsibility, documented in E2. +- **Pure release-tracking JSON ledgers and their plan-parsing meta-tests** as committed artifacts — the `pr-test-control-matrix.v1.json` P0/P1 finding→gate ledger, the `r0b-harness-dependency-ledger.v1.json`, `service-objectives.v1.json`, and the `gate-ledger-evidence` / control-matrix / R0b-harness meta-tests that only parsed those documents — were plan-tracking scaffolding, removed in §7. **Not removed:** the *runtime/architecture-contract* ledgers that an ordinary (non-plan-parsing) test actually consumes — `support-matrix.v1.json` (the topology-profile mirror asserted identical to `ContactCenterTopologyProfiles`), `feature-dependency-violations.v1.json` (the architecture-characterization ledger `ContactCenterFeatureDependencyArchitectureTests` reads), and `feature-lifecycle-contracts.v1.json` (the lifecycle contract `ContactCenterFeatureLifecycleTests` reads) — these encode real behavioral contracts and stay. The behaviors that matter (topology validation, no-Elasticsearch, feature-dependency correctness) remain enforced by those ordinary tests. + +--- + +## 6. Deferred to Track B (post-release; explicitly not started) + +Real gaps, but none blocks a credible single-active-process voice-ACD release; starting them now multiplies surface over the current base. Each ships as a documented known limitation with an operator note: + +- **Concurrently-active multi-node (distributed ARI ownership + cross-process channel-ready coordination):** the deferred core of old W3.1/W3.2. v1 is single-active-process (§1, B6); running 2–4 concurrently-active nodes requires a distributed ownership lock and cross-process channel-ready signalling. Documented as unsupported for v1. +- **Open channel model (old W10.6):** `InteractionChannel` stays a closed enum for v1. **Consequence to document:** because W11.8 locked the public-API baseline, adding chat/email/SMS channels later is a **post-GA breaking API change**. Accepted for a voice-only v1; stated explicitly so it is a conscious trade, not a silent one. +- **Media/telephony depth:** dual-channel/stereo recording (old W12.1 — v1 is mono, documented as mono with no diarization); codec negotiation & SRTP/DTLS enforcement & TURN credential lifetime (W12.3, minus any trivial hardening pulled forward in E1); orphan bridge/channel sweeper beyond existing reconciliation + optional ARI channel-list adoption (W12.6); PBX failover across multiple ARI endpoints (W12.7 — external PBX/SBC HA is the operator's responsibility); media-quality telemetry MOS/jitter/loss (W12.8); DTMF/IVR, music-on-hold, call park, CLI presentation (W12.9 — direct-DID queue routing only in v1); STIR/SHAKEN passthrough (W12.10 — carrier/SBC responsibility, regional limits documented). +- **Signed origination markers (W12.12):** evaluated in E1 → **accepted as a documented trusted boundary.** The `CRESTAPPS_ORIGINATED` channel marker is forgeable in theory but is not the security boundary; tenant-unique ARI application ownership + store-driven call bindings are the real guards, so Asterisk/dialplan is a trusted boundary. The signed-HMAC marker stays deferred (the fix is not small and adds no boundary the ARI-app ownership does not already provide). +- **Product-wide accessibility & localization beyond the agent desktop (W14):** admin/supervisor/report a11y and full i18n. Agent desktop (W6) + server-side localized display values are done; whole-product axe coverage is Track B. +- **Move agent-session heartbeat timestamp out of the session document (W16.7 second half).** +- **Multi-region, chat/email/SMS channels, predictive dialing, WFM/QM, skill proficiency/bullseye routing, virtual hold.** + +--- + +## 7. Final tasks (run last, in order) + +- [x] **T1. Clean up planning scaffolding so the PR is merge-ready.** Remove `.playwright-mcp/`; remove the superseded plan docs (`PLAN.md`, `PLAN-2-SINGLE-NODE-COMPLETION.md`, `PLAN-3-PRODUCTION-SINGLE-NODE-DISTRIBUTED.md`, `R0-BASELINE.md`) and the **pure release-tracking ledgers** (`pr-test-control-matrix.v1.json`, `r0b-harness-dependency-ledger.v1.json`, `service-objectives.v1.json`); remove the test/CI that only exists to parse those documents (the ledger-evidence, PR-test-control-matrix, and R0b-harness meta-tests) and the docs pages that only describe them (`pr-test-control-matrix.md`, `service-objectives.md`); update `.github/copilot-instructions.md` to drop references to the removed files. **Keep** the runtime/architecture-contract ledgers an ordinary test consumes — `support-matrix.v1.json`, `feature-dependency-violations.v1.json`, `feature-lifecycle-contracts.v1.json` — and their consuming tests. Keep this `PRODUCTION-READINESS.md` as the short retained record (drop its transitional `ledger-authority` marker once the meta-tests are gone). *Exit:* no plan-tracking scaffolding remains; the retained runtime-contract ledgers and their tests still pass; build + tests green after removals. + +- [x] **T2. PR-wide implementation deep-dive.** Review every changed file in this PR: correct implementation, reuse of pre-existing design patterns, extension of Orchard Core features where possible, and clean code per `.editorconfig` and `copilot-instructions.md`. Fix what falls short. *Exit:* review complete; strict Release build with 0 warnings; full unit suite green. + +- [x] **T3. Documentation review.** Deep-dive the `src/CrestApps.Docs` pages: ensure they align exactly with the shipped code, are internally consistent, and all links resolve. Update the changelog file matching `VersionPrefix`. *Exit:* docs site builds; link check passes; claims match code. + +- [x] **T4. Aspire host smoke test.** Run `src/Startup/CrestApps.Aspire.AppHost` locally and confirm it starts with no failure. *Exit:* the AppHost boots cleanly. + +--- + +## Progress log + +Append a one-line entry per completed item (item id, commit, build/test result). This replaces the previous multi-thousand-line evidence ledger. + +- **Plan finalized & cross-reviewed (GO/GO).** Consolidated from PLAN/PLAN-2/PLAN-3/R0. Independently reviewed by GPT-5.6 and Opus-5; both returned **GO** with no remaining blockers after three revision rounds (health-check integration corrected to functional Redis/lock/backplane `DependencyTag` probes with no bespoke aggregate; C1 hardened to atomic recording/media erasure with tombstone + retention legal-hold fix + fail-closed tenant removal; single-active-process scope decision with multi-node deferred to Track B; G1 base-voice audio proof made a fail-closed Production release gate; `KnownDevelopmentValues` deleted). +- **A1 done** (commit `0e79b59b`). Deleted `KnownDevelopmentValues` + its test; inlined explicit dev-credential rejection at both call sites (`DefaultAsteriskOptionsValidator`, `AsteriskSoftPhoneRegistrationConfigContributor`). Independent GPT-5.6 review: GO (added exact TURN-secret validator test per its non-blocking note). Asterisk module builds 0 warnings; 9 focused tests pass. +- **A2 done.** Added three functional `DependencyTag` probes without any bespoke aggregate: `ContactCenterDistributedLockHealthCheck` (acquire/release a dedicated probe lock), `ContactCenterRedisConnectivityHealthCheck` (bounded `ConnectAsync`+`PingAsync`), and `ContactCenterBackplaneHealthCheck` (bounded pub/sub round-trip on an invocation-unique, tenant-qualified channel). All resolve `IRedisService` optionally and report Healthy-when-absent (topology validator, not the probe, decides whether Redis is required). No hard dependency on `OrchardCore.HealthChecks`. Backplane check allowlisted in the architecture guard (legit `ISubscriber` need). Independent GPT-5.6 review: initial NO-GO on 3 blockers (arch-guard, non-unique unsubscribe channel, unbounded ops) then a 4th (subscription leak on subscribe-timeout) — all fixed; final GO. 10 focused tests pass (incl. a Moq timeout-cleanup regression test); module builds 0 warnings. +- **A3/A4/A5 done.** Dead/dishonest-surface removal in one landing: **A3** deleted the never-implemented generic provider-webhook ingress (`IProviderVoiceWebhookAdapter`, `HmacProviderVoiceWebhookAdapterBase`, `ProviderVoiceWebhookProcessor`, endpoint + models + 2 test fakes; DI/endpoint mapping removed from `Startup`). **A4** removed `MonitorMode.TakeOver`, the `TakeOver` capability bit (`1 << 12`), its `ContactCenterMonitoringService` entry and `ResolveCapability` arm, and the supervisor-dashboard take-over control; added `ContactCenterVoiceProviderCapabilityCoverageTests` asserting every capability bit is provider-declared or explicitly reserved (replaces the removed governance gate). **A5** deleted the entire `Maintenance` feature (Abstractions/Maintenance + Core/Maintenance surface, service/controller/views/view-models/admin-menu/permission, manifest Feature block, `ManagePreviewData` permission, startup) — config export/import now flows through Deployment/Recipes only and the destructive preview reset is removed entirely (operational recovery = DB backup/restore per E2). Regenerated `PublicApiApprovalTests` baselines for `ContactCenter.Abstractions` + `.Core`; applied a minimal governance-ledger patch (removed the take-over/webhook/maintenance references now, full release-ledger removal deferred to T1) and cleaned all docs stale refs. Independent GPT-5.6 review: NO-GO on stale-reference residue → all fixed and under final confirmatory claude-opus-5 review (this line is updated to the recorded verdict only once that review returns GO). Governance (113), PublicApi/capability/monitoring (143), headless-closure (3), lifecycle (18), support-matrix + feature-dependency-architecture suites all green. +- **A2 follow-up correctness fix (folded into the A3/A4/A5 landing).** The original A2 commit left four architecture-governance tests red because the Redis probes took an **optional** `IRedisService` — the exact pattern the pinned `ContactCenterOptionalDependencyTests` invariant forbids (an optional injected collaborator silently changes behaviour when its owning feature is off). Reconciled to the established feature-owns-its-dependency pattern (identical to how the Voice feature owns the provider-ingress check): made `IRedisService` **mandatory** on `ContactCenterRedisConnectivityHealthCheck` and `ContactCenterBackplaneHealthCheck`, moved all three distributed-dependency probes (distributed-lock, Redis-connectivity, backplane) out of the base feature into a new `AddContactCenterRedisHealthChecks()` registered by a `[RequireFeatures("OrchardCore.Redis")]` startup. The base-feature health-check set is now identical to pre-A2, so the readiness/dependency-split contract tests pass unchanged; added a registration test proving the three probes are Redis-gated and dependency-tagged, and a guard test proving the base feature registers none of them; dropped the two now-impossible "healthy when Redis absent" probe tests; corrected the options-discovery floor after A5's options-type removal. Full ContactCenter suite 1477 pass; feature-activation 55 pass. +- **B1 done** (commit `f1e02f43`). The listener-start ordering (provider-local validation via `HasRequiredConfiguration` + ARI ownership via `IAsteriskAriApplicationGate.TryAcquire`, both in `AsteriskRealtimeVoiceTenantEvents.ResolveListeners`) and Contact Center admission gating (`IContactCenterFeatureWorkManager.TryEnter` → `ContactCenterTopologyState.IsAdmissible`, fail-closed) were already implemented; the admission half was already covered by three `ContactCenterFeatureLifecycleTests`. Added the missing listener-side proof: introduced an `IAsteriskRealtimeVoiceListener` seam (concrete now implements it; DI registers by interface; `IAsyncDisposable` disposal preserved) so `AsteriskRealtimeVoiceTenantEventsTests` can assert the WebSocket opens only after both validation and ownership pass, and that the ownership gate is not even consulted when provider-local validation fails. Independent GPT-5.6 review: GO. Asterisk module builds 0 warnings; Telephony+ContactCenter 1967 pass (1 skipped); feature-activation 55 pass (2 skipped). +- **B2 done** (commit `5dcb4085`). Investigation confirmed the durable path (`ProviderCommandProcessor`) already settles `OutcomeUnknown` on timeout/cancellation, and the monitoring/transfer synchronous services already return explicit `Unknown` on Timeout/OCE — the only gap was `ContactCenterRecordingService` collapsing unknown outcomes to a silent `false`. Added `TelephonyCommandNotAdmittedException : OperationCanceledException`, thrown by `DefaultTelephonyCommandExecutor` at entry when `ApplicationStopping` is already requested (provider never contacted ⇒ definite non-application); deriving from OCE lets existing monitoring/transfer catches degrade to `Unknown` while precise callers catch it first for a definite `Failure`. Changed `IContactCenterRecordingService` Start/Pause/Resume/Stop to return a tri-state `RecordingCommandResult` (mirrors `TransferResult`): NotAdmitted/guard/governance → `Failure`; timeout/in-flight OCE/unconfirmed provider outcome → `Unknown`; confirmed change → `Success`. Added executor + recording shutdown tests and regenerated the Telephony.Abstractions and ContactCenter.Core public-API baselines. Independent GPT-5.6 review: GO. ContactCenter.Core + Telephony build 0 warnings; ContactCenter+Telephony+PublicApi 2004 pass (1 skipped); feature-activation 55 pass (2 skipped). +- **B3 done.** Investigation found the only report provider with mutable per-execution instance fields is `EnterpriseInteractionReportProvider` (`_agentUserNames`, `_absentFeatureIds`), both re-captured from injected tenant-scoped services at the start of every `RunAsync` and independent of the per-call `ReportContext`. No leak is reachable: the provider is registered `AddScoped` so concurrent requests never share an instance, and reuse re-captures both fields each call. Closed with a test-only proof and **no production change** (plan's "prove before refactoring; do not refactor if no defect" directive): added `EnterpriseInteractionReportConcurrencyTests` with (a) `ConfigureServices_RegistersEveryReportProviderWithScopedLifetime` — runs the real `AnalyticsStartup.ConfigureServices` and asserts every `IReport` descriptor is `ServiceLifetime.Scoped`, so a future singleton misregistration (the only way concurrent requests could share an instance) fails the build; and (b) `RunAsync_WhenOneInstanceIsReusedAcrossRequests_DoesNotCarryStaleCapturedState` — a real-SQLite behavioural test flipping capability + agent state between two runs on one instance and asserting no stale voice columns or agent names carry over. Independent GPT-5.6 review: initial NO-GO (the first draft's two-separate-instance "overlap" test and gate could not observe a field leak) → replaced with the scoped-lifetime registration assertion + kept the reuse test → re-review GO. 2 focused tests pass; full Reports suite 184 pass. +- **B4 done.** `AsteriskRealtimeVoiceEventMapper.BuildIdempotencyKey` hashed the raw payload string, so a whitespace/property-order or numeric-token change from an Asterisk upgrade or a re-serializing proxy would silently defeat dedupe and double-process. ARI events carry no eventId/sequence, so the key is now built from a **canonical** form of the payload: object properties ordered by ordinal name, arrays preserved in order, numeric tokens normalized (Int64/UInt64 exact, else shortest round-trippable finite double, else raw fallback), SHA-256 hashed and prefixed `{provider}:{eventType}:`. Re-serialized redeliveries of one event dedupe; two genuinely distinct same-type events on one call (differing in timestamp/varset value/sub-state) keep distinct keys and are both processed — avoiding the event-loss failure of a coarse (provider, callId, type) key. Added mapper tests for property-order re-serialization, numeric re-serialization (`16` vs `1.6e1`), two distinct same-type hold events on one call, and the key prefix. Independent GPT-5.6 review: initial NO-GO (numeric tokens not canonicalized) → added Int64/UInt64/double numeric normalization + a numeric re-serialization test → re-review GO. Asterisk module builds 0 warnings; Telephony+Asterisk suite 640 pass (1 skipped). +- **B5 done.** Closed as **already-correct with a regression guard** (plan's escape hatch). Verified `MediaTopologyId` is absent from all production `src/**/*.cs`; the live `CallSession` topology is fully typed (`Bridge`, `ConsultCall`, `CallLeg`, `CallRelationship`, `MonitorSession`; `ConsultCall.TargetType` is the typed `InteractionTransferTargetType`); the only historical transfer strings are the audit-only `InteractionTransferHistoryEntry.TargetType`/`.Result`, which no neutral consumer parses back into typed values (report providers only read `TransferHistory.Count` or group the raw strings for display). Kept the two fields as `string` (retyping would change the persisted YesSql JSON of existing interactions — the plan forbids a compatibility break). Clarified the two fields' XML docs as historical text snapshots that are never re-parsed, and added `ContactCenterTransferTopologyTypingArchitectureTests` (live consult topology uses the typed enum; no live topology model exposes `MediaTopologyId` or a string `TargetType`; `MediaTopologyId` stays absent from production source). Independent GPT-5.6 review: GO. 3 focused tests pass. *Note for T2:* the distributed-test harness `ContactCenterStoreTestHarness` still declares a stale, larger `CallSessionIndex` schema (extra `MediaTopologyId`/`ConferenceId`/`SupervisorAgentId`/`SupervisorLegId`/`RecordingId`/`AgentSessionId`/`DurableCommandId` columns not on the current 11-column production index) — test infra only; fold the harness resync into the T2 PR-wide deep-dive. +- **B6 done** (`b4dc0adb`): `AsteriskAriApplicationOwnershipRegistry.TryClaim` now fails closed — blank baseUrl/applicationName/tenantName/ownershipToken are denied and logged (was silently granting). Injected `ILogger`; updated interface contract doc. Added a startup information log stating the single-active-process ownership constraint when the Asterisk listener starts. Documented the non-overlapping deployment requirement in `runbooks.md` (new "Asterisk single-active-process listener ownership" subsection under Provider failure). Updated all registry/gate test call sites for the new ctor and rewrote the blank-claim test to assert denial + added a blank-tenant-name test. 41 ownership/gate tests pass. gpt-5.6-sol rubber-duck: NO-GO (runbook lost `## Node failure` heading + misleading unique-per-tenant guidance) → fixed → GO. +- B7 (2025): `ReservationExpiryBackgroundTask` now bounds each run to a 90s wall-clock budget (IClock between-op checks + hard `CancelAfter` on a linked CTS) < the 120s lock, participates in the Routing work-admission drain (`TryEnter` + registered `ContactCenterFeatureWorkLifecycleParticipant`), propagates shutdown cancellation, and `ActivityReservationService.ExpireDueAsync` observes cancellation between per-item lock acquisitions. Contract ledger → implemented-r3. gpt-5.6-sol GO. Commit c94ec207. +- B8 (2025): `DialPadTelephonyProvider.ReadDateTimeOffset` parses with `CultureInfo.InvariantCulture` + `AssumeUniversal | AdjustToUniversal` so offset-less provider timestamps are UTC (not server-local); helper made internal for cross-culture unit tests. gpt-5.6-sol GO. Commit 6c4aa4d4. +- **C1 done** (commit `f3f466ff`; ledger W17). Recording/media erasure & lifecycle proven end-to-end: authenticated `RecordingErasureEndpoint` → `EraseAsync` clears `Interaction` + `CallSession` pointers, stamps the tombstone, and enqueues durable outbox media deletion in one YesSql unit of work; `RecordingMediaDeletionHandler` deletes encrypted bytes idempotently in a fresh scope and writes an audit-trail receipt; `RecordingErasureGuard` reads the tombstone through a fresh child scope so `AsteriskRecordingIngestService` refuses/cleans up a late ingest (deterministic-key delete even across a pre-persist crash); retention enqueues media deletion before row delete, excludes indexed `RecordingLegalHold` rows (per-record veto backstop), and runs each batch atomically — a failed batch is discarded with `ISession.ResetAsync` so no partial event-without-outbox can commit and orphan media; tenant decommission purges media via `ISupportsTenantMediaPurge`/`RecordingMediaTenantEvents`, fail-closed. gpt-5.6-sol rubber-duck: first NO-GO on 6 findings, second NO-GO on 3 (retention partial-commit, mirror-only pointer, legal-hold TOCTOU) — partial-commit fixed with the atomic batch discard, the other two validated unreachable (capture-time-only hold stamping; mirror always seeded from the interaction reference) → GO. ContactCenter + PublicApi 1,411 pass / 0 fail; ContactCenter + Asterisk modules build 0 warnings; docs site builds clean. +- **C2 done** (commit `3d5865ed`). Widened `CallSessionIndex.ProviderCallId` 128→256 and the derived `ProviderCallClaimKey` 261→385 (= ProviderName 128 + sep 1 + ProviderCallId 256; 770 bytes < SQL Server's 900-byte key limit) as a real schema change, not an additive no-op. Because SQLite has no ALTER COLUMN, widening runs through a new reusable `IndexStringColumnRebuild.WidenAsync` add/copy/drop/rename rebuild, dropping the unique claim index + covering index first and recreating them after. **B1**: SQL-Server-only pre-drop of the auto-named default constraint before `DropColumn` (name read from `sys.default_constraints`); the additive-migration guard gains a raw-SQL in-place-rebuild contract entry with an `(anonymous)` restored-object sentinel scoped strictly to raw-SQL entries. **B2**: tolerant per-index `DropIndex` unrolled to one call per literally-named index (MySQL has no DROP INDEX IF EXISTS). **B3**: real-PostgreSQL migration test proves both columns reach the wider length, a seeded value survives, and the claim-key UQ still rejects a duplicate (SQLSTATE 23505 + constraint name) — `contact_center_operations_gates.yml#redis-backplane-two-node` / `release_ci.yml#distributed_test`. **N1**: copy step resumes (early-return when temp column absent). **N3**: duplicate-claim-key preflight before recreating the UQ index aborts with repair guidance if a duplicate slipped into the transient index-absent window on an autocommitting engine; SQLite N-1 harness + additive guard + uniqueness throw-branch test via `pr_ci.yml#build_test`. SQL Server/MySQL are construction-correct-only (not exercised in the Postgres-only CI). Also fixed pre-existing CS0105 duplicate-usings blocking strict warnaserror builds. **Two independent claude-opus-5 rubber-duck reviews → GO** (blockers were documentation accuracy: corrected the reject-vs-truncate failure mode and the in-place-rebuild exception to the additive-only audit). Strict warnaserror builds of all 5 touched projects 0 warnings; 86 additive-guard + 16 uniqueness + 118 targeted + 156 Architecture + 3 Postgres migration tests pass; docs build clean. Heartbeat-relocation half of W16.7 stays **deferred**. +- **D1 done.** Bounded real-time event channel with genuine backpressure + a saturation metric for the Asterisk voice listener. The listener already used `Channel.CreateBounded(FullMode.Wait)`; the fix moved the write path to `AsteriskRealtimeIngestionWriter` (`await Writer.WriteAsync` with a bounded `RealtimeEventBackpressureTimeout` wait, default **5s**) and emits `asterisk.realtime.ingestion.saturated` (meter `CrestApps.OrchardCore.Asterisk`) on the first wait of a saturation episode (episode reset only when the buffer fully drains). Buffer capacity is a validated option (`RealtimeEventBufferCapacity`, default 1000, upper-bounded by `AsteriskConstants.MaxRealtimeEventBufferCapacity = 100_000`; both `> 0` and `<= cap` validators in `Startup`). On backpressure timeout the listener reconnects and runs the existing coalesced, pointer-driven reconciliation. Post-disconnect drain extracted to `AsteriskRealtimeIngestionDrainer` — progress-based **and** hard-bounded by `max(backpressure timeout, 30s)` so a healthy-but-slow dispatcher finishes while a stalled one is cancelled and abandoned (worst case ~30s offline, not tens of minutes). Reconnect backoff decays via a `Stopwatch` measuring receive uptime captured **before** the finally-drain and started **after** reconciliation, so drain/reconcile time never masquerades as healthy uptime. **B1 (Asterisk precondition):** client backpressure only reaches the provider as real flow control when Asterisk's `[general] websocket_write_timeout` exceeds the configured backpressure window; the sample `ari.conf` sets it to `10000` (ms) > 5s. Docs (production-support.md, runbooks.md, changelog v2.0.0) reworded to state the precondition, the bounded-drain caveat (a dispatch wedged in non-cancellable tenant-scope work can still delay reconnect), and that the stream is **not** lossless under any config. **Three independent claude-opus-5 rubber-duck reviews → GO** (rounds 2 and 3 fixed reviewer-found defects: backoff stopwatch had spanned the drain; the progress-based drain initially had no overall budget); all four final non-blocking suggestions (budget-exhaustion test, reconcile-excluded uptime, per-cause abandon log, doc precision) were also landed. Asterisk + Tests strict warnaserror builds 0 warnings; 687 targeted tests pass (1 skip, the WebRTC E2E) incl. 5 drainer + 6 writer + options-rejection tests via `pr_ci.yml#build_test`; docs build clean (only the pre-existing `#backup-and-restore` anchor). W12.13 durable pre-buffer journal stays **out of scope** (Track B). +- **D2 done.** Reconciliation-liveness proof: the Asterisk `ITelephonyCallStateProvider` (`AsteriskTelephonyProviderBase.GetCallStateAsync`) is the surface reconciliation depends on (`ProviderCallStateSynchronizationService`/`TelephonyInteractionSynchronizationService` dispatch on a runtime `is ITelephonyCallStateProvider` check), so a new cassette-backed contract test `AsteriskCallStateReconciliationContractTests` drives the production provider against the pinned Asterisk release's recorded ARI channel (`Cassettes/Asterisk/22.10.1/rest/responses.json`, `GET channels/{channelId}`) and asserts it issues a **live** `GET channels/{callId}` and parses the recorded payload into `TelephonyCallLookupResult` (Found, `CallState.Connected` from `"Up"`, From/To from the recorded caller/connected numbers); a second test proves a live 404 reconciles to Found=false; a third pins that `AsteriskTelephonyProvider` still implements `ITelephonyCallStateProvider` so reconciliation cannot silently skip ARI. Added a reusable `AsteriskContractCassettes.TryReadRecordedRestResponse(...)` accessor. The channelId/state/numbers are all read from the recorded body, so an Asterisk upgrade that changes the channel shape also fails here. **Mutation-verified by the independent claude-opus-5 reviewer**: short-circuiting `GetCallStateAsync` to return the exact cassette values *without* issuing the ARI GET makes both liveness tests FAIL (empty request collection) — proving the exit criterion "a cassette test fails if the provider stops querying ARI." The transition-table half (illegal-edge rejection via `CallSessionLifecycle.CanTransition`/`CallSession.TransitionTo`→`InvalidStateTransitionException`) was already covered by `AggregateLifecycleTableTests`/`AggregateStateTransitionTests` and re-verified. claude-opus-5 rubber-duck: **GO** (landed NB-1 interface-pin fact, NB-2 direction comment, S-3 doc wording). Tests strict warnaserror build 0 warnings; 3 new + full Telephony/ContactCenter 2029 pass (1 skip, WebRTC E2E) via `pr_ci.yml#build_test`; docs build clean (only pre-existing `#backup-and-restore` anchor). Also documented in production-support.md that restored state is read from live ARI and pinned by this contract test. +- **D3 done.** Minimum operational signals + voice handover/rollback runbook. **Signals:** two new Asterisk meter counters (`asterisk.realtime.connected`, `asterisk.realtime.reconnect_attempted`, meter `CrestApps.OrchardCore.Asterisk`, tagged `provider`) wired into `AsteriskRealtimeVoiceListener` (connected recorded after `ConnectAsync` succeeds; reconnect recorded before each backoff `Task.Delay`), plus two store-count "gauge" health checks surfacing live counts as `HealthCheckResult.Data` — `contactcenter-active-calls` (`active_calls`, `ICallSessionStore.CountActiveAsync` = `EndedUtc == null`) registered by the base Area feature, and `contactcenter-queue-backlog` (`queued_interactions`, `IQueueItemStore.CountAllWaitingAsync` = `Status == Waiting`) registered by the **Queues** feature via a new `AddContactCenterQueuesHealthChecks()`. Counts are surfaced as health-data (not `ObservableGauge` metrics) deliberately: a gauge callback is synchronous with no ambient tenant scope and a process-global count is wrong across tenants/nodes, whereas a health check runs in tenant scope with an async body; both stay Healthy at any count (Unhealthy only on store read failure). The ownership split is required — a check must be registered by the feature owning **all** its store dependencies (dep chain Queues→Availability→Agents→Area) or it throws on a tenant that enables the registering feature without the owning one. **Runbook:** new `## Voice listener handover and rollback` section in runbooks.md (linked from Rolling + Blue-green and from production-support.md), stating the single-active-process constraint, the prohibited overlapping deployment, the **bounded-interruption** reality (no zero-downtime claim), and explicitly what happens to live calls during the gap: bridged media keeps flowing while control pauses; channels parked in `Stasis()` with no dialplan continuation are stranded; and new inbound calls whose `StasisStart` was missed are **not** recoverable by reconciliation (store-driven, no ARI channel enumeration) — mitigation is a dialplan continuation + upstream carrier retry. **Three independent claude-opus-5 rubber-duck reviews → GO**: round 1 NO-GO caught (B1) unregenerated PublicApi baselines (branch red), (B2) a false "reconciled on reconnect" claim for missed-StasisStart calls, and (B3) a wrong `active_calls` recovery direction (it stays flat through the gap and only ever drops toward the true live count post-sweep) — all fixed; NB1 (provider tag is per-node/process, not per-tenant), NB3 (queue-backlog test now runs the real `QueueItemIndexMigrations` so `Status` is asserted against the shipped `Column`), and NB4 (deep link from the deployment doc) landed. The reviewer's NB2 mutation-gap point was then closed for the reconnect call site with a deterministic, non-flaky `AsteriskRealtimeVoiceListenerReconnectMetricTests` (real listener against a closed loopback port → `ConnectAsync` fails immediately → increment fires before any backoff timer; 3× repeat green, ~90ms). Asterisk + Tests strict warnaserror builds 0 warnings; new metric/health/endpoint/reconnect tests + full Telephony/ContactCenter 2039 pass (1 skip, WebRTC E2E) via `pr_ci.yml#build_test`; docs build clean (only the pre-existing `#backup-and-restore` anchor). +- **E1 done.** Omnichannel/CRM layer review (was W13, PLAN-3 §W13) across ~287 `.cs` files / 5 areas — authorization & tenant isolation, migration safety, concurrency/CAS, retention/PII, background schedulers — plus the W12.12 origination-marker evaluation. **Real defects fixed in `AutomatedActivitiesProcessorBackgroundTask` (S1):** (1) **pagination skip** — the page query combined a moving `DocumentId > cursor` keyset with an increasing `.Skip(iterationCount++ * _batchSize)` OFFSET, advancing the window twice per batch and silently skipping every other page of due activities; replaced with pure keyset pagination (removed `Skip`/`iterationCount`). (2) **cross-node double-send** — the loop committed only once at end-of-run under `LockExpiration = 90_000`; on multi-node a run that outlived the 90s lease let another node re-acquire the distributed lock and reprocess still-uncommitted activities, and `SmsOmnichannelProcessor.StartAsync` sends with no idempotency key **before** flipping `Status = AwaitingCustomerAnswer`, so re-processing = duplicate SMS to real contacts. Fix: **commit per batch**; a **wall-clock budget** that stops a run at 60% of the lease so a *still-running* node ends well before its lease can lapse (OrchardCore does **not** cancel `DoWorkAsync` when the lease elapses, so per-batch commit alone does not stop an alive node from leap-frogging a peer — the budget is what does), enforced **per item** in both the expiry and send loops (not just per batch) so worst case is the 6-min budget plus a single in-flight send (≤ the ~100 s HTTP timeout) ≈ 7.7 min, still comfortably inside the 10-min lease; **lease raised `90_000 → 600_000`**, deliberately *above* the 5-min schedule interval so it is not designed to expire exactly when the next tick fires; bounded work per invocation (`_maxActivitiesPerInvocation = 1000`). **Honest bound:** this does not make the SMS send idempotent — it means a *crashed/killed* node re-sends at most the single uncommitted in-flight batch (≤`_batchSize`), not the whole backlog, and an alive node never hands an uncommitted backlog to a peer. After the first per-batch commit YesSql keeps `_save = true` for the session, so a later throw commits the already-flushed in-flight batch instead of rolling it back — the desired behaviour here (sent messages stay marked). The lease is 10 min against a 5-min schedule, so a crashed node's lock is held for up to two ticks before another node retries its in-flight batch — an accepted trade to keep the lease safely above run duration. The `catch` also excludes `OperationCanceledException`, so a tenant shutdown/recycle during a send is not logged as a failure and does not consume a `ProcessingAttempts` slot. True per-item idempotency (an idempotency key on the send) remains follow-up S-1; the residual duplicate window is specifically a post-send throw (status flip / prompt persistence) after `SmsOmnichannelProcessor.StartAsync` has already sent, now bounded to at most `_maxAttempts` rather than unbounded. (3) **cap-induced permanent stall (regression caught in round-1 review, B1)** — with the new per-invocation cap, the pre-existing no-op failure path (a caught `StartAsync` exception left the activity `NotStated`/due, `Attempts` never incremented) would let one permanently failing activity (e.g. a misconfigured AI profile) occupy a slot on every invocation and starve all healthy outbound; the `catch` now transitions the failure on a **separate internal `ProcessingAttempts` counter** (a document-only field, *not* the routing-owned, indexed, report-and-UI-surfaced `Attempts` that the contact-center work-state projector overwrites) — it increments `ProcessingAttempts`, reschedules with linear backoff, and marks `Status = Failed` at `_maxAttempts` — so a failing activity always leaves the due set and the counter can never be reset out from under it. (4) **expiry head-of-line stall (NB-2)** — `ExpireNoResponseActivitiesAsync` took an unordered page and `continue`d no-timeout rows with no state change, so ≥`_batchSize` such rows would permanently block expiry; it now **restricts the query to the subject content types whose flow actually defines a no-response timeout** (computed once per run from the flow settings) and keyset-pages within them, so no-timeout conversations never enter the candidate set at all — no per-row flow lookup and, crucially, **no sentinel written into the user-visible, sorted, filtered `ScheduledUtc`** field. One pre-existing edge is accepted (not introduced here): because no-timeout flows never stamp a deadline, if an operator later adds a timeout to such a flow, its in-flight `AwaitingCustomerAnswer` conversations carry a stale past `ScheduledUtc` and would be mass-expired on the next tick until the customer next replies — identical to the pre-E1 behaviour, tracked as a follow-up (arm the deadline from `ModifiedUtc` when `ScheduledUtc` predates the status transition). **Tests (`AutomatedActivitiesProcessorBackgroundTaskTests`):** a 250-activity case proving every due activity is processed **exactly once** (teeth-verified: fails at **150/250** with the `Skip` regression) and a two-invocation failure case proving a throwing activity is attempted once and **not** re-attempted the next tick (teeth-verified: **200 vs 150** without the failure transition). **Inbound webhook hardening (NB-3/NB-4):** `TwilioWebhookEndpoint` built its signed URL from the raw request scheme/host and omitted the path base — silently `Forbid()`ing genuine deliveries behind a TLS-terminating proxy — and used `signature.First()` (empty header → 500, not 403); it now **reuses the Event Grid endpoint's tested `IsRequestValid`/`GetExternalRequestUrl` helper** (honours `site.BaseUrl` + path base, `signature.ToString()`), so both inbound SMS paths share the one validator covered by `TwilioEventGridEndpointSignatureTests`. **Dismissed as governed (F1/F2):** every migration `Drop` in `OmnichannelActivityIndexMigrations.UpdateFrom3Async` (2) and `OmnichannelContactsMigrations` (6) is registered in `MigrationAdditiveOnlyGuardTests` with `MigrationContractJustification.NeverReleased` — the guard verifies that against real git release tags (columns/tables existed only in the unreleased 2.0.0 line). **Accepted with dated rationale (W12.12, 2026-07):** the `CRESTAPPS_ORIGINATED` channel marker is forgeable in theory but is **not** the security boundary — tenant-unique ARI application ownership + store-driven call bindings are the real guards; Asterisk/dialplan is recorded as a documented **trusted boundary** and the signed-HMAC marker stays deferred. **Accepted with dated rationale (PII P1–P3, 2026-07):** plaintext SMS body/from/to at rest, no automated CRM retention purge, and no per-contact subject-erasure are deferred for the unreleased product — at-rest encryption is a datastore/deployment concern, and **E2 must state explicitly that `OmnichannelMessage.Content`/`CustomerAddress`/`ServiceAddress` are stored plaintext** (recording media is separately advertised as encrypted at rest, so silence here would mislead); **per-contact subject erasure is recorded as a GA blocker** (a legal obligation at general availability), not open-ended post-GA. No open high-severity CRM finding remains. **Independent claude-opus-5 rubber-duck review (three adversarial rounds) → GO** — round 1 caught B1 the cap-induced stall, B2 the over-claimed ≤batch bound, and B3 the count-cap-≠-time-budget error; round 2 caught B4 (the failure counter reused the routing-owned `Attempts` field, which the work-state projector overwrites — resetting the retry ladder and corrupting reports), B5 (the wall-clock budget was enforced only per batch, so a full page could still run past the deadline), and B6 (the no-timeout deferral wrote a year-3026 sentinel into the user-visible `ScheduledUtc`); all fixed and re-verified — B4 by repo-wide grep and the post-reload test assertion that the routing-owned `Attempts` stays 1 while `ProcessingAttempts` records the failure, B5 by the per-item budget placed before the cursor advance in both loops, and B6 by an empirical probe confirming zero `ScheduledUtc` writes and exactly the right expiry set. **Recorded verdict: GO.** Two pre-existing, unchanged schema follow-ups the reviewer noted are tracked separately: the `SubjectContentType` index column is `WithLength(26)` (the pre-filter is now correctness-load-bearing on it — widen toward 255 like `ContactContentType`), and no SQL index matches the processor's query shape (`Status, InteractionType, ScheduledUtc, Channel/SubjectContentType, DocumentId`), a scan-plus-filter given the 12k/hr target. Omnichannel.Managements + Sms + Tests strict warnaserror builds 0 warnings; new tests + full `Modules.Omnichannel` suite pass via `pr_ci.yml#build_test`; docs build clean (only the pre-existing `#backup-and-restore` anchor). + +- **E2 done.** Operator documentation truthful & complete (PLAN-3 §E2), a docs-only truthfulness pass over `src/CrestApps.Docs` verifying every operator claim against shipped code. **Five scope items delivered:** (1) **data-residency/plaintext disclosure** — new `## Data at rest and privacy` section in `omnichannel/management.md` states `OmnichannelMessage.Content`/`CustomerAddress`/`ServiceAddress` are stored **plaintext** (addresses also projected plaintext into `OmnichannelMessageIndex`), contrasts the separately-advertised encrypted recording media store, and records no-automated-subject-erasure as a GA blocker (closes the E1 P1–P3 carry-over); (2) **recording-is-mono** — refined the `contact-center/index.md` "Encrypted media store and secure ingest" paragraph to conversation-bridge mixed **mono WAV**, with monitor/whisper NOT captured (separate supervisor bridge) and barge IS captured (joins the recorded conversation bridge), diarization still needed; (3) **single-node-distributed reference deployment** — added the node-census clause to `contact-center/production-support.md` clarifying the single-active-process constraint is not runtime-verified (`ContactCenterTopologyEvaluator` never reads `Min/MaximumApplicationNodes`) and is only *mitigated* by the distributed lock serializing the critical sections that take it, not by a count check; (4) **emergency-calling scope + operator warning** — rewrote the `contact-center/voice-routing.md` `:::danger Not an emergency-calling service` admonition to disclose that `ExternalDestinationPolicy` denial is **PARTIAL** (server-side first-dial/orchestrated-transfer only) and the agent **soft-phone bypasses it entirely** (`TelephonyHub.DialAsync`→`AsteriskTelephonyProviderBase.DialAsync` validates only non-empty `To`), so operators must block emergency/premium ranges at the Asterisk dialplan / SIP trunk; a bare short code is refused earlier by the **E.164 form and minimum-length gate**, not the emergency check; (5) **new `## Data residency` table** in `production-support.md` enumerating every system that holds/processes customer data — tenant SQL DB (plaintext content), recording media store, Asterisk host's transient unencrypted source file, Redis backplane (in-transit call-state payloads + lock keys, not persisted state), third-party SMS/email providers (Twilio, ACS — email+SMS, no ACS voice module), third-party voice provider (DialPad, agent-device-native so it holds ALL call media and Orchard never bridges it), and the AI completion provider (receives inbound SMS bodies via `SmsOmnichannelEventHandler`→`_aICompletionService.CompleteAsync`). Also fixed a pre-existing broken `#backup-and-restore` anchor and added a governance cross-ref bullet stating a Contact Center `Interaction` is **deleted outright** at retention (`ContactCenterRetentionPolicyBase.cs:145 DeleteAsync`) while `OmnichannelMessage` has no retention window. Changelog `v2.0.0.md` bullet updated to carry these operator-facing truths. **Independent claude-opus-5 rubber-duck review (three adversarial rounds) → GO** — round 1 caught B1 (emergency admonition contradicted by the unfiltered soft-phone path — my "no emergency-origination path" claim was an overclaim), B2 (approved-destination transfer catalog has zero production consumers — claim over-strong), B3 (no actual residency statement delivered); round 2 caught B4 (a new false "addresses anonymized on retention" bullet — `Anonymize` is enum/catalog-only with zero implementation, retention DELETES the row), B5 (residency table mislabeled ACS as voice and omitted DialPad + the AI provider), B6 (catalog still presented as an operative control despite the orchestrated transfer path having no agent-facing caller); all fixed and re-verified against code, plus NB1–NB7 landed (mono/barge precision, E.164-form gate, "mitigated by" vs "upheld by", Redis in-transit wording, orchestrated-vs-soft-phone transfer reality). **Recorded verdict: GO.** Pre-existing unimplemented "Anonymize" claim in the per-entity governance table noted but deliberately left untouched (out of E2-added scope, tracked separately). Docs build clean via `pr_ci.yml#build_test` (zero broken anchors). +- **T1 done.** Planning-scaffolding cleanup (§7 T1). Removed `.playwright-mcp/` (30 files), the four superseded plan docs (`PLAN.md`, `PLAN-2-SINGLE-NODE-COMPLETION.md`, `PLAN-3-PRODUCTION-SINGLE-NODE-DISTRIBUTED.md`, `R0-BASELINE.md`), the three pure release-tracking ledgers (`pr-test-control-matrix.v1.json`, `r0b-harness-dependency-ledger.v1.json`, `service-objectives.v1.json`), the plan-parsing meta-tests (the `Governance` ledger-evidence test + `WorkflowJobCatalog`, and the ContactCenter PR-test-control-matrix / R0b-harness / service-objectives tests), and the two docs pages that only described them (`pr-test-control-matrix.md`, `service-objectives.md`). **Kept** the runtime/architecture-contract ledgers that ordinary tests consume — `support-matrix.v1.json`, `feature-dependency-violations.v1.json`, `feature-lifecycle-contracts.v1.json` — and excised only the one orphaned, vacuous `EveryKnownViolation_OwnsAControlMatrixGate` method (empty `knownViolations`) from the kept `ContactCenterFeatureDependencyArchitectureTests`. Updated `.github/copilot-instructions.md` and the docs (index / production-support / public-api-surface / runbooks + changelog `v2.0.0.md`) to drop references to the removed files, and added `.playwright-mcp/` to `.gitignore`. **This also removes the `LedgerEvidenceTests.AuthoritativeLedger_BacksEveryCompletedItemWithARealCiJob` meta-test that was the sole red gate on PR #501 (the W13 review-type gate that structurally cannot name a CI job), turning CI green.** Full main test project 3733 passed / 0 failed / 1 skipped; strict warnaserror build 0 warnings; docs build clean (`gate:pr_ci.yml#build_test`). **Independent claude-opus-5 rubber-duck review → GO.** + +- **F1 done.** Decomposed the 718-line `VoiceContactCenterCallRouter` (§4 Group F) into three focused, single-responsibility services with **no behavior change**: (1) `InboundVoiceCallProcessor` (`IInboundVoiceCallProcessor`) owns inbound routing — distributed-lock + work-lease orchestration, flow/contact/queue resolution, activity + interaction creation, and terminalization; (2) `VoiceQueueOfferService` (`IVoiceQueueOfferService`) owns `OfferNextAsync` (reserve next agent for a queue and offer the queued call); (3) `VoiceContactCenterCallRouter` is now a thin 114-line facade implementing both public interfaces (`IVoiceContactCenterCallRouter` + `IInboundVoiceService`), delegating inbound→processor and offer→offer-service and keeping outbound in place. All method bodies moved verbatim; the shared `IContactCenterFeatureWorkManager` singleton preserves the nested lease semantics of the inbound→offer path. Three static architecture/retention guards were truthfully repointed to the file that now carries the moved code (`InboundVoiceCallProcessor.cs`): the scope-executor guard (`ContactCenterFeatureDependencyArchitectureTests`), the `_settlementWriters["Interaction"]` retention guard (`ContactCenterRetentionCoverageTests`), and the aggregate-lifecycle scanned-files spot-check (`AggregateLifecycleArchitectureTests`). No public-API baseline change (the concrete router is not in any `*.approved.txt`; only the two untouched interfaces are). Full main test project 3733 passed / 0 failed / 1 skipped; strict warnaserror build 0 warnings (`gate:pr_ci.yml#build_test`). **Independent claude-opus-5 rubber-duck review → GO** (verbatim relocation confirmed method-by-method, lease/lock pairing unchanged, all three guards verified truthfully enforced; the one clarity suggestion — disambiguating the new offer service from the pre-existing `IQueuedVoiceWorkOfferService` — was applied by naming it `IVoiceQueueOfferService`). +- **F2 done.** Collapsed 7 structurally-identical catalog-CRUD admin controllers (`Queues`, `QueueGroups`, `EntryPoints`, `DialerProfiles`, `AgentStateReasonCodes`, `BusinessHoursCalendars`, `Skills`) onto a new generic base `ContactCenterCatalogController where TModel : CatalogItem, INameAwareModel, new()` with **no behavior change** — net **−699 lines** (610 insertions / 1309 deletions). The base owns all list/filter/create/edit/delete orchestration once (auth `Forbid`, pager + `Options.Search` round-trip, `SummaryAdmin` display, `CatalogEntryValidation.ValidateAsync` before the `ModelState` check, `NotFound` on missing id, success notifications); each derived controller keeps only the thin routing shell — `[Admin("url","route-name")]` + `[Feature]` + `[HttpPost]`/`[ActionName]`/`[FormValueRequired]` attributes, its specific `IXxxManager`/`IStringLocalizer`/`IHtmlLocalizer` ctor wiring, and the literal `S["…"]`/`H["…"]` label overrides (kept literal so localization extraction is unaffected). Routes, route names, permissions (incl. `EntryPoints`/`BusinessHoursCalendars` deliberately reusing `ManageQueues`), verbs, localized strings, and the `nameof(Model)` validation prefix (now `typeof(TModel).Name`) are all byte-identical to the originals. View resolution is unchanged: base `View(vm)` resolves the view **name** from the executing action descriptor and the **location** from the runtime (derived) controller name, so `Views/{Controller}/{Action}.cshtml` still binds. **`AgentEntitlementsController` intentionally excluded** — it is bespoke (no `IDisplayManager` editor pipeline, no `Delete`, custom `UserManager` binding + duplicate-user validation, writes via `_presenceManager.UpdateEntitlementsAsync`), so forcing it onto the base would change behavior. **Architecture-guard erosion fixed in the same change (review Issue 1):** `ValidationOwnershipArchitectureTests.EveryConfigurationCatalogEditor_ValidatesThroughTheHandlersBeforeSaving` selected editors by `IDisplayManager` and would have passed **vacuously** for the 7 controllers once their write sites moved into the `IDisplayManager` base (whose `TModel` is not in the entity set); the guard now **follows inheritance** — when a derived editor forwards to a base controller it also inspects the base file's members — so removing `ValidateAsync` from the base fails all 7 (teeth-verified: negative probe produced 7 violations, restored to green). Also tightened the shared `H`/`S` fields to `private protected` to preserve the originals' `internal` reach (review Issue 2). Strict warnaserror builds 0 warnings (module + tests); full main test project 3733 passed / 0 failed / 1 skipped; `ValidationOwnershipArchitectureTests` 4/4 (`gate:pr_ci.yml#build_test`). **Independent claude-opus-5 rubber-duck review → GO** (mechanically verified via ordered attribute/route/localized-string diffs, per-controller single-permission cardinality, `typeof(TModel).Name`↔`nameof` correspondence, a normalized line-for-line diff of the extracted orchestration against the original, no manager-interface member hiding, unambiguous `ValidateAsync` overload resolution, and zero external references to the changed types; the `AgentEntitlements` exclusion confirmed correct). +- **F3 done.** Decomposed the two remaining mega-files with **no behavior change**. **(1) `EnterpriseInteractionReportProvider`** (1554 lines): extracted the pure, stateless metric-calculation logic into a new `internal static InteractionMetricsCalculator` (`Reports/Services/`) — `Aggregate`, `CalculateQueueServiceLevel`, `CalculateCombinedQueueServiceLevel`, `IsInboundOffered`, `IsAbandoned`, `GetWaitSeconds`, `GetWaitUntilEndSeconds`, `GetTalkSeconds`, `GetWrapUpSeconds` — moved **byte-identically** (six were `private static` → `public static` because `using static` only imports accessible members; the container is `internal`, so assembly-visible surface is unchanged), and relocated the two result models `InteractionMetrics`/`QueueServiceLevelMetrics` from nested types into top-level `internal sealed` types under `Reports/Models/`. The provider keeps all instance-stateful work (`RunAsync`, every `Build*` section/document method that depends on `S`/`_agentUserNames`/`_absentFeatureIds`/`_capabilityGuard`, `AgentPerformanceMode`, `DisplayOrUnknown`, `ResolveAgentName`, `CreatePerformanceRow`) and gains `using static …InteractionMetricsCalculator;` so its ~30 bare call sites resolve unchanged; now ~1297 lines. Two test call sites retargeted to `InteractionMetricsCalculator.*`. **(2) `AsteriskContactCenterVoiceProvider`** (2412 lines): this type implements **seven** segregated voice-capability interfaces but is **DI-registered once** (`Startup.cs:137`) and consumers discover capabilities by casting the single instance (`provider is IContactCenterVoiceXProvider`) across ContactCenter.Core — so it **must remain one compiled type**. The only behavior-identical decomposition is a **partial-class split by capability**: `internal sealed class` → `internal sealed partial class`, main file retains usings/fields/ctor/props/`DialAsync`/`ConnectToAgentAsync` + shared private helpers + the sole class-closing brace (down to ~775 lines), and five new partials carry one capability group each — `.Recording.cs` (213), `.Transfer.cs` (320), `.Conference.cs` (325), `.AttendedTransfer.cs` (293, incl. nested `ResolvedConsultContext`), `.Monitoring.cs` (565). Member multiset preserved exactly (**64 → 64**, none lost/duplicated/split); interface list untouched so all capability casts are unaffected. The 11-line using block is repeated per partial for consistency (repo does not enforce unused-using removal; CI `-warnaserror` build is 0 warnings). Asterisk + test-project strict warnaserror builds 0/0; full main test project **3733 passed / 0 failed / 1 skipped** (`gate:pr_ci.yml#build_test`). **Independent claude-opus-5 rubber-duck review → GO** (re-derived every claim: all 11 report blocks + both models byte-identical to `HEAD`; provider diff is exactly `+using static` plus two pure deletions; `using static` resolves with no shadowing incl. the `GetWaitUntilEndSeconds` method-group conversions; Asterisk class-body line multiset and 64→64 member set verified, 56/56 contiguous member blocks matched verbatim against `HEAD`, single class decl + single column-0 brace per file, `ResolvedConsultContext` moved wholesale, one deliberate cross-partial helper call; reviewer reran CI-exact Release `-warnaserror` builds 0/0 and `~ContactCenter` 1466/0 + `~Asterisk` 444/0/1). +- **T3 done.** Documentation review (§7 T3). Deep-dived `src/CrestApps.Docs`: confirmed the shipped Contact Center pages (base-voice acceptance/`production-support.md`, recording ship-disabled default, supervision capability wording) match the code as reconciled in G1/E2, the changelog file `docs/changelog/v2.0.0.md` matches `VersionPrefix` `2.0.0` and already carries the G1 + DI-cycle-fix bullets. The Group F work (F1/F2/F3) is internal maintainability refactoring with **no public-behavior, configuration, or API surface change**, so no doc/changelog update was required for it. Docusaurus production build succeeded with no broken-link warnings (`gate:pr_ci.yml` docs job / `validate-docs`). +- **T4 done.** Aspire host smoke test (§7 T4). Ran `src/Startup/CrestApps.Aspire.AppHost` (`dotnet run -c Release`) against a live Docker daemon: the distributed application **booted cleanly** ("Distributed application started", dashboard on `:17260`, zero startup errors), provisioned its infrastructure — custom `crestapps/asterisk-webrtc` image built and reached **healthy**, plus Coturn and Redis **Up** — and the `OrchardCoreCMS` project started and served **HTTP 200** on `https://localhost:5001`. The Ollama resource stayed in `Created` because `.WithGPUSupport()` requires GPU passthrough that Docker Desktop on macOS does not provide; this is an environment limitation and is intentionally non-blocking (the CMS does not `WaitFor` Ollama). AppHost also compiles as part of the full-solution strict `-warnaserror` Release build (0 warnings). Containers were torn down cleanly on shutdown. +- **T2 done.** PR-wide implementation deep-dive (§7 T2). Confirmed the mechanical exit criteria: full-solution strict `-warnaserror` Release build (`RunAnalyzers=true`) **0 warnings / 0 errors** and the full unit suite **3733 passed / 0 failed / 1 skipped**. For the review portion, ran an **independent claude-opus-5** review (three parallel area reviewers — Core orchestration, ContactCenter module, Asterisk/Telephony — plus the reviewer's own programmatic cross-checks) over the ~1015 changed production `.cs` files. Verdict **GO**: nothing blocking, nothing should-fix. Independently verified invariants included all 24 YesSql indexes matching their migration columns, all 24 feature-ids declared in `Manifest.cs`, all 12 permissions registered, all 15 minimal-API endpoints authorized (the two anonymous ones are signature-verified webhooks), fenced/optimistic-concurrency outbox claims, bounded idempotency keys, post-commit `ShellScope.AddDeferredTask` dispatch, fail-closed topology admission, and the single shared `ExternalDestinationPolicy` (strict E.164) enforced at every dial decision point; layer boundary holds (`Interaction` carries no disposition). Convention scans across all changed files: `DateTime.UtcNow`=0, `async void`=0, non-literal `S[...]`=0. **Fixed the two surfaced nits** (T2 mandates fixing what falls short): (1) the canonical provider-name column width was inconsistent (128 in `InteractionIndex`/`CallSessionIndex`, 100 in `ProviderCommandIndex`/`ProviderWebhookInboxMessageIndex`) — promoted a single `ContactCenterConstants.ProviderNameLength = 128` and referenced it from all four migrations (safe pre-release `CreateAsync` alignment; regenerated the ContactCenter.Abstractions public-API baseline for the new constant); (2) `AsteriskResolvedSettings.PjsipRealtimeConnectionString` carried ciphertext while its sibling secrets were unprotected in the same initializers — now unprotected at both tenant-settings construction sites (`AsteriskTelephonyProvider` via `UnprotectPassword`, `AsteriskSoftPhoneRegistrationConfigContributor` via `Unprotect`), matching the credential store's own handling; verified the value is never emitted to the browser and `resolved` is never persisted, so no plaintext-at-rest or leak regression. Both helpers are null/empty-safe. Strict module + test builds 0/0; full suite re-run 3733/0/1 (`gate:pr_ci.yml#build_test`). diff --git a/.github/contact-center/feature-dependency-violations.v1.json b/.github/contact-center/feature-dependency-violations.v1.json new file mode 100644 index 000000000..df06587b0 --- /dev/null +++ b/.github/contact-center/feature-dependency-violations.v1.json @@ -0,0 +1,498 @@ +{ + "version": "1.0", + "description": "Machine-readable R0a ledger for the Contact Center feature-dependency architecture contract. `acceptedExternalDependencies` lists direct manifest dependencies from a Contact Center feature onto a feature outside the Contact Center family that are intentional and are not a P0 finding. `knownViolations` lists every currently known P0 architecture-graph finding the R0a contract tests must detect and no others. `acceptedLeafDependencies` lists external feature ids reachable from the Contact Center family through the SignalR, Telephony, Telephony Soft Phone, and Omnichannel Management manifests that are treated as opaque, out-of-scope leaves. `featureDependencyClosures` pins the exact transitive closure computed today from the current manifest declarations for every Contact Center feature; any deliberate change to the manifest graph in R2 must update this file in the same change.", + "acceptedExternalDependencies": [ + { + "feature": "CrestApps.OrchardCore.ContactCenter", + "dependsOn": "CrestApps.OrchardCore.Omnichannel.Activities", + "reason": "The headless base feature owns orchestration records that reference the shared omnichannel work-item model, and depends on the headless activity services rather than the administration experience." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.Admin", + "dependsOn": "CrestApps.OrchardCore.Omnichannel.Managements", + "reason": "The independently selectable administration integration composes Contact Center with the Omnichannel management experience." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.RealTime", + "dependsOn": "CrestApps.OrchardCore.SignalR", + "reason": "Real-Time intentionally hosts the SignalR hub used by the agent desktop and supervisor dashboard." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.Voice", + "dependsOn": "CrestApps.OrchardCore.Telephony", + "reason": "Server-side voice orchestration intentionally uses provider-agnostic Telephony services without requiring the soft-phone UI." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone", + "dependsOn": "CrestApps.OrchardCore.Telephony.SoftPhone", + "reason": "The independently selectable soft-phone projection integrates Contact Center voice and real-time state with the Telephony widget." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.AgentDesktop", + "dependsOn": "CrestApps.OrchardCore.Omnichannel.Managements", + "reason": "The independently selectable agent desktop integrates Contact Center work with the Omnichannel management experience." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.Analytics", + "dependsOn": "CrestApps.OrchardCore.Reports", + "reason": "Analytics intentionally contributes reports to the shared Reports framework." + }, + { + "feature": "CrestApps.OrchardCore.ContactCenter.Workflows", + "dependsOn": "OrchardCore.Workflows", + "reason": "The independently selectable workflow bridge contributes a Contact Center domain-event activity to Orchard Core Workflows." + } + ], + "knownViolations": [], + "acceptedLeafDependencies": [ + "OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.Users", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Reports", + "OrchardCore.Workflows", + "CrestApps.OrchardCore.Omnichannel.Activities" + ], + "featureDependencyClosures": { + "CrestApps.OrchardCore.ContactCenter": [ + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Admin": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Agents.Admin": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Admin", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Queues.Admin": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Admin", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Dialer.Admin": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Admin", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Dialer", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Recording.Admin": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Admin", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Recording", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.EntryPoints.Admin": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Admin", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.EntryPoints", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Agents": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Queues": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Availability": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Dialer": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Compliance": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Dialer", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Dialer.Automated": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Compliance", + "CrestApps.OrchardCore.ContactCenter.Dialer", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Voice": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.EntryPoints": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Voice.Media": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Recording": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Routing": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.RealTime", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.Telephony.SoftPhone", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.AgentDesktop": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.RealTime", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.Omnichannel.Managements", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Resources", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.Telephony.SoftPhone", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.ContentTypes", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Supervision": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.RealTime", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.Telephony", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.RealTime": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.SignalR", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Analytics": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.Reports", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users" + ], + "CrestApps.OrchardCore.ContactCenter.Workflows": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContentFields", + "CrestApps.OrchardCore.Omnichannel", + "CrestApps.OrchardCore.Omnichannel.Activities", + "CrestApps.OrchardCore.PhoneNumbers", + "CrestApps.OrchardCore.TimeZones", + "CrestApps.OrchardCore.Users", + "OrchardCore.Contents", + "OrchardCore.Flows", + "OrchardCore.Users", + "OrchardCore.Workflows" + ] + } +} diff --git a/.github/contact-center/feature-lifecycle-contracts.v1.json b/.github/contact-center/feature-lifecycle-contracts.v1.json new file mode 100644 index 000000000..3a7c6a924 --- /dev/null +++ b/.github/contact-center/feature-lifecycle-contracts.v1.json @@ -0,0 +1,207 @@ +{ + "version": "1.0", + "scope": "inactive-idle-and-r3-active-work", + "activeWorkMilestone": "implemented", + "entries": [ + { + "featureId": "CrestApps.OrchardCore.ContactCenter", + "component": "OutboxDispatchBackgroundTask", + "kind": "background-task", + "quiesce": "The pre-disable participant closes base Contact Center admission before Orchard mutates the descriptor; new dispatch passes return without claiming work.", + "drain": "Disable waits up to the configured tenant drain timeout for every admitted dispatch pass to release its lease.", + "reEnable": "The fresh shell reopens admission; durable messages retain their expected handler ids and resume without silent completion.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter", + "component": "ContactCenterRetentionBackgroundTask", + "kind": "background-task", + "quiesce": "Orchard stops scheduling the feature-owned task after the tenant shell reloads.", + "drain": "Idle contract requires no retention pass to be running.", + "reEnable": "The fresh shell resolves exactly one new task registration.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter", + "component": "ContactCenterMetricRollupBackgroundTask", + "kind": "background-task", + "quiesce": "Orchard stops scheduling the feature-owned task after the tenant shell reloads.", + "drain": "Idle contract requires no fold to be running. Contributions left unfolded are not lost: the next fold reads whatever is waiting, and a reader adds unfolded contributions to the stored totals in the meantime.", + "reEnable": "The fresh shell resolves exactly one new task registration.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Availability", + "component": "AgentSessionCleanupBackgroundTask", + "kind": "background-task", + "quiesce": "Orchard stops scheduling the feature-owned task after the tenant shell reloads.", + "drain": "Idle contract requires no cleanup pass to be running.", + "reEnable": "The fresh shell resolves exactly one new task registration.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Availability", + "component": "AgentAvailabilityRecoveryBackgroundTask", + "kind": "background-task", + "quiesce": "Orchard stops scheduling the feature-owned task after the tenant shell reloads.", + "drain": "Idle contract requires no availability-recovery pass to be running.", + "reEnable": "The fresh shell resolves exactly one new task registration.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Routing", + "component": "ReservationExpiryBackgroundTask", + "kind": "background-task", + "quiesce": "The Routing lifecycle participant closes admission before descriptor mutation; new reservation-and-assignment passes return without claiming work.", + "drain": "Disable waits up to the configured tenant drain timeout for the admitted reservation-and-assignment pass.", + "reEnable": "The fresh shell reopens Routing admission and the scheduled pass resumes.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Dialer", + "component": "CallbackDispatchBackgroundTask", + "kind": "background-task", + "quiesce": "The Dialer lifecycle participant closes admission before descriptor mutation; new callback passes return without claiming work.", + "drain": "Disable waits up to the configured tenant drain timeout for admitted callback work.", + "reEnable": "The fresh shell reopens Dialer admission and durable callbacks resume.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Dialer.Automated", + "component": "DialerPacingBackgroundTask", + "kind": "background-task", + "quiesce": "The Automated Dialer lifecycle participant closes admission before descriptor mutation; new pacing cycles return without starting attempts.", + "drain": "Disable waits up to the configured tenant drain timeout for admitted pacing cycles.", + "reEnable": "The fresh shell reopens admission and durable campaign work resumes.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Voice", + "component": "ProviderWebhookInboxBackgroundTask", + "kind": "background-task", + "quiesce": "The Voice lifecycle participant closes admission before descriptor mutation; new inbox passes return without claiming durable deliveries.", + "drain": "Disable waits for admitted inbox work to settle through its existing owner and fence.", + "reEnable": "Admission reopens and the next fresh-scope pass re-evaluates retained durable deliveries without blocking activation.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Voice", + "component": "ProviderCallStateReconciliationBackgroundTask", + "kind": "background-task", + "quiesce": "The Voice lifecycle participant closes admission; new scheduled reconciliation passes return without provider or database work.", + "drain": "Disable waits for an admitted reconciliation pass to finish within the configured timeout.", + "reEnable": "Admission reopens and a normal fresh scope performs provider-truth reconciliation within the next minute.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Voice", + "component": "ProviderCommandRecoveryBackgroundTask", + "kind": "background-task", + "quiesce": "The Voice lifecycle participant closes admission; new recovery and direct dispatch passes leave commands durable without a provider call.", + "drain": "Disable waits for admitted command work to settle; sent or outcome-unknown commands remain fenced for later reconciliation.", + "reEnable": "Admission reopens and the next fresh-scope recovery pass re-evaluates retained commands exactly once through durable ownership.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.Voice", + "component": "ContactCenterVoiceTenantEvents", + "kind": "lifecycle-participant", + "quiesce": "The pre-disable coordinator invokes the Voice participant and atomically rejects new tenant-shell Voice work before the feature descriptor changes.", + "drain": "The participant waits for admitted Voice work up to the validated tenant timeout and fails feature disable closed on timeout.", + "reEnable": "A fresh shell reopens admission and schedules provider-truth reconciliation in a normal scope.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.ContactCenter.RealTime", + "component": "ContactCenterHub", + "kind": "signalr-hub", + "quiesce": "Real-Time admission closes and the tenant-local registry aborts every active Contact Center hub connection before descriptor mutation.", + "drain": "Each connection holds a lease until disconnect cleanup unregisters it; disable waits up to the configured timeout.", + "reEnable": "The fresh shell reopens registration and admission before new hub connections are accepted.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.Telephony", + "component": "TelephonyInteractionReconciliationBackgroundTask", + "kind": "background-task", + "quiesce": "Orchard stops scheduling the feature-owned task after the tenant shell reloads.", + "drain": "Idle contract requires no reconciliation pass to be running.", + "reEnable": "The fresh shell resolves one task and runs tenant activation reconciliation.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.Telephony", + "component": "TelephonyHub", + "kind": "signalr-hub", + "quiesce": "The feature-owned hub route is absent after the tenant pipeline reloads.", + "drain": "Idle contract requires zero connected soft-phone clients.", + "reEnable": "The fresh shell maps one hub route and creates new connection state.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.Omnichannel.Activities", + "component": "AutomatedActivitiesProcessorBackgroundTask", + "kind": "background-task", + "quiesce": "Orchard stops scheduling the feature-owned task after the tenant shell reloads.", + "drain": "Idle contract requires no automated-activity batch to be running.", + "reEnable": "The fresh shell resolves exactly one new task registration.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.Asterisk", + "component": "AsteriskRealtimeVoiceListener", + "kind": "provider-listener", + "quiesce": "Tenant termination cancels the listener and awaits its run task.", + "drain": "Idle listener shutdown completes after cancellation with no provider event in flight.", + "reEnable": "Tenant activation starts one fresh listener guarded against duplicate starts.", + "idleStatus": "verified-idle", + "activeWorkStatus": "deferred-follow-up" + }, + { + "featureId": "CrestApps.OrchardCore.Asterisk.ContactCenterVoice", + "component": "AsteriskContactCenterVoiceProvider", + "kind": "provider-adapter", + "quiesce": "The provider feature closes admission before descriptor mutation; every adapter operation rejects new execution after quiesce.", + "drain": "Disable waits for admitted adapter calls to release their provider-feature leases.", + "reEnable": "The fresh shell reopens admission and the resolver creates scoped adapters normally.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.Asterisk.ContactCenterMedia", + "component": "AsteriskContactCenterVoiceMediaProvider", + "kind": "provider-media", + "quiesce": "The media feature closes admission before descriptor mutation; new media-session creation is rejected.", + "drain": "Each open media session retains a media-feature lease through StopAsync or disposal, and disable waits for cleanup.", + "reEnable": "The fresh shell reopens admission and new scoped media providers can create sessions.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + }, + { + "featureId": "CrestApps.OrchardCore.DialPad.ContactCenterVoice", + "component": "DialPadContactCenterVoiceProvider", + "kind": "provider-adapter", + "quiesce": "The provider feature closes admission before descriptor mutation; every adapter operation rejects new execution after quiesce.", + "drain": "Disable waits for admitted adapter calls to release their provider-feature leases.", + "reEnable": "The fresh shell reopens admission and the resolver creates scoped adapters normally.", + "idleStatus": "verified-idle", + "activeWorkStatus": "implemented-r3" + } + ] +} diff --git a/.github/contact-center/support-matrix.v1.json b/.github/contact-center/support-matrix.v1.json new file mode 100644 index 000000000..6ad25b623 --- /dev/null +++ b/.github/contact-center/support-matrix.v1.json @@ -0,0 +1,150 @@ +{ + "version": "1.0", + "releaseStatus": "blocked-until-r0-r8-pass", + "capacityTier": { + "id": "tier-1", + "maxTenantsPerDeployment": 5, + "maxConcurrentSignedInAgentsPerTenant": 100, + "maxConcurrentVoiceInteractionsPerTenant": 50, + "maxNewInteractionsPerSecondPerTenant": 10, + "maxConcurrentSignedInAgentsPerDeployment": 250, + "maxConcurrentVoiceInteractionsPerDeployment": 100 + }, + "databases": [ + { + "id": "postgresql-16", + "versions": [ + "16.x" + ], + "production": true + }, + { + "id": "sqlite", + "versions": [ + "3.x" + ], + "production": false + } + ], + "topologies": [ + { + "id": "single-node-distributed", + "production": true, + "minimumApplicationNodes": 1, + "maximumApplicationNodes": 1, + "redisBackplaneRequired": true, + "redisDistributedLockRequired": true, + "sharedRelationalDatabaseRequired": true + }, + { + "id": "single-region-multi-node", + "production": false, + "minimumApplicationNodes": 2, + "maximumApplicationNodes": 4, + "redisBackplaneRequired": true, + "redisDistributedLockRequired": true, + "sharedRelationalDatabaseRequired": true + }, + { + "id": "single-node-development", + "production": false, + "minimumApplicationNodes": 1, + "maximumApplicationNodes": 1, + "redisBackplaneRequired": false, + "redisDistributedLockRequired": false, + "sharedRelationalDatabaseRequired": false + } + ], + "providerProfiles": [ + { + "id": "asterisk-ga-core", + "provider": "asterisk", + "allowedCapabilities": [ + "inbound-voice", + "manual-dial", + "preview-dial" + ], + "prohibitedCapabilities": [ + "automated-dial", + "predictive-dial", + "recording", + "monitor", + "whisper", + "barge", + "bidirectional-media" + ] + }, + { + "id": "dialpad-ga-core", + "provider": "dialpad", + "allowedCapabilities": [ + "inbound-voice", + "manual-dial", + "preview-dial", + "call-transfer" + ], + "prohibitedCapabilities": [ + "automated-dial", + "predictive-dial", + "recording", + "monitor", + "whisper", + "barge", + "bidirectional-media" + ] + } + ], + "tenantProfiles": [ + { + "id": "ga-core-asterisk", + "providerProfile": "asterisk-ga-core", + "database": "postgresql-16", + "topology": "single-node-distributed", + "features": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone", + "CrestApps.OrchardCore.ContactCenter.AgentDesktop", + "CrestApps.OrchardCore.ContactCenter.RealTime", + "CrestApps.OrchardCore.ContactCenter.Dialer", + "CrestApps.OrchardCore.Asterisk", + "CrestApps.OrchardCore.Asterisk.ContactCenterVoice" + ] + }, + { + "id": "ga-core-dialpad", + "providerProfile": "dialpad-ga-core", + "database": "postgresql-16", + "topology": "single-node-distributed", + "features": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Availability", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Routing", + "CrestApps.OrchardCore.ContactCenter.Voice", + "CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone", + "CrestApps.OrchardCore.ContactCenter.AgentDesktop", + "CrestApps.OrchardCore.ContactCenter.RealTime", + "CrestApps.OrchardCore.ContactCenter.Dialer", + "CrestApps.OrchardCore.DialPad", + "CrestApps.OrchardCore.DialPad.ContactCenterVoice" + ] + } + ], + "prohibitedCombinations": [ + "production with SQLite", + "production with a single application node without Redis distributed locking and a Redis SignalR backplane", + "production with more than one application node", + "multi-node without a Redis SignalR backplane", + "multi-node without OrchardCore.Redis.Lock distributed locking", + "multi-region active-active", + "more than one voice provider profile in one tenant", + "Power, Progressive, or Predictive dialing", + "recording, monitor, whisper, barge, or bidirectional media", + "Elasticsearch in routing, assignment, provider ingest, or another correctness path", + "unlisted feature, provider, database, or topology combinations" + ] +} diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9c1ee38a2..7b073ef02 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,6 +10,17 @@ CrestApps.OrchardCore is a collection of open-source modules for **Orchard Core **Target Framework**: .NET 10.0 (net10.0) **Architecture**: Modular, multi-tenant application framework +## Contact Center module (active, multi-phase project) + +The **Contact Center** module set is a large, multi-phase orchestration layer built between the CRM (Omnichannel) and Telephony modules. The planning scaffolding (the multi-phase plan documents and their release-tracking ledgers) has been retired now that the work has landed; the short retained record of what shipped and what remains as an operator step is [`.github/contact-center/PRODUCTION-READINESS.md`](contact-center/PRODUCTION-READINESS.md). Consult it for the shipped scope, the MVP boundary, and any outstanding deployment-acceptance steps before doing Contact Center work (anything under `src/**/CrestApps.OrchardCore.ContactCenter*`). + +Key rules for this module set: + +- Respect the layer boundary: **CRM (Omnichannel) owns business work data, Contact Center owns orchestration, Telephony owns media execution.** `OmnichannelActivity` remains the universal work item; `Interaction` is communication history for one attempt and never owns workflow or disposition. +- **Never** write competitor product names in code, comments, identifiers, or public docs. Adopt only generic, industry-standard terminology. +- Group related capabilities into separate, feature-gated Orchard modules/features, the way commercial platforms separate licensed capabilities. +- The runtime/architecture-contract ledgers under `.github/contact-center/` (`support-matrix.v1.json`, `feature-dependency-violations.v1.json`, `feature-lifecycle-contracts.v1.json`) are consumed by ordinary tests and must stay in sync with the code they describe. + ## Working Effectively ### Prerequisites and Environment Setup @@ -136,6 +147,7 @@ dotnet run #### Documentation * Keep public docs and comments accurate and aligned with the code. +* In Markdown docs, do not manually hard-wrap prose at a fixed column. Keep each paragraph or list item on a single line and let the editor/viewer wrap it visually. * Always document: * Every public method (including constructors) with XML `` and `` tags. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index cfe7edc9c..2c73e7495 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,9 +3,17 @@ updates: - package-ecosystem: "nuget" directory: "/" schedule: - interval: "daily" - # Disable version update PRs; only Dependabot security updates create PRs. - open-pull-requests-limit: 0 + interval: "weekly" + # Version update PRs are enabled so patched versions arrive before an advisory is published. + # NuGetAudit fails the build on a known advisory, so a dependency left to drift becomes a hard + # stop rather than a warning. OrchardCore packages stay pinned via the ignore list below. + open-pull-requests-limit: 10 + groups: + # One PR per weekly run for routine bumps keeps review load proportional to risk. + non-major-dependencies: + update-types: + - "minor" + - "patch" ignore: # OrchardCore packages are pinned; never auto-update them. - dependency-name: "OrchardCore.*" @@ -19,11 +27,22 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "daily" - open-pull-requests-limit: 0 + interval: "weekly" + open-pull-requests-limit: 5 + groups: + non-major-dependencies: + update-types: + - "minor" + - "patch" - package-ecosystem: "github-actions" directory: "/" schedule: - interval: "daily" - open-pull-requests-limit: 0 + interval: "weekly" + # Action versions are part of the supply chain: a stale action is an unpatched dependency that runs + # with repository credentials. + open-pull-requests-limit: 5 + groups: + actions: + patterns: + - "*" diff --git a/.github/scripts/license-inventory.py b/.github/scripts/license-inventory.py new file mode 100644 index 000000000..2f2ab6918 --- /dev/null +++ b/.github/scripts/license-inventory.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +"""Generate a third-party license inventory for every package the solution resolves. + +The inventory is built from data the build already produces rather than from a third-party scanner: +`dotnet list package --include-transitive --format json` supplies the resolved graph, and each package's +license is read from the `.nuspec` in the local NuGet cache. That keeps the inventory reproducible and +verifiable offline once the packages are restored. + +Exits non-zero when a package resolves to no discoverable license, so an unreviewed dependency cannot +enter the graph unnoticed. +""" + +import argparse +import json +import os +import pathlib +import subprocess +import sys +import xml.etree.ElementTree as ElementTree + +# Packages produced by this repository are covered by the repository licence itself. +OWN_PACKAGE_PREFIXES = ("CrestApps.",) + +# Packages that ship no licence metadata in their nuspec at all. Each entry records the licence found by +# reading the upstream repository the package itself names, so the inventory stays complete without +# weakening the gate. Remove an entry once the package starts declaring its licence. +REVIEWED_LICENSES = { + "Aspire.Hosting.Elasticsearch": { + "license": "Apache-2.0", + "source": "https://github.com/elastic/elastic-aspire-dotnet (repository named in the package nuspec)", + }, + "SQLitePCLRaw.lib.e_sqlite3": { + "license": "Apache-2.0", + "source": "https://github.com/ericsink/SQLitePCL.raw (upstream project of the SQLitePCLRaw packages)", + }, +} + + +def enumerate_packages(repository_root): + """Return the distinct (id, version) pairs the solution resolves.""" + result = subprocess.run( + ["dotnet", "list", "package", "--include-transitive", "--format", "json"], + cwd=repository_root, + capture_output=True, + text=True, + check=True, + ) + + document = json.loads(result.stdout) + packages = set() + + for project in document.get("projects") or []: + for framework in project.get("frameworks") or []: + for key in ("topLevelPackages", "transitivePackages"): + for package in framework.get(key) or []: + version = package.get("resolvedVersion") or package.get("requestedVersion") + + if package.get("id") and version: + packages.add((package["id"], version)) + + return sorted(packages) + + +def read_license(packages_root, package_id, version): + """Return the license metadata recorded in a package's nuspec, or None when it declares none.""" + nuspec = packages_root / package_id.lower() / version.lower() / f"{package_id.lower()}.nuspec" + + if not nuspec.is_file(): + return None + + try: + root = ElementTree.parse(nuspec).getroot() + except ElementTree.ParseError: + return None + + namespace = "" + + if root.tag.startswith("{"): + namespace = root.tag[: root.tag.index("}") + 1] + + metadata = root.find(f"{namespace}metadata") + + if metadata is None: + return None + + def text(name): + node = metadata.find(f"{namespace}{name}") + + return node.text.strip() if node is not None and node.text else None + + license_node = metadata.find(f"{namespace}license") + expression = None + license_file = None + + if license_node is not None and license_node.text: + if license_node.get("type") == "file": + license_file = license_node.text.strip() + else: + expression = license_node.text.strip() + + return { + "id": package_id, + "version": version, + "license": expression, + "licenseFile": license_file, + "licenseUrl": text("licenseUrl"), + "projectUrl": text("projectUrl"), + "authors": text("authors"), + } + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--output", required=True, help="Path of the JSON inventory to write.") + parser.add_argument( + "--packages-root", + default=os.environ.get("NUGET_PACKAGES", str(pathlib.Path.home() / ".nuget" / "packages")), + help="Root of the local NuGet package cache.", + ) + arguments = parser.parse_args() + + repository_root = pathlib.Path(__file__).resolve().parents[2] + packages_root = pathlib.Path(arguments.packages_root) + entries = [] + unlicensed = [] + + for package_id, version in enumerate_packages(repository_root): + entry = read_license(packages_root, package_id, version) + + if entry is None: + entry = { + "id": package_id, + "version": version, + "license": None, + "licenseFile": None, + "licenseUrl": None, + "projectUrl": None, + "authors": None, + } + + has_license = entry["license"] or entry["licenseFile"] or entry["licenseUrl"] + + if not has_license: + reviewed = REVIEWED_LICENSES.get(package_id) + + if reviewed is not None: + entry["license"] = reviewed["license"] + entry["licenseSource"] = reviewed["source"] + has_license = True + + entries.append(entry) + + is_own = any(package_id.startswith(prefix) for prefix in OWN_PACKAGE_PREFIXES) + + if not has_license and not is_own: + unlicensed.append(f"{package_id} {version}") + + output = pathlib.Path(arguments.output) + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(json.dumps({"packages": entries}, indent=2) + "\n") + + print(f"Wrote {len(entries)} packages to {output}.") + + if unlicensed: + print(f"::error::{len(unlicensed)} package(s) declare no license: {', '.join(sorted(unlicensed))}") + + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 15a391bd5..71da481ca 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -35,7 +35,7 @@ jobs: config-file: ./.github/codeql/codeql-config.yml - name: Build solution - run: dotnet build CrestApps.OrchardCore.slnx -c Release /p:NuGetAudit=false + run: dotnet build CrestApps.OrchardCore.slnx -c Release - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/contact_center_browser_gates.yml b/.github/workflows/contact_center_browser_gates.yml new file mode 100644 index 000000000..800a41274 --- /dev/null +++ b/.github/workflows/contact_center_browser_gates.yml @@ -0,0 +1,64 @@ +name: Contact Center - Browser Gates + +on: + pull_request: + branches: [ main, release/** ] + paths: + - '.github/workflows/contact_center_browser_gates.yml' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'src/**' + - 'tests/**' + push: + branches: [ main, release/** ] + paths: + - '.github/workflows/contact_center_browser_gates.yml' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'src/**' + - 'tests/**' + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + soft-phone-browser: + name: Soft Phone Browser Suite + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Build browser test project + run: >- + dotnet build + -c Release + ./tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/CrestApps.OrchardCore.Telephony.PlaywrightTests.csproj + - name: Install Playwright browsers + # The suite also self-installs on first use, but installing here keeps a browser download + # failure reported as an infrastructure step rather than as a test failure. + run: pwsh tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/bin/Release/net10.0/playwright.ps1 install --with-deps chromium + - name: Run soft phone browser tests + run: >- + dotnet test + -c Release + --no-build + --logger "trx;LogFileName=soft-phone-browser.trx" + --results-directory ${{ github.workspace }}/artifacts/browser-gates + ./tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/CrestApps.OrchardCore.Telephony.PlaywrightTests.csproj + - name: Upload browser gate results + if: always() + uses: actions/upload-artifact@v4 + with: + name: contact-center-browser-gates + path: ${{ github.workspace }}/artifacts/browser-gates + if-no-files-found: error + retention-days: 30 diff --git a/.github/workflows/contact_center_feature_activation_matrix.yml b/.github/workflows/contact_center_feature_activation_matrix.yml new file mode 100644 index 000000000..c79cabb91 --- /dev/null +++ b/.github/workflows/contact_center_feature_activation_matrix.yml @@ -0,0 +1,58 @@ +name: Contact Center - Feature Activation Matrix + +on: + pull_request: + branches: [ main, release/** ] + paths: + - '.github/contact-center/**' + - '.github/workflows/contact_center_feature_activation_matrix.yml' + - 'CrestApps.OrchardCore.slnx' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'src/**' + - 'tests/**' + push: + branches: [ main, release/** ] + paths: + - '.github/contact-center/**' + - '.github/workflows/contact_center_feature_activation_matrix.yml' + - 'CrestApps.OrchardCore.slnx' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + - 'src/**' + - 'tests/**' + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + fresh-tenant-activation: + name: Fresh Tenant Activation + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Run Contact Center feature activation matrix + run: >- + dotnet test + -c Release + --logger "trx;LogFileName=contact-center-feature-activation.trx" + --results-directory ./artifacts/TestResults/ContactCenterFeatureActivation + ./tests/CrestApps.OrchardCore.ContactCenter.FeatureActivationTests/CrestApps.OrchardCore.ContactCenter.FeatureActivationTests.csproj + - name: Upload feature activation evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: contact-center-feature-activation-results + path: artifacts/TestResults/ContactCenterFeatureActivation + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/contact_center_operations_gates.yml b/.github/workflows/contact_center_operations_gates.yml new file mode 100644 index 000000000..985d44e48 --- /dev/null +++ b/.github/workflows/contact_center_operations_gates.yml @@ -0,0 +1,78 @@ +name: Contact Center - Operations Gates + +on: + pull_request: + branches: [ main, release/** ] + paths: + - '.github/contact-center/**' + - '.github/workflows/contact_center_operations_gates.yml' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + # Any source change can affect the distributed contract. An explicit module list previously + # omitted ContactCenter.Core, ContactCenter.Abstractions, Asterisk, DialPad, and Omnichannel.Core, + # so a PR touching only Core silently skipped the only distributed gate. + - 'src/**' + - 'tests/**' + push: + branches: [ main, release/** ] + paths: + - '.github/contact-center/**' + - '.github/workflows/contact_center_operations_gates.yml' + - 'Directory.Build.props' + - 'Directory.Build.targets' + - 'Directory.Packages.props' + # Any source change can affect the distributed contract. An explicit module list previously + # omitted ContactCenter.Core, ContactCenter.Abstractions, Asterisk, DialPad, and Omnichannel.Core, + # so a PR touching only Core silently skipped the only distributed gate. + - 'src/**' + - 'tests/**' + +permissions: + contents: read + +env: + CONTACT_CENTER_REDIS_CONFIGURATION: localhost:6379,abortConnect=false + # The query-plan budget is asserted on the engine single-node production deployments run. SQLite's planner is + # not PostgreSQL's, so a statement that seeks an index on one can sequentially scan on the other, and the + # results are identical either way: only the plan on the deployed engine is evidence. + CONTACT_CENTER_POSTGRES_CONNECTION: Server=localhost;Port=5432;User Id=postgres;Password=postgres;Database=postgres; + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + redis-backplane-two-node: + name: Redis Backplane Two-Node + runs-on: ubuntu-latest + timeout-minutes: 10 + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + postgres: + image: postgres:16-alpine + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Run distributed Contact Center tests + run: >- + dotnet test + -c Release + ./tests/CrestApps.OrchardCore.ContactCenter.DistributedTests/CrestApps.OrchardCore.ContactCenter.DistributedTests.csproj diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml index d9c87bf3f..36ccd3dc1 100644 --- a/.github/workflows/main_ci.yml +++ b/.github/workflows/main_ci.yml @@ -27,6 +27,9 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v6 + with: + # The migration additive-only gate checks never-released justifications against stable release tags. + fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: "15" @@ -37,7 +40,7 @@ jobs: - name: Build # See pr_ci.yml for the reason why we disable NuGet audit warnings. run: | - dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true - name: Unit Tests run: | dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml index 2d17eff3f..e87721382 100644 --- a/.github/workflows/pr_ci.yml +++ b/.github/workflows/pr_ci.yml @@ -20,6 +20,9 @@ jobs: name: Build & Test steps: - uses: actions/checkout@v6 + with: + # The migration additive-only gate checks never-released justifications against stable release tags. + fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: "15" @@ -33,7 +36,7 @@ jobs: # treat warnings as errors could break anytime, without us changing the code. This prevents that. Treating them as # warnings and other better approaches don't work, see https://github.com/OrchardCMS/OrchardCore/pull/16317. run: | - dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true - name: Unit Tests run: | dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj diff --git a/.github/workflows/preview_ci.yml b/.github/workflows/preview_ci.yml index 381acd880..6c3322640 100644 --- a/.github/workflows/preview_ci.yml +++ b/.github/workflows/preview_ci.yml @@ -16,6 +16,9 @@ jobs: name: Build, Test, Deploy steps: - uses: actions/checkout@v6 + with: + # The migration additive-only gate checks never-released justifications against stable release tags. + fetch-depth: 0 - name: Check if should publish id: check-publish shell: pwsh @@ -40,7 +43,7 @@ jobs: if: steps.check-publish.outputs.should-publish == 'true' # See pr_ci.yml for the reason why we disable NuGet audit warnings. run: | - dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false + dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true - name: Unit Tests if: steps.check-publish.outputs.should-publish == 'true' run: | diff --git a/.github/workflows/release_ci.yml b/.github/workflows/release_ci.yml index 3d7eb0cd3..96f200091 100644 --- a/.github/workflows/release_ci.yml +++ b/.github/workflows/release_ci.yml @@ -15,7 +15,37 @@ env: DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true DOTNET_CLI_TELEMETRY_OPTOUT: true jobs: + # The distributed gate needs a Redis service container, which GitHub Actions only supports on Linux + # runners, so it cannot join the cross-platform matrix below. Publishing depends on it so a release + # can never ship without the distributed contract having passed. + distributed_test: + runs-on: ubuntu-latest + name: Distributed Tests + timeout-minutes: 20 + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + env: + CONTACT_CENTER_REDIS_CONFIGURATION: localhost:6379,abortConnect=false + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: | + 10.0.x + - name: Contact Center distributed tests + run: | + dotnet test -c Release ./tests/CrestApps.OrchardCore.ContactCenter.DistributedTests/CrestApps.OrchardCore.ContactCenter.DistributedTests.csproj + test: + needs: distributed_test runs-on: ${{ matrix.os }} name: Build, Test, Deploy strategy: @@ -31,6 +61,9 @@ jobs: echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT shell: bash - uses: actions/checkout@v6 + with: + # The migration additive-only gate checks never-released justifications against stable release tags. + fetch-depth: 0 - uses: actions/setup-node@v6 with: node-version: "15" @@ -42,13 +75,18 @@ jobs: if: matrix.os == 'ubuntu-latest' run: echo "BuildNumber=$(( $GITHUB_RUN_NUMBER ))" >> $GITHUB_ENV - name: Build - # NuGetAudit is intentionally not disabled here like it is for other CI builds, because we need to address any - # vulnerable packages before releasing a new version. run: | dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true -p:Version=${{ steps.get_version.outputs.VERSION }} - name: Unit Tests run: | - dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj + dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj + - name: Contact Center feature activation tests + run: | + dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.ContactCenter.FeatureActivationTests/CrestApps.OrchardCore.ContactCenter.FeatureActivationTests.csproj + - name: Telephony browser tests + if: matrix.os == 'ubuntu-latest' + run: | + dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/CrestApps.OrchardCore.Telephony.PlaywrightTests.csproj - name: Deploy release NuGet packages if: matrix.os == 'ubuntu-latest' run: | diff --git a/.github/workflows/supply_chain.yml b/.github/workflows/supply_chain.yml new file mode 100644 index 000000000..4c8871468 --- /dev/null +++ b/.github/workflows/supply_chain.yml @@ -0,0 +1,181 @@ +name: Supply Chain + +on: + pull_request: + branches: [ main, release/** ] + push: + branches: [ main, release/** ] + +permissions: + contents: read + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true + +jobs: + # The build already fails on advisories because NuGetAudit is enabled with the audit warnings promoted + # to errors. This job reports the whole dependency graph in one place so a reviewer can see what is + # resolved rather than only what is broken, and it fails independently of compiler diagnostics. + vulnerability-audit: + name: Dependency Vulnerability Audit + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Restore + run: dotnet restore + - name: Audit transitive and direct packages + run: | + set -o pipefail + dotnet list package --vulnerable --include-transitive 2>&1 | tee audit.txt + if grep -qiE '\bhas the following vulnerable packages' audit.txt; then + echo "::error::Vulnerable packages detected. Pin the patched version in Directory.Packages.props." + exit 1 + fi + echo "No vulnerable packages resolved." + - name: Upload audit report + if: always() + uses: actions/upload-artifact@v4 + with: + name: dependency-audit + path: audit.txt + if-no-files-found: error + retention-days: 30 + + secret-scan: + name: Secret Scan + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + # gitleaks needs history to scan commits rather than only the working tree. + fetch-depth: 0 + - name: Install gitleaks + # The published action requires a paid licence for organisation repositories, so the open source + # binary is used directly and pinned to an exact release. + run: | + set -o pipefail + curl -sSL -o gitleaks.tar.gz https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz + tar -xzf gitleaks.tar.gz gitleaks + chmod +x gitleaks + - name: Scan history for secrets + run: ./gitleaks detect --source . --config .gitleaks.toml --redact --report-format sarif --report-path gitleaks.sarif --exit-code 1 + - name: Upload secret scan report + if: always() + uses: actions/upload-artifact@v4 + with: + name: gitleaks-report + path: gitleaks.sarif + if-no-files-found: error + retention-days: 30 + + license-inventory: + name: Third-Party License Inventory + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Restore + run: dotnet restore + - name: Generate license inventory + # Built from the resolved graph and the packages' own nuspec metadata rather than a third-party + # scanner, so the inventory is reproducible and fails when a dependency declares no licence. + run: python3 .github/scripts/license-inventory.py --output artifacts/licenses/third-party-licenses.json + - name: Upload license inventory + if: always() + uses: actions/upload-artifact@v4 + with: + name: third-party-licenses + path: artifacts/licenses + if-no-files-found: error + retention-days: 90 + + sbom: + name: Software Bill of Materials + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: 10.0.x + - name: Build + run: dotnet build -c Release + - name: Generate SBOM + run: | + set -o pipefail + curl -sSL -o sbom-tool https://github.com/microsoft/sbom-tool/releases/download/v4.1.5/sbom-tool-linux-x64 + chmod +x sbom-tool + mkdir -p artifacts/sbom + ./sbom-tool generate \ + -b . \ + -bc . \ + -pn CrestApps.OrchardCore \ + -pv ${{ github.sha }} \ + -ps CrestApps \ + -nsb https://crestapps.com/${{ github.repository }} \ + -m artifacts/sbom + - name: Upload SBOM + if: always() + uses: actions/upload-artifact@v4 + with: + name: sbom + path: artifacts/sbom + if-no-files-found: error + retention-days: 90 + + container-scan: + name: Container Image Scan + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + security-events: write + strategy: + fail-fast: false + matrix: + include: + # The Asterisk image is built from the repository, so it is scanned as built rather than pulled. + - name: asterisk + build: true + image: crestapps/asterisk-webrtc:ci-scan + - name: redis + build: false + image: redis:7.4.2 + - name: coturn + build: false + image: coturn/coturn:4.6.3 + steps: + - uses: actions/checkout@v6 + - name: Build the Asterisk image + if: matrix.build + run: docker build -t ${{ matrix.image }} src/Startup/CrestApps.Aspire.AppHost/Asterisk + # The gate blocks only on images this repository builds, because those are the only findings it can + # actually remediate. Pulled upstream images are still scanned and still published to code scanning + # through the SARIF upload below, but they cannot fail the build: a CVE in `coturn/coturn` or `redis` + # is fixed by the upstream maintainer, and for coturn in particular every tag checked (4.6.3, 4.7.0, + # alpine) still reports CRITICAL/HIGH findings, so blocking on them would leave the pipeline + # permanently red without improving security. Raise the pinned tags when upstream ships a cleaner one. + - name: Scan ${{ matrix.name }} + uses: aquasecurity/trivy-action@v0.36.0 + with: + image-ref: ${{ matrix.image }} + format: sarif + output: trivy-${{ matrix.name }}.sarif + severity: CRITICAL,HIGH + ignore-unfixed: true + exit-code: ${{ matrix.build && '1' || '0' }} + - name: Upload scan results + if: always() + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-${{ matrix.name }}.sarif + category: trivy-${{ matrix.name }} diff --git a/.github/workflows/validate_docs.yml b/.github/workflows/validate_docs.yml index c86f89c2d..fe9fb961d 100644 --- a/.github/workflows/validate_docs.yml +++ b/.github/workflows/validate_docs.yml @@ -3,7 +3,9 @@ name: Validate Documentation on: pull_request: paths: - - 'src/CrestApps.Docs/**' + - '.github/workflows/validate_docs.yml' + - 'src/**' + - 'tests/**' permissions: contents: read @@ -29,7 +31,10 @@ jobs: run: npm ci - name: Build and validate links + # `set -o pipefail` is required: without it the pipeline exit status is `tee`'s, so a failing + # docusaurus build reported success and only broken links were ever caught. run: | + set -o pipefail npx docusaurus build 2>&1 | tee build-output.txt if grep -qi "broken link" build-output.txt; then echo "" diff --git a/.github/workflows/validate_prompts.yml b/.github/workflows/validate_prompts.yml index 985ff7fca..0fca74dd9 100644 --- a/.github/workflows/validate_prompts.yml +++ b/.github/workflows/validate_prompts.yml @@ -24,7 +24,7 @@ jobs: 10.0.x - name: Build prompt validation tool run: | - dotnet build -c Release src/Common/CrestApps.Core.Templates/CrestApps.Core.Templates.csproj /p:NuGetAudit=false + dotnet build -c Release src/Common/CrestApps.Core.Templates/CrestApps.Core.Templates.csproj - name: Validate template file structure shell: bash run: | diff --git a/.gitignore b/.gitignore index 5aa6a12e0..588bba933 100644 --- a/.gitignore +++ b/.gitignore @@ -472,3 +472,9 @@ src/docs/node_modules/ src/docs/build/ src/docs/.docusaurus/ src/docs/.cache-loader/ + +# Public API approval output written when a recorded surface no longer matches. +*.received.txt + +# Playwright MCP planning scaffolding (not part of the shipped product). +.playwright-mcp/ diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 000000000..12b27ebca --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,16 @@ +# Secret scanning configuration. +# +# The bundled rule set is extended rather than replaced, so new detectors arrive with gitleaks upgrades. +# Allowlist entries must stay as narrow as possible: prefer an exact literal over a path, and a path over +# a rule. Never allowlist a rule wholesale. +[extend] +useDefault = true + +[allowlist] +description = "Fixture values that are not credentials." +regexTarget = "match" +regexes = [ + # Literal used by the API key authentication handler tests. It authenticates nothing: the tests + # construct the handler with this value as both the configured and the presented key. + '''test-api-key-12345''', +] diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index e665832a5..5190141b1 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -17,6 +17,7 @@ + @@ -28,6 +29,7 @@ + @@ -35,6 +37,7 @@ + @@ -63,7 +66,9 @@ + + @@ -95,12 +100,15 @@ + + + diff --git a/Directory.Build.props b/Directory.Build.props index 3c3d902fb..a019da691 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -17,9 +17,37 @@ $(DefaultTargetFramework) + + + true + + + + + true + all + low + $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904 + + - $(CommonTargetFrameworks) + $(CommonTargetFrameworks) + $(CommonTargetFrameworks) enable Mike Alhayek CrestApps diff --git a/Directory.Packages.props b/Directory.Packages.props index f7ecda378..a0813cb11 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -32,6 +32,7 @@ + @@ -42,6 +43,7 @@ + @@ -80,6 +82,7 @@ + @@ -144,6 +147,7 @@ + @@ -157,8 +161,10 @@ + + diff --git a/gulpfile.js b/gulpfile.js index 496688fc4..f5ade377f 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -99,7 +99,7 @@ gulp.task('default', gulp.series(['build'])); */ function getAssetGroups() { - var assetManifestPaths = glob.sync("./src/{Modules,Resources,Themes}/*/Assets.json", {}); + var assetManifestPaths = glob.sync("./src/{Modules,Resources,Startup,Themes}/*/Assets.json", {}); var assetGroups = []; assetManifestPaths.forEach(function (assetManifestPath) { var assetManifest = require("./" + assetManifestPath); diff --git a/package-lock.json b/package-lock.json index 3e7b3bc8e..76702df09 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "hasInstallScript": true, "dependencies": { "chart.js": "^4.5.1", - "extendable-media-recorder": "^9.2.36" + "extendable-media-recorder": "^9.2.36", + "sip.js": "0.21.2" }, "devDependencies": { "@babel/core": "^7.29.7", @@ -6999,6 +7000,15 @@ "node": ">= 0.4" } }, + "node_modules/sip.js": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/sip.js/-/sip.js-0.21.2.tgz", + "integrity": "sha512-tSqTcIgrOd2IhP/rd70JablvAp+fSfLSxO4hGNY6LkWRY1SKygTO7OtJEV/BQb8oIxtMRx0LE7nUF2MaqGbFzA==", + "license": "MIT", + "engines": { + "node": ">=10.0" + } + }, "node_modules/source-map": { "version": "0.7.6", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", diff --git a/package.json b/package.json index da033f93e..ac9151826 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,8 @@ }, "dependencies": { "chart.js": "^4.5.1", - "extendable-media-recorder": "^9.2.36" + "extendable-media-recorder": "^9.2.36", + "sip.js": "0.21.2" }, "overrides": { "brace-expansion": "^5.0.5", diff --git a/src/Abstractions/CrestApps.OrchardCore.Abstractions/Configuration/TenantOptionsStartupValidator.cs b/src/Abstractions/CrestApps.OrchardCore.Abstractions/Configuration/TenantOptionsStartupValidator.cs new file mode 100644 index 000000000..d1a67fff7 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Abstractions/Configuration/TenantOptionsStartupValidator.cs @@ -0,0 +1,97 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Configuration; + +/// +/// Runs every registration when a tenant is +/// activated, so an invalid configuration stops the tenant instead of surfacing later as a failure in whatever +/// request first happened to read the option. +/// +/// +/// +/// records its validators against +/// , which the generic host invokes once, against the root container, while +/// starting. Orchard builds a separate container per tenant and nothing in the framework invokes +/// for those containers, so a ValidateOnStart call written inside a +/// module's Startup has no effect on its own: the rule only fires when something first resolves the +/// option, which may be minutes into serving traffic or never. This type closes that gap by invoking the +/// validator at the one moment that corresponds to "start" for a tenant. +/// +/// +/// Failure is deliberately fatal to activation. The alternative - recording the fault and continuing - is right +/// for a dependency that may be transiently absent, but wrong for configuration, which is static, is fixed by +/// editing a configuration source rather than through the admin UI, and would otherwise let a deployment run +/// indefinitely on values an operator believes were rejected. +/// +/// +public sealed class TenantOptionsStartupValidator : ModularTenantEvents +{ + private readonly IServiceProvider _serviceProvider; + private readonly ShellSettings _shellSettings; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant service provider the startup validator is resolved from. + /// The tenant shell settings, used to name the tenant in the failure log. + /// The logger. + public TenantOptionsStartupValidator( + IServiceProvider serviceProvider, + ShellSettings shellSettings, + ILogger logger) + { + _serviceProvider = serviceProvider; + _shellSettings = shellSettings; + _logger = logger; + } + + /// + public override Task ActivatingAsync() + { + var validator = _serviceProvider.GetService(); + + // Absent when no module in this tenant registered a ValidateOnStart rule, which is a valid state and + // not a failure to report. + if (validator is null) + { + return Task.CompletedTask; + } + + try + { + validator.Validate(); + } + catch (OptionsValidationException ex) + { + if (_logger.IsEnabled(LogLevel.Critical)) + { + _logger.LogCritical( + "Tenant '{TenantName}' has an invalid configuration for options '{OptionsType}' and cannot be activated: {Failures}", + _shellSettings.Name, + ex.OptionsType?.Name ?? "unknown", + string.Join(" ", ex.Failures)); + } + + throw; + } + catch (AggregateException ex) + { + if (_logger.IsEnabled(LogLevel.Critical)) + { + _logger.LogCritical( + "Tenant '{TenantName}' has an invalid configuration and cannot be activated: {Failures}", + _shellSettings.Name, + string.Join(" ", ex.InnerExceptions.Select(inner => inner.Message))); + } + + throw; + } + + return Task.CompletedTask; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Abstractions/Configuration/TenantOptionsValidationServiceCollectionExtensions.cs b/src/Abstractions/CrestApps.OrchardCore.Abstractions/Configuration/TenantOptionsValidationServiceCollectionExtensions.cs new file mode 100644 index 000000000..34fdb7608 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Abstractions/Configuration/TenantOptionsValidationServiceCollectionExtensions.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Configuration; + +/// +/// Service collection extensions that enable tenant-scoped options validation. +/// +public static class TenantOptionsValidationServiceCollectionExtensions +{ + /// + /// Ensures every ValidateOnStart rule registered by any feature in this tenant is evaluated when the + /// tenant activates, rather than when the option is first read. + /// + /// + /// Safe to call from more than one feature: the registration is de-duplicated, so the validation runs once + /// per tenant activation no matter how many features ask for it. + /// + /// The service collection. + /// The same service collection, for chaining. + /// Thrown when is . + public static IServiceCollection ValidateTenantOptionsOnActivation(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddEnumerable( + ServiceDescriptor.Scoped()); + + return services; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs new file mode 100644 index 000000000..a77c05567 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -0,0 +1,819 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Contains constant values shared across the Contact Center module set. +/// +public static class ContactCenterConstants +{ + /// + /// The YesSql collection name used to store Contact Center documents in isolation from other modules. + /// + public const string CollectionName = "ContactCenter"; + + /// + /// The current schema version applied to newly published Contact Center domain events. + /// + public const int CurrentEventSchemaVersion = 1; + + /// + /// The stable, versioned identifier of the daily event-count metrics projection. It namespaces the + /// projection's deduplication markers and replay checkpoint, so its value must never change for a given + /// projection logic version. + /// + public const string MetricsProjectionHandlerId = "ContactCenter/MetricsProjection/v1"; + + /// + /// The projection logic version of the daily event-count metrics projection. Bumping it forces a full + /// replay because the stored checkpoint version no longer matches. + /// + public const int MetricsProjectionVersion = 1; + + /// + /// Identifies a system actor for events that are not originated by an interactive user. + /// + public const string SystemActor = "system"; + + /// + /// The maximum stored length, in characters, of a canonicalized telephony provider name. The same canonical + /// value is persisted across several Contact Center index tables, so every migration that stores it must pin + /// this width to keep the column definitions consistent and avoid a value that fits in one table but truncates + /// in another. + /// + public const int ProviderNameLength = 128; + + /// + /// Contains the identifiers used to register and select the Contact Center operational health checks. + /// The readiness endpoint selects checks by , so a registration that + /// omits the tag silently disappears from readiness. Both sides must reference these constants. + /// + public static class HealthChecks + { + /// + /// The tag applied to every Contact Center health check, used to distinguish them from checks + /// contributed by other modules. + /// + public const string AreaTag = "contactcenter"; + + /// + /// The tag applied to node-local readiness checks. The readiness probe selects this tag and nothing + /// else, so a check that observes a condition every node shares must never carry it: gating rotation on + /// such a condition drains the whole fleet at once. + /// + /// + /// The tag is namespaced rather than the conventional bare ready because the probe selects by tag + /// across the whole shell container. A bare tag would silently enlist any other module's readiness check + /// — including a shared-infrastructure check such as a Redis backplane probe — and reintroduce exactly + /// the fleet-wide drain the split exists to prevent. + /// + public const string ReadyTag = "contactcenter-ready"; + + /// + /// The tag applied to checks that consult an external dependency. These are alerting signals surfaced + /// through the dependency probe and must never gate load balancer rotation. + /// + public const string DependencyTag = "contactcenter-dependency"; + + /// + /// The registration name of the node-local readiness check. + /// + public const string NodeCheckName = "contactcenter-node"; + + /// + /// The registration name of the opt-in node-local serving gate. + /// + public const string NodeServingCheckName = "contactcenter-node-serving"; + + /// + /// The registration name of the durable-storage reachability check. + /// + public const string StorageCheckName = "contactcenter-storage"; + + /// + /// The registration name of the event outbox backlog check. + /// + public const string OutboxCheckName = "contactcenter-outbox"; + + /// + /// The registration name of the live active-call gauge check that surfaces the count of call sessions + /// that have not yet ended as health data. It reports the number of live calls an operator would + /// interrupt by draining a node rather than gating a probe, so it is healthy whenever the count can be + /// read. + /// + public const string ActiveCallsCheckName = "contactcenter-active-calls"; + + /// + /// The registration name of the queued-interaction backlog gauge check that surfaces the count of + /// interactions waiting for an agent across every queue as health data. It reports the routed work still + /// waiting rather than gating a probe, so it is healthy whenever the count can be read. + /// + public const string QueueBacklogCheckName = "contactcenter-queue-backlog"; + + /// + /// The registration name of the provider ingress inbox backlog check. + /// + public const string ProviderIngressCheckName = "contactcenter-provider-ingress"; + + /// + /// The registration name of the deployment topology check. + /// + /// + /// This is one of two readiness checks that observe a condition every node shares (the other is + /// ). The exception is deliberate: a topology violation + /// cannot self-heal, and serving traffic from a deployment that does not satisfy its declared support + /// contract is the failure being prevented rather than collateral damage from preventing it. + /// + public const string TopologyCheckName = "contactcenter-topology"; + + /// + /// The registration name of the base-voice audio verification check. + /// + /// + /// Like , this readiness check observes a deployment-wide condition rather + /// than node-local state, and the exception is deliberate for the same reason: whether the base-voice + /// media path was proven is fixed infrastructure that no amount of waiting repairs, so a production host + /// that has not acknowledged the verification withholds readiness rather than serve an unproven voice + /// path. + /// + public const string BaseVoiceVerificationCheckName = "contactcenter-base-voice-verification"; + + /// + /// The registration name of the distributed-lock acquire/release probe. + /// + /// + /// A dependency probe that proves the resolved IDistributedLock can be taken and released within a + /// bounded time. In a production topology this exercises the Redis-backed lock end to end; in a + /// development topology it exercises the process-local lock and is trivially satisfied. + /// + public const string DistributedLockCheckName = "contactcenter-distributed-lock"; + + /// + /// The registration name of the Redis connectivity probe. + /// + /// + /// A dependency probe that pings the Redis connection shared by the distributed lock and the SignalR + /// backplane. It reports healthy with nothing probed when Redis is not enabled, because a deployment + /// that declares no Redis dependency has none to be unhealthy about; the topology validator, not this + /// probe, decides whether Redis is required. + /// + public const string RedisConnectivityCheckName = "contactcenter-redis"; + + /// + /// The registration name of the SignalR backplane publish/subscribe round-trip probe. + /// + /// + /// Redis connectivity alone does not prove the backplane works: a pub/sub round-trip on a dedicated, + /// tenant-qualified channel is the only signal that a message published on one node would reach the + /// subscribers on another. Reports healthy with nothing probed when Redis is not enabled. + /// + public const string BackplaneCheckName = "contactcenter-backplane"; + + /// + /// The default path of the process liveness probe. It reports only that the process can serve a + /// request and never consults a dependency, so a failing database or a growing backlog cannot trigger a + /// restart. + /// + /// + /// Liveness is answered by host middleware placed ahead of the Orchard Core pipeline, not by a tenant + /// feature. A route mapped inside a shell answers 404 whenever that tenant is disabled, renamed, or + /// fails to start, and an orchestrator reads 404 as a probe failure — so a tenant-scoped liveness route + /// restarts an otherwise healthy process for a tenant-level problem. + /// + /// The path deliberately avoids /health/live, which is the default route of the + /// OrchardCore.HealthChecks module. Host middleware short-circuits before routing, so taking that + /// path would silently shadow that module's endpoint for every tenant in the process — including + /// tenants that never enable Contact Center — and answer a permanent 200 Healthy in its place. + /// Shadowing a health endpoint with an unconditional success is a worse failure than any it could + /// report. + /// + /// + public const string ProcessLivenessPath = "/health/process"; + + /// + /// The route of the readiness probe. It aggregates every check tagged , which is + /// only node-local state, and reports whether this node should receive traffic for this tenant. + /// + public const string ReadinessRoute = "api/contact-center/health/ready"; + + /// + /// The route of the dependency probe. It aggregates every check tagged and + /// reports per-check detail, so it requires authorization and must never be wired to an orchestrator + /// probe or a load balancer. + /// + public const string DependenciesRoute = "api/contact-center/health/dependencies"; + } + + /// + /// Contains the feature identifiers exposed by the Contact Center module set. + /// + public static class Feature + { + /// + /// The identifier of the base Contact Center feature. + /// + public const string Area = "CrestApps.OrchardCore.ContactCenter"; + + /// + /// The identifier of the Contact Center administration feature, which enables every capability's + /// administration screens together. + /// + public const string Admin = "CrestApps.OrchardCore.ContactCenter.Admin"; + + /// + /// The identifier of the agent administration screens. + /// + public const string AgentsAdmin = "CrestApps.OrchardCore.ContactCenter.Agents.Admin"; + + /// + /// The identifier of the queue, skill, and business-hours administration screens. + /// + public const string QueuesAdmin = "CrestApps.OrchardCore.ContactCenter.Queues.Admin"; + + /// + /// The identifier of the outbound dialer administration screens. + /// + public const string DialerAdmin = "CrestApps.OrchardCore.ContactCenter.Dialer.Admin"; + + /// + /// The identifier of the recording and monitoring settings screens. + /// + public const string RecordingAdmin = "CrestApps.OrchardCore.ContactCenter.Recording.Admin"; + + /// + /// The identifier of the inbound entry-point administration screens. + /// + public const string EntryPointsAdmin = "CrestApps.OrchardCore.ContactCenter.EntryPoints.Admin"; + + /// + /// The identifier of the agent, presence, and queue-membership feature. + /// + public const string Agents = "CrestApps.OrchardCore.ContactCenter.Agents"; + + /// + /// The identifier of the agent availability, presence, and durable session feature. + /// + public const string Availability = "CrestApps.OrchardCore.ContactCenter.Availability"; + + /// + /// The identifier of the queue and reservation feature. + /// + public const string Queues = "CrestApps.OrchardCore.ContactCenter.Queues"; + + /// + /// The identifier of the routing strategy and assignment orchestration feature. + /// + public const string Routing = "CrestApps.OrchardCore.ContactCenter.Routing"; + + /// + /// The identifier of the outbound dialer feature. + /// + public const string Dialer = "CrestApps.OrchardCore.ContactCenter.Dialer"; + + /// + /// The identifier of the outbound dialing compliance feature. + /// + public const string Compliance = "CrestApps.OrchardCore.ContactCenter.Compliance"; + + /// + /// The identifier of automated power and progressive dialing feature. + /// + public const string DialerAutomated = "CrestApps.OrchardCore.ContactCenter.Dialer.Automated"; + + /// + /// The identifier of the inbound voice integration feature. + /// + public const string Voice = "CrestApps.OrchardCore.ContactCenter.Voice"; + + /// + /// The identifier of the Contact Center bidirectional voice-media feature. + /// + public const string VoiceMedia = "CrestApps.OrchardCore.ContactCenter.Voice.Media"; + + /// + /// The identifier of the inbound voice entry-point qualification feature. + /// + public const string EntryPoints = "CrestApps.OrchardCore.ContactCenter.EntryPoints"; + + /// + /// The identifier of the Contact Center recording orchestration feature. + /// + public const string Recording = "CrestApps.OrchardCore.ContactCenter.Recording"; + + /// + /// The identifier of the Contact Center soft-phone integration feature. + /// + public const string VoiceSoftPhone = "CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone"; + + /// + /// The identifier of the CRM-integrated agent desktop feature. + /// + public const string AgentDesktop = "CrestApps.OrchardCore.ContactCenter.AgentDesktop"; + + /// + /// The identifier of the real-time supervisor dashboard and monitoring feature. + /// + public const string Supervision = "CrestApps.OrchardCore.ContactCenter.Supervision"; + + /// + /// The identifier of the shared Contact Center real-time transport feature. + /// + public const string RealTime = "CrestApps.OrchardCore.ContactCenter.RealTime"; + + /// + /// The identifier of the reporting and analytics feature. + /// + public const string Analytics = "CrestApps.OrchardCore.ContactCenter.Analytics"; + + /// + /// The identifier of the Orchard Core Workflows integration feature. + /// + public const string Workflows = "CrestApps.OrchardCore.ContactCenter.Workflows"; + } + + /// + /// Contains the well-known names of the Contact Center components that originate domain events. + /// + public static class Components + { + /// + /// The interaction management component. + /// + public const string Interactions = "Interactions"; + + /// + /// The queue management component. + /// + public const string Queues = "Queues"; + + /// + /// The routing engine component. + /// + public const string Routing = "Routing"; + + /// + /// The agent and presence management component. + /// + public const string Agents = "Agents"; + + /// + /// The voice channel adapter component. + /// + public const string Voice = "Voice"; + + /// + /// The outbound dialer component. + /// + public const string Dialer = "Dialer"; + + /// + /// The call session management component. + /// + public const string CallSessions = "CallSessions"; + + /// + /// The real-time agent and supervisor experience component. + /// + public const string RealTime = "RealTime"; + } + + /// + /// Contains stable metadata keys shared across Contact Center command boundaries. + /// + public static class CommandMetadata + { + /// + /// Identifies the idempotent provider command associated with an interaction. + /// + public const string CommandId = "providerCommandId"; + + /// + /// Identifies the monotonic fence token for the current provider-command claim. + /// + public const string FenceToken = "providerCommandFence"; + } + + /// + /// Contains stable provider-result metadata keys describing a captured recording, shared between voice providers + /// and the recording service that persists them onto the interaction. + /// + public static class RecordingMetadata + { + /// + /// Identifies the recording as the provider itself names it, used as the retrieval reference when the + /// provider reports no durable storage reference. + /// + public const string ProviderRecordingId = "providerRecordingId"; + + /// + /// Identifies the durable storage reference used to retrieve the recording media. + /// + public const string StorageReference = "storageReference"; + + /// + /// Identifies the recording media format. + /// + public const string Format = "format"; + + /// + /// Identifies the recording duration, in seconds, when the provider reports it. + /// + public const string DurationSeconds = "durationSeconds"; + + /// + /// Identifies the provider-relative path used to retrieve the stored recording. + /// + public const string RetrievalPath = "retrievalPath"; + } + + /// + /// Contains stable machine-readable reason codes describing why a recording governance policy denied recording, + /// shared between the governance policy and the recording service that records them on denial events. + /// + public static class RecordingGovernanceDenyReason + { + /// + /// Recording is disabled for the tenant by the recording governance policy. + /// + public const string RecordingDisabled = "recordingDisabled"; + + /// + /// Recording requires explicit party consent that has not been captured on the interaction. + /// + public const string ConsentRequired = "consentRequired"; + } + + /// + /// Contains stable machine-readable reason codes describing why a recording governance policy denied a + /// right-to-erasure request, shared between the erasure service and the denial events it publishes. + /// + public static class RecordingErasureDenyReason + { + /// + /// The interaction has no captured recording reference to erase. + /// + public const string NoRecording = "noRecording"; + + /// + /// The recording is under legal hold and is exempt from erasure until the hold is released. + /// + public const string LegalHold = "legalHold"; + } + + /// + /// Contains stable machine-readable reason codes describing why the recording media is being erased, carried + /// on the erasure event so downstream media deletion and audit can attribute the cause. + /// + public static class RecordingErasureReason + { + /// + /// The recording media is being erased because its retention window elapsed. + /// + public const string Retention = "retention"; + } + + /// + /// Contains stable request-metadata keys the transfer service passes to a voice provider so the provider can + /// execute a resolved transfer destination without receiving raw client input. + /// + public static class TransferMetadata + { + /// + /// Identifies the Orchard user id of the destination agent for an agent transfer, so a provider can resolve + /// that agent's live endpoint. The client never supplies this; the transfer service resolves it server-side. + /// + public const string AgentUserId = "transferAgentUserId"; + } + + /// + /// Contains stable request-metadata keys the conference service passes to a voice provider so the provider can + /// add a resolved conference participant without receiving raw client input. + /// + public static class ConferenceMetadata + { + /// + /// Identifies the Orchard user id of the agent to add to a live conversation as a conference participant, so + /// a provider can resolve that agent's live endpoint. The client never supplies this; the conference service + /// resolves it server-side. + /// + public const string AgentUserId = "conferenceAgentUserId"; + } + + /// + /// Contains stable request- and result-metadata keys used to drive an attended (consultative) transfer across + /// its begin, complete, and cancel phases so a provider can execute a resolved consult without receiving raw + /// client input. + /// + public static class AttendedTransferMetadata + { + /// + /// Identifies the Orchard user id of the destination agent to consult with, so a provider can resolve that + /// agent's live endpoint. The client never supplies this; the transfer service resolves it server-side. + /// + public const string AgentUserId = "attendedTransferAgentUserId"; + } + + /// + /// Contains site-settings configuration identifiers used by the Contact Center module set. + /// + public static class Settings + { + /// + /// The site settings group identifier used for Contact Center administrative configuration. + /// Every Contact Center settings display driver must use this group identifier so all + /// Contact Center settings appear together on the same settings screen. + /// + public const string GroupId = "contactcenter"; + } + + /// + /// Contains stable metadata keys written to call sessions and interactions for provider-reported telephony details. + /// + public static class TelephonyMetadata + { + /// + /// The key under which the AMD (Answering Machine Detection) answer classification is stored. + /// + public const string AnswerClassification = "amd_answer_classification"; + } + + /// + /// Contains the canonical Contact Center domain event type names. + /// Names are channel-neutral and stable so they can be persisted, projected, and replayed. + /// + public static class Events + { + /// + /// Raised when a new interaction is created. + /// + public const string InteractionCreated = "InteractionCreated"; + + /// + /// Raised when an interaction is linked to a CRM activity. + /// + public const string InteractionLinkedToActivity = "InteractionLinkedToActivity"; + + /// + /// Raised when an activity is reserved by routing, a dialer, or an agent. + /// + public const string ActivityReserved = "ActivityReserved"; + + /// + /// Raised when an activity assignment changes. + /// + public const string ActivityAssignmentChanged = "ActivityAssignmentChanged"; + + /// + /// Raised when an activity disposition is applied. + /// + public const string ActivityDispositionApplied = "ActivityDispositionApplied"; + + /// + /// Raised when the communication session for an interaction starts. + /// + public const string InteractionStarted = "InteractionStarted"; + + /// + /// Raised when an interaction is updated. + /// + public const string InteractionUpdated = "InteractionUpdated"; + + /// + /// Raised when an interaction is transferred. + /// + public const string InteractionTransferred = "InteractionTransferred"; + + /// + /// Raised when an interaction transfer is denied by authorization or destination policy. + /// + public const string InteractionTransferDenied = "InteractionTransferDenied"; + + /// + /// Raised when the communication session for an interaction ends. + /// + public const string InteractionEnded = "InteractionEnded"; + + /// + /// Raised when an interaction fails. + /// + public const string InteractionFailed = "InteractionFailed"; + + /// + /// Raised when routing evaluates a queued activity and its candidate agents. + /// + public const string RoutingDecisionMade = "RoutingDecisionMade"; + + /// + /// Raised when an activity is added to a queue. + /// + public const string QueueItemAdded = "QueueItemAdded"; + + /// + /// Raised when a queue item is reserved for an agent. + /// + public const string QueueItemReserved = "QueueItemReserved"; + + /// + /// Raised when a queue item is assigned to an agent. + /// + public const string QueueItemAssigned = "QueueItemAssigned"; + + /// + /// Raised when a queue item leaves the queue. + /// + public const string QueueItemDequeued = "QueueItemDequeued"; + + /// + /// Raised when a waiting queue item is moved to an overflow queue. + /// + public const string QueueItemOverflowed = "QueueItemOverflowed"; + + /// + /// Raised when an agent signs in. + /// + public const string AgentSignedIn = "AgentSignedIn"; + + /// + /// Raised when an agent signs out. + /// + public const string AgentSignedOut = "AgentSignedOut"; + + /// + /// Raised when an agent presence state changes. + /// + public const string AgentPresenceChanged = "AgentPresenceChanged"; + + /// + /// Raised when manager-owned agent queue or campaign entitlements change. + /// + public const string AgentEntitlementsChanged = "AgentEntitlementsChanged"; + + /// + /// Raised when an agent is reserved for an offer. + /// + public const string AgentReserved = "AgentReserved"; + + /// + /// Raised when an agent reservation is released. + /// + public const string AgentReleased = "AgentReleased"; + + /// + /// Raised when a dialer run starts. + /// + public const string DialerRunStarted = "DialerRunStarted"; + + /// + /// Raised when a dialer attempt is scheduled. + /// + public const string DialerAttemptScheduled = "DialerAttemptScheduled"; + + /// + /// Raised when a dialer attempt starts dialing. + /// + public const string DialerAttemptStarted = "DialerAttemptStarted"; + + /// + /// Raised when a dialer attempt completes. + /// + public const string DialerAttemptCompleted = "DialerAttemptCompleted"; + + /// + /// Raised when the outbound compliance gate suppresses a dialing attempt. + /// + public const string DialSuppressed = "DialSuppressed"; + + /// + /// Raised when a callback is scheduled. + /// + public const string CallbackScheduled = "CallbackScheduled"; + + /// + /// Raised when a due callback is promoted into outbound work. + /// + public const string CallbackPromoted = "CallbackPromoted"; + + /// + /// Raised when a callback is completed or canceled. + /// + public const string CallbackCompleted = "CallbackCompleted"; + + /// + /// Raised when a call session is created for an interaction. + /// + public const string CallSessionCreated = "CallSessionCreated"; + + /// + /// Raised when a call session state changes. + /// + public const string CallSessionUpdated = "CallSessionUpdated"; + + /// + /// Raised when a live call is connected (bridged) to an agent. + /// + public const string CallConnected = "CallConnected"; + + /// + /// Raised when a live call is placed on hold. + /// + public const string CallHeld = "CallHeld"; + + /// + /// Raised when a live call resumes from hold. + /// + public const string CallResumed = "CallResumed"; + + /// + /// Raised when a live call is muted. + /// + public const string CallMuted = "CallMuted"; + + /// + /// Raised when a live call is unmuted. + /// + public const string CallUnmuted = "CallUnmuted"; + + /// + /// Raised when a provider reports a conference or participant topology change for a call. + /// + public const string CallConferenceChanged = "CallConferenceChanged"; + + /// + /// Raised when a call session ends. + /// + public const string CallEnded = "CallEnded"; + + /// + /// Raised when an agent accepts an offered interaction. + /// + public const string OfferAccepted = "OfferAccepted"; + + /// + /// Raised when an agent declines an offered interaction. + /// + public const string OfferDeclined = "OfferDeclined"; + + /// + /// Raised when a failed delivery attempt returns offered work to inbound routing. + /// + public const string OfferRequeued = "OfferRequeued"; + + /// + /// Raised when call recording starts. + /// + public const string RecordingStarted = "RecordingStarted"; + + /// + /// Raised when call recording pauses. + /// + public const string RecordingPaused = "RecordingPaused"; + + /// + /// Raised when call recording resumes. + /// + public const string RecordingResumed = "RecordingResumed"; + + /// + /// Raised when call recording stops. + /// + public const string RecordingStopped = "RecordingStopped"; + + /// + /// Raised when the recording governance policy denies a request to start or resume recording. + /// + public const string RecordingDenied = "RecordingDenied"; + + /// + /// Raised when a captured recording is accessed or retrieved, recording who accessed it and why for the + /// recording access audit trail. + /// + public const string RecordingAccessed = "RecordingAccessed"; + + /// + /// Raised when a captured recording reference is erased at the orchestration layer in response to a + /// right-to-erasure request, delegating media deletion to the owning media store. + /// + public const string RecordingErased = "RecordingErased"; + + /// + /// Raised when the recording governance policy denies a right-to-erasure request, for example because the + /// recording is under legal hold. + /// + public const string RecordingErasureDenied = "RecordingErasureDenied"; + + /// + /// Raised when the underlying recording media has actually been deleted from the owning media store, as + /// the confirmed-deletion receipt that follows a request. This is the + /// durable proof of completed deletion, distinct from mere acceptance of the erasure request. + /// + public const string RecordingMediaDeleted = "RecordingMediaDeleted"; + + /// + /// Raised when a supervisor starts monitoring, whispering, barging, or taking over a live call. + /// + public const string SupervisorMonitorStarted = "SupervisorMonitorStarted"; + + /// + /// Raised when a supervisor stops a monitoring, whisper, or barge engagement on a live call. + /// + public const string SupervisorMonitorStopped = "SupervisorMonitorStopped"; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterFeatureLifecycleOptions.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterFeatureLifecycleOptions.cs new file mode 100644 index 000000000..312d2321d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterFeatureLifecycleOptions.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Configures bounded Contact Center feature work draining during Orchard shell replacement. +/// +public sealed class ContactCenterFeatureLifecycleOptions +{ + /// + /// The default maximum number of seconds feature disable waits for admitted work to finish. + /// + public const int DefaultDrainTimeoutSeconds = 30; + + /// + /// Gets or sets the maximum number of seconds feature disable waits for admitted work to finish. + /// + public int DrainTimeoutSeconds { get; set; } = DefaultDrainTimeoutSeconds; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterHandlerReplaySafety.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterHandlerReplaySafety.cs new file mode 100644 index 000000000..30553324a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterHandlerReplaySafety.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Declares how a Contact Center message handler behaves when the same message is delivered more than +/// once. Contact Center dispatch is at-least-once, so every handler must state a machine-readable replay +/// contract that registration validation can enforce and reviewers can audit. +/// +public enum ContactCenterHandlerReplaySafety +{ + /// + /// No replay contract was declared. Registration validation rejects this value so a handler can never + /// silently default to an unproven idempotency claim. + /// + Unspecified = 0, + + /// + /// Replaying the handler converges to the same observable state. The handler performs only + /// last-write-wins projections, broadcasts, or reads and holds no accumulating side effect, so a + /// duplicate delivery cannot corrupt state or double an effect. + /// + NaturallyIdempotent, + + /// + /// The handler dedupes on the durable event identifier before applying a non-idempotent effect (such as + /// incrementing a counter or triggering a workflow), so a duplicate delivery is a no-op. + /// + DeduplicatedByEventId, + + /// + /// Idempotency is enforced by a durable downstream store keyed on a stable identity (for example a + /// provider inbox uniqueness constraint or an optimistic-concurrency compare-and-set), so a duplicate + /// delivery is rejected or collapsed by that store rather than by the handler itself. + /// + GuardedByDurableStore, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessHealthApplicationBuilderExtensions.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessHealthApplicationBuilderExtensions.cs new file mode 100644 index 000000000..5a9779159 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessHealthApplicationBuilderExtensions.cs @@ -0,0 +1,144 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Adds the process liveness probe to the host pipeline. +/// +/// +/// Liveness must be answered by the process itself, ahead of the Orchard Core pipeline, because it answers +/// exactly one question: should this process be restarted. A probe mapped inside a tenant shell cannot answer +/// that. It returns 404 whenever the tenant is disabled, renamed, given a different request URL prefix, or +/// fails to start — and an orchestrator reads 404 as a probe failure, so a healthy process is restarted for a +/// tenant-level problem, forever. +/// +/// This is deliberately a short-circuiting middleware rather than a mapped endpoint. Endpoints registered on a +/// are executed by terminal middleware appended after everything the application +/// added, so an endpoint would be evaluated after the Orchard Core pipeline had already handled the request. +/// +/// +public static class ContactCenterProcessHealthApplicationBuilderExtensions +{ + /// + /// The route the OrchardCore.HealthChecks module uses when no route is configured. + /// + private const string DefaultSharedHealthEndpointRoute = "/health/live"; + + /// + /// Answers the process liveness probe before the request reaches the Orchard Core pipeline. + /// + /// The application builder to add the middleware to. + /// The same so calls can be chained. + /// + /// Call this before UseOrchardCore. The probe consults nothing: reaching it already proves the + /// process is scheduling requests, which is the only claim a liveness probe may make. + /// + /// The path is deliberately not a parameter here. It is supplied once to + /// AddContactCenterProcessLiveness, which is also what validates it against every configured tenant. + /// Accepting a path here as well would allow the middleware to answer on a path that was never validated, + /// which is exactly the collision this probe exists to prevent. + /// + /// + public static IApplicationBuilder UseContactCenterProcessLiveness(this IApplicationBuilder app) + { + ArgumentNullException.ThrowIfNull(app); + + var options = app.ApplicationServices.GetService() + ?? throw new InvalidOperationException( + "The Contact Center process liveness probe has not been registered. Call " + + "'services.AddContactCenterProcessLiveness()' before building the application, so the reserved " + + "path is validated against every configured tenant. Without it, a tenant that maps its health " + + "endpoint on the same path would be silently shadowed by an unconditional '200 Healthy'."); + + var configuration = app.ApplicationServices.GetService(); + + ThrowIfShadowsSharedHealthEndpoint( + options.Path, + configuration?["OrchardCore_HealthChecks:Url"], + tenantName: null); + + var livenessPath = new PathString(options.Path); + + return app.Use(async (context, next) => + { + if (!IsLivenessRequest(context, livenessPath)) + { + await next(context); + + return; + } + + context.Response.StatusCode = StatusCodes.Status200OK; + context.Response.ContentType = "text/plain"; + context.Response.Headers.CacheControl = "no-store, no-cache"; + + await context.Response.WriteAsync("Healthy", context.RequestAborted); + }); + } + + /// + /// Throws when the liveness path would shadow the OrchardCore.HealthChecks module's endpoint. + /// + /// The path this middleware will answer. + /// The configured shared health endpoint route, if any. + /// The tenant the route was configured on, or for the host. + /// The two paths are the same. + /// + /// This middleware short-circuits before routing, so a collision does not surface as a duplicate-route + /// error. It surfaces as the shared endpoint silently answering an unconditional 200 Healthy for + /// every tenant in the process, including tenants that never enable Contact Center. A permanently healthy + /// health endpoint is worse than no health endpoint, so the collision fails startup instead. + /// + public static void ThrowIfShadowsSharedHealthEndpoint( + string livenessPath, + string sharedEndpointRoute, + string tenantName) + { + var effectiveSharedRoute = string.IsNullOrWhiteSpace(sharedEndpointRoute) + ? DefaultSharedHealthEndpointRoute + : sharedEndpointRoute.Trim(); + + if (!Normalize(livenessPath).Equals(Normalize(effectiveSharedRoute), StringComparison.OrdinalIgnoreCase)) + { + return; + } + + var scope = tenantName is null + ? "the host configuration" + : $"tenant '{tenantName}'"; + + throw new InvalidOperationException( + $"The Contact Center process liveness probe is configured at '{livenessPath}', which is the same " + + $"route as the OrchardCore.HealthChecks module's endpoint ('{effectiveSharedRoute}') in {scope}. " + + "Liveness runs " + + "as host middleware ahead of routing, so it would shadow that endpoint for every tenant in this " + + "process — including tenants that do not enable Contact Center — and answer an unconditional " + + "'200 Healthy' in its place. Move one of them: pass a different path to " + + "AddContactCenterProcessLiveness, or set 'OrchardCore_HealthChecks:Url' to another route."); + } + + /// + /// Normalizes a route for comparison by trimming whitespace and surrounding slashes. + /// + /// The route to normalize. + /// The normalized route. + private static string Normalize(string route) + => route.Trim().Trim('/'); + + /// + /// Determines whether the request targets the liveness probe. + /// + /// The request under consideration. + /// The configured liveness path. + /// when the request is a readable probe of the liveness path. + /// + /// Only GET and HEAD are answered. A liveness probe never mutates, and answering every verb would make the + /// path a silent 200 for requests an operator would expect to be rejected. + /// + private static bool IsLivenessRequest(HttpContext context, PathString livenessPath) + => (HttpMethods.IsGet(context.Request.Method) || HttpMethods.IsHead(context.Request.Method)) + && context.Request.Path.Equals(livenessPath, StringComparison.OrdinalIgnoreCase); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessHealthServiceCollectionExtensions.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessHealthServiceCollectionExtensions.cs new file mode 100644 index 000000000..0f4bfbbbf --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessHealthServiceCollectionExtensions.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using OrchardCore.Environment.Shell; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Registers the host-level process liveness probe. +/// +public static class ContactCenterProcessHealthServiceCollectionExtensions +{ + /// + /// Registers the process liveness probe on its default path. + /// + /// The service collection to add the registrations to. + /// The same so calls can be chained. + public static IServiceCollection AddContactCenterProcessLiveness(this IServiceCollection services) + => services.AddContactCenterProcessLiveness(ContactCenterConstants.HealthChecks.ProcessLivenessPath); + + /// + /// Registers the process liveness probe on a custom path. + /// + /// The service collection to add the registrations to. + /// The path the probe should answer on. + /// The same so calls can be chained. + /// + /// This also registers a startup validator that fails the host when any configured tenant maps the shared + /// health-check endpoint on the same path. The probe short-circuits before routing, so such a collision + /// would otherwise replace that tenant's health endpoint with an unconditional success rather than produce + /// a routing error. + /// + public static IServiceCollection AddContactCenterProcessLiveness(this IServiceCollection services, string path) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + var existing = services.FirstOrDefault(descriptor => + descriptor.ServiceType == typeof(ContactCenterProcessLivenessOptions)); + + if (existing?.ImplementationInstance is ContactCenterProcessLivenessOptions registered) + { + if (!registered.Path.Equals(path, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"The Contact Center process liveness probe is already registered at '{registered.Path}' and " + + $"cannot also be registered at '{path}'. The probe answers on exactly one path, and only the " + + "last registration would take effect while every earlier one was silently discarded. Register " + + "it once."); + } + + return services; + } + + // Registered as a concrete singleton rather than through IOptions, because IOptions always resolves to + // a default instance and could not tell the middleware whether this method had been called at all. + services.AddSingleton(new ContactCenterProcessLivenessOptions { Path = path }); + + // The shell settings manager is resolved leniently so the probe can also be hosted outside an Orchard + // Core application, where there are no tenants to validate. + services.AddSingleton(serviceProvider => new ContactCenterProcessLivenessPathValidator( + serviceProvider.GetService(), + serviceProvider.GetRequiredService())); + + return services; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessLivenessOptions.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessLivenessOptions.cs new file mode 100644 index 000000000..b9993f9c7 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessLivenessOptions.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Configures the host-level process liveness probe. +/// +public sealed class ContactCenterProcessLivenessOptions +{ + /// + /// Gets or sets the path the process liveness probe answers on. + /// + /// + /// The default deliberately avoids /health/live, the default route of the + /// OrchardCore.HealthChecks module, because host middleware short-circuits before routing and would + /// otherwise replace that module's endpoint with an unconditional success for every tenant in the process. + /// + public string Path { get; set; } = ContactCenterConstants.HealthChecks.ProcessLivenessPath; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessLivenessPathValidator.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessLivenessPathValidator.cs new file mode 100644 index 000000000..0840ad2e1 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterProcessLivenessPathValidator.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.Hosting; +using OrchardCore.Environment.Shell; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Fails host startup when any tenant has configured the shared health-check endpoint on the path reserved by +/// the process liveness probe. +/// +/// +/// The liveness probe runs as host middleware ahead of routing, so it short-circuits before any tenant route is +/// considered. A tenant that maps its health endpoint on the same path does not produce a duplicate-route error +/// — its endpoint simply becomes unreachable and is replaced by an unconditional 200 Healthy. A health +/// endpoint that can only report success is worse than no health endpoint, so the collision must be loud. +/// +/// Tenant configuration is not host configuration, so the check performed when the middleware is added cannot +/// see it. This validator reads each tenant's own configuration instead. Reading through the tenant settings +/// indexer resolves the same value the shared health-check module resolves, including sources contributed by +/// per-tenant configuration files, and it matches either the underscore or the dotted key spelling. +/// +/// +/// A tenant created after startup is not covered here. Tenants that enable Contact Center are covered by the +/// shell-level guard; for tenants that do not, the reserved path is documented instead. +/// +/// +public sealed class ContactCenterProcessLivenessPathValidator : IHostedService +{ + private readonly IShellSettingsManager _shellSettingsManager; + private readonly ContactCenterProcessLivenessOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The manager used to enumerate the configured tenants, or when the probe is hosted + /// outside an Orchard Core application and there are no tenants to validate. + /// + /// The configured process liveness options. + public ContactCenterProcessLivenessPathValidator( + IShellSettingsManager shellSettingsManager, + ContactCenterProcessLivenessOptions options) + { + _shellSettingsManager = shellSettingsManager; + _options = options; + } + + /// + public async Task StartAsync(CancellationToken cancellationToken) + { + if (_shellSettingsManager is null) + { + return; + } + + var livenessPath = _options.Path; + + var allSettings = await _shellSettingsManager.LoadSettingsAsync(); + + foreach (var settings in allSettings) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Reading the indexer builds the tenant configuration on first access, and it does so by blocking on + // the asynchronous builder. Building it here keeps that work off the synchronous path so a host with + // many tenants does not consume a thread per tenant while starting. + await settings.EnsureConfigurationAsync(); + + ContactCenterProcessHealthApplicationBuilderExtensions.ThrowIfShadowsSharedHealthEndpoint( + livenessPath, + settings["OrchardCore_HealthChecks:Url"], + settings.Name); + } + } + + /// + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions.csproj b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions.csproj new file mode 100644 index 000000000..438ea1027 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions.csproj @@ -0,0 +1,24 @@ + + + + CrestApps.OrchardCore.ContactCenter + CrestApps OrchardCore Contact Center Abstractions + + $(CrestAppsDescription) + + Provider-agnostic abstractions and shared domain vocabulary for the CrestApps OrchardCore + Contact Center module set. Contains the interaction, channel, routing, and event constants + and enumerations shared across Contact Center features and channel adapters. + + $(PackageTags) ContactCenter Interactions Abstractions + + + + + + + + + + + diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureLifecycleParticipant.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureLifecycleParticipant.cs new file mode 100644 index 000000000..25a32ded5 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureLifecycleParticipant.cs @@ -0,0 +1,30 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Defines feature-owned work that must stop accepting new operations, drain, and reconcile across Orchard feature reloads. +/// +public interface IContactCenterFeatureLifecycleParticipant +{ + /// + /// Gets the Orchard feature that owns the participant. + /// + string FeatureId { get; } + + /// + /// Stops the feature component from accepting new work before the owning feature is disabled. + /// + /// The token to monitor for cancellation requests. + Task QuiesceAsync(CancellationToken cancellationToken = default); + + /// + /// Waits for admitted work to finish before the owning feature is disabled. + /// + /// The token to monitor for cancellation requests. + Task DrainAsync(CancellationToken cancellationToken = default); + + /// + /// Reconciles feature-owned state when a fresh tenant shell activates. + /// + /// The token to monitor for cancellation requests. + Task ReconcileAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureWorkLease.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureWorkLease.cs new file mode 100644 index 000000000..1dda66e1f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureWorkLease.cs @@ -0,0 +1,8 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Represents feature-owned work admitted before feature quiescence. +/// +public interface IContactCenterFeatureWorkLease : IDisposable +{ +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureWorkManager.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureWorkManager.cs new file mode 100644 index 000000000..f4f896497 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterFeatureWorkManager.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Coordinates tenant-local feature work admission and bounded draining during Orchard shell replacement. +/// +public interface IContactCenterFeatureWorkManager +{ + /// + /// Attempts to admit work owned by the specified feature. + /// + /// The owning Orchard feature identifier. + /// A lease that must be disposed when the work finishes, or when the feature is quiescing. + IContactCenterFeatureWorkLease TryEnter(string featureId); + + /// + /// Stops admitting new work for the specified feature. + /// + /// The owning Orchard feature identifier. + void Quiesce(string featureId); + + /// + /// Waits for admitted work to finish. + /// + /// The owning Orchard feature identifier. + /// The maximum amount of time to wait. + /// The token to monitor for cancellation requests. + Task DrainAsync(string featureId, TimeSpan timeout, CancellationToken cancellationToken = default); + + /// + /// Reopens work admission for the specified feature after a failed disable or fresh-shell reconciliation. + /// + /// The owning Orchard feature identifier. + void Activate(string featureId); + + /// + /// Determines whether work admission is currently closed for the specified feature. Maintenance procedures + /// that must not race against live traffic use this to prove the tenant is quiesced before they run. + /// + /// The owning Orchard feature identifier. + /// when the feature is quiescing; otherwise, . + bool IsQuiescing(string featureId); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterRealTimeNotifier.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterRealTimeNotifier.cs new file mode 100644 index 000000000..e361f55ab --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterRealTimeNotifier.cs @@ -0,0 +1,49 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Broadcasts Contact Center real-time updates to the appropriate audiences without exposing the +/// underlying transport or group naming details to callers. +/// +public interface IContactCenterRealTimeNotifier +{ + /// + /// Broadcasts an agent presence change to the agent's own connections and to supervisors. + /// + /// The presence change. + /// The token to monitor for cancellation requests. + Task NotifyPresenceChangedAsync(AgentPresenceNotification notification, CancellationToken cancellationToken = default); + + /// + /// Broadcasts a work offer to the target agent, the originating queue watchers, and supervisors. + /// + /// The offer. + /// The token to monitor for cancellation requests. + Task NotifyOfferReceivedAsync(AgentOfferNotification notification, CancellationToken cancellationToken = default); + + /// + /// Broadcasts the revocation of a work offer to the target agent, the originating queue watchers, and supervisors. + /// + /// The revoked offer. + /// The token to monitor for cancellation requests. + Task NotifyOfferRevokedAsync(AgentOfferRevokedNotification notification, CancellationToken cancellationToken = default); + + /// + /// Broadcasts a queue depth change to the queue's watchers and to supervisors. + /// + /// The updated queue statistics. + /// The token to monitor for cancellation requests. + Task NotifyQueueStatsChangedAsync(QueueStatsNotification notification, CancellationToken cancellationToken = default); + + /// + /// Removes an agent's active connections from revoked queue groups and requests a fresh client snapshot. + /// + /// The Orchard user identifier. + /// The queue memberships that were revoked. + /// The token to monitor for cancellation requests. + Task NotifyAgentMembershipChangedAsync( + string userId, + IEnumerable removedQueueIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceAttendedTransferProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceAttendedTransferProvider.cs new file mode 100644 index 000000000..3b562614c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceAttendedTransferProvider.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes provider-confirmed attended (consultative) transfers as three explicit phases, because a warm +/// transfer cannot be expressed by the single-shot boundary: +/// the initiating agent first speaks privately with the destination agent while the customer is held, then +/// either completes the handoff (the customer is joined to the destination agent and the initiating agent +/// leaves) or cancels it (the destination agent is dropped and the customer returns to the initiating agent). +/// +public interface IContactCenterVoiceAttendedTransferProvider +{ + /// + /// Begins a consult: holds the customer and rings the destination agent into a private conversation with the + /// initiating agent, leaving the original call fully intact if the destination agent never answers. + /// + /// The attended-transfer request identifying the customer call and destination agent. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task BeginConsultAsync( + ContactCenterVoiceAttendedTransferRequest request, + CancellationToken cancellationToken = default); + + /// + /// Completes a consult: resumes the held customer, hands ownership of the conversation to the destination + /// agent, and releases the initiating agent, so the customer keeps talking to the destination agent. + /// + /// The attended-transfer request identifying the customer call and destination agent. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task CompleteConsultAsync( + ContactCenterVoiceAttendedTransferRequest request, + CancellationToken cancellationToken = default); + + /// + /// Cancels a consult: drops the destination agent and resumes the held customer with the initiating agent, + /// leaving the original call ownership unchanged. + /// + /// The attended-transfer request identifying the customer call and destination agent. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task CancelConsultAsync( + ContactCenterVoiceAttendedTransferRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceCallControlProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceCallControlProvider.cs new file mode 100644 index 000000000..96dd9884f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceCallControlProvider.cs @@ -0,0 +1,29 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes Contact Center call-control operations supported by a voice provider. +/// +public interface IContactCenterVoiceCallControlProvider +{ + /// + /// Places an outbound dialer call. + /// + /// The dial request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task DialAsync( + ContactCenterDialRequest request, + CancellationToken cancellationToken = default); + + /// + /// Connects a live provider call to the selected agent. + /// + /// The connect request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task ConnectToAgentAsync( + ContactCenterConnectRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceCommandReconciler.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceCommandReconciler.cs new file mode 100644 index 000000000..883f40c62 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceCommandReconciler.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Defines optional provider support for reconciling an outbound voice command by its stable command identifier. +/// +public interface IContactCenterVoiceCommandReconciler +{ + /// + /// Queries the provider for the outcome of a previously sent command without issuing the command again. + /// + /// The stable command identifier supplied with the original request. + /// The token to monitor for cancellation requests. + /// The provider's current knowledge of the command outcome. + Task ReconcileCommandAsync( + string commandId, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceConferenceProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceConferenceProvider.cs new file mode 100644 index 000000000..9f9f598e9 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceConferenceProvider.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes provider-confirmed conference operations. +/// +public interface IContactCenterVoiceConferenceProvider +{ + /// + /// Creates or updates a conference for live provider calls. + /// + /// The conference request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task ConferenceAsync( + ContactCenterVoiceConferenceRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaProvider.cs new file mode 100644 index 000000000..356be3a11 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaProvider.cs @@ -0,0 +1,24 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Defines the optional executable live-media operations implemented by a voice provider. +/// +public interface IContactCenterVoiceMediaProvider +{ + /// + /// Gets the technical name of the corresponding Contact Center voice provider. + /// + string TechnicalName { get; } + + /// + /// Opens a bidirectional media session for an existing provider call. + /// + /// The media-session request. + /// The token to monitor for cancellation requests. + /// The opened live media session. + Task OpenSessionAsync( + ContactCenterVoiceMediaSessionRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaProviderResolver.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaProviderResolver.cs new file mode 100644 index 000000000..6c551feb4 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaProviderResolver.cs @@ -0,0 +1,21 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Resolves registered live-media providers whose corresponding voice providers advertise bidirectional media support. +/// +public interface IContactCenterVoiceMediaProviderResolver +{ + /// + /// Resolves the live-media provider with the specified technical name, or the default voice provider's + /// media implementation when no name is supplied. + /// + /// The provider technical name, or to resolve the default. + /// The matching media provider, or when bidirectional media is unavailable. + IContactCenterVoiceMediaProvider Get(string technicalName = null); + + /// + /// Gets every registered media provider whose corresponding voice provider advertises bidirectional media support. + /// + /// The available live-media providers. + IEnumerable GetAll(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaSession.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaSession.cs new file mode 100644 index 000000000..372eaa0b5 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMediaSession.cs @@ -0,0 +1,51 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Represents an active bidirectional media session attached to a provider call. +/// +public interface IContactCenterVoiceMediaSession : IAsyncDisposable +{ + /// + /// Gets the provider-assigned media-session identifier. + /// + string SessionId { get; } + + /// + /// Gets the provider call identifier associated with the session. + /// + string ProviderCallId { get; } + + /// + /// Gets the format used for caller audio received from the provider. + /// + ContactCenterVoiceMediaFormat IncomingFormat { get; } + + /// + /// Gets the format required for audio written back to the provider. + /// + ContactCenterVoiceMediaFormat OutgoingFormat { get; } + + /// + /// Reads incoming caller-audio frames until the session ends or cancellation is requested. + /// + /// The token to monitor for cancellation requests. + /// The incoming audio-frame stream. + IAsyncEnumerable ReadIncomingAsync(CancellationToken cancellationToken = default); + + /// + /// Writes an application-generated audio frame to the live call. + /// + /// The audio frame to write. + /// The token to monitor for cancellation requests. + ValueTask WriteOutgoingAsync( + ContactCenterVoiceMediaFrame frame, + CancellationToken cancellationToken = default); + + /// + /// Stops the media session without ending the underlying provider call. + /// + /// The token to monitor for cancellation requests. + Task StopAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMonitoringProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMonitoringProvider.cs new file mode 100644 index 000000000..e56b4eac3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceMonitoringProvider.cs @@ -0,0 +1,31 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes provider-confirmed supervisor monitoring engagements. +/// +public interface IContactCenterVoiceMonitoringProvider +{ + /// + /// Starts the requested supervisor engagement on a live provider call. + /// + /// The monitoring request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task EngageAsync( + ContactCenterVoiceMonitoringRequest request, + CancellationToken cancellationToken = default); + + /// + /// Stops a previously started supervisor engagement, releasing only the supervisor-owned media legs without + /// affecting the underlying customer-to-agent call. It is idempotent: stopping an engagement whose resources + /// are already gone succeeds. + /// + /// The monitoring request identifying the engagement to stop (interaction, supervisor, and mode). + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task StopAsync( + ContactCenterVoiceMonitoringRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs new file mode 100644 index 000000000..a81d75d6a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Identifies a Contact Center voice provider and the executable capability contracts it advertises. +/// +public interface IContactCenterVoiceProvider +{ + /// + /// Gets the stable technical name used to resolve the provider. + /// + string TechnicalName { get; } + + /// + /// Gets the localized, human-readable name of the provider. + /// + LocalizedString Name { get; } + + /// + /// Gets the provider capabilities supported for Contact Center orchestration. + /// + ContactCenterVoiceProviderCapabilities Capabilities { get; } + + /// + /// Gets the delivery model that describes how the provider delivers a live call to an agent. + /// + VoiceProviderDeliveryModel DeliveryModel { get; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProviderResolver.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProviderResolver.cs new file mode 100644 index 000000000..688a20ac9 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProviderResolver.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Resolves registered implementations by technical name. +/// +public interface IContactCenterVoiceProviderResolver +{ + /// + /// Resolves the Contact Center voice provider with the specified technical name, or the default provider when no name is supplied. + /// + /// The provider technical name, or to resolve the default. + /// The matching provider, or when none is found. + IContactCenterVoiceProvider Get(string technicalName = null); + + /// + /// Gets every registered Contact Center voice provider. + /// + /// The registered voice providers. + IEnumerable GetAll(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceQueueAssignmentProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceQueueAssignmentProvider.cs new file mode 100644 index 000000000..146bda3c4 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceQueueAssignmentProvider.cs @@ -0,0 +1,29 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes provider-owned call assignment and queue placement. +/// +public interface IContactCenterVoiceQueueAssignmentProvider +{ + /// + /// Assigns an existing provider call to an agent. + /// + /// The assignment request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task AssignCallAsync( + ContactCenterCallAssignmentRequest request, + CancellationToken cancellationToken = default); + + /// + /// Places or moves a call into a provider-owned queue. + /// + /// The queue request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task QueueCallAsync( + ContactCenterQueueCallRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceRecordingProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceRecordingProvider.cs new file mode 100644 index 000000000..7df0e8c28 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceRecordingProvider.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes provider-confirmed recording state changes. +/// +public interface IContactCenterVoiceRecordingProvider +{ + /// + /// Changes the recording state of a live provider call. + /// + /// The recording request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task SetRecordingStateAsync( + ContactCenterVoiceRecordingRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceTransferProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceTransferProvider.cs new file mode 100644 index 000000000..17914991c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceTransferProvider.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Executes provider-confirmed live-call transfers. +/// +public interface IContactCenterVoiceTransferProvider +{ + /// + /// Transfers a live provider call. + /// + /// The transfer request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task TransferAsync( + ContactCenterVoiceTransferRequest request, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IInboundVoiceEventSink.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IInboundVoiceEventSink.cs new file mode 100644 index 000000000..88f4ec3be --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IInboundVoiceEventSink.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Routes normalized inbound provider voice events into Contact Center work. +/// +public interface IInboundVoiceEventSink +{ + /// + /// Routes the specified inbound provider event. + /// + /// The normalized inbound voice event. + /// The token to monitor for cancellation requests. + /// The routing outcome, exposing the durable interaction created for the call when one was produced. + Task RouteAsync( + InboundVoiceEvent inboundEvent, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IInboundVoiceInteractionProbe.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IInboundVoiceInteractionProbe.cs new file mode 100644 index 000000000..9e9f29780 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IInboundVoiceInteractionProbe.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Probes whether a durable, still-active Contact Center interaction exists for a provider call, so a provider can +/// recover a caller leg forward instead of tearing down a live, routed call. +/// +public interface IInboundVoiceInteractionProbe +{ + /// + /// Determines whether an active (non-terminal) interaction exists for the specified provider call. + /// + /// The provider technical name that owns the call. + /// The provider-assigned call identifier. + /// The token to monitor for cancellation requests. + /// when a durable, non-terminal interaction exists; otherwise, . + Task HasActiveInteractionAsync( + string providerName, + string providerCallId, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderCallStateReconciler.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderCallStateReconciler.cs new file mode 100644 index 000000000..2bc34db69 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderCallStateReconciler.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Reconciles Contact Center call state against authoritative provider state. +/// +public interface IProviderCallStateReconciler +{ + /// + /// Reconciles active interactions for the specified provider. + /// + /// The provider technical name. + /// The token to monitor for cancellation requests. + /// The number of interactions whose state was refreshed. + Task ReconcileAsync( + string providerName, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceEventSink.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceEventSink.cs new file mode 100644 index 000000000..1d4e0f486 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceEventSink.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Ingests normalized provider voice events without exposing Contact Center persistence models. +/// +public interface IProviderVoiceEventSink +{ + /// + /// Attempts to ingest a normalized provider voice event. + /// + /// The normalized provider voice event. + /// The token to monitor for cancellation requests. + /// when a Contact Center call session handled the event; otherwise, . + Task IngestAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookInbox.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookInbox.cs new file mode 100644 index 000000000..31395c877 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookInbox.cs @@ -0,0 +1,63 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Durably accepts authenticated provider webhook deliveries and dispatches persisted payloads with retry recovery. +/// +public interface IProviderWebhookInbox +{ + /// + /// Commits an authenticated normalized delivery before any state-changing handler runs. + /// + /// The normalized delivery to accept. + /// The token to monitor until the durable commit completes. + /// The acceptance result. + Task AcceptAsync( + ProviderWebhookInboxDelivery delivery, + CancellationToken cancellationToken = default); + + /// + /// Attempts to process one persisted inbox message. + /// + /// The durable inbox message identifier. + /// The token to monitor for cancellation requests. + /// when the message completed and was removed; otherwise . + Task DispatchAsync(string messageId, CancellationToken cancellationToken = default); + + /// + /// Dispatches one normalized payload to its feature-scoped handler in an isolated shell scope. + /// + /// The stable technical name of the handler. + /// The normalized serialized payload. + /// The token to monitor for cancellation requests. + Task DispatchHandlerAsync( + string handlerName, + string payload, + CancellationToken cancellationToken = default); + + /// + /// Settles a claimed delivery in a fresh scope after handler execution, rejecting stale owners by fence. + /// + /// The durable inbox message identifier. + /// The owner token captured when the message was claimed. + /// The fence token captured when the message was claimed. + /// Whether the handler completed successfully. + /// The handler exception type name when execution failed. + /// The token to monitor for cancellation requests. + /// when the message completed; otherwise . + Task SettleClaimAsync( + string messageId, + string ownerToken, + long fenceToken, + bool succeeded, + string errorType, + CancellationToken cancellationToken = default); + + /// + /// Processes the pending inbox messages whose retry time is due. + /// + /// The token to monitor for cancellation requests. + /// The number of messages completed and removed. + Task DispatchDueAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookInboxHandler.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookInboxHandler.cs new file mode 100644 index 000000000..7170af740 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookInboxHandler.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Processes one normalized payload from the durable provider webhook inbox. +/// +public interface IProviderWebhookInboxHandler +{ + /// + /// Gets the stable technical name used to route persisted payloads to this handler. + /// + string TechnicalName { get; } + + /// + /// Gets the machine-readable replay contract for this handler. Provider webhook delivery is + /// at-least-once, so this value states honestly how the handler stays idempotent when the same + /// persisted payload is dispatched again. Registration validation rejects an + /// contract. + /// + ContactCenterHandlerReplaySafety ReplaySafety { get; } + + /// + /// Processes a normalized serialized payload. + /// + /// The normalized serialized payload. + /// The token to monitor for cancellation requests. + Task HandleAsync(string payload, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookIngressLimiter.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookIngressLimiter.cs new file mode 100644 index 000000000..edd8f522f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderWebhookIngressLimiter.cs @@ -0,0 +1,31 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Enforces tenant-local concurrency and authenticated provider rate limits for webhook ingress. +/// +public interface IProviderWebhookIngressLimiter +{ + /// + /// Attempts to acquire capacity before a webhook request body is buffered. + /// + /// The token to monitor for cancellation requests. + /// A lease that must be disposed after request processing. + ValueTask AcquireConcurrencyAsync(CancellationToken cancellationToken = default); + + /// + /// Attempts to consume an authenticated delivery permit for a canonical provider. + /// + /// The canonical provider technical name. + /// The token to monitor for cancellation requests. + /// A lease that must be disposed after the rate-limit decision is consumed. + ValueTask AcquireRateAsync(string provider, CancellationToken cancellationToken = default); + + /// + /// Determines whether a provider-signed UTC event timestamp is inside the configured freshness window. + /// + /// The provider-signed event occurrence time, or when omitted. + /// when the timestamp is present, UTC, and inside the accepted window. + bool IsFresh(DateTime? occurredUtc); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IRecordingErasureGuard.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IRecordingErasureGuard.cs new file mode 100644 index 000000000..99b012a07 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IRecordingErasureGuard.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Guards recording ingest against resurrecting media whose interaction has already had its recording erased. +/// A late ingest that stores media after an erasure request would silently re-create deleted recording bytes, +/// so ingest consults this guard before and after storing and cleans up any media it wrote for an erased +/// interaction. +/// +public interface IRecordingErasureGuard +{ + /// + /// Determines whether the recording for the specified interaction has been erased and therefore must not be + /// (re-)ingested. A missing interaction is treated as erased, because ingesting media for an interaction that + /// no longer exists would orphan it. + /// + /// The identifier of the interaction whose recording is being ingested. + /// The token to monitor for cancellation requests. + /// when the recording has been erased (or the interaction is gone) and ingest must not proceed; otherwise, . + Task IsRecordingErasedAsync(string interactionId, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferNotification.cs new file mode 100644 index 000000000..b929ddcdd --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferNotification.cs @@ -0,0 +1,53 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Describes a work item being offered to a specific agent in real time so the agent desktop can render +/// the offer without waiting for the telephony ring event. +/// +public sealed class AgentOfferNotification +{ + /// + /// Gets or sets the identifier of the Orchard user the offer is presented to. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the identifier of the agent profile the offer is reserved for. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the identifier of the reservation that backs the offer. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the identifier of the CRM activity being offered. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets whether the assigned activity should open automatically in the agent experience. + /// + public bool AutoOpenActivity { get; set; } + + /// + /// Gets or sets the identifier of the queue item being offered. + /// + public string QueueItemId { get; set; } + + /// + /// Gets or sets the identifier of the queue the offer originated from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the UTC time the offer expires when it is not accepted. + /// + public DateTime ExpiresUtc { get; set; } + + /// + /// Gets or sets the authoritative server UTC time, used by the client to align the countdown timer. + /// + public DateTime ServerTimeUtc { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedNotification.cs new file mode 100644 index 000000000..c44865dae --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedNotification.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Describes an offer that is no longer presented to an agent so the agent desktop can dismiss the +/// pending offer and supervisor dashboards can update. +/// +public sealed class AgentOfferRevokedNotification +{ + /// + /// Gets or sets the identifier of the Orchard user the offer was presented to. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the identifier of the agent profile the offer was reserved for. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the identifier of the reservation that backed the offer. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the identifier of the queue the offer originated from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the reason the offer was revoked. + /// + public AgentOfferRevokedReason Reason { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedReason.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedReason.cs new file mode 100644 index 000000000..9f5ff2755 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedReason.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies why an offer presented to an agent was revoked. +/// +public enum AgentOfferRevokedReason +{ + /// + /// The reservation expired before the agent accepted it. + /// + Expired, + + /// + /// The offer was released back to the queue, for example after a decline or cancellation. + /// + Released, + + /// + /// The offer was accepted, so the pending offer should be cleared. + /// + Accepted, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceNotification.cs new file mode 100644 index 000000000..d1736448c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceNotification.cs @@ -0,0 +1,43 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Describes a real-time change to an agent's presence broadcast to the agent's own connections and to +/// supervisor dashboards. +/// +public sealed class AgentPresenceNotification +{ + /// + /// Gets or sets the identifier of the Orchard user the presence change applies to. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the identifier of the agent profile. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the display name shown for the agent. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the current presence status name. + /// + public string Status { get; set; } + + /// + /// Gets or sets the optional reason code associated with the current presence status. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the queues the agent is signed in to. + /// + public IList QueueIds { get; set; } = []; + + /// + /// Gets or sets the UTC time the presence change occurred. + /// + public DateTime ChangedUtc { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs new file mode 100644 index 000000000..48363b391 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs @@ -0,0 +1,67 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the live availability state of a Contact Center agent. +/// +public enum AgentPresenceStatus +{ + /// + /// The agent is signed out and cannot receive work. + /// + Offline, + + /// + /// The agent is signed in and available to receive work. + /// + Available, + + /// + /// The agent has been reserved for an offer but has not yet accepted it. + /// + Reserved, + + /// + /// The agent is actively working an interaction. + /// + Busy, + + /// + /// The agent is completing post-interaction wrap-up work. + /// + WrapUp, + + /// + /// The agent is signed in but temporarily not ready for work. + /// + Break, + + /// + /// The agent requested a break that will be granted when no assignment is in progress. + /// + RequestBreak, + + /// + /// The agent is signed in but away from the desk. + /// + Away, + + /// + /// The agent is signed in but should not receive work. + /// + DoNotDisturb, + + /// + /// The agent is unavailable because they are in a meeting. + /// + Meeting, + + /// + /// The agent is unavailable because they are in training. + /// + Training, + + /// + /// The agent is unavailable outside staffed hours. + /// + AfterHoursUnavailable, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/CallbackRequestStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/CallbackRequestStatus.cs new file mode 100644 index 000000000..7751d44f0 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/CallbackRequestStatus.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the lifecycle state of a scheduled callback request. +/// +public enum CallbackRequestStatus +{ + /// + /// The callback is scheduled and waiting for its due time. + /// + Pending, + + /// + /// The callback became due and was promoted into outbound work. + /// + Scheduled, + + /// + /// The callback is being dialed. + /// + InProgress, + + /// + /// The callback was completed. + /// + Completed, + + /// + /// The callback was canceled before completion. + /// + Canceled, + + /// + /// The callback failed after exhausting attempts. + /// + Failed, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterCallAssignmentRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterCallAssignmentRequest.cs new file mode 100644 index 000000000..538e3d3ec --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterCallAssignmentRequest.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a request to assign a provider call to an agent. +/// +public sealed class ContactCenterCallAssignmentRequest +{ + /// + /// Gets or sets the CRM activity identifier receiving the assignment. + /// + public string ActivityId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the agent identifier receiving the call. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the queue identifier the call is assigned from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets provider-specific assignment metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterConnectRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterConnectRequest.cs new file mode 100644 index 000000000..26eec6872 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterConnectRequest.cs @@ -0,0 +1,49 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a request to connect (bridge) a live provider call to a selected agent. The Contact +/// Center owns the activity, queue, agent, and reservation decisions; the provider only executes the +/// connect operation when its delivery model is . +/// +public sealed class ContactCenterConnectRequest +{ + /// + /// Gets or sets the CRM activity identifier the call belongs to. + /// + public string ActivityId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier the call belongs to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier of the live call to connect. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the identifier of the agent profile receiving the call. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the identifier of the Orchard user the agent profile represents. + /// + public string AgentUserId { get; set; } + + /// + /// Gets or sets the provider-side agent endpoint to ring or bridge (for example an extension or SIP address), when known. + /// + public string AgentEndpoint { get; set; } + + /// + /// Gets or sets the queue identifier the call is being delivered from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets provider-specific metadata for the connect request. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterDialRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterDialRequest.cs new file mode 100644 index 000000000..d2f9c4a8d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterDialRequest.cs @@ -0,0 +1,57 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents an outbound Contact Center dial request. +/// +public sealed class ContactCenterDialRequest +{ + /// + /// Gets or sets the CRM activity identifier being dialed. + /// + public string ActivityId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier for this attempt. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the stable idempotency identifier for this provider command. + /// + public string CommandId { get; set; } + + /// + /// Gets or sets the reserved agent identifier, when the dialing mode requires an agent before dialing. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the Orchard user identifier represented by the reserved agent. + /// + public string AgentUserId { get; set; } + + /// + /// Gets or sets the queue identifier the activity belongs to. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the campaign identifier the activity belongs to. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the destination address to dial. + /// + public string Destination { get; set; } + + /// + /// Gets or sets the caller identifier to present when supported. + /// + public string CallerId { get; set; } + + /// + /// Gets or sets provider-specific metadata for the dial request. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterQueueCallRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterQueueCallRequest.cs new file mode 100644 index 000000000..c3838dd9d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterQueueCallRequest.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a request to place or move a provider call into a provider-side queue. +/// +public sealed class ContactCenterQueueCallRequest +{ + /// + /// Gets or sets the CRM activity identifier associated with the queued call. + /// + public string ActivityId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the Contact Center queue identifier. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the priority to apply when the provider supports provider-side queue priority. + /// + public int Priority { get; set; } + + /// + /// Gets or sets provider-specific queue metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceAttendedTransferRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceAttendedTransferRequest.cs new file mode 100644 index 000000000..fb34dd378 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceAttendedTransferRequest.cs @@ -0,0 +1,25 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider request for one phase of an attended (consultative) transfer of a live Contact Center +/// call. The same request shape drives every phase — beginning the consult, completing the handoff, and +/// cancelling it — so the orchestration layer resolves the destination agent once and replays it across phases. +/// +public sealed class ContactCenterVoiceAttendedTransferRequest +{ + /// + /// Gets or sets the interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier of the customer leg whose live conversation is being transferred. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets provider-specific metadata. The destination agent for the consult is carried through the + /// server-resolved key. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceCommandReconciliationOutcome.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceCommandReconciliationOutcome.cs new file mode 100644 index 000000000..457e13048 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceCommandReconciliationOutcome.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the provider's knowledge of a previously sent voice command. +/// +public enum ContactCenterVoiceCommandReconciliationOutcome +{ + /// + /// The provider confirmed that the command executed. + /// + Confirmed, + + /// + /// The provider authoritatively confirmed that the command did not execute. + /// + NotExecuted, + + /// + /// The provider cannot prove whether the command executed. + /// + Unknown, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceCommandReconciliationResult.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceCommandReconciliationResult.cs new file mode 100644 index 000000000..175d9c496 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceCommandReconciliationResult.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents the provider's reconciliation result for a previously sent voice command. +/// +public sealed class ContactCenterVoiceCommandReconciliationResult +{ + /// + /// Gets or sets the provider's knowledge of the command outcome. + /// + public ContactCenterVoiceCommandReconciliationOutcome Outcome { get; set; } + + /// + /// Gets or sets the provider-assigned call identifier when the command is confirmed. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets a provider-safe explanation of the reconciliation result. + /// + public string Message { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceConferenceRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceConferenceRequest.cs new file mode 100644 index 000000000..7817057a4 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceConferenceRequest.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider request to create or update a live-call conference. +/// +public sealed class ContactCenterVoiceConferenceRequest +{ + /// + /// Gets or sets the interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifiers participating in the conference. + /// + public IList ProviderCallIds { get; set; } = []; + + /// + /// Gets or sets provider-specific metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaEncoding.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaEncoding.cs new file mode 100644 index 000000000..7252afe5b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaEncoding.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the audio encoding exchanged with a Contact Center voice media provider. +/// +public enum ContactCenterVoiceMediaEncoding +{ + /// + /// The encoding is not known. + /// + Unknown, + + /// + /// Signed linear pulse-code modulation. + /// + LinearPcm, + + /// + /// G.711 mu-law pulse-code modulation. + /// + MuLaw, + + /// + /// G.711 A-law pulse-code modulation. + /// + ALaw, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaFormat.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaFormat.cs new file mode 100644 index 000000000..61114c9e2 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaFormat.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Describes the audio format exchanged by a live voice media session. +/// +public sealed class ContactCenterVoiceMediaFormat +{ + /// + /// Gets or sets the audio encoding. + /// + public ContactCenterVoiceMediaEncoding Encoding { get; set; } + + /// + /// Gets or sets the sample rate in hertz. + /// + public int SampleRate { get; set; } + + /// + /// Gets or sets the number of audio channels. + /// + public int Channels { get; set; } = 1; + + /// + /// Gets or sets the preferred frame duration in milliseconds. + /// + public int FrameDurationMilliseconds { get; set; } = 20; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaFrame.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaFrame.cs new file mode 100644 index 000000000..4fcba7798 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaFrame.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents one ordered frame of audio exchanged with a live provider call. +/// +public sealed class ContactCenterVoiceMediaFrame +{ + /// + /// Gets or sets the monotonically increasing frame sequence number. + /// + public long SequenceNumber { get; set; } + + /// + /// Gets or sets the audio payload in the session's negotiated format. + /// + public ReadOnlyMemory Data { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaSessionRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaSessionRequest.cs new file mode 100644 index 000000000..fad02ba20 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMediaSessionRequest.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Describes a request to attach a bidirectional media session to an existing provider call. +/// +public sealed class ContactCenterVoiceMediaSessionRequest +{ + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the preferred format for caller audio received from the provider. + /// + public ContactCenterVoiceMediaFormat PreferredIncomingFormat { get; set; } + + /// + /// Gets or sets the preferred format for application audio written to the provider. + /// + public ContactCenterVoiceMediaFormat PreferredOutgoingFormat { get; set; } + + /// + /// Gets or sets provider-specific metadata required to open the media session. + /// + public IDictionary Metadata { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMonitoringRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMonitoringRequest.cs new file mode 100644 index 000000000..16815dae8 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceMonitoringRequest.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider request for a supervisor monitoring engagement. +/// +public sealed class ContactCenterVoiceMonitoringRequest +{ + /// + /// Gets or sets the interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the supervisor identifier. + /// + public string SupervisorId { get; set; } + + /// + /// Gets or sets the monitoring mode. + /// + public MonitorMode Mode { get; set; } + + /// + /// Gets or sets provider-specific metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs new file mode 100644 index 000000000..4c4fa93da --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs @@ -0,0 +1,74 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies Contact Center orchestration capabilities supported by a voice provider. +/// +[Flags] +public enum ContactCenterVoiceProviderCapabilities +{ + /// + /// The provider does not expose Contact Center-specific voice operations. + /// + None = 0, + + /// + /// The provider can place outbound calls on behalf of the dialer. + /// + DialerDial = 1 << 0, + + /// + /// The provider can assign an existing call to an agent. + /// + AgentCallAssignment = 1 << 1, + + /// + /// The provider can place or move calls into provider-side queues. + /// + ProviderQueue = 1 << 2, + + /// + /// The provider can report provider queue events to Contact Center. + /// + QueueEvents = 1 << 3, + + /// + /// The provider can synchronize agent availability or PBX presence with Contact Center. + /// + AgentPresenceSync = 1 << 4, + + /// + /// The provider can connect (bridge) a live call to a selected agent. Required for providers whose + /// delivery model is . + /// + AgentConnect = 1 << 5, + + /// + /// The provider can transfer a live call to another agent, queue, or external destination. + /// + CallTransfer = 1 << 6, + + /// + /// The provider can add participants to a live call (conference). + /// + Conference = 1 << 7, + + /// + /// The provider can record a live call (start/stop/pause/resume). + /// + Recording = 1 << 8, + + /// + /// The provider can silently monitor a live call. + /// + Monitor = 1 << 9, + + /// + /// The provider can whisper to the agent on a live call without the customer hearing. + /// + Whisper = 1 << 10, + + /// + /// The provider can barge into a live call so all parties hear the supervisor. + /// + Barge = 1 << 11, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderResult.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderResult.cs new file mode 100644 index 000000000..375a75d9b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderResult.cs @@ -0,0 +1,48 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents the result of a Contact Center voice provider operation. +/// +public sealed class ContactCenterVoiceProviderResult +{ + /// + /// Gets or sets a value indicating whether the provider operation succeeded. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets a value indicating whether the provider may have executed the operation but its outcome could not be observed. + /// + public bool OutcomeUnknown { get; set; } + + /// + /// Gets or sets the provider call identifier returned by the provider. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the provider identifier of the call leg the operation created, when it created one, such + /// as a supervisor's monitoring leg or the private leg of a consult. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the technical name of the provider that executed the operation. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider error code when the operation failed. + /// + public string ErrorCode { get; set; } + + /// + /// Gets or sets the provider error message when the operation failed. + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets provider-specific result metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceRecordingRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceRecordingRequest.cs new file mode 100644 index 000000000..0c3f12452 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceRecordingRequest.cs @@ -0,0 +1,29 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider request to change recording state for a live call. +/// +public sealed class ContactCenterVoiceRecordingRequest +{ + /// + /// Gets or sets the interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the requested recording state. + /// + public RecordingState State { get; set; } + + /// + /// Gets or sets provider-specific metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceTransferRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceTransferRequest.cs new file mode 100644 index 000000000..67c8f38ad --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceTransferRequest.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider request to transfer a live Contact Center call. +/// +public sealed class ContactCenterVoiceTransferRequest +{ + /// + /// Gets or sets the interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the transfer type. + /// + public InteractionTransferType TransferType { get; set; } + + /// + /// Gets or sets the destination type. + /// + public InteractionTransferTargetType TargetType { get; set; } + + /// + /// Gets or sets the provider-resolvable transfer destination. + /// + public string Target { get; set; } + + /// + /// Gets or sets provider-specific metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerMode.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerMode.cs new file mode 100644 index 000000000..84f2b6c18 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerMode.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the outbound dialing strategy used by a dialer profile. +/// +public enum DialerMode +{ + /// + /// The agent chooses and places the call manually. + /// + Manual, + + /// + /// The agent reviews the activity, then accepts or skips before dialing. + /// + Preview, + + /// + /// The system reserves agents and dials a controlled number of calls per available agent. + /// + Power, + + /// + /// The system dials one call per reserved agent as agents become available. + /// + Progressive, + + /// + /// The system forecasts answer rates and dials ahead of agent availability. + /// + Predictive, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerModeExtensions.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerModeExtensions.cs new file mode 100644 index 000000000..0a14b1af3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerModeExtensions.cs @@ -0,0 +1,31 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Provides helpers that classify values so pacing, compliance, and feature-gating +/// decisions share one authoritative definition. +/// +public static class DialerModeExtensions +{ + /// + /// Gets a value indicating whether the mode is an automated pacing mode that dials without an agent + /// initiating each call and can therefore abandon a connected party. + /// + /// The dialer mode to classify. + /// for Power, Progressive, and Predictive; otherwise . + public static bool IsAutomated(this DialerMode mode) + { + return mode is DialerMode.Power or DialerMode.Progressive or DialerMode.Predictive; + } + + /// + /// Gets a value indicating whether the mode requires the Contact Center Automated Dialer feature to be + /// enabled before a profile may use it. Predictive is excluded because it is disabled entirely regardless + /// of the feature state. + /// + /// The dialer mode to classify. + /// for Power and Progressive; otherwise . + public static bool RequiresAutomatedDialerFeature(this DialerMode mode) + { + return mode is DialerMode.Power or DialerMode.Progressive; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerSuppressionReason.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerSuppressionReason.cs new file mode 100644 index 000000000..b9a4b618c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerSuppressionReason.cs @@ -0,0 +1,56 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies why the outbound dialer compliance gate suppressed a dialing attempt. +/// +public enum DialerSuppressionReason +{ + /// + /// The attempt is eligible and was not suppressed. + /// + None, + + /// + /// The activity has no preferred destination to dial. + /// + NoDestination, + + /// + /// The activity already reached the configured maximum number of attempts. + /// + MaxAttemptsReached, + + /// + /// A previous attempt is still within the configured retry cool-down window. + /// + RetryCoolDown, + + /// + /// The contact opted out of phone calls through its communication preferences. + /// + DoNotCall, + + /// + /// The destination is listed on a national do-not-call registry. + /// + NationalDoNotCallRegistry, + + /// + /// The contact's local time is outside the configured calling window. + /// + OutsideCallingWindow, + + /// + /// The dialer profile's rolling abandonment rate reached or exceeded its configured cap, or the + /// abandonment statistics required to prove compliance were unavailable. + /// + AbandonmentRateExceeded, + + /// + /// A compliance check could not be completed, so the attempt was not permitted. This is distinct from a + /// check that completed and cleared the destination: a registry that is unreachable has reported nothing, + /// and dialing on the strength of nothing is what this reason exists to prevent. The suppression is not + /// terminal, because the destination has not been shown to be unreachable — only unverified right now. + /// + ComplianceScreeningUnavailable, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/EntryPointClosedAction.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/EntryPointClosedAction.cs new file mode 100644 index 000000000..fae202346 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/EntryPointClosedAction.cs @@ -0,0 +1,28 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies what an inbound entry point does with a call while it is closed (outside business hours or +/// when no agent can take the call). +/// +public enum EntryPointClosedAction +{ + /// + /// Keep the call in the target queue until an agent is available or the queue reopens. + /// + HoldInQueue, + + /// + /// Send the caller to voicemail. + /// + Voicemail, + + /// + /// Route the call to the overflow queue. + /// + Overflow, + + /// + /// Reject the call with an after-hours message. + /// + Reject, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceEvent.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceEvent.cs new file mode 100644 index 000000000..bf7e30e83 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceEvent.cs @@ -0,0 +1,44 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider-agnostic inbound voice event after a telephony provider or PBX webhook has +/// been normalized. It is the entry point the Contact Center uses to create an interaction, resolve +/// CRM context, queue the work, and route it to an available agent. +/// +public sealed class InboundVoiceEvent +{ + /// + /// Gets or sets the technical name of the provider that produced the call. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider-specific identifier of the inbound call. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the address of the caller (the customer's phone number). + /// + public string FromAddress { get; set; } + + /// + /// Gets or sets the address the call was placed to (the dialed number or DID that identifies the channel endpoint). + /// + public string ToAddress { get; set; } + + /// + /// Gets or sets the optional caller display name supplied by the provider. + /// + public string CallerName { get; set; } + + /// + /// Gets or sets the UTC time the inbound call was received. When not supplied, the current time is used. + /// + public DateTime? ReceivedUtc { get; set; } + + /// + /// Gets or sets additional provider metadata to retain on the interaction for troubleshooting. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceRouteOutcome.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceRouteOutcome.cs new file mode 100644 index 000000000..6066b6fb8 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceRouteOutcome.cs @@ -0,0 +1,19 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents the provider-facing outcome of routing an inbound voice event into Contact Center work. +/// +public sealed class InboundVoiceRouteOutcome +{ + /// + /// Gets or sets the identifier of the durable Contact Center interaction created or resolved for the inbound + /// call, or when routing did not produce a durable interaction. + /// + public string InteractionId { get; set; } + + /// + /// Gets a value indicating whether routing produced a durable Contact Center interaction a provider may promote + /// its caller leg against. + /// + public bool HasInteraction => !string.IsNullOrEmpty(InteractionId); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionChannel.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionChannel.cs new file mode 100644 index 000000000..7d1fd4a7d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionChannel.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the channel an interaction is conducted on. +/// +public enum InteractionChannel +{ + /// + /// A voice (telephone) interaction. + /// + Voice, + + /// + /// An SMS/text interaction. + /// + Sms, + + /// + /// An email interaction. + /// + Email, + + /// + /// A web chat or messaging interaction. + /// + Chat, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionDirection.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionDirection.cs new file mode 100644 index 000000000..62c46ae4f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionDirection.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the direction of an interaction relative to the contact center. +/// +public enum InteractionDirection +{ + /// + /// The customer initiated the interaction (for example, an inbound call). + /// + Inbound, + + /// + /// The contact center initiated the interaction (for example, an outbound dial). + /// + Outbound, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionParticipantRole.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionParticipantRole.cs new file mode 100644 index 000000000..1a3dd8076 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionParticipantRole.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the role a participant plays in an interaction. +/// +public enum InteractionParticipantRole +{ + /// + /// The external customer the interaction is with. + /// + Customer, + + /// + /// An agent handling the interaction. + /// + Agent, + + /// + /// A supervisor monitoring or assisting with the interaction. + /// + Supervisor, + + /// + /// An automated system actor (for example, a dialer or virtual agent). + /// + System, + + /// + /// An external party such as a third-party transfer target. + /// + External, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionPriority.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionPriority.cs new file mode 100644 index 000000000..dc48efdaa --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionPriority.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the routing priority of an interaction. Higher values are handled before lower values. +/// +public enum InteractionPriority +{ + /// + /// The lowest routing priority. + /// + Lowest = 0, + + /// + /// A low routing priority. + /// + Low = 1, + + /// + /// The default routing priority. + /// + Normal = 2, + + /// + /// A high routing priority. + /// + High = 3, + + /// + /// The highest routing priority. + /// + Highest = 4, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatus.cs new file mode 100644 index 000000000..41cbb0bd6 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatus.cs @@ -0,0 +1,47 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the communication-session status of an interaction. +/// +public enum InteractionStatus +{ + /// + /// The interaction has been created but no provider session is active yet. + /// + Created, + + /// + /// The interaction is ringing or waiting for the remote party to answer. + /// + Ringing, + + /// + /// The interaction is connected. + /// + Connected, + + /// + /// The interaction is connected but temporarily on hold. + /// + Held, + + /// + /// The interaction is being transferred to another destination. + /// + Transferring, + + /// + /// The interaction has more than two active parties. + /// + Conferenced, + + /// + /// The interaction's communication session ended. + /// + Ended, + + /// + /// The interaction failed due to an error or provider failure. + /// + Failed, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatuses.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatuses.cs new file mode 100644 index 000000000..3ddf0e414 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatuses.cs @@ -0,0 +1,49 @@ +using System.Collections.Immutable; + +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Declares the interaction status sets the hot query paths select on. The sets are inclusive rather than +/// expressed as a chain of inequalities: an inclusive IN can be answered from an index that leads with the +/// status column, while a chain of != tests cannot and forces a scan of every row for the agent. +/// +public static class InteractionStatuses +{ + /// + /// The statuses in which an interaction occupies the agent handling it, and therefore counts against that + /// agent's capacity. A created interaction is excluded because no provider session exists for it yet, and the + /// settled statuses are excluded because the agent has been released. + /// + public static readonly ImmutableArray OccupyingAgent = + [ + InteractionStatus.Ringing, + InteractionStatus.Connected, + InteractionStatus.Held, + InteractionStatus.Transferring, + InteractionStatus.Conferenced, + ]; + + /// + /// The statuses in which an interaction's communication session has not reached an outcome, so provider + /// reconciliation must still ask the provider what happened to it. + /// + public static readonly ImmutableArray Unsettled = + [ + InteractionStatus.Created, + InteractionStatus.Ringing, + InteractionStatus.Connected, + InteractionStatus.Held, + InteractionStatus.Transferring, + InteractionStatus.Conferenced, + ]; + + /// + /// The statuses in which an interaction's communication session has reached an outcome and will not change + /// again. + /// + public static readonly ImmutableArray Settled = + [ + InteractionStatus.Ended, + InteractionStatus.Failed, + ]; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferTargetType.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferTargetType.cs new file mode 100644 index 000000000..ba3ad02e8 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferTargetType.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the kind of destination a live interaction is transferred to. +/// +public enum InteractionTransferTargetType +{ + /// + /// The interaction is transferred to a specific agent. + /// + Agent, + + /// + /// The interaction is transferred to a queue for re-routing. + /// + Queue, + + /// + /// The interaction is transferred to an external destination (for example an external phone number). + /// + External, + + /// + /// The interaction is transferred to an inbound entry point (for example an IVR or announcement). + /// + EntryPoint, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferType.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferType.cs new file mode 100644 index 000000000..814bf893a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferType.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies how a live interaction is transferred to a new destination. +/// +public enum InteractionTransferType +{ + /// + /// The interaction is handed off immediately without the initiating agent speaking to the destination. + /// + Blind, + + /// + /// The initiating agent consults the destination before completing the handoff (warm transfer). + /// + Consultative, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/MonitorMode.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/MonitorMode.cs new file mode 100644 index 000000000..ac8aa811a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/MonitorMode.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the way a supervisor engages a live call. +/// +public enum MonitorMode +{ + /// + /// The supervisor listens silently; neither party hears the supervisor. + /// + Monitor, + + /// + /// The supervisor speaks only to the agent; the customer does not hear the supervisor. + /// + Whisper, + + /// + /// The supervisor joins the call so all parties hear the supervisor. + /// + Barge, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxAcceptanceResult.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxAcceptanceResult.cs new file mode 100644 index 000000000..e11f13376 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxAcceptanceResult.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents the result of accepting a provider webhook delivery into the durable inbox. +/// +public sealed class ProviderWebhookInboxAcceptanceResult +{ + /// + /// Gets or sets the acceptance status. + /// + public ProviderWebhookInboxAcceptanceStatus Status { get; set; } + + /// + /// Gets or sets the durable inbox message identifier when acceptance or duplicate detection succeeded. + /// + public string MessageId { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxAcceptanceStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxAcceptanceStatus.cs new file mode 100644 index 000000000..ba028a6c8 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxAcceptanceStatus.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the result of accepting a provider webhook delivery into the durable inbox. +/// +public enum ProviderWebhookInboxAcceptanceStatus +{ + /// + /// A new durable inbox message was committed. + /// + Accepted, + + /// + /// The delivery was already committed and remains idempotently accepted. + /// + Duplicate, + + /// + /// The delivery could not acquire its distributed acceptance lock. + /// + Busy, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxDelivery.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxDelivery.cs new file mode 100644 index 000000000..47e30a193 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookInboxDelivery.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents an authenticated, normalized provider webhook delivery ready for durable acceptance. +/// +public sealed class ProviderWebhookInboxDelivery +{ + /// + /// Gets or sets the canonical provider technical name. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider-scoped idempotency key for the delivery. + /// + public string DeliveryId { get; set; } + + /// + /// Gets or sets the stable technical name of the inbox handler that owns the payload. + /// + public string HandlerName { get; set; } + + /// + /// Gets or sets the normalized serialized payload. + /// + public string Payload { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookIngressLease.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookIngressLease.cs new file mode 100644 index 000000000..1e0c8097f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderWebhookIngressLease.cs @@ -0,0 +1,41 @@ +using System.Threading.RateLimiting; + +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents an acquired or rejected provider webhook ingress permit. +/// +public sealed class ProviderWebhookIngressLease : IDisposable +{ + private readonly RateLimitLease _lease; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying rate-limit lease. + public ProviderWebhookIngressLease(RateLimitLease lease) + { + _lease = lease; + + if (lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + { + RetryAfter = retryAfter; + } + } + + /// + /// Gets a value indicating whether the webhook may proceed. + /// + public bool IsAcquired => _lease.IsAcquired; + + /// + /// Gets the suggested delay before a rejected request retries, when supplied by the limiter. + /// + public TimeSpan? RetryAfter { get; } + + /// + public void Dispose() + { + _lease.Dispose(); + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueAfterHoursAction.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueAfterHoursAction.cs new file mode 100644 index 000000000..4f35deaa6 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueAfterHoursAction.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies how a queue handles waiting work while its business-hours calendar reports that the queue is closed. +/// +public enum QueueAfterHoursAction +{ + /// + /// Keeps waiting items in the queue and pauses assignment until the queue is open again. + /// + HoldInQueue, + + /// + /// Moves waiting items to the configured overflow queue while the queue is closed. + /// + Overflow, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueItemStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueItemStatus.cs new file mode 100644 index 000000000..021bc4bbf --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueItemStatus.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the lifecycle state of a queued activity waiting for an agent. +/// +public enum QueueItemStatus +{ + /// + /// The item is waiting in the queue for routing. + /// + Waiting, + + /// + /// The item is reserved for an agent and awaiting acceptance. + /// + Reserved, + + /// + /// The item was assigned to an agent. + /// + Assigned, + + /// + /// The item left the queue because work completed. + /// + Completed, + + /// + /// The item left the queue without being completed (overflow, abandon, cancel). + /// + Removed, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueRoutingStrategy.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueRoutingStrategy.cs new file mode 100644 index 000000000..2689e2308 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueRoutingStrategy.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the strategy a queue uses to pick which available agent receives the next queued activity. +/// +public enum QueueRoutingStrategy +{ + /// + /// Offers work to the agent who has been available the longest. + /// + LongestIdle, + + /// + /// Distributes work fairly by offering to the agent who least recently received an assignment. + /// + RoundRobin, + + /// + /// Offers work to the agent currently handling the fewest active interactions. + /// + LeastBusy, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueStatsNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueStatsNotification.cs new file mode 100644 index 000000000..fccde865a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueStatsNotification.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Describes the current depth of a queue broadcast to the queue's watchers and supervisor dashboards so +/// wallboards and the agent desktop can show live waiting counts. +/// +public sealed class QueueStatsNotification +{ + /// + /// Gets or sets the identifier of the queue the statistics describe. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the number of items currently waiting in the queue. + /// + public int WaitingCount { get; set; } + + /// + /// Gets or sets the UTC time the statistics were calculated. + /// + public DateTime ChangedUtc { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ReservationStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ReservationStatus.cs new file mode 100644 index 000000000..b8568f750 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ReservationStatus.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the lifecycle state of an agent/activity reservation. +/// +public enum ReservationStatus +{ + /// + /// The reservation is active and awaiting agent acceptance. + /// + Pending, + + /// + /// The reservation was accepted and converted into an assignment. + /// + Accepted, + + /// + /// The reservation was rejected by the agent. + /// + Rejected, + + /// + /// The reservation expired before it was accepted. + /// + Expired, + + /// + /// The reservation was canceled by the system or a supervisor. + /// + Canceled, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/UnansweredOfferAction.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/UnansweredOfferAction.cs new file mode 100644 index 000000000..3a26ad200 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/UnansweredOfferAction.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies what a queue should do when an offered reservation expires before the agent accepts it. +/// +public enum UnansweredOfferAction +{ + /// + /// Returns the work item to the queue so it can be offered again. + /// + Requeue, + + /// + /// Sends the live voice call to voicemail and removes the work item from the queue. + /// + Voicemail, + + /// + /// Rejects the live voice call and removes the work item from the queue. + /// + Reject, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/VoiceProviderDeliveryModel.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/VoiceProviderDeliveryModel.cs new file mode 100644 index 000000000..aac138577 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/VoiceProviderDeliveryModel.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies how a Contact Center voice provider delivers a live call to the selected agent. The +/// orchestration layer must branch on this value to decide whether it has to bridge media to the +/// agent itself or whether the provider already rings the agent's device. +/// +public enum VoiceProviderDeliveryModel +{ + /// + /// The provider rings the agent's own registered device or soft-phone client (for example WebRTC). + /// The live call already reaches the agent, so the Contact Center reserves, offers, and tracks the + /// work, but the agent answers the media on their device and the platform does not bridge it. + /// + AgentDeviceNative, + + /// + /// The provider parks or queues the live call server-side. The Contact Center must explicitly ask + /// the provider to connect (bridge) the call to the selected agent once the offer is accepted. + /// + ServerSideAcd, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs index 9d24ec78a..5b72cfeed 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs @@ -1,7 +1,7 @@ -using CrestApps.OrchardCore.Telephony; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; -namespace Microsoft.Extensions.DependencyInjection; +namespace CrestApps.OrchardCore.Telephony.Extensions; /// /// Provides extension methods to register telephony providers with the dependency injection container. diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs new file mode 100644 index 000000000..a3eff8524 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Contributes contextual cards shown alongside a ringing inbound call in the soft-phone +/// incoming-call modal. Other modules implement this contract to surface related records, such as a +/// Contact Center showing customers matched by the caller's phone number, without the Telephony +/// module taking a dependency on them. +/// +public interface IIncomingCallContextProvider +{ + /// + /// Contributes cards for the ringing inbound call described by the supplied context. + /// + /// The contribution context that carries the call and accepts the cards. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. + Task ContributeAsync(IncomingCallContributionContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs new file mode 100644 index 000000000..20b1c3859 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Pushes a ringing inbound call to a specific user's connected soft phone. The dispatcher gathers +/// the contextual cards from the registered instances and +/// raises the incoming-call modal on every soft-phone connection the user currently has open. +/// +public interface IIncomingCallDispatcher +{ + /// + /// Offers the ringing inbound call to the specified user's soft phone. + /// + /// The identifier of the user the call is offered to. + /// The ringing inbound call. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. + Task DispatchAsync(string userId, TelephonyCall call, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IProviderIdentityProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IProviderIdentityProvider.cs new file mode 100644 index 000000000..6950d4ad1 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IProviderIdentityProvider.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Contributes canonical provider identities so that voice ingress can resolve provider aliases to a +/// single stable technical name without referencing provider implementation assemblies. Provider modules +/// implement this contract to register their canonical name and any alternate runtime names. +/// +public interface IProviderIdentityProvider +{ + /// + /// Gets the canonical provider identities contributed by the implementing provider module. + /// + /// The canonical provider identities and their aliases. + IEnumerable GetIdentities(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IRecordingMediaStore.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IRecordingMediaStore.cs new file mode 100644 index 000000000..68cded861 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IRecordingMediaStore.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Persists completed conversation recordings into a durable media store, encrypting them at rest. The store +/// is provider-neutral and pluggable: any voice provider can ingest recordings through the same contract, and +/// concrete backends (local encrypted files, cloud blob storage, and so on) can be swapped without changing +/// callers. Recording bytes are never stored inside the Contact Center orchestration data; the orchestration +/// layer keeps only the opaque storage key that this store maps back to the encrypted media. +/// +public interface IRecordingMediaStore +{ + /// + /// Encrypts and stores the supplied recording bytes at rest, keyed by the request's deterministic storage + /// key. The operation is idempotent for a given storage key: re-storing the same recording overwrites the + /// previously persisted bytes rather than creating a duplicate. + /// + /// The recording bytes together with the deterministic key and correlation metadata. + /// The token to monitor for cancellation requests. + /// The opaque storage reference that addresses the stored recording for later reads or deletion. + Task StoreAsync(RecordingMediaWriteRequest request, CancellationToken cancellationToken = default); + + /// + /// Opens a readable, decrypted stream over a previously stored recording. + /// + /// The storage reference returned when the recording was stored. + /// The token to monitor for cancellation requests. + /// + /// A readable stream of the decrypted recording bytes, or when no recording is stored + /// for the supplied reference. + /// + Task OpenReadAsync(string storageReference, CancellationToken cancellationToken = default); + + /// + /// Deletes a stored recording. The operation is idempotent: deleting a recording that is already absent is + /// treated as a successful no-op so a right-to-erasure request can be safely retried. + /// + /// The storage reference returned when the recording was stored. + /// The token to monitor for cancellation requests. + /// + /// when the store confirms that no media remains for the supplied reference; otherwise, + /// when deletion could not be confirmed. + /// + Task DeleteAsync(string storageReference, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneCredentialRevoker.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneCredentialRevoker.cs new file mode 100644 index 000000000..5d4915d5b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneCredentialRevoker.cs @@ -0,0 +1,26 @@ +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Revokes the browser soft-phone credentials owned by an authenticated user. Providers that mint +/// short-lived, server-owned browser SIP credentials implement this so the credentials can be torn +/// down on sign-out or session termination instead of lingering until natural expiry. +/// +public interface ISoftPhoneCredentialRevoker +{ + /// + /// Gets the technical provider name handled by this revoker. + /// + string ProviderName { get; } + + /// + /// Revokes every live browser credential owned by the specified authenticated user. + /// + /// The authenticated user identifier whose credentials must be revoked. + /// The reason recorded for the revocation. + /// The token to monitor for cancellation requests. + /// The number of credentials revoked for the user. + Task RevokeForUserAsync( + string userId, + string reason, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneRegistrationConfigContributor.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneRegistrationConfigContributor.cs new file mode 100644 index 000000000..f5b5658f0 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneRegistrationConfigContributor.cs @@ -0,0 +1,24 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Builds provider-specific browser soft-phone registration configuration for the active tenant. +/// +public interface ISoftPhoneRegistrationConfigContributor +{ + /// + /// Gets the technical provider name handled by this contributor. + /// + string ProviderName { get; } + + /// + /// Builds a short-lived browser registration configuration for the current soft-phone session. + /// + /// The registration request context. + /// The cancellation token. + /// The registration configuration, or when the provider is unavailable. + Task BuildAsync( + SoftPhoneRegistrationConfigContext context, + CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISupportsTenantMediaPurge.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISupportsTenantMediaPurge.cs new file mode 100644 index 000000000..f30904a67 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISupportsTenantMediaPurge.cs @@ -0,0 +1,14 @@ +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Provides tenant-wide recording media cleanup for an . +/// +public interface ISupportsTenantMediaPurge +{ + /// + /// Deletes every recording owned by the current tenant. + /// + /// The token to monitor for cancellation requests. + /// when all tenant media was removed; otherwise, . + Task TryPurgeAllAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAttendedTransferProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAttendedTransferProvider.cs new file mode 100644 index 000000000..ed6eb0a16 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAttendedTransferProvider.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the attended (warm) transfer operation a telephony provider supports, where the transferring +/// party consults the destination before the call is released to it. +/// +public interface ITelephonyAttendedTransferProvider +{ + /// + /// Starts an attended transfer of an active call to another destination. + /// + /// The transfer request describing the destination. + /// The cancellation token. + /// A describing the outcome. + Task StartAttendedTransferAsync(TransferRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAudioProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAudioProvider.cs new file mode 100644 index 000000000..ba54f9905 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAudioProvider.cs @@ -0,0 +1,24 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Describes how a telephony provider delivers live call audio to the agent. +/// +public interface ITelephonyAudioProvider +{ + /// + /// Gets the audio delivery modes supported by the provider. + /// + TelephonyAudioCapabilities AudioCapabilities { get; } + + /// + /// Gets the provider-configured audio delivery mode when more than one mode is supported. + /// + TelephonyAudioMode ConfiguredAudioMode { get; } + + /// + /// Gets the browser media adapter name registered by the provider when browser audio is supported. + /// + string BrowserMediaAdapterName { get; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallControlProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallControlProvider.cs new file mode 100644 index 000000000..7b6c76452 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallControlProvider.cs @@ -0,0 +1,26 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the call-control operations a telephony provider supports. A provider that cannot place or +/// end calls simply does not implement this contract, and the soft phone refuses those operations. +/// +public interface ITelephonyCallControlProvider +{ + /// + /// Places an outbound call. + /// + /// The dial request describing the destination and caller identifier. + /// The cancellation token. + /// A describing the placed call or the failure reason. + Task DialAsync(DialRequest request, CancellationToken cancellationToken = default); + + /// + /// Ends an active call. + /// + /// A reference to the call to end. + /// The cancellation token. + /// A describing the outcome. + Task HangupAsync(CallReference call, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs new file mode 100644 index 000000000..30a093d79 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Exposes provider-truth call-state lookups for telephony providers that can query the current server +/// state of a live call. +/// +public interface ITelephonyCallStateProvider +{ + /// + /// Queries the provider for the current state of the specified call. + /// + /// The provider call identifier. + /// The token to monitor for cancellation requests. + /// The lookup result describing whether the call was found and, when available, its current state. + Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs index 1350d8e93..52c4053c2 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs @@ -19,8 +19,9 @@ public interface ITelephonyClient /// Notifies the client that an inbound call is ringing. /// /// The inbound call. + /// The contextual cards contributed for the call, such as matched customers. /// A task that represents the asynchronous operation. - Task IncomingCall(TelephonyCall call); + Task IncomingCall(TelephonyCall call, IncomingCallContext context); /// /// Notifies the client that the provider issued new connection credentials. diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCommandExecutor.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCommandExecutor.cs new file mode 100644 index 000000000..a1c1b0585 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCommandExecutor.cs @@ -0,0 +1,18 @@ +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes a telephony provider mutation with a bounded server-owned cancellation token. +/// +public interface ITelephonyCommandExecutor +{ + /// + /// Executes a telephony provider mutation independently of caller or connection cancellation. + /// + /// The operation result type. + /// The operation that receives the bounded server-owned cancellation token. + /// The operation result. + /// Thrown when the operation is not confirmed before the server-owned deadline expires. + /// Thrown when the host is shutting down before the operation is dispatched; the provider is never contacted, so the command is guaranteed not to have been applied. + /// Thrown when the operation is interrupted after dispatch because the host is shutting down; the provider outcome is indeterminate. + Task ExecuteAsync(Func> operation); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyConferenceProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyConferenceProvider.cs new file mode 100644 index 000000000..e51d5e1a7 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyConferenceProvider.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the conference operations a telephony provider supports. +/// +public interface ITelephonyConferenceProvider +{ + /// + /// Merges two active calls into a single conference. + /// + /// The merge request describing the calls to join. + /// The cancellation token. + /// A describing the outcome. + Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDirectoryProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDirectoryProvider.cs new file mode 100644 index 000000000..c424989cf --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDirectoryProvider.cs @@ -0,0 +1,16 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Defines optional provider directory lookup support for transfer destinations. +/// +public interface ITelephonyDirectoryProvider +{ + /// + /// Gets directory entries that can be used as call-transfer destinations. + /// + /// The cancellation token. + /// The provider directory lookup result. + Task GetDirectoryAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDtmfProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDtmfProvider.cs new file mode 100644 index 000000000..05144cb5b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDtmfProvider.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the DTMF operations a telephony provider supports. +/// +public interface ITelephonyDtmfProvider +{ + /// + /// Sends DTMF digits to an active call. + /// + /// The request describing the call and the digits to send. + /// The cancellation token. + /// A describing the outcome. + Task SendDigitsAsync(SendDigitsRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyHoldProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyHoldProvider.cs new file mode 100644 index 000000000..5b8507e5f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyHoldProvider.cs @@ -0,0 +1,25 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the hold and resume operations a telephony provider supports. +/// +public interface ITelephonyHoldProvider +{ + /// + /// Places an active call on hold. + /// + /// A reference to the call to place on hold. + /// The cancellation token. + /// A describing the outcome. + Task HoldAsync(CallReference call, CancellationToken cancellationToken = default); + + /// + /// Resumes a call that is currently on hold. + /// + /// A reference to the call to resume. + /// The cancellation token. + /// A describing the outcome. + Task ResumeAsync(CallReference call, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInboundCallProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInboundCallProvider.cs new file mode 100644 index 000000000..62dfef0e3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInboundCallProvider.cs @@ -0,0 +1,26 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the inbound-call operations a telephony provider supports. A provider that only places +/// outbound calls does not implement this contract. +/// +public interface ITelephonyInboundCallProvider +{ + /// + /// Answers a ringing inbound call. + /// + /// A reference to the inbound call to answer. + /// The cancellation token. + /// A describing the outcome. + Task AnswerAsync(CallReference call, CancellationToken cancellationToken = default); + + /// + /// Rejects a ringing inbound call. + /// + /// A reference to the inbound call to reject. + /// The cancellation token. + /// A describing the outcome. + Task RejectAsync(CallReference call, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs index 6ceec1403..fa7fe0e99 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs @@ -17,13 +17,59 @@ public interface ITelephonyInteractionStore /// /// Updates an existing interaction. The write is guarded by an optimistic-concurrency check, so a - /// caller that mutated a stale copy fails instead of silently discarding a concurrent update. + /// caller that mutated a stale copy fails loudly instead of silently discarding a concurrent update. /// /// The interaction to update. /// The cancellation token. /// A task that represents the asynchronous operation. Task UpdateAsync(TelephonyInteraction interaction, CancellationToken cancellationToken = default); + /// + /// Applies a mutation to the interaction with the given identifier inside a dedicated session, re-reading + /// and reapplying the mutation whenever a concurrent writer commits first. + /// + /// The interaction identifier. + /// + /// The mutation to apply to the freshly read interaction. Returning abandons the + /// attempt without writing, which lets a caller decline based on state it can only observe after the read. + /// + /// The cancellation token. + /// + /// The interaction as it was read and mutated, or when no interaction matches. + /// + Task UpdateByIdAsync( + string interactionId, + Func mutate, + CancellationToken cancellationToken = default); + + /// + /// Applies a mutation to the interaction for the given provider and provider call identifier inside a + /// dedicated session, re-reading and reapplying the mutation whenever a concurrent writer commits first. + /// + /// The technical provider name. + /// The provider-specific call identifier. + /// + /// The mutation to apply to the freshly read interaction. Returning abandons the + /// attempt without writing, which lets a caller decline based on state it can only observe after the read. + /// + /// The cancellation token. + /// + /// The interaction as it was read and mutated, or when no interaction matches. + /// + Task UpdateByProviderCallIdAsync( + string providerName, + string callId, + Func mutate, + CancellationToken cancellationToken = default); + + /// + /// Deletes an interaction that no longer exists at the telephony provider. + /// + /// The interaction to delete. + /// The cancellation token. + /// A task that represents the asynchronous operation. + Task DeleteAsync(TelephonyInteraction interaction, CancellationToken cancellationToken = default); + /// /// Finds the interaction for the given user and provider call identifier. /// @@ -33,6 +79,49 @@ public interface ITelephonyInteractionStore /// The interaction, or when none matches. Task FindByCallIdAsync(string userId, string callId, CancellationToken cancellationToken = default); + /// + /// Finds the interaction for the given provider and provider call identifier, regardless of the + /// current user's connection state. + /// + /// The technical provider name. + /// The provider-specific call identifier. + /// The cancellation token. + /// The interaction, or when none matches. + Task FindByProviderCallIdAsync(string providerName, string callId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent in-progress interaction for the given user. + /// + /// The user identifier. + /// The cancellation token. + /// The active interaction, or when none matches. + Task FindActiveByUserAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Lists all in-progress interactions for the given user. + /// + /// The user identifier. + /// The cancellation token. + /// The user's active interactions, newest first. + Task> ListActiveByUserAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Lists in-progress interactions that can be reconciled against their providers, oldest first and bounded for reconciliation sweeps. + /// + /// The maximum number of interactions to return. + /// The cancellation token. + /// The oldest active interactions bounded by . + Task> ListActiveAsync(int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists in-progress interactions for the specified provider, oldest first and bounded for reconciliation sweeps. + /// + /// The technical provider name. + /// The maximum number of interactions to return. + /// The cancellation token. + /// The oldest active interactions for the provider bounded by . + Task> ListActiveAsync(string providerName, int maxCount, CancellationToken cancellationToken = default); + /// /// Gets the most recent interactions for the given user, newest first. /// diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs new file mode 100644 index 000000000..fc829442b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Reconciles locally persisted telephony interactions with provider-authoritative call state. +/// +public interface ITelephonyInteractionSynchronizationService +{ + /// + /// Gets the current provider-authoritative call for a user. + /// + /// The user identifier. + /// The cancellation token. + /// The provider call lookup result. + Task GetActiveCallAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Gets every provider-authoritative active call for a user. + /// + /// The user identifier. + /// The cancellation token. + /// The provider call-list lookup result. + Task GetActiveCallsAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Reconciles all active telephony interactions. + /// + /// The cancellation token. + /// The number of interactions whose persisted state changed. + Task ReconcileActiveInteractionsAsync(CancellationToken cancellationToken = default); + + /// + /// Reconciles active telephony interactions for one provider. + /// + /// The technical provider name. + /// The cancellation token. + /// The number of interactions whose persisted state changed. + Task ReconcileProviderInteractionsAsync(string providerName, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyMuteProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyMuteProvider.cs new file mode 100644 index 000000000..5a1da952e --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyMuteProvider.cs @@ -0,0 +1,25 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the mute and unmute operations a telephony provider supports. +/// +public interface ITelephonyMuteProvider +{ + /// + /// Mutes the local audio of an active call. + /// + /// A reference to the call to mute. + /// The cancellation token. + /// A describing the outcome. + Task MuteAsync(CallReference call, CancellationToken cancellationToken = default); + + /// + /// Unmutes the local audio of an active call. + /// + /// A reference to the call to unmute. + /// The cancellation token. + /// A describing the outcome. + Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs index 95cc4fda1..fad00b5a2 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs @@ -4,8 +4,9 @@ namespace CrestApps.OrchardCore.Telephony; /// -/// Defines the provider-agnostic operations a telephony provider must implement so the soft phone -/// can control calls through any configured provider. +/// Identifies a telephony provider and the capabilities it advertises. Executable operations live on the +/// separate capability contracts a provider chooses to implement, so a provider is never obliged to answer +/// for an operation it cannot perform. /// public interface ITelephonyProvider { @@ -15,102 +16,8 @@ public interface ITelephonyProvider LocalizedString Name { get; } /// - /// Gets the set of operations the provider supports. + /// Gets the set of operations the provider supports. Advertising a capability is not sufficient on its + /// own: the provider must also implement the matching executable contract or the operation fails closed. /// TelephonyCapabilities Capabilities { get; } - - /// - /// Places an outbound call. - /// - /// The dial request describing the destination and caller identifier. - /// The cancellation token. - /// A describing the placed call or the failure reason. - Task DialAsync(DialRequest request, CancellationToken cancellationToken = default); - - /// - /// Ends an active call. - /// - /// A reference to the call to end. - /// The cancellation token. - /// A describing the outcome. - Task HangupAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Places an active call on hold. - /// - /// A reference to the call to place on hold. - /// The cancellation token. - /// A describing the outcome. - Task HoldAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Resumes a call that is currently on hold. - /// - /// A reference to the call to resume. - /// The cancellation token. - /// A describing the outcome. - Task ResumeAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Mutes the local audio of an active call. - /// - /// A reference to the call to mute. - /// The cancellation token. - /// A describing the outcome. - Task MuteAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Unmutes the local audio of an active call. - /// - /// A reference to the call to unmute. - /// The cancellation token. - /// A describing the outcome. - Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Transfers an active call to another destination. - /// - /// The transfer request describing the destination and transfer mode. - /// The cancellation token. - /// A describing the outcome. - Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default); - - /// - /// Merges two active calls into a single conference. - /// - /// The merge request describing the calls to join. - /// The cancellation token. - /// A describing the outcome. - Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default); - - /// - /// Sends DTMF digits to an active call. - /// - /// The request describing the call and the digits to send. - /// The cancellation token. - /// A describing the outcome. - Task SendDigitsAsync(SendDigitsRequest request, CancellationToken cancellationToken = default); - - /// - /// Answers a ringing inbound call. - /// - /// A reference to the inbound call to answer. - /// The cancellation token. - /// A describing the outcome. - Task AnswerAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Rejects a ringing inbound call. - /// - /// A reference to the inbound call to reject. - /// The cancellation token. - /// A describing the outcome. - Task RejectAsync(CallReference call, CancellationToken cancellationToken = default); - - /// - /// Issues the bootstrap configuration a soft phone client needs to connect to the provider. - /// - /// The cancellation token. - /// The for the provider. - Task GetClientCredentialsAsync(CancellationToken cancellationToken = default); } diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs index a59b60d8c..1d2107abd 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs @@ -96,6 +96,14 @@ public interface ITelephonyService /// A describing the outcome. Task RejectAsync(CallReference call, CancellationToken cancellationToken = default); + /// + /// Sends a ringing inbound call to voicemail using the default provider. + /// + /// A reference to the inbound call to send to voicemail. + /// The cancellation token. + /// A describing the outcome. + Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default); + /// /// Issues the bootstrap configuration a soft phone client needs to connect to the default provider. /// @@ -103,6 +111,13 @@ public interface ITelephonyService /// The for the default provider. Task GetClientCredentialsAsync(CancellationToken cancellationToken = default); + /// + /// Gets transfer destinations from the configured provider directory. + /// + /// The cancellation token. + /// The provider directory lookup result. + Task GetDirectoryAsync(CancellationToken cancellationToken = default); + /// /// Gets the capabilities of the configured default provider. /// diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonySoftPhoneCredentialsProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonySoftPhoneCredentialsProvider.cs new file mode 100644 index 000000000..87d09f35f --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonySoftPhoneCredentialsProvider.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Issues the bootstrap configuration a soft phone client needs to connect to a telephony provider. A +/// provider that is driven only from the server, with no browser client, does not implement this contract. +/// +public interface ITelephonySoftPhoneCredentialsProvider +{ + /// + /// Issues the bootstrap configuration a soft phone client needs to connect to the provider. + /// + /// The cancellation token. + /// The for the provider. + Task GetClientCredentialsAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyTransferProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyTransferProvider.cs new file mode 100644 index 000000000..7ef6321d3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyTransferProvider.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the blind transfer operation a telephony provider supports. Attended transfer is a separate +/// contract, because a provider can release a call to a destination without being able to consult it first. +/// +public interface ITelephonyTransferProvider +{ + /// + /// Transfers an active call to another destination without consulting the destination first. + /// + /// The transfer request describing the destination. + /// The cancellation token. + /// A describing the outcome. + Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyVoicemailProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyVoicemailProvider.cs new file mode 100644 index 000000000..7d3c3312a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyVoicemailProvider.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Executes the voicemail operations a telephony provider supports. +/// +public interface ITelephonyVoicemailProvider +{ + /// + /// Sends a ringing inbound call to voicemail. + /// + /// A reference to the inbound call to send to voicemail. + /// The cancellation token. + /// A describing the outcome. + Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/AnswerClassification.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/AnswerClassification.cs new file mode 100644 index 000000000..de9ac3aff --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/AnswerClassification.cs @@ -0,0 +1,30 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Identifies the provider-neutral outcome of automated answer detection for an outbound voice call. +/// AMD (Answering Machine Detection) classifies how or whether the remote party answered so the +/// dialer, compliance, and analytics layers can take the correct follow-up action regardless of +/// the telephony provider that reported it. +/// +public enum AnswerClassification +{ + /// + /// A live person answered the call. + /// + Human, + + /// + /// An answering machine or voicemail greeting was detected instead of a live person. + /// + Machine, + + /// + /// A fax machine tone was detected. + /// + Fax, + + /// + /// Detection completed but the outcome could not be determined with sufficient confidence. + /// + Unknown, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs index 7823bd1f1..c5779c1ca 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs @@ -9,4 +9,11 @@ public sealed class CallReference /// Gets or sets the provider-specific identifier of the call. /// public string CallId { get; set; } + + /// + /// Gets or sets optional provider-neutral metadata associated with the call action. + /// Providers can inspect this bag for routing or policy hints without requiring new shared + /// interface properties for each integration-specific scenario. + /// + public IDictionary Metadata { get; set; } } diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/HangupCause.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/HangupCause.cs new file mode 100644 index 000000000..2f5037e4e --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/HangupCause.cs @@ -0,0 +1,60 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Identifies the provider-neutral reason a voice call ended. Providers report their own release +/// causes — Q.850 cause codes, textual release reasons, or vendor-specific tokens — which are +/// normalized into these values so outbound compliance reporting, abandon analytics, and retry +/// policy can reason about how a call ended independently of the provider that ended it. +/// +public enum HangupCause +{ + /// + /// The provider ended the call without reporting any release cause. This value exists so an + /// unreported cause is recorded honestly rather than being silently reported as a normal + /// clearing; it must never be produced when the provider did report a cause. + /// + Unknown = 0, + + /// + /// The call was answered and then released normally by one of the parties. + /// + NormalClearing = 1, + + /// + /// The remote party was busy. + /// + Busy = 2, + + /// + /// The call alerted the remote party but was never answered. + /// + NoAnswer = 3, + + /// + /// The remote party or the network explicitly rejected the call. + /// + Rejected = 4, + + /// + /// The call could not be completed because the network or a switch was congested, or no + /// circuit was available. Unlike , a congested call is normally retryable. + /// + Congestion = 5, + + /// + /// The call failed for a reason that is not expected to succeed on retry, such as an + /// unallocated number, an invalid number format, or an incompatible destination. + /// + Failed = 6, + + /// + /// The originating side abandoned the call before it was answered. + /// + Canceled = 7, + + /// + /// The call was answered by an answering machine, voicemail greeting, or fax tone rather than + /// by a live person. + /// + AnsweringMachine = 8, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs new file mode 100644 index 000000000..8547ef02a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs @@ -0,0 +1,66 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents a record a module contributes to an incoming-call modal, such as a customer matched by +/// the caller's phone number. Cards let other modules (for example the Contact Center) enrich the +/// incoming-call experience with related records and shortcuts without the Telephony module taking a +/// dependency on them. +/// +public sealed class IncomingCallCard +{ + /// + /// Gets or sets the stable identifier of the card, unique within a single incoming-call context. + /// + public string Id { get; set; } + + /// + /// Gets or sets the primary title of the card, such as the matched contact's display name. + /// + public string Title { get; set; } + + /// + /// Gets or sets the optional secondary text of the card, such as the matched phone number. + /// + public string Subtitle { get; set; } + + /// + /// Gets or sets the optional descriptive text shown under the title and subtitle. + /// + public string Description { get; set; } + + /// + /// Gets or sets the optional CSS class of the icon shown for the card. + /// + public string Icon { get; set; } + + /// + /// Gets or sets the optional URL the agent can open as a shortcut, such as the matched contact content item. + /// When set, the modal renders an answer-and-open action that answers the call and opens this URL. + /// + public string Url { get; set; } + + /// + /// Gets or sets a value indicating whether opens in a new browser tab. Defaults to . + /// + public bool OpenInNewTab { get; set; } = true; + + /// + /// Gets or sets the contributing source name, used for grouping and diagnostics. + /// + public string Source { get; set; } + + /// + /// Gets or sets the sort priority of the card. Cards with a lower value are shown first. + /// + public int Priority { get; set; } + + /// + /// Gets or sets the badges shown on the card, such as a queue name or a tag. + /// + public IList Badges { get; set; } = []; + + /// + /// Gets or sets additional links shown for the card, such as related records or actions. + /// + public IList Links { get; set; } = []; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs new file mode 100644 index 000000000..aa60acaec --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs @@ -0,0 +1,29 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents a link a module contributes to an incoming-call card, such as a shortcut that opens a +/// related record. Links are rendered as clickable actions next to the matched record in the +/// incoming-call modal. +/// +public sealed class IncomingCallCardLink +{ + /// + /// Gets or sets the visible text of the link. + /// + public string Text { get; set; } + + /// + /// Gets or sets the URL the link navigates to. + /// + public string Url { get; set; } + + /// + /// Gets or sets the optional CSS class of the icon shown next to the link. + /// + public string Icon { get; set; } + + /// + /// Gets or sets a value indicating whether the link opens in a new browser tab. Defaults to . + /// + public bool OpenInNewTab { get; set; } = true; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs new file mode 100644 index 000000000..00b137127 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs @@ -0,0 +1,24 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents the contextual information shown alongside a ringing inbound call in the soft-phone +/// incoming-call modal. Modules contribute the cards through an +/// . The context is serialized to the soft-phone client. +/// +public sealed class IncomingCallContext +{ + /// + /// Gets or sets the optional heading shown above the contributed cards. + /// + public string Heading { get; set; } + + /// + /// Gets or sets the cards contributed for the incoming call, ordered for display. + /// + public IList Cards { get; set; } = []; + + /// + /// Gets or sets additional metadata contributors can attach for the client, such as a queue name. + /// + public IDictionary Properties { get; set; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs new file mode 100644 index 000000000..c88adfc48 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs @@ -0,0 +1,50 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Carries the state an uses to contribute cards for a +/// ringing inbound call. Providers add cards to and may share state through +/// . +/// +public sealed class IncomingCallContributionContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The ringing inbound call. + /// The identifier of the user the call is being offered to. + public IncomingCallContributionContext(TelephonyCall call, string userId) + { + Call = call; + UserId = userId; + } + + /// + /// Gets the ringing inbound call the cards are contributed for. + /// + public TelephonyCall Call { get; } + + /// + /// Gets the identifier of the user the call is being offered to. + /// + public string UserId { get; } + + /// + /// Gets or sets the optional heading shown above the contributed cards. + /// + public string Heading { get; set; } + + /// + /// Gets the cards contributed so far. Providers add their cards to this collection. + /// + public IList Cards { get; } = []; + + /// + /// Gets the metadata contributors attach for the client, such as a queue name or offer lifecycle URLs. + /// + public IDictionary Properties { get; } = new Dictionary(); + + /// + /// Gets a mutable bag providers can use to share state while contributing cards. + /// + public IDictionary Items { get; } = new Dictionary(); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs index 54983a162..7f8e9d684 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs @@ -1,22 +1,29 @@ namespace CrestApps.OrchardCore.Telephony.Models; /// -/// Represents a request to merge two active calls into a single conference. +/// Represents a request to merge active calls into a single conference. /// public sealed class MergeRequest { /// - /// Gets or sets the identifier of the primary call that hosts the conference. + /// Gets or sets the identifiers of the calls to merge. /// - public string PrimaryCallId { get; set; } + public IReadOnlyList CallIds { get; set; } = []; /// - /// Gets or sets the identifier of the secondary call to merge into the conference. + /// Gets or sets an optional name for the resulting conference. /// - public string SecondaryCallId { get; set; } + public string ConferenceName { get; set; } /// - /// Gets or sets an optional name for the resulting conference. + /// Gets the distinct, non-empty call identifiers to merge. /// - public string ConferenceName { get; set; } + /// The call identifiers to merge. + public IReadOnlyList GetCallIds() + { + return (CallIds ?? []) + .Where(callId => !string.IsNullOrWhiteSpace(callId)) + .Distinct(StringComparer.Ordinal) + .ToList(); + } } diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderIdentity.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderIdentity.cs new file mode 100644 index 000000000..b972d0f40 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderIdentity.cs @@ -0,0 +1,36 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes the canonical technical identity of a telephony or voice provider together with the +/// alternate names (aliases) that resolve to it. A single provider family can register multiple +/// runtime names (for example a tenant-configured provider and a configuration-backed default +/// provider) that must all map to one stable identity before it is used to build inbox, event, or +/// call keys. +/// +public sealed class ProviderIdentity +{ + /// + /// Initializes a new instance of the class. + /// + /// The stable canonical technical name of the provider. + /// The alternate provider names that resolve to . + public ProviderIdentity(string canonicalName, params string[] aliases) + { + ArgumentException.ThrowIfNullOrEmpty(canonicalName); + + CanonicalName = canonicalName; + Aliases = aliases is null || aliases.Length == 0 + ? [] + : aliases; + } + + /// + /// Gets the stable canonical technical name of the provider. + /// + public string CanonicalName { get; } + + /// + /// Gets the alternate provider names that resolve to . + /// + public IReadOnlyCollection Aliases { get; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderVoiceEvent.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderVoiceEvent.cs new file mode 100644 index 000000000..585b17fc1 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderVoiceEvent.cs @@ -0,0 +1,199 @@ +using System.Collections; +using System.Collections.Concurrent; +using System.Collections.Frozen; +using System.Collections.Immutable; + +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents a provider-neutral voice event after a telephony provider or PBX webhook has been +/// normalized. It is the single entry point through which provider call-state changes (ringing, +/// answered, held, transferred, ended, failed) flow into every consumer projection, so each projection +/// built on the same provider stream stays in sync regardless of the provider. +/// +/// +/// The type is immutable. It is a public provider contract that ingestion also has to adjust — the provider +/// identity is canonicalized and the idempotency key is scoped by it — and while it was mutable those +/// adjustments were applied to the caller's own instance, so ingestion had to defend itself with a hand-written +/// copy whose completeness was a separate thing to get wrong. It was got wrong once: the copy dropped +/// , and because a session infers a cause when none is supplied, every call reported +/// the inferred cause instead of the one the provider gave, with nothing anywhere to say the real one was lost. +/// Adjustments are now made with , which copies every member by construction. +/// +public sealed record ProviderVoiceEvent +{ + private static readonly MetadataSnapshot _emptyMetadata = + new(new Dictionary(StringComparer.Ordinal)); + + private readonly IReadOnlyDictionary _metadata = _emptyMetadata; + + /// + /// Gets the technical name of the provider that produced the event. + /// + public string ProviderName { get; init; } + + /// + /// Gets the provider-specific identifier of the call the event relates to. + /// + public string ProviderCallId { get; init; } + + /// + /// Gets the provider-specific call leg identifier, when the channel has leg-level tracking. + /// + public string ProviderLegId { get; init; } + + /// + /// Gets the normalized call state the event represents. + /// + public VoiceCallState State { get; init; } + + /// + /// Gets the address of the calling party, when supplied. + /// + public string FromAddress { get; init; } + + /// + /// Gets the address of the called party, when supplied. + /// + public string ToAddress { get; init; } + + /// + /// Gets the UTC time the event occurred. When not supplied, the current time is used. + /// + public DateTime? OccurredUtc { get; init; } + + /// + /// Gets an idempotency key that uniquely identifies this provider event so duplicate + /// deliveries can be de-duplicated. When set, replays of the same event are ignored. + /// + public string IdempotencyKey { get; init; } + + /// + /// Gets an optional provider-supplied monotonic sequence number for the call stream. When + /// supplied, ingestion uses it as the authoritative ordering high-water mark and rejects stale or + /// equal-order deliveries. Providers that only supply timestamps or idempotency keys leave it + /// and ingestion falls back to timestamp-based ordering. + /// + public long? SequenceNumber { get; init; } + + /// + /// Gets a value indicating whether the provider reports the call as muted. + /// When , the event does not change the current mute state. + /// + public bool? IsMuted { get; init; } + + /// + /// Gets the provider-reported recording state. + /// When , the event does not change the current recording state. + /// + public RecordingState? RecordingState { get; init; } + + /// + /// Gets the provider recording reference for the session, when recording is active or retained. + /// + public string RecordingReference { get; init; } + + /// + /// Gets a value indicating whether the provider reports the call as a conference or + /// multi-party session. When , the event does not change the current conference flag. + /// + public bool? IsConference { get; init; } + + /// + /// Gets the number of active participants the provider reports for the session. + /// When , the event does not change the current participant count. + /// + public int? ParticipantCount { get; init; } + + /// + /// Gets the provider-neutral AMD (Answering Machine Detection) answer classification when the provider + /// reports AMD for this event. When , the provider did not report AMD and the event does + /// not change the current answer classification. + /// + public AnswerClassification? AnswerClassification { get; init; } + + /// + /// Gets the provider-neutral reason the call ended. It is required whenever + /// is terminal, because a call that ended for an unrecorded reason cannot be counted in outbound + /// compliance reporting or abandon analytics. When the provider ends a call without reporting any + /// release cause, records that honestly instead of + /// presenting the call as a normal clearing. + /// + public HangupCause? HangupCause { get; init; } + + /// + /// Gets additional provider metadata to retain for troubleshooting. The value is snapshotted on + /// assignment, so a caller that keeps its own reference to the dictionary cannot change the event + /// after it has been handed over. + /// + public IReadOnlyDictionary Metadata + { + get => _metadata; + init => _metadata = Snapshot(value); + } + + private static MetadataSnapshot Snapshot(IReadOnlyDictionary value) + { + if (value is null || value.Count == 0) + { + return _emptyMetadata; + } + + if (value is MetadataSnapshot snapshot) + { + return snapshot; + } + + return new MetadataSnapshot(new Dictionary(value, ComparerOf(value))); + } + + private static IEqualityComparer ComparerOf(IReadOnlyDictionary value) + { + // The comparer is carried over wherever the source can report one, because providers key their + // metadata case-insensitively and a snapshot that quietly became case-sensitive would change what + // consumers can find. An implementation that reports no comparer is keyed ordinally, which is the + // only honest choice when the source will not say how it compares its own keys. + return value switch + { + MetadataSnapshot snapshot => snapshot.Comparer, + Dictionary dictionary => dictionary.Comparer, + ConcurrentDictionary dictionary => dictionary.Comparer, + ImmutableDictionary dictionary => dictionary.KeyComparer, + FrozenDictionary dictionary => dictionary.Comparer, + _ => StringComparer.Ordinal, + }; + } + + /// + /// An immutable view over metadata that reports the comparer its keys are held under, so a snapshot + /// taken from another event's keeps the comparer the provider supplied instead + /// of silently falling back to ordinal comparison. + /// + private sealed class MetadataSnapshot : IReadOnlyDictionary + { + private readonly Dictionary _values; + + public MetadataSnapshot(Dictionary values) + { + _values = values; + } + + public IEqualityComparer Comparer => _values.Comparer; + + public string this[string key] => _values[key]; + + public IEnumerable Keys => _values.Keys; + + public IEnumerable Values => _values.Values; + + public int Count => _values.Count; + + public bool ContainsKey(string key) => _values.ContainsKey(key); + + public bool TryGetValue(string key, out string value) => _values.TryGetValue(key, out value); + + public IEnumerator> GetEnumerator() => _values.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingMediaWriteRequest.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingMediaWriteRequest.cs new file mode 100644 index 000000000..4d1469bfa --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingMediaWriteRequest.cs @@ -0,0 +1,34 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes a completed conversation recording whose bytes are being ingested into a durable, encrypted +/// media store. The request carries the opaque bytes together with the deterministic key that addresses the +/// recording, so a store implementation can persist and later retrieve the same recording without any +/// provider-specific knowledge. +/// +public sealed class RecordingMediaWriteRequest +{ + /// + /// Gets or sets the deterministic, provider-neutral key that uniquely and stably addresses this recording. + /// The same key is used to read or delete the stored recording, so it must be derivable without any + /// additional state. + /// + public string StorageKey { get; set; } + + /// + /// Gets or sets the identifier of the interaction the recording belongs to, used to namespace the stored + /// media per conversation and to correlate audit records. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the media format the recording bytes are encoded in (for example, wav). + /// + public string Format { get; set; } + + /// + /// Gets or sets the raw, unencrypted recording bytes to persist. The store is responsible for encrypting + /// them at rest. + /// + public byte[] Content { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingState.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingState.cs new file mode 100644 index 000000000..2096e27be --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingState.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Identifies the recording state of an interaction. +/// +public enum RecordingState +{ + /// + /// The interaction is not being recorded. + /// + None, + + /// + /// The interaction is actively recording. + /// + Recording, + + /// + /// Recording is paused (for example during sensitive data capture). + /// + Paused, + + /// + /// Recording has stopped. + /// + Stopped, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneCredentialConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneCredentialConfig.cs new file mode 100644 index 000000000..dd2c92612 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneCredentialConfig.cs @@ -0,0 +1,25 @@ +using System.Text.Json.Serialization; + +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes the short-lived browser SIP credential. +/// +public sealed class SoftPhoneCredentialConfig +{ + /// + /// Gets or sets the credential type. Supported values are password and ephemeralToken. + /// + public string Type { get; set; } + + /// + /// Gets or sets the credential value. + /// + public string Value { get; set; } + + /// + /// Gets or sets the UTC expiration instant for the credential. + /// + [JsonPropertyName("expiresAtUtc")] + public DateTime ExpiresAtUtc { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceConfig.cs new file mode 100644 index 000000000..8c5afeb03 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceConfig.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes ICE server configuration for browser media negotiation. +/// +public sealed class SoftPhoneIceConfig +{ + /// + /// Gets or sets the ICE servers available to the browser. + /// + public IList IceServers { get; set; } = []; + + /// + /// Gets or sets the ICE transport policy. + /// + public string IceTransportPolicy { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceServerConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceServerConfig.cs new file mode 100644 index 000000000..654a9556d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceServerConfig.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes a single ICE server entry. +/// +public sealed class SoftPhoneIceServerConfig +{ + /// + /// Gets or sets the STUN or TURN URLs for this server. + /// + public IList Urls { get; set; } = []; + + /// + /// Gets or sets the optional time-limited TURN user name. + /// + public string Username { get; set; } + + /// + /// Gets or sets the optional time-limited TURN credential. + /// + public string Credential { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneMediaConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneMediaConfig.cs new file mode 100644 index 000000000..9d10a3580 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneMediaConfig.cs @@ -0,0 +1,12 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes media preferences for the browser soft phone. +/// +public sealed class SoftPhoneMediaConfig +{ + /// + /// Gets or sets the preferred audio codecs. + /// + public IList Codecs { get; set; } = []; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfig.cs new file mode 100644 index 000000000..10b5a93d4 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfig.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Provides the browser soft-phone registration configuration consumed by the page-local media adapter. +/// +public sealed class SoftPhoneRegistrationConfig +{ + /// + /// Gets or sets the technical provider name. + /// + public string Provider { get; set; } + + /// + /// Gets or sets the SIP signaling configuration. + /// + public SoftPhoneSignalingConfig Signaling { get; set; } + + /// + /// Gets or sets the short-lived SIP credential. + /// + public SoftPhoneCredentialConfig Credential { get; set; } + + /// + /// Gets or sets the ICE configuration. + /// + public SoftPhoneIceConfig Ice { get; set; } + + /// + /// Gets or sets the media configuration. + /// + public SoftPhoneMediaConfig Media { get; set; } + + /// + /// Gets or sets the soft-phone session metadata. + /// + public SoftPhoneSessionConfig Session { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfigContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfigContext.cs new file mode 100644 index 000000000..beb918e7d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfigContext.cs @@ -0,0 +1,29 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes the current browser soft-phone registration request. +/// +public sealed class SoftPhoneRegistrationConfigContext +{ + /// + /// Gets or sets the technical provider name selected for the tenant. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the current user identifier. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the display name to present in SIP signaling. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the optional interaction identifier associated with this media session. This value is + /// non-authoritative metadata only: it must never be used to authorize credential issuance or to + /// derive the server-owned media session identity, because it can be supplied by the caller. + /// + public string InteractionId { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSessionConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSessionConfig.cs new file mode 100644 index 000000000..de44122c9 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSessionConfig.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization; + +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes the browser media session associated with a short-lived registration. +/// +public sealed class SoftPhoneSessionConfig +{ + /// + /// Gets or sets the interaction identifier bound to the credential. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the UTC expiration instant for the browser media session. + /// + [JsonPropertyName("expiresAtUtc")] + public DateTime ExpiresAtUtc { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSignalingConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSignalingConfig.cs new file mode 100644 index 000000000..283e5639a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSignalingConfig.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Describes the SIP-over-WebSocket signaling endpoint used by the browser media adapter. +/// +public sealed class SoftPhoneSignalingConfig +{ + /// + /// Gets or sets the secure WebSocket URL for SIP signaling. + /// + public string WebSocketUrl { get; set; } + + /// + /// Gets or sets the SIP address of record assigned to the browser agent. + /// + public string SipUri { get; set; } + + /// + /// Gets or sets the SIP authorization user. + /// + public string AuthorizationUser { get; set; } + + /// + /// Gets or sets the display name to present in SIP signaling. + /// + public string DisplayName { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs new file mode 100644 index 000000000..9ec6a8475 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents the floating soft phone widget rendered through Orchard Core display management. +/// +public sealed class SoftPhoneWidget +{ + /// + /// Gets or sets the accent color used by the widget. + /// + public string AccentColor { get; set; } = "#2f6fed"; + + /// + /// Gets or sets the maximum number of recent calls displayed in the history tab. + /// + public int RecentCallsCount { get; set; } = 30; + + /// + /// Gets or sets the telephony operations supported by the active provider. + /// + public TelephonyCapabilities Capabilities { get; set; } + + /// + /// Gets or sets the provider's executable audio delivery capabilities. + /// + public TelephonyAudioCapabilities AudioCapabilities { get; set; } + + /// + /// Gets or sets the effective audio delivery mode. + /// + public TelephonyAudioMode AudioMode { get; set; } + + /// + /// Gets or sets the browser media adapter name when browser audio is active. + /// + public string BrowserMediaAdapterName { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioCapabilities.cs new file mode 100644 index 000000000..65d8c33c7 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioCapabilities.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Identifies the live audio delivery modes supported by a telephony provider. +/// +[Flags] +public enum TelephonyAudioCapabilities +{ + /// + /// The provider does not expose an executable agent audio path. + /// + None = 0, + + /// + /// The provider can deliver live audio through a browser media adapter and the agent's microphone. + /// + Browser = 1 << 0, + + /// + /// The provider delivers live audio through an external device or provider-owned application. + /// + ExternalDevice = 1 << 1, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioMode.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioMode.cs new file mode 100644 index 000000000..0ff9d6fb6 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioMode.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Identifies the effective live audio delivery mode used by the soft phone. +/// +public enum TelephonyAudioMode +{ + /// + /// No executable agent audio path is available. + /// + None, + + /// + /// The soft phone captures microphone audio and plays remote audio in the browser. + /// + Browser, + + /// + /// Audio is handled by an external device or provider-owned application. + /// + ExternalDevice, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs index 3b74d1766..3b63456d9 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs @@ -50,4 +50,11 @@ public sealed class TelephonyCall /// Gets or sets the time, in UTC, when the call started. /// public DateTimeOffset? StartedUtc { get; set; } + + /// + /// Gets or sets optional provider-neutral metadata associated with the call. + /// Providers and orchestration modules can use this bag to carry routing hints or contextual + /// data without extending the shared telephony contracts with provider-specific properties. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); } diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallListLookupResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallListLookupResult.cs new file mode 100644 index 000000000..e6b9fe0a3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallListLookupResult.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents the result of querying telephony providers for a user's active calls. +/// +public sealed class TelephonyCallListLookupResult +{ + /// + /// Gets or sets a value indicating whether every provider lookup completed successfully. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the provider-authoritative active calls. + /// + public IReadOnlyList Calls { get; set; } = []; + + /// + /// Gets or sets the error message when a provider lookup failed. + /// + public string Error { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs new file mode 100644 index 000000000..dce717816 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents the result of querying a telephony provider for the current state of a call. +/// +public sealed class TelephonyCallLookupResult +{ + /// + /// Gets or sets a value indicating whether the lookup completed successfully. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets a value indicating whether the provider still reports the call. + /// + public bool Found { get; set; } + + /// + /// Gets or sets the current provider call state when the lookup succeeded and found the call. + /// + public TelephonyCall Call { get; set; } + + /// + /// Gets or sets the error message when the lookup failed. + /// + public string Error { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs index eb313759a..016322c33 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs @@ -38,7 +38,7 @@ public enum TelephonyCapabilities Mute = 1 << 4, /// - /// The provider can transfer a call to another destination. + /// The provider can transfer a call to another destination without consulting it first. /// Transfer = 1 << 5, @@ -56,4 +56,20 @@ public enum TelephonyCapabilities /// The provider can receive inbound calls. /// ReceiveCalls = 1 << 8, + + /// + /// The provider can send a ringing inbound call to voicemail. + /// + Voicemail = 1 << 9, + + /// + /// The provider can list directory destinations for call transfer. + /// + Directory = 1 << 10, + + /// + /// The provider can perform an attended (warm) transfer, where the transferring party consults the + /// destination before the call is released to it. + /// + AttendedTransfer = 1 << 11, } diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs index 84dbdb14b..f8c0b0284 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs @@ -22,6 +22,22 @@ public sealed class TelephonyClientCredentials /// public DateTimeOffset? ExpiresUtc { get; set; } + /// + /// Gets or sets the executable audio delivery modes advertised by the provider. + /// + public TelephonyAudioCapabilities AudioCapabilities { get; set; } + + /// + /// Gets or sets the effective audio delivery mode selected for the provider. + /// + public TelephonyAudioMode AudioMode { get; set; } + + /// + /// Gets or sets the browser media adapter name when is + /// . + /// + public string BrowserMediaAdapterName { get; set; } + /// /// Gets or sets an optional collection of non-sensitive, provider-specific settings the client /// SDK needs in order to initialize. diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryEntry.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryEntry.cs new file mode 100644 index 000000000..44f4989fe --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryEntry.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents one provider directory destination available for call transfer. +/// +public sealed class TelephonyDirectoryEntry +{ + /// + /// Gets or sets the provider-specific entry identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the human-readable entry name. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the destination sent to the provider when transferring a call. + /// + public string Destination { get; set; } + + /// + /// Gets or sets the entry's internal extension, when available. + /// + public string Extension { get; set; } + + /// + /// Gets or sets the entry's external phone number, when available. + /// + public string PhoneNumber { get; set; } + + /// + /// Gets or sets provider-specific status or grouping text. + /// + public string Detail { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryResult.cs new file mode 100644 index 000000000..ea2fa6de2 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryResult.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents the outcome of a provider directory lookup. +/// +public sealed class TelephonyDirectoryResult +{ + /// + /// Gets or sets a value indicating whether the directory lookup succeeded. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the directory entries. + /// + public IReadOnlyList Entries { get; set; } = []; + + /// + /// Gets or sets a provider-neutral error message when the lookup fails. + /// + public string Error { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs index d7dddca40..bb7627655 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs @@ -10,6 +10,11 @@ public sealed class TelephonyResult /// public bool Succeeded { get; init; } + /// + /// Gets a value indicating whether the provider may have executed the operation but its outcome could not be observed. + /// + public bool OutcomeUnknown { get; init; } + /// /// Gets the error message describing why the operation failed, when is /// . @@ -36,4 +41,19 @@ public static TelephonyResult Success(TelephonyCall call = null) /// A failed . public static TelephonyResult Failed(string error) => new() { Succeeded = false, Error = error }; + + /// + /// Creates a result for an operation whose provider outcome could not be determined. + /// + /// The error message describing why the outcome is unknown. + /// An indeterminate . + public static TelephonyResult Unknown(string error) + { + return new TelephonyResult + { + Succeeded = false, + OutcomeUnknown = true, + Error = error, + }; + } } diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallState.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallState.cs new file mode 100644 index 000000000..3a7a3dde6 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallState.cs @@ -0,0 +1,69 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Identifies the normalized, provider-neutral state of a voice call. Providers report their own call +/// state, which is normalized into these values so ingress, routing, analytics, and the agent and +/// supervisor experiences can reason about calls independently of any specific provider. +/// +public enum VoiceCallState +{ + /// + /// The call has been planned (for example reserved for an outbound dial) but not yet placed. + /// + Planned, + + /// + /// An outbound call is being placed and is awaiting connection. + /// + Dialing, + + /// + /// The call is alerting and waiting for the remote party or agent to answer. + /// + Ringing, + + /// + /// The call is connected and media is flowing. + /// + Connected, + + /// + /// The call is connected but currently on hold. + /// + OnHold, + + /// + /// The call is in the process of ending. + /// + Ending, + + /// + /// The call ended normally. + /// + Ended, + + /// + /// The call failed due to an error or provider failure. + /// + Failed, + + /// + /// The outbound call was not answered. + /// + NoAnswer, + + /// + /// The call was rejected by the remote party or agent. + /// + Rejected, + + /// + /// The call was canceled before it connected. + /// + Canceled, + + /// + /// The call was transferred to another destination. + /// + Transferred, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallStateProjection.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallStateProjection.cs new file mode 100644 index 000000000..b335f8de3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallStateProjection.cs @@ -0,0 +1,115 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Projects between the canonical twelve-state voice call vocabulary and the seven-state +/// telephony soft-phone vocabulary, and derives the terminal voice call state implied by a +/// provider-reported hangup cause. +/// +/// This type is the only place either projection may be written. The soft-phone vocabulary is a +/// strict, lossy projection of the canonical vocabulary: four distinct terminal outcomes +/// collapse onto or . When each +/// call site was free to write its own projection, those collapses were applied inconsistently and +/// in the widening direction they discarded the outcome entirely, so every provider hangup became +/// regardless of why the call ended. +/// +/// +public static class VoiceCallStateProjection +{ + /// + /// Widens a soft-phone call state into the canonical voice call state, refining the + /// terminal outcome from the provider-reported hangup cause when one is available. + /// + /// The soft-phone call state reported by the provider. + /// Whether the provider additionally reports the call as held. + /// The provider-reported hangup cause, when the call has ended. + /// The canonical voice call state. + /// + /// is the soft phone's "no live call" sentinel rather than a call + /// state, so widening it for a call a provider still reports means the call is over. It therefore + /// maps to and does not round-trip back from + /// . + /// + public static VoiceCallState ToVoiceCallState( + CallState state, + bool isOnHold = false, + HangupCause? hangupCause = null) + { + return state switch + { + CallState.Idle => VoiceCallState.Ended, + CallState.Connecting => VoiceCallState.Dialing, + CallState.Ringing => VoiceCallState.Ringing, + CallState.Connected when isOnHold => VoiceCallState.OnHold, + CallState.Connected => VoiceCallState.Connected, + CallState.OnHold => VoiceCallState.OnHold, + CallState.Disconnected => ToTerminalVoiceCallState(hangupCause, VoiceCallState.Ended), + CallState.Failed => ToTerminalVoiceCallState(hangupCause, VoiceCallState.Failed), + _ => VoiceCallState.Ended, + }; + } + + /// + /// Narrows the canonical Contact Center call state into the soft-phone call state. + /// + /// The canonical voice call state. + /// The soft-phone call state. + public static CallState ToTelephonyCallState(VoiceCallState state) + { + return state switch + { + VoiceCallState.Planned => CallState.Idle, + VoiceCallState.Dialing => CallState.Connecting, + VoiceCallState.Ringing => CallState.Ringing, + VoiceCallState.Connected => CallState.Connected, + VoiceCallState.OnHold => CallState.OnHold, + VoiceCallState.Ending => CallState.Disconnected, + VoiceCallState.Ended => CallState.Disconnected, + VoiceCallState.Transferred => CallState.Disconnected, + VoiceCallState.Canceled => CallState.Disconnected, + VoiceCallState.NoAnswer => CallState.Failed, + VoiceCallState.Rejected => CallState.Failed, + VoiceCallState.Failed => CallState.Failed, + _ => CallState.Idle, + }; + } + + /// + /// Resolves the terminal Contact Center call state implied by a provider-reported hangup cause. + /// + /// The provider-reported hangup cause, when one was reported. + /// The terminal state to use when no usable cause was reported. + /// The terminal Contact Center call state. + public static VoiceCallState ToTerminalVoiceCallState( + HangupCause? hangupCause, + VoiceCallState fallback) + { + return hangupCause switch + { + HangupCause.NormalClearing => VoiceCallState.Ended, + HangupCause.AnsweringMachine => VoiceCallState.Ended, + HangupCause.Busy => VoiceCallState.Rejected, + HangupCause.Rejected => VoiceCallState.Rejected, + HangupCause.NoAnswer => VoiceCallState.NoAnswer, + HangupCause.Canceled => VoiceCallState.Canceled, + HangupCause.Congestion => VoiceCallState.Failed, + HangupCause.Failed => VoiceCallState.Failed, + _ => fallback, + }; + } + + /// + /// Determines whether the supplied Contact Center call state is terminal, meaning the call can + /// no longer change state. + /// + /// The Contact Center call state to evaluate. + /// when the state is terminal; otherwise . + public static bool IsTerminal(VoiceCallState state) + { + return state is VoiceCallState.Ended or + VoiceCallState.Failed or + VoiceCallState.NoAnswer or + VoiceCallState.Rejected or + VoiceCallState.Canceled or + VoiceCallState.Transferred; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyAudioModeResolver.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyAudioModeResolver.cs new file mode 100644 index 000000000..7cf7e52bb --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyAudioModeResolver.cs @@ -0,0 +1,48 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Resolves a provider's effective audio mode from its executable capabilities and configuration. +/// +public static class TelephonyAudioModeResolver +{ + /// + /// Resolves the effective audio mode. + /// + /// The provider's executable audio capabilities. + /// The provider-selected mode when multiple modes are supported. + /// The registered browser media adapter name. + /// The effective audio mode, or when the configuration is not executable. + public static TelephonyAudioMode Resolve( + TelephonyAudioCapabilities capabilities, + TelephonyAudioMode configuredMode, + string browserMediaAdapterName) + { + var supportsBrowser = capabilities.HasFlag(TelephonyAudioCapabilities.Browser) && + !string.IsNullOrWhiteSpace(browserMediaAdapterName); + var supportsExternalDevice = capabilities.HasFlag(TelephonyAudioCapabilities.ExternalDevice); + + if (supportsBrowser && supportsExternalDevice) + { + return configuredMode switch + { + TelephonyAudioMode.Browser => TelephonyAudioMode.Browser, + TelephonyAudioMode.ExternalDevice => TelephonyAudioMode.ExternalDevice, + _ => TelephonyAudioMode.None, + }; + } + + if (supportsBrowser) + { + return TelephonyAudioMode.Browser; + } + + if (supportsExternalDevice) + { + return TelephonyAudioMode.ExternalDevice; + } + + return TelephonyAudioMode.None; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCapabilityContracts.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCapabilityContracts.cs new file mode 100644 index 000000000..e83c4c0d0 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCapabilityContracts.cs @@ -0,0 +1,40 @@ +using System.Collections.Frozen; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Maps each advertised telephony capability to the executable contract a provider must implement before the +/// capability may be exercised. +/// +public static class TelephonyCapabilityContracts +{ + private static readonly FrozenDictionary _contractsByCapability = new Dictionary + { + [TelephonyCapabilities.Dial] = typeof(ITelephonyCallControlProvider), + [TelephonyCapabilities.Hangup] = typeof(ITelephonyCallControlProvider), + [TelephonyCapabilities.Hold] = typeof(ITelephonyHoldProvider), + [TelephonyCapabilities.Resume] = typeof(ITelephonyHoldProvider), + [TelephonyCapabilities.Mute] = typeof(ITelephonyMuteProvider), + [TelephonyCapabilities.Transfer] = typeof(ITelephonyTransferProvider), + [TelephonyCapabilities.AttendedTransfer] = typeof(ITelephonyAttendedTransferProvider), + [TelephonyCapabilities.Merge] = typeof(ITelephonyConferenceProvider), + [TelephonyCapabilities.SendDigits] = typeof(ITelephonyDtmfProvider), + [TelephonyCapabilities.ReceiveCalls] = typeof(ITelephonyInboundCallProvider), + [TelephonyCapabilities.Voicemail] = typeof(ITelephonyVoicemailProvider), + [TelephonyCapabilities.Directory] = typeof(ITelephonyDirectoryProvider), + }.ToFrozenDictionary(); + + /// + /// Gets the executable contract required by each advertised capability. + /// + public static IReadOnlyDictionary ContractsByCapability => _contractsByCapability; + + /// + /// Gets the executable contract required by the given capability. + /// + /// The advertised capability. + /// The contract type, or when the capability requires none. + public static Type GetContract(TelephonyCapabilities capability) + => _contractsByCapability.TryGetValue(capability, out var contract) ? contract : null; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandNotAdmittedException.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandNotAdmittedException.cs new file mode 100644 index 000000000..49194dd92 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandNotAdmittedException.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Thrown by when a telephony provider mutation is refused because +/// the host is shutting down. The provider is never contacted, so the command is guaranteed not to have +/// been applied and the caller may safely treat it as a definite non-application (rather than an +/// indeterminate outcome). +/// +/// +/// Derives from so callers that already treat cancellation as an +/// indeterminate outcome continue to fail safe, while callers that want the more precise +/// "definitely not applied" signal can catch this type first. +/// +public sealed class TelephonyCommandNotAdmittedException : OperationCanceledException +{ + /// + /// Initializes a new instance of the class. + /// + public TelephonyCommandNotAdmittedException() + : base("The telephony command was refused because the application is stopping.") + { + } + + /// + /// Initializes a new instance of the class with the + /// cancellation token that triggered the refusal. + /// + /// The shutdown token that caused the command to be refused. + public TelephonyCommandNotAdmittedException(CancellationToken cancellationToken) + : base("The telephony command was refused because the application is stopping.", cancellationToken) + { + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandOptions.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandOptions.cs new file mode 100644 index 000000000..3a65a01d7 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandOptions.cs @@ -0,0 +1,29 @@ +namespace CrestApps.OrchardCore.Telephony; + +/// +/// Defines the server-owned execution deadline for telephony provider mutations. +/// +public sealed class TelephonyCommandOptions +{ + /// + /// The default provider mutation timeout, in seconds. + /// + public const int DefaultTimeoutSeconds = 10; + + /// + /// The minimum provider mutation timeout, in seconds. + /// + public const int MinimumTimeoutSeconds = 1; + + /// + /// The maximum provider mutation timeout, in seconds. + /// + public const int MaximumTimeoutSeconds = 120; + + /// + /// Gets or sets the maximum duration of one telephony provider mutation before its outcome is + /// treated as unknown. This outer command deadline intentionally supersedes longer + /// provider-specific retry budgets. + /// + public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(DefaultTimeoutSeconds); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs index f53f44b3e..53411bdcd 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs @@ -17,6 +17,34 @@ public static class TelephonyConstants /// public const string TokenProtectorPurpose = "CrestApps.OrchardCore.Telephony.UserTokens"; + /// + /// The data protection purpose used to encrypt conversation recording media at rest in the default local + /// recording media store. + /// + public const string RecordingMediaProtectorPurpose = "CrestApps.OrchardCore.Telephony.RecordingMedia"; + + /// + /// The tenant-scoped application-data folder name under which the default local recording media store + /// persists encrypted recordings. + /// + public const string RecordingMediaFolderName = "RecordingMedia"; + + /// + /// Contains metadata keys that have provider-neutral command semantics. + /// + public static class RequestMetadata + { + /// + /// Identifies a stable command that providers should use for idempotent execution when supported. + /// + public const string IdempotencyKey = "idempotencyKey"; + + /// + /// Identifies the monotonic fence token associated with an idempotent provider command. + /// + public const string FenceToken = "commandFenceToken"; + } + /// /// Contains the well-known authentication scheme identifiers a telephony provider can use. /// @@ -65,9 +93,8 @@ public static class Feature public const string SoftPhone = "CrestApps.OrchardCore.Telephony.SoftPhone"; /// - /// The legacy identifier of the soft phone feature. + /// The identifier of the Telephony administration feature. /// - [Obsolete("Use SoftPhone instead.")] - public const string SoftPhoneWidget = SoftPhone; + public const string Admin = "CrestApps.OrchardCore.Telephony.Admin"; } } diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs b/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs index a798a4fa3..3c356a847 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs @@ -1,6 +1,6 @@ using CrestApps.Core.AI.Models; -using CrestApps.Core.Security; using CrestApps.Core.AI.Tooling; +using CrestApps.Core.Security; using Microsoft.AspNetCore.Authorization; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs b/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs index c08e1a2e3..463661f47 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs @@ -5,8 +5,6 @@ using CrestApps.Core.AI.Models; using CrestApps.Core.AI.Services; using CrestApps.Core.AI.Tooling; -using CrestApps.Core.Builders; -using CrestApps.Core.Data.YesSql; using CrestApps.Core.Infrastructure.Indexing; using CrestApps.Core.Services; using CrestApps.OrchardCore.AI.Core.Handlers; diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs b/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs index ed6d36373..00600ce24 100644 --- a/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs +++ b/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs @@ -8,10 +8,8 @@ using CrestApps.Core.Data.YesSql; using CrestApps.Core.Data.YesSql.Indexes.AIChat; using Microsoft.AspNetCore.Http; -using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using OrchardCore; using OrchardCore.Modules; using YesSql; using ISession = YesSql.ISession; diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs new file mode 100644 index 000000000..40e9c9739 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs @@ -0,0 +1,69 @@ +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.ContactCenter.Core; + +/// +/// Defines the permissions exposed by the base Contact Center feature. +/// +public static class ContactCenterPermissions +{ + /// + /// Grants full management of the Contact Center, including configuration and every interaction. + /// + public static readonly Permission ManageContactCenter = new("ManageContactCenter", "Manage the Contact Center"); + + /// + /// Grants management of interactions. + /// + public static readonly Permission ManageInteractions = new("ManageInteractions", "Manage interactions", [ManageContactCenter]); + + /// + /// Grants read-only access to interactions. + /// + public static readonly Permission ViewInteractions = new("ViewInteractions", "View interactions", [ManageInteractions, ManageContactCenter]); + + /// + /// Grants management of agent profiles, presence, and queue membership. + /// + public static readonly Permission ManageAgents = new("ManageContactCenterAgents", "Manage Contact Center agents", [ManageContactCenter]); + + /// + /// Grants management of queues, queue items, and assignment. + /// + public static readonly Permission ManageQueues = new("ManageContactCenterQueues", "Manage Contact Center queues", [ManageContactCenter]); + + /// + /// Grants management of queue groups used for catalog organization and reporting. + /// + public static readonly Permission ManageQueueGroups = new("ManageContactCenterQueueGroups", "Manage Contact Center queue groups", [ManageQueues, ManageContactCenter]); + + /// + /// Grants management of skills used by routing and agent sign-in. + /// + public static readonly Permission ManageSkills = new("ManageContactCenterSkills", "Manage Contact Center skills", [ManageContactCenter]); + + /// + /// Grants management of dialer profiles and outbound dialing. + /// + public static readonly Permission ManageDialer = new("ManageContactCenterDialer", "Manage the Contact Center dialer", [ManageContactCenter]); + + /// + /// Grants an agent the ability to sign in to queues and campaigns and change their own presence. + /// + public static readonly Permission SignIntoQueues = new("ContactCenterSignIntoQueues", "Sign in to Contact Center queues and campaigns"); + + /// + /// Grants read-only, real-time visibility into queues, agents, and live interactions for supervisors. + /// + public static readonly Permission MonitorContactCenter = new("MonitorContactCenter", "Monitor the Contact Center in real time", [ManageContactCenter]); + + /// + /// Grants permission to transfer live interactions to approved external destinations. + /// + public static readonly Permission TransferExternally = new("ContactCenterTransferExternally", "Transfer Contact Center calls externally", [MonitorContactCenter, ManageContactCenter]); + + /// + /// Grants read-only access to the Contact Center historical reports and their exports. + /// + public static readonly Permission ViewReports = new("ViewContactCenterReports", "View Contact Center reports", [MonitorContactCenter, ManageContactCenter]); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj new file mode 100644 index 000000000..cd9c0d7dc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj @@ -0,0 +1,35 @@ + + + + CrestApps OrchardCore Contact Center Core + + $(CrestAppsDescription) + + Core domain models, stores, managers, and the event log for the CrestApps OrchardCore + Contact Center module set. Owns communication-history interactions and the durable domain event history. + + $(PackageTags) ContactCenter Interactions + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/DialerActivitySourceHelper.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/DialerActivitySourceHelper.cs new file mode 100644 index 000000000..cc5d4d0c5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/DialerActivitySourceHelper.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core; + +/// +/// Resolves the activity source classification for a dialer mode. +/// +public static class DialerActivitySourceHelper +{ + /// + /// Gets the activity source identifier that corresponds to the specified dialer mode. + /// + /// The dialer mode. + /// The activity source identifier used to classify activities created for the dialer mode. + public static string GetActivitySource(DialerMode mode) + { + return mode switch + { + DialerMode.Power => ActivitySources.PowerDial, + DialerMode.Progressive => ActivitySources.ProgressiveDial, + DialerMode.Predictive => ActivitySources.PredictiveDial, + _ => ActivitySources.PreviewDial, + }; + } + + /// + /// Determines whether an activity source represents agent-facing dialer work. + /// + /// The activity source identifier. + /// when the source is a dialer source; otherwise, . + public static bool IsDialerSource(string source) + { + return string.Equals(source, ActivitySources.Dialer, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, ActivitySources.PreviewDial, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, ActivitySources.PowerDial, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, ActivitySources.ProgressiveDial, StringComparison.OrdinalIgnoreCase) || + string.Equals(source, ActivitySources.PredictiveDial, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ExternalDestinationPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ExternalDestinationPolicy.cs new file mode 100644 index 000000000..fa3718db8 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ExternalDestinationPolicy.cs @@ -0,0 +1,88 @@ +using CrestApps.OrchardCore.PhoneNumbers; + +namespace CrestApps.OrchardCore.ContactCenter.Core; + +/// +/// Decides whether the platform is willing to place or transfer a call to an external destination. +/// +/// This is a safety policy rather than a formatting concern. It is the single place that decides the answer, +/// because the question is asked at three different moments — when an administrator saves a transfer +/// destination, when a transfer is resolved at call time, and when a dial command is executed — and an +/// address that is refused at one of those moments must be refused at all of them. Keeping one definition is +/// what prevents a destination from being rejected in the settings screen but still reachable through a +/// workflow that dials it directly. +/// +/// +public static class ExternalDestinationPolicy +{ + // The bound is on the whole E.164 value, so it admits seven digits after the leading plus sign. It is + // carried over unchanged from the three implementations this replaced, because changing what is dialable + // is not what consolidating them was for. + private const int MinimumLength = 8; + + /// + /// Determines whether the supplied address is an external destination the platform will dial. + /// + /// The raw address to evaluate. A value that is not already in E.164 form is refused. + /// when the address may be dialed; otherwise, . + public static bool IsAllowed(string address) + { + if (!PhoneNumber.TryFromE164(address, out var phoneNumber)) + { + return false; + } + + return IsAllowed(phoneNumber); + } + + /// + /// Determines whether the supplied canonical number is an external destination the platform will dial. + /// + /// The canonical number to evaluate. + /// when the number may be dialed; otherwise, . + public static bool IsAllowed(PhoneNumber phoneNumber) + { + if (!phoneNumber.HasValue || phoneNumber.Value.Length < MinimumLength) + { + return false; + } + + var digits = phoneNumber.Digits; + + return !IsEmergencyNumber(digits) && !IsPremiumNumber(digits); + } + + /// + /// Determines whether the supplied digits address an emergency service. + /// + /// The digits of the address, without the leading plus sign. + /// when the digits address an emergency service; otherwise, . + public static bool IsEmergencyNumber(string digits) + { + if (string.IsNullOrEmpty(digits)) + { + return false; + } + + return digits.EndsWith("911", StringComparison.Ordinal) || + digits.EndsWith("112", StringComparison.Ordinal) || + digits.EndsWith("999", StringComparison.Ordinal); + } + + /// + /// Determines whether the supplied digits address a premium-rate service. + /// + /// The digits of the address, without the leading plus sign. + /// when the digits address a premium-rate service; otherwise, . + public static bool IsPremiumNumber(string digits) + { + if (string.IsNullOrEmpty(digits)) + { + return false; + } + + return digits.StartsWith("1900", StringComparison.Ordinal) || + digits.StartsWith("1976", StringComparison.Ordinal) || + digits.StartsWith("4470", StringComparison.Ordinal); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/BacklogHealthEvaluator.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/BacklogHealthEvaluator.cs new file mode 100644 index 000000000..e351f0c53 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/BacklogHealthEvaluator.cs @@ -0,0 +1,61 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Evaluates the health of a durable message queue (outbox or provider ingress) from its dead-letter and overdue +/// backlog counts. The decision is a pure function of the counts and configured thresholds so it can be unit +/// tested without a database, and so every queue-backed health check shares one consistent contract. +/// +public static class BacklogHealthEvaluator +{ + /// + /// Evaluates a queue's health from its current backlog signals. + /// + /// The human-readable subsystem name used in the health result description. + /// The number of dead-lettered messages requiring operator intervention. + /// The number of pending or claimed messages already past their due time. + /// The configured thresholds. + /// The resulting . + public static HealthCheckResult Evaluate( + string subsystem, + int deadLetterCount, + int overdueCount, + ContactCenterHealthCheckOptions options) + { + ArgumentException.ThrowIfNullOrEmpty(subsystem); + ArgumentNullException.ThrowIfNull(options); + + options.Normalize(); + + var data = new Dictionary(StringComparer.Ordinal) + { + ["deadLettered"] = deadLetterCount, + ["overdue"] = overdueCount, + }; + + if (deadLetterCount >= options.DeadLetterUnhealthyThreshold || + overdueCount >= options.OverdueBacklogUnhealthyThreshold) + { + return new HealthCheckResult( + HealthStatus.Unhealthy, + $"{subsystem} is unhealthy: {deadLetterCount} dead-lettered, {overdueCount} overdue.", + data: data); + } + + if (deadLetterCount >= options.DeadLetterDegradedThreshold || + overdueCount >= options.OverdueBacklogDegradedThreshold) + { + return new HealthCheckResult( + HealthStatus.Degraded, + $"{subsystem} is degraded: {deadLetterCount} dead-lettered, {overdueCount} overdue.", + data: data); + } + + return new HealthCheckResult( + HealthStatus.Healthy, + $"{subsystem} is healthy.", + data: data); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterActiveCallsHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterActiveCallsHealthCheck.cs new file mode 100644 index 000000000..422f408d1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterActiveCallsHealthCheck.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Surfaces the tenant's live active-call gauge — the number of call sessions that have not yet ended — as +/// health-check data. This is the count an operator weighs before draining a node during a handover: how many +/// live calls a stop would interrupt. +/// +/// +/// The count is surfaced as health data rather than as an observable +/// gauge on purpose. An observable-gauge callback is synchronous and runs with no ambient tenant scope, so it +/// cannot safely issue the per-tenant store query this count requires, and a process-global count would be wrong +/// across tenants and nodes. A health check already runs inside the tenant scope with an async body, so it is the +/// correct seam for a store-backed gauge. The check reports the count and stays healthy whenever it can read it; +/// it deliberately does not degrade on a high count because the acceptable ceiling is deployment specific. +/// +public sealed class ContactCenterActiveCallsHealthCheck : IHealthCheck +{ + private readonly ICallSessionStore _callSessionStore; + + /// + /// Initializes a new instance of the class. + /// + /// The call session store used to count active calls. + public ContactCenterActiveCallsHealthCheck(ICallSessionStore callSessionStore) + { + _callSessionStore = callSessionStore; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var activeCalls = await _callSessionStore.CountActiveAsync(cancellationToken); + + var data = new Dictionary + { + ["active_calls"] = activeCalls, + }; + + var description = string.Format( + CultureInfo.InvariantCulture, + "{0} active call(s).", + activeCalls); + + return HealthCheckResult.Healthy(description, data); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "Unable to read the Contact Center active-call count.", ex); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterBaseVoiceVerificationHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterBaseVoiceVerificationHealthCheck.cs new file mode 100644 index 000000000..7695442dc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterBaseVoiceVerificationHealthCheck.cs @@ -0,0 +1,89 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports whether the operator has verified the base-voice audio path for this deployment. +/// +/// +/// This is a readiness check that observes a condition every node shares, and — like +/// — the exception is deliberate. Whether the WebRTC media path +/// works is fixed infrastructure that no amount of waiting repairs, and serving voice traffic from a deployment +/// whose base-voice path was never proven is the failure being prevented, not collateral damage. In a +/// production host environment an unacknowledged deployment therefore fails readiness closed. Outside a +/// production host environment the gate does not withhold readiness, so development and test hosts are not +/// blocked; the accompanying startup warning covers those cases. +/// +public sealed class ContactCenterBaseVoiceVerificationHealthCheck : IHealthCheck +{ + private readonly BaseVoiceVerificationOptions _options; + private readonly IHostEnvironment _hostEnvironment; + + /// + /// Initializes a new instance of the class. + /// + /// The operator-declared base-voice verification options. + /// The host environment, used to decide whether the gate fails closed. + public ContactCenterBaseVoiceVerificationHealthCheck( + IOptions options, + IHostEnvironment hostEnvironment) + { + _options = options.Value; + _hostEnvironment = hostEnvironment; + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + return Task.FromResult(Evaluate( + context, + _options.AudioVerificationAcknowledged, + _hostEnvironment.IsProduction(), + _options.AudioVerificationEvidenceReference)); + } + + /// + /// Decides the readiness verdict from the declared verification state. + /// + /// The health check context supplying the configured failure status. + /// Whether the operator has acknowledged the base-voice audio verification. + /// Whether the host is running in a production environment. + /// An optional reference to the retained verification evidence. + /// The readiness verdict for this deployment. + public static HealthCheckResult Evaluate( + HealthCheckContext context, + bool acknowledged, + bool isProductionEnvironment, + string evidenceReference) + { + ArgumentNullException.ThrowIfNull(context); + + if (acknowledged) + { + return string.IsNullOrWhiteSpace(evidenceReference) + ? HealthCheckResult.Healthy("The base-voice audio path has been verified and acknowledged for this deployment.") + : HealthCheckResult.Healthy($"The base-voice audio path has been verified and acknowledged for this deployment (evidence: {evidenceReference})."); + } + + if (isProductionEnvironment) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "The base-voice audio path has not been verified for this production deployment. " + + "Complete the base-voice deployment acceptance step and set " + + "'CrestApps_ContactCenter:BaseVoiceVerification:AudioVerificationAcknowledged' to 'true'. " + + "Readiness is withheld until it is acknowledged."); + } + + return HealthCheckResult.Healthy( + "The base-voice audio path has not been verified for this deployment. " + + "This is tolerated outside a production host environment; a production host withholds readiness until it is acknowledged."); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterDistributedLockHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterDistributedLockHealthCheck.cs new file mode 100644 index 000000000..8c47cce3d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterDistributedLockHealthCheck.cs @@ -0,0 +1,62 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using OrchardCore.Locking.Distributed; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Proves the resolved can be acquired and released within a bounded time by +/// taking a dedicated probe lock. In a production topology this exercises the Redis-backed lock end to end; in +/// a development topology it exercises the process-local lock and is trivially satisfied. A failure here means +/// the lock backend cannot serialize the overlapping processes a rolling restart depends on. +/// +public sealed class ContactCenterDistributedLockHealthCheck : IHealthCheck +{ + internal const string ProbeLockKey = "CONTACTCENTER_HEALTHCHECK_DISTRIBUTED_LOCK"; + + private static readonly TimeSpan _acquireTimeout = TimeSpan.FromSeconds(2); + private static readonly TimeSpan _lockExpiration = TimeSpan.FromSeconds(5); + + private readonly IDistributedLock _distributedLock; + + /// + /// Initializes a new instance of the class. + /// + /// The resolved distributed lock to probe. + public ContactCenterDistributedLockHealthCheck(IDistributedLock distributedLock) + { + _distributedLock = distributedLock; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + (var locker, var acquired) = await _distributedLock.TryAcquireLockAsync( + ProbeLockKey, + _acquireTimeout, + _lockExpiration); + + if (!acquired) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "The Contact Center distributed lock could not be acquired within the probe timeout."); + } + + await using (locker) + { + return HealthCheckResult.Healthy("The Contact Center distributed lock is responsive."); + } + } + catch (Exception ex) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "The Contact Center distributed lock backend is unreachable.", + ex); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterNodeHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterNodeHealthCheck.cs new file mode 100644 index 000000000..7c4c6c271 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterNodeHealthCheck.cs @@ -0,0 +1,82 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Hosting; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports whether this node should receive traffic, based only on node-local state. +/// +/// +/// This is the only check wired to the readiness probe, and it consults no external dependency on purpose. +/// +/// Readiness removes a node from the load balancer. A condition shared by every node — a database outage, a +/// growing outbox backlog, an unreachable provider — is reported identically by every node, so wiring it to +/// readiness drains the entire fleet at once and converts a degraded dependency into a total outage. Readiness +/// must therefore only reflect conditions that actually differ between nodes: whether this process finished +/// starting, and whether it is shutting down. +/// +/// +/// Dependency health is still observed — it is reported through the dependency probe and the metrics — but it +/// is an alerting signal, never a routing signal. +/// +/// +public sealed class ContactCenterNodeHealthCheck : IHealthCheck +{ + private readonly IHostApplicationLifetime _lifetime; + + /// + /// Initializes a new instance of the class. + /// + /// The host lifetime used to observe startup and shutdown of this node. + public ContactCenterNodeHealthCheck(IHostApplicationLifetime lifetime) + { + _lifetime = lifetime; + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + return Task.FromResult(Evaluate( + context, + hasStarted: _lifetime.ApplicationStarted.IsCancellationRequested, + isStopping: _lifetime.ApplicationStopping.IsCancellationRequested)); + } + + /// + /// Decides the node readiness verdict from the observed host lifetime state. + /// + /// The health check context supplying the configured failure status. + /// Whether the host has signalled that startup completed. + /// Whether the host has signalled that shutdown has begun. + /// The readiness verdict for this node. + /// + /// Shutdown is evaluated before startup so a node that begins draining during startup still reports + /// draining. Reporting unready while stopping is what lets a load balancer evict the node before the + /// process stops accepting connections, which is the difference between a graceful deployment and dropped + /// calls. + /// + public static HealthCheckResult Evaluate(HealthCheckContext context, bool hasStarted, bool isStopping) + { + ArgumentNullException.ThrowIfNull(context); + + if (isStopping) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "This node is shutting down and should be drained."); + } + + if (!hasStarted) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "This node has not finished starting."); + } + + return HealthCheckResult.Healthy("This node has started and is accepting traffic."); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterNodeServingHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterNodeServingHealthCheck.cs new file mode 100644 index 000000000..dd2b23016 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterNodeServingHealthCheck.cs @@ -0,0 +1,83 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports whether this node can still reach the tenant's durable store well enough to serve traffic. +/// +/// +/// This check exists because "shared dependency" does not mean "fails identically on every node". A pod with an +/// exhausted connection pool, a stale DNS entry, an expired trust store, or exhausted outbound ports will fail +/// every database call while its peers are perfectly healthy, and nothing else in the readiness contract would +/// notice. Such a node keeps accepting its share of calls and failing all of them. +/// +/// It is nonetheless disabled by default. When the store itself is down every node observes the failure, +/// so the gate would drain the whole fleet — the very failure mode the readiness split exists to prevent. +/// Enabling it is therefore safe only where the load balancer fails open once too few targets remain healthy +/// (for example an Envoy or Istio panic threshold), or where partial drain is preferable to partial failure. +/// +/// +/// When disabled the check performs no I/O at all and reports healthy immediately, so readiness stays free. +/// +/// +public sealed class ContactCenterNodeServingHealthCheck : IHealthCheck +{ + private readonly IContactCenterOutboxStore _outboxStore; + private readonly NodeServingStateTracker _tracker; + private readonly IOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The durable store used as a lightweight serving probe. + /// The per-node tracker that applies hysteresis to the observed outcomes. + /// The configured health check options. + public ContactCenterNodeServingHealthCheck( + IContactCenterOutboxStore outboxStore, + NodeServingStateTracker tracker, + IOptions options) + { + _outboxStore = outboxStore; + _tracker = tracker; + _options = options; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (!_options.Value.EnableNodeServingGate) + { + return HealthCheckResult.Healthy("The node serving gate is disabled."); + } + + bool succeeded; + + try + { + await _outboxStore.CountByStatusAsync(OutboxMessageStatus.Completed, cancellationToken); + succeeded = true; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // A cancelled probe says nothing about this node, so it must not count as a failure. + throw; + } + catch + { + succeeded = false; + } + + return _tracker.Record(succeeded) + ? HealthCheckResult.Healthy("This node can reach the Contact Center store.") + : new HealthCheckResult( + context.Registration.FailureStatus, + "This node has failed consecutive store probes and should be drained."); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterOutboxHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterOutboxHealthCheck.cs new file mode 100644 index 000000000..82430b77c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterOutboxHealthCheck.cs @@ -0,0 +1,51 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports the health of the durable Contact Center event outbox from its dead-letter and overdue backlog counts. +/// +public sealed class ContactCenterOutboxHealthCheck : IHealthCheck +{ + private readonly IContactCenterOutboxStore _outboxStore; + private readonly ContactCenterHealthCheckOptions _options; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The durable outbox message store. + /// The configured health-check thresholds. + /// The clock used to select overdue messages. + public ContactCenterOutboxHealthCheck( + IContactCenterOutboxStore outboxStore, + IOptions options, + IClock clock) + { + _outboxStore = outboxStore; + _options = options.Value; + _clock = clock; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var deadLettered = await _outboxStore.CountByStatusAsync(OutboxMessageStatus.DeadLettered, cancellationToken); + var overdue = await _outboxStore.CountOverdueAsync(_clock.UtcNow, cancellationToken); + + return BacklogHealthEvaluator.Evaluate("Contact Center event outbox", deadLettered, overdue, _options); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "Unable to read the Contact Center event outbox.", ex); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterProviderIngressHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterProviderIngressHealthCheck.cs new file mode 100644 index 000000000..2368f03e8 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterProviderIngressHealthCheck.cs @@ -0,0 +1,51 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports the health of the durable provider webhook ingress inbox from its dead-letter and overdue backlog counts. +/// +public sealed class ContactCenterProviderIngressHealthCheck : IHealthCheck +{ + private readonly IProviderWebhookInboxStore _inboxStore; + private readonly ContactCenterHealthCheckOptions _options; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The durable provider webhook inbox store. + /// The configured health-check thresholds. + /// The clock used to select overdue messages. + public ContactCenterProviderIngressHealthCheck( + IProviderWebhookInboxStore inboxStore, + IOptions options, + IClock clock) + { + _inboxStore = inboxStore; + _options = options.Value; + _clock = clock; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var deadLettered = await _inboxStore.CountByStatusAsync(ProviderWebhookInboxStatus.DeadLettered, cancellationToken); + var overdue = await _inboxStore.CountOverdueAsync(_clock.UtcNow, cancellationToken); + + return BacklogHealthEvaluator.Evaluate("Contact Center provider ingress", deadLettered, overdue, _options); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "Unable to read the Contact Center provider ingress inbox.", ex); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterQueueBacklogHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterQueueBacklogHealthCheck.cs new file mode 100644 index 000000000..2313be7e6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterQueueBacklogHealthCheck.cs @@ -0,0 +1,59 @@ +using System.Globalization; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Surfaces the tenant's queued-interaction backlog gauge — the number of interactions waiting for an agent +/// across every queue — as health-check data. This is the routed work an operator weighs before draining a node +/// during a handover: how much waiting work the deployment is still carrying. +/// +/// +/// The count is surfaced as health data rather than as an observable +/// gauge on purpose. An observable-gauge callback is synchronous and runs with no ambient tenant scope, so it +/// cannot safely issue the per-tenant store query this count requires, and a process-global count would be wrong +/// across tenants and nodes. A health check already runs inside the tenant scope with an async body, so it is the +/// correct seam for a store-backed gauge. The check reports the count and stays healthy whenever it can read it; +/// it deliberately does not degrade on a high count because the acceptable ceiling is deployment specific. +/// +public sealed class ContactCenterQueueBacklogHealthCheck : IHealthCheck +{ + private readonly IQueueItemStore _queueItemStore; + + /// + /// Initializes a new instance of the class. + /// + /// The queue item store used to count waiting interactions. + public ContactCenterQueueBacklogHealthCheck(IQueueItemStore queueItemStore) + { + _queueItemStore = queueItemStore; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + var queuedInteractions = await _queueItemStore.CountAllWaitingAsync(cancellationToken); + + var data = new Dictionary + { + ["queued_interactions"] = queuedInteractions, + }; + + var description = string.Format( + CultureInfo.InvariantCulture, + "{0} queued interaction(s).", + queuedInteractions); + + return HealthCheckResult.Healthy(description, data); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "Unable to read the Contact Center queued-interaction count.", ex); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterStorageHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterStorageHealthCheck.cs new file mode 100644 index 000000000..950be0dd9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterStorageHealthCheck.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports whether the Contact Center persistence store is reachable by issuing a cheap, side-effect-free query. +/// A failure here indicates the tenant database or its Contact Center collection is unavailable. +/// +public sealed class ContactCenterStorageHealthCheck : IHealthCheck +{ + private readonly IContactCenterOutboxStore _outboxStore; + + /// + /// Initializes a new instance of the class. + /// + /// The durable outbox store used as a lightweight storage probe. + public ContactCenterStorageHealthCheck(IContactCenterOutboxStore outboxStore) + { + _outboxStore = outboxStore; + } + + /// + public async Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + try + { + await _outboxStore.CountByStatusAsync(OutboxMessageStatus.Completed, cancellationToken); + + return HealthCheckResult.Healthy("Contact Center storage is reachable."); + } + catch (Exception ex) + { + return new HealthCheckResult(context.Registration.FailureStatus, "Contact Center storage is unreachable.", ex); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterTopologyHealthCheck.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterTopologyHealthCheck.cs new file mode 100644 index 000000000..be0ffbc41 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/ContactCenterTopologyHealthCheck.cs @@ -0,0 +1,74 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Reports whether this deployment satisfies the topology its operator declared. +/// +/// +/// This is the one readiness check that observes a condition every node shares, and the exception is +/// deliberate. Readiness normally excludes shared conditions because a shared dependency failure would +/// drain the whole fleet and turn a degraded database into a total outage. A topology violation is a different +/// kind of condition: it cannot self-heal, no amount of waiting fixes it, and continuing to serve traffic is +/// itself the failure being prevented. Refusing traffic on an uncertified deployment is the correct outcome, +/// not collateral damage. +/// +public sealed class ContactCenterTopologyHealthCheck : IHealthCheck +{ + private readonly ContactCenterTopologyState _state; + + /// + /// Initializes a new instance of the class. + /// + /// The recorded topology verdict for this tenant. + public ContactCenterTopologyHealthCheck(ContactCenterTopologyState state) + { + _state = state; + } + + /// + public Task CheckHealthAsync( + HealthCheckContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + return Task.FromResult(Evaluate(context, _state.Result)); + } + + /// + /// Decides the readiness verdict from the recorded topology result. + /// + /// The health check context supplying the configured failure status. + /// The recorded verdict, or when validation has not run yet. + /// The readiness verdict for this deployment. + public static HealthCheckResult Evaluate( + HealthCheckContext context, + ContactCenterTopologyValidationResult result) + { + ArgumentNullException.ThrowIfNull(context); + + if (result is null) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "The Contact Center deployment topology has not been validated yet."); + } + + if (!result.IsSatisfied) + { + return new HealthCheckResult( + context.Registration.FailureStatus, + "This deployment does not satisfy the Contact Center topology it declared: " + string.Join(" ", result.Failures)); + } + + if (result.DeclaredProfileId is null) + { + return HealthCheckResult.Healthy("No Contact Center topology profile is declared; this deployment does not claim production support."); + } + + return HealthCheckResult.Healthy($"This deployment satisfies the '{result.DeclaredProfileId}' Contact Center topology."); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/NodeServingStateTracker.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/NodeServingStateTracker.cs new file mode 100644 index 000000000..31e35f6e1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/NodeServingStateTracker.cs @@ -0,0 +1,116 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Tracks whether this node is currently able to serve, from the outcomes of real dependency probes observed +/// on this node. +/// +/// +/// A single failed probe must never remove a node from rotation: dependency calls fail transiently all the +/// time, and reacting to one blip converts noise into lost capacity. Equally, a node whose own connection pool +/// is exhausted, whose DNS is stale, or whose TLS trust store has expired will fail every probe while its peers +/// stay healthy, and it must be drained. +/// +/// Hysteresis separates the two: a node drains only after a run of consecutive failures, and returns only after +/// a run of consecutive successes. That also bounds the fleet-wide risk, because a shared outage still trips +/// every node — which is why the gate that consumes this tracker is opt-in and documented as requiring a load +/// balancer with fail-open behaviour. +/// +/// +/// Instances are per tenant shell and are mutated from concurrent probe requests, so all state transitions are +/// performed under a lock. +/// +/// +public sealed class NodeServingStateTracker +{ + private readonly int _consecutiveFailuresBeforeUnready; + private readonly int _consecutiveSuccessesBeforeReady; + private readonly Lock _gate = new(); + + private int _consecutiveFailures; + private int _consecutiveSuccesses; + private bool _isServing = true; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The number of consecutive failed probes required before the node reports that it cannot serve. Values + /// below one are raised to one. + /// + /// + /// The number of consecutive successful probes required before a draining node reports that it can serve + /// again. Values below one are raised to one. + /// + public NodeServingStateTracker(int consecutiveFailuresBeforeUnready, int consecutiveSuccessesBeforeReady) + { + _consecutiveFailuresBeforeUnready = Math.Max(1, consecutiveFailuresBeforeUnready); + _consecutiveSuccessesBeforeReady = Math.Max(1, consecutiveSuccessesBeforeReady); + } + + /// + /// Gets a value indicating whether the node is currently considered able to serve. + /// + /// + /// A node starts able to serve, so a deployment is never blocked by a probe that has not run yet. + /// + public bool IsServing + { + get + { + lock (_gate) + { + return _isServing; + } + } + } + + /// + /// Records the outcome of a dependency probe observed on this node and returns the resulting state. + /// + /// Whether the probe succeeded. + /// when the node is considered able to serve after this outcome. + public bool Record(bool succeeded) + { + lock (_gate) + { + if (succeeded) + { + _consecutiveFailures = 0; + + if (_isServing) + { + _consecutiveSuccesses = 0; + + return true; + } + + _consecutiveSuccesses++; + + if (_consecutiveSuccesses >= _consecutiveSuccessesBeforeReady) + { + _isServing = true; + _consecutiveSuccesses = 0; + } + + return _isServing; + } + + _consecutiveSuccesses = 0; + + if (!_isServing) + { + return false; + } + + _consecutiveFailures++; + + if (_consecutiveFailures >= _consecutiveFailuresBeforeUnready) + { + _isServing = false; + _consecutiveFailures = 0; + } + + return _isServing; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/SharedHealthCheckEndpointGuard.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/SharedHealthCheckEndpointGuard.cs new file mode 100644 index 000000000..2e2a0e9c5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/HealthChecks/SharedHealthCheckEndpointGuard.cs @@ -0,0 +1,74 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.HealthChecks; + +/// +/// Validates the route of the shared, unfiltered health-check endpoint contributed by the +/// OrchardCore.HealthChecks module. +/// +/// +/// That module maps a single endpoint with no registration predicate, so it aggregates every check registered +/// by every enabled module. Once Contact Center is enabled, that aggregate includes dependency checks such as +/// the event outbox backlog. An endpoint whose route claims liveness but whose content reports readiness is a +/// trap: wiring it to an orchestrator's liveness probe turns a slow outbox into a restart loop, and restarting +/// a node cannot drain an outbox. Contact Center is what makes that endpoint dangerous, so it refuses to +/// introduce the hazard silently. +/// +public static class SharedHealthCheckEndpointGuard +{ + /// + /// The route the OrchardCore.HealthChecks module uses when no route is configured. + /// + public const string DefaultSharedEndpointRoute = "/health/live"; + + private static readonly string[] _livenessSegments = ["live", "liveness"]; + + /// + /// Determines whether the shared aggregate endpoint's route claims to be a liveness probe. + /// + /// The configured route, or when unset. + /// when the effective route claims liveness. + public static bool IsUnsafeRoute(string configuredRoute) + { + var effectiveRoute = string.IsNullOrWhiteSpace(configuredRoute) + ? DefaultSharedEndpointRoute + : configuredRoute.Trim(); + + var lastSegment = effectiveRoute + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .LastOrDefault(); + + if (lastSegment is null) + { + return false; + } + + return _livenessSegments.Contains(lastSegment, StringComparer.OrdinalIgnoreCase); + } + + /// + /// Throws when the shared aggregate endpoint is named as a liveness probe and the operator has not + /// acknowledged the risk. + /// + /// The configured route, or when unset. + /// Whether the operator accepted the shared endpoint's route. + /// The route claims liveness and was not acknowledged. + public static void Validate(string configuredRoute, bool acknowledged) + { + if (acknowledged || !IsUnsafeRoute(configuredRoute)) + { + return; + } + + var effectiveRoute = string.IsNullOrWhiteSpace(configuredRoute) + ? DefaultSharedEndpointRoute + : configuredRoute.Trim(); + + throw new InvalidOperationException( + $"The OrchardCore.HealthChecks module is enabled and maps its aggregate endpoint at '{effectiveRoute}'. " + + "That endpoint applies no registration filter, so it reports the Contact Center dependency checks as " + + "well. Using it as a liveness probe restarts healthy nodes whenever a dependency degrades, and a " + + "restart cannot drain an event outbox. Set 'OrchardCore_HealthChecks:Url' to a route that does not " + + "claim liveness, such as '/health/aggregate', and probe '/health/process' for liveness and " + + "'api/contact-center/health/ready' for readiness. To keep the current route anyway, set " + + "'CrestApps_ContactCenter:HealthChecks:AllowUnsafeSharedEndpointRoute' to true."); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueGroupIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueGroupIndex.cs new file mode 100644 index 000000000..f3d674548 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueGroupIndex.cs @@ -0,0 +1,19 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query queue groups. +/// +public sealed class ActivityQueueGroupIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique queue-group name. + /// + public string Name { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueIndex.cs new file mode 100644 index 000000000..4ee41f451 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueIndex.cs @@ -0,0 +1,29 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query queues. +/// +public sealed class ActivityQueueIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique name of the queue. + /// + public string Name { get; set; } + + /// + /// Gets or sets the optional queue-group identifier. + /// + public string QueueGroupId { get; set; } + + /// + /// Gets or sets a value indicating whether the queue is enabled for routing. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityReservationIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityReservationIndex.cs new file mode 100644 index 000000000..05a3f12e9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityReservationIndex.cs @@ -0,0 +1,51 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query activity reservations. +/// +public sealed class ActivityReservationIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the CRM activity identifier that is reserved. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the unique key that prevents more than one active reservation for an activity. + /// + public string ActivityClaimKey { get; set; } + + /// + /// Gets or sets the agent the activity is reserved for. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the unique key that prevents more than one pending reservation for an agent. + /// + public string AgentClaimKey { get; set; } + + /// + /// Gets or sets the lifecycle status of the reservation. + /// + public ReservationStatus Status { get; set; } + + /// + /// Gets or sets the UTC time the reservation expires. + /// + public DateTime ExpiresUtc { get; set; } + + /// + /// Gets or sets the time the reservation reached a terminal status, which is the age settled reservations + /// are purged by. Null while the reservation is still pending or held by an agent. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentProfileIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentProfileIndex.cs new file mode 100644 index 000000000..008eb03fb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentProfileIndex.cs @@ -0,0 +1,30 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query agent profiles. +/// +public sealed class AgentProfileIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique name of the agent profile. + /// + public string Name { get; set; } + + /// + /// Gets or sets the identifier of the user the profile represents. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the current presence state of the agent. + /// + public AgentPresenceStatus PresenceStatus { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentQueueMembershipIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentQueueMembershipIndex.cs new file mode 100644 index 000000000..5795601f2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentQueueMembershipIndex.cs @@ -0,0 +1,32 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the normalized, query-aligned YesSql index that maps one row per queue an agent is both +/// entitled to and currently signed in to. Routing can select the agents for a queue with a single +/// indexed lookup instead of loading every available agent and filtering membership in memory. +/// +public sealed class AgentQueueMembershipIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier of the source agent profile. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the queue the agent is entitled to and signed in to. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the current presence state of the agent. + /// + public AgentPresenceStatus PresenceStatus { get; set; } + + /// + /// Gets or sets the maximum number of concurrent interactions the agent can handle. + /// + public int MaxConcurrentInteractions { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentSessionIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentSessionIndex.cs new file mode 100644 index 000000000..ea1078d94 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentSessionIndex.cs @@ -0,0 +1,29 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query live agent sessions by user and online status. +/// +public sealed class AgentSessionIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the identifier of the user the session belongs to. + /// + public string UserId { get; set; } + + /// + /// Gets or sets a value indicating whether the agent currently has at least one live connection. + /// + public bool IsOnline { get; set; } + + /// + /// Gets or sets the UTC time the agent's client last sent a heartbeat. + /// + public DateTime? LastHeartbeatUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentStateReasonCodeIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentStateReasonCodeIndex.cs new file mode 100644 index 000000000..543fdd759 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentStateReasonCodeIndex.cs @@ -0,0 +1,29 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query agent state reason codes. +/// +public sealed class AgentStateReasonCodeIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique reason code name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the relative order the reason code is listed in. + /// + public int SortOrder { get; set; } + + /// + /// Gets or sets a value indicating whether the reason code is enabled. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/BusinessHoursCalendarIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/BusinessHoursCalendarIndex.cs new file mode 100644 index 000000000..107c76504 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/BusinessHoursCalendarIndex.cs @@ -0,0 +1,24 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query business-hours calendars. +/// +public sealed class BusinessHoursCalendarIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique calendar name. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the calendar is enabled. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallSessionIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallSessionIndex.cs new file mode 100644 index 000000000..bc2ff28e5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallSessionIndex.cs @@ -0,0 +1,68 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query call sessions. +/// +public sealed class CallSessionIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the interaction the call session belongs to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the CRM activity the call belongs to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the provider name that owns the call. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the portable, non-null claim key that enforces one call session per canonical + /// provider-call identity. It is {ProviderName}|{ProviderCallId} when the session has a + /// provider call identifier; otherwise it falls back to the globally unique item identifier so + /// sessions without a provider call cannot collide. + /// + public string ProviderCallClaimKey { get; set; } + + /// + /// Gets or sets the normalized call state. + /// + public VoiceCallState State { get; set; } + + /// + /// Gets or sets the agent connected to the call. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the queue the call was delivered from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the UTC time the call session was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the call session ended. + /// + public DateTime? EndedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs new file mode 100644 index 000000000..f9e99b0db --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs @@ -0,0 +1,36 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query callback requests. +/// +public sealed class CallbackRequestIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the callback status. + /// + public CallbackRequestStatus Status { get; set; } + + /// + /// Gets or sets the UTC time the callback becomes due. + /// + public DateTime ScheduledUtc { get; set; } + + /// + /// Gets or sets the UTC time the current promotion lease expires, when the callback is claimed. + /// + public DateTime? LeaseExpiresUtc { get; set; } + + /// + /// Gets or sets the UTC time the callback was last modified, which is the settlement time for a callback + /// that has reached a terminal status and is what retention ages it by. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEntryPointIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEntryPointIndex.cs new file mode 100644 index 000000000..5384ca6c1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEntryPointIndex.cs @@ -0,0 +1,24 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query inbound entry points. +/// +public sealed class ContactCenterEntryPointIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique entry point name. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the entry point is enabled. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricDeltaIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricDeltaIndex.cs new file mode 100644 index 000000000..c834ce281 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricDeltaIndex.cs @@ -0,0 +1,40 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query unrolled daily event count contributions. +/// +public sealed class ContactCenterEventMetricDeltaIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the day the contribution counts toward, formatted as yyyy-MM-dd. + /// + public string DateKey { get; set; } + + /// + /// Gets or sets the UTC date (midnight) the contribution counts toward. + /// + public DateTime Date { get; set; } + + /// + /// Gets or sets the domain event type being counted. + /// + public string EventType { get; set; } + + /// + /// Gets or sets the number of events this contribution represents. It is carried on the index so the + /// contributions waiting to be folded can be totalled without joining to the documents that hold them. + /// + public long Count { get; set; } + + /// + /// Gets or sets the UTC time the contribution was appended. + /// + public DateTime CreatedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricIndex.cs new file mode 100644 index 000000000..805980c7c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricIndex.cs @@ -0,0 +1,29 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query daily event metrics. +/// +public sealed class ContactCenterEventMetricIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the day the metric counts, formatted as yyyy-MM-dd. + /// + public string DateKey { get; set; } + + /// + /// Gets or sets the UTC date (midnight) the metric counts, used for range queries. + /// + public DateTime Date { get; set; } + + /// + /// Gets or sets the domain event type being counted. + /// + public string EventType { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterOutboxMessageIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterOutboxMessageIndex.cs new file mode 100644 index 000000000..2e49ef7c6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterOutboxMessageIndex.cs @@ -0,0 +1,36 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query Contact Center outbox messages that are due for retry. +/// +public sealed class ContactCenterOutboxMessageIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the identifier of the event the message retries. + /// + public string EventId { get; set; } + + /// + /// Gets or sets the dispatch state of the message. + /// + public OutboxMessageStatus Status { get; set; } + + /// + /// Gets or sets the UTC time the next dispatch attempt is due. + /// + public DateTime NextAttemptUtc { get; set; } + + /// + /// Gets or sets the UTC time the message was created. Retention purges settled messages by age, and the + /// retry time is not an age: a settled message keeps whatever retry time it last held. + /// + public DateTime CreatedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterProcessedEventIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterProcessedEventIndex.cs new file mode 100644 index 000000000..4d7ccaa3b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterProcessedEventIndex.cs @@ -0,0 +1,30 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to enforce and query per-handler event idempotency markers. +/// +public sealed class ContactCenterProcessedEventIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the stable, versioned identifier of the handler that processed the event. + /// + public string HandlerId { get; set; } + + /// + /// Gets or sets the durable identifier of the event that was processed. + /// + public string EventId { get; set; } + + /// + /// Gets or sets the UTC time the event was processed. Retention purges these markers by age, which is only + /// safe once redelivery of the event is no longer possible, so the column exists to make that age queryable. + /// + public DateTime ProcessedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterProjectionCheckpointIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterProjectionCheckpointIndex.cs new file mode 100644 index 000000000..489cad747 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterProjectionCheckpointIndex.cs @@ -0,0 +1,24 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query projection checkpoints by handler. +/// +public sealed class ContactCenterProjectionCheckpointIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the stable, versioned identifier of the projection the checkpoint belongs to. + /// + public string HandlerId { get; set; } + + /// + /// Gets or sets the projection logic version the checkpoint was last rebuilt with. + /// + public int Version { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterSkillIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterSkillIndex.cs new file mode 100644 index 000000000..ff9b89cfe --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterSkillIndex.cs @@ -0,0 +1,24 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query Contact Center skills. +/// +public sealed class ContactCenterSkillIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique skill name. + /// + public string Name { get; set; } + + /// + /// Gets or sets a value indicating whether the skill is enabled. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterWorkStateIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterWorkStateIndex.cs new file mode 100644 index 000000000..2af479f23 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterWorkStateIndex.cs @@ -0,0 +1,46 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query Contact Center work state by activity, agent, or reservation. +/// +public sealed class ContactCenterWorkStateIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the CRM activity identifier the work state belongs to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the routing-owned assignment status of the activity. + /// + public ActivityAssignmentStatus AssignmentStatus { get; set; } + + /// + /// Gets or sets the identifier of the reservation currently holding the activity. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the user identifier of the agent the activity is reserved for. + /// + public string ReservedById { get; set; } + + /// + /// Gets or sets the user identifier of the agent the activity is assigned to. + /// + public string AssignedToId { get; set; } + + /// + /// Gets or sets the time the work state was last mutated, which is the age it is purged by. There is no + /// terminal assignment status to key on, because the work item's closure is owned by the CRM activity. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/DialerProfileIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/DialerProfileIndex.cs new file mode 100644 index 000000000..0e942b077 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/DialerProfileIndex.cs @@ -0,0 +1,34 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query dialer profiles. +/// +public sealed class DialerProfileIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the unique name of the dialer profile. + /// + public string Name { get; set; } + + /// + /// Gets or sets the campaign the dialer profile targets. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the queue the dialer profile feeds. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets a value indicating whether the dialer profile is enabled. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionEventIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionEventIndex.cs new file mode 100644 index 000000000..94e6041c4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionEventIndex.cs @@ -0,0 +1,56 @@ +using CrestApps.Core.Data.YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query the durable interaction event history. +/// +public sealed class InteractionEventIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the identifier of the interaction the event belongs to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the canonical event type name. + /// + public string EventType { get; set; } + + /// + /// Gets or sets the type of aggregate the event applies to. + /// + public string AggregateType { get; set; } + + /// + /// Gets or sets the identifier of the aggregate the event applies to. + /// + public string AggregateId { get; set; } + + /// + /// Gets or sets the correlation identifier of the event. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the idempotency key used to de-duplicate provider-originated events. + /// + public string IdempotencyKey { get; set; } + + /// + /// Gets or sets the portable, non-null claim key that enforces idempotency-key uniqueness at the + /// database level. It is the when one is present; otherwise it falls + /// back to the globally unique item identifier so events without a key cannot collide. + /// + public string IdempotencyClaimKey { get; set; } + + /// + /// Gets or sets the UTC time the event occurred. + /// + public DateTime OccurredUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionIndex.cs new file mode 100644 index 000000000..bff474135 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionIndex.cs @@ -0,0 +1,91 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query interactions. +/// +public sealed class InteractionIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the channel the interaction is conducted on. + /// + public InteractionChannel Channel { get; set; } + + /// + /// Gets or sets the direction of the interaction. + /// + public InteractionDirection Direction { get; set; } + + /// + /// Gets or sets the lifecycle status of the interaction. + /// + public InteractionStatus Status { get; set; } + + /// + /// Gets or sets the identifier of the CRM activity the interaction is linked to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the provider name that produced the interaction. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider interaction or call identifier. + /// + public string ProviderInteractionId { get; set; } + + /// + /// Gets or sets the provider call leg identifier. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the queue that handled the interaction, when applicable. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the agent connected to the interaction. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the correlation identifier of the interaction. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the UTC time the interaction was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the interaction ended. + /// + public DateTime? EndedUtc { get; set; } + + /// + /// Gets or sets the UTC time after-call wrap-up started. + /// + public DateTime? WrapUpStartedUtc { get; set; } + + /// + /// Gets or sets the UTC time after-call wrap-up was completed. + /// + public DateTime? WrapUpCompletedUtc { get; set; } + + /// + /// Gets or sets a value indicating whether the interaction's recording is under legal hold. Held interactions + /// are excluded from age-based retention so the query never fetches a record it is not allowed to delete. + /// + public bool RecordingLegalHold { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ProviderCommandIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ProviderCommandIndex.cs new file mode 100644 index 000000000..a22f0c2ff --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ProviderCommandIndex.cs @@ -0,0 +1,58 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to enforce provider command idempotency and to query commands that are +/// due for dispatch, reconciliation, or lease reclamation. +/// +public sealed class ProviderCommandIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the stable idempotency key. It is unique per tenant so a command is never duplicated. + /// + public string CommandId { get; set; } + + /// + /// Gets or sets the canonical provider technical name the command targets. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the current lifecycle status of the command. + /// + public ProviderCommandStatus Status { get; set; } + + /// + /// Gets or sets the monotonically increasing fence token. + /// + public long FenceToken { get; set; } + + /// + /// Gets or sets the interaction identifier the command relates to, when applicable. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the UTC time the next dispatch or reconciliation attempt is due. + /// + public DateTime NextAttemptUtc { get; set; } + + /// + /// Gets or sets the UTC time the current claim lease expires. + /// + public DateTime LeaseExpiresUtc { get; set; } + + /// + /// Gets or sets the UTC time the command reached a terminal state, or while it is + /// still in flight. Retention purges settled commands by this time rather than by their retry or lease + /// times, neither of which advances once a command has finished. + /// + public DateTime? CompletedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ProviderWebhookInboxMessageIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ProviderWebhookInboxMessageIndex.cs new file mode 100644 index 000000000..8dbdb4a64 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ProviderWebhookInboxMessageIndex.cs @@ -0,0 +1,44 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used for provider webhook idempotency and due-message queries. +/// +public sealed class ProviderWebhookInboxMessageIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the canonical provider technical name. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider-scoped delivery identifier. + /// + public string DeliveryId { get; set; } + + /// + /// Gets or sets the durable processing status. + /// + public ProviderWebhookInboxStatus Status { get; set; } + + /// + /// Gets or sets the UTC time the next processing attempt is due. + /// + public DateTime NextAttemptUtc { get; set; } + + /// + /// Gets or sets the UTC time the delivery reached a terminal outcome. Retention purges settled deliveries by + /// this age. Receipt time cannot serve, because settlement lags receipt by the whole retry envelope and would + /// shorten the redelivery tombstone below its guarantee; the retry time cannot serve either, because a settled + /// delivery keeps whatever retry time it last held. + /// + public DateTime? ProcessedUtc { get; set; } + +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/QueueItemIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/QueueItemIndex.cs new file mode 100644 index 000000000..b977a40cb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/QueueItemIndex.cs @@ -0,0 +1,57 @@ +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Indexes; + +/// +/// Represents the YesSql index used to query queue items. +/// +public sealed class QueueItemIndex : CatalogItemIndex +{ + /// + /// Gets or sets the document identifier. + /// + public long DocumentId { get; set; } + + /// + /// Gets or sets the queue that owns the item. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the CRM activity identifier the item represents. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the unique key that prevents more than one active queue item for an activity. + /// + public string ActivityClaimKey { get; set; } + + /// + /// Gets or sets the lifecycle status of the item. + /// + public QueueItemStatus Status { get; set; } + + /// + /// Gets or sets the routing priority of the item. + /// + public InteractionPriority Priority { get; set; } + + /// + /// Gets or sets the agent assigned to the item. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the UTC time the item entered the queue. + /// + public DateTime EnqueuedUtc { get; set; } + + /// + /// Gets or sets the UTC time the item left the queue, or while it is still waiting. + /// Retention purges settled items by when they left rather than by when they arrived, so an item that + /// waited a long time is not purged the moment it is finally handled. + /// + public DateTime? DequeuedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs new file mode 100644 index 000000000..b6b47a33d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs @@ -0,0 +1,113 @@ +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a Contact Center work queue that holds and prioritizes activities waiting for agents. +/// +public sealed class ActivityQueue : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the optional queue-group identifier used for catalog organization and reporting. + /// Queue groups do not affect routing, SLA settings, entitlements, or queue behavior. + /// + public string QueueGroupId { get; set; } + + /// + /// Gets or sets the unique name of the queue. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the queue. + /// + public string Description { get; set; } + + /// + /// Gets or sets the default priority applied to items added to the queue. + /// + public InteractionPriority DefaultPriority { get; set; } = InteractionPriority.Normal; + + /// + /// Gets or sets the strategy used to choose which available agent receives the next queued item. + /// + public QueueRoutingStrategy RoutingStrategy { get; set; } = QueueRoutingStrategy.LongestIdle; + + /// + /// Gets or sets a value indicating whether routing prefers the activity's last assigned user (sticky agent) + /// when that agent is eligible and available. + /// + public bool PreferStickyAgent { get; set; } + + /// + /// Gets or sets a value indicating whether a waiting item's effective priority increases the longer it waits + /// beyond the SLA threshold, so aging work is routed ahead of newer higher-priority work. + /// + public bool EnableSlaAging { get; set; } + + /// + /// Gets or sets the service-level threshold, in seconds, after which a waiting item breaches SLA. + /// + public int SlaThresholdSeconds { get; set; } = 120; + + /// + /// Gets or sets the seconds a reservation remains valid before it expires and the item is re-queued. + /// + public int ReservationTimeoutSeconds { get; set; } = 30; + + /// + /// Gets or sets what happens when an offered reservation expires before the agent accepts it. + /// + public UnansweredOfferAction UnansweredOfferAction { get; set; } = UnansweredOfferAction.Requeue; + + /// + /// Gets or sets the identifier of the business-hours calendar that gates when the queue routes work. + /// When empty, the queue routes around the clock. + /// + public string BusinessHoursCalendarId { get; set; } + + /// + /// Gets or sets the action taken for waiting items while the queue's business-hours calendar reports closed. + /// + public QueueAfterHoursAction AfterHoursAction { get; set; } = QueueAfterHoursAction.HoldInQueue; + + /// + /// Gets or sets the identifier of the queue that receives overflowed items. When empty, overflow is disabled. + /// + public string OverflowQueueId { get; set; } + + /// + /// Gets or sets the seconds an item may wait before it overflows to the overflow queue. Zero disables + /// wait-time overflow while still allowing after-hours overflow. + /// + public int OverflowAfterSeconds { get; set; } + + /// + /// Gets or sets the skills required to be eligible to handle work from this queue. + /// + public IList RequiredSkills { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the queue is enabled for routing. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the identifier of the inbound channel endpoint (the dialed number or DID) whose + /// calls are routed to this queue. When set, the inbound voice flow enqueues calls received on + /// that endpoint into this queue. + /// + public string InboundChannelEndpointId { get; set; } + + /// + /// Gets or sets the UTC time the queue was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the queue was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueueGroup.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueueGroup.cs new file mode 100644 index 000000000..7f4530d5a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueueGroup.cs @@ -0,0 +1,30 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a catalog group used to organize Contact Center queues for administration and reporting. +/// +public sealed class ActivityQueueGroup : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique queue-group name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the queue-group description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the UTC time the queue group was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the queue group was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityReservation.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityReservation.cs new file mode 100644 index 000000000..afe44e5f3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityReservation.cs @@ -0,0 +1,101 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a short-lived lock that reserves an activity for an agent before assignment is finalized. +/// +public sealed class ActivityReservation : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the identifier of the CRM activity that is reserved. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the queue the reservation originated from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the queue item that is reserved. + /// + public string QueueItemId { get; set; } + + /// + /// Gets or sets the agent the activity is reserved for. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the lifecycle status of the reservation. + /// + [JsonInclude] + public ReservationStatus Status { get; private set; } + + /// + /// Moves the ActivityReservation to the specified reservation status. + /// + /// The status to move to. + /// The ActivityReservation cannot reach the status from the one it is in. + public void TransitionTo(ReservationStatus status) + { + if (!ReservationLifecycle.CanTransition(Status, status)) + { + throw new InvalidStateTransitionException(nameof(ActivityReservation), Status, status); + } + + Status = status; + } + + /// + /// Determines whether the ActivityReservation can move to the specified status. + /// + /// The status to test. + /// when the transition is admitted; otherwise . + public bool CanTransitionTo(ReservationStatus status) + => ReservationLifecycle.CanTransition(Status, status); + + /// + /// Gets a value indicating whether the reservation has resolved and no longer holds its lock. + /// + [JsonIgnore] + public bool IsResolved => ReservationLifecycle.IsResolved(Status); + + /// + /// Restores a status that was decided elsewhere, without consulting the lifecycle. + /// + /// The status to restore. + /// The same ActivityReservation, so it can be used at the end of an object initializer. + /// + /// This bypasses every transition rule and exists only so a test can arrange a state directly. Production code + /// must never call it: AggregateLifecycleArchitectureTests fails the build if any file under src/ + /// does, so the bypass cannot quietly become a shortcut. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public ActivityReservation RestorePersistedStatus(ReservationStatus status) + { + Status = status; + + return this; + } + + /// + /// Gets or sets the UTC time the reservation was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the reservation expires when not accepted. + /// + public DateTime ExpiresUtc { get; set; } + + /// + /// Gets or sets the UTC time the reservation was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidate.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidate.cs new file mode 100644 index 000000000..88ccc36d4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidate.cs @@ -0,0 +1,50 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents an agent considered during a Contact Center routing decision. +/// +public sealed class ActivityRoutingCandidate +{ + /// + /// Initializes a new instance of the class. + /// + /// The agent profile being scored. + public ActivityRoutingCandidate(AgentProfile agent) + { + ArgumentNullException.ThrowIfNull(agent); + + Agent = agent; + } + + /// + /// Gets the agent profile being considered. + /// + public AgentProfile Agent { get; } + + /// + /// Gets or sets a value indicating whether the candidate is eligible for the queued item. + /// + public bool IsEligible { get; set; } = true; + + /// + /// Gets or sets the aggregate routing score assigned by routing strategies. + /// + public double Score { get; set; } + + /// + /// Gets the explainable reasons contributed by routing strategies. + /// + public IList Reasons { get; } = []; + + /// + /// Adds an explainable routing reason to the candidate. + /// + /// The routing reason. + public void AddReason(string reason) + { + if (!string.IsNullOrEmpty(reason)) + { + Reasons.Add(reason); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidateDecisionData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidateDecisionData.cs new file mode 100644 index 000000000..3ddf9acd9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidateDecisionData.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents one candidate captured in an auditable routing decision event. +/// +public sealed class ActivityRoutingCandidateDecisionData +{ + /// + /// Gets or sets the agent profile identifier. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the Orchard user identifier linked to the agent. + /// + public string UserId { get; set; } + + /// + /// Gets or sets a value indicating whether the candidate was eligible. + /// + public bool IsEligible { get; set; } + + /// + /// Gets or sets the final routing score. + /// + public double Score { get; set; } + + /// + /// Gets or sets the explainable routing reasons. + /// + public IList Reasons { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingContext.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingContext.cs new file mode 100644 index 000000000..18e267650 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingContext.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the queue item and agent candidates through routing strategies. +/// +public sealed class ActivityRoutingContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The queue being routed. + /// The queue item being assigned. + /// The candidate agents. + public ActivityRoutingContext( + ActivityQueue queue, + QueueItem queueItem, + IEnumerable candidates) + { + ArgumentNullException.ThrowIfNull(queue); + ArgumentNullException.ThrowIfNull(queueItem); + ArgumentNullException.ThrowIfNull(candidates); + + Queue = queue; + QueueItem = queueItem; + Candidates = candidates.ToList(); + } + + /// + /// Gets the queue being routed. + /// + public ActivityQueue Queue { get; } + + /// + /// Gets the queue item being assigned. + /// + public QueueItem QueueItem { get; } + + /// + /// Gets the candidate agents that routing strategies can score or reject. + /// + public IList Candidates { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecision.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecision.cs new file mode 100644 index 000000000..2fd6288e7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecision.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of selecting an agent for a queued activity. +/// +public sealed class ActivityRoutingDecision +{ + /// + /// Gets or sets a value indicating whether an agent was selected. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the routed queue. + /// + public ActivityQueue Queue { get; set; } + + /// + /// Gets or sets the queue item being assigned. + /// + public QueueItem QueueItem { get; set; } + + /// + /// Gets or sets the selected agent, or when no eligible agent was available. + /// + public AgentProfile Agent { get; set; } + + /// + /// Gets or sets the human-readable routing outcome. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the scored routing candidates. + /// + public IList Candidates { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecisionEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecisionEventData.cs new file mode 100644 index 000000000..32c5c8a96 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecisionEventData.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the serialized payload for an auditable routing decision event. +/// +public sealed class ActivityRoutingDecisionEventData +{ + /// + /// Gets or sets the queue identifier. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the queue item identifier. + /// + public string QueueItemId { get; set; } + + /// + /// Gets or sets the CRM activity identifier. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the selected agent profile identifier. + /// + public string SelectedAgentId { get; set; } + + /// + /// Gets or sets a value indicating whether routing selected an agent. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the human-readable routing outcome. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the candidate scoring details. + /// + public IList Candidates { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentAvailability.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentAvailability.cs new file mode 100644 index 000000000..15f8ef484 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentAvailability.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the computed routing availability of an agent at a point in time. +/// +public sealed class AgentAvailability +{ + /// + /// Gets or sets the agent profile. + /// + public AgentProfile Agent { get; set; } + + /// + /// Gets or sets the last recorded session heartbeat in UTC. + /// + public DateTime LastHeartbeatUtc { get; set; } + + /// + /// Gets or sets the number of active interactions consuming the agent's capacity. + /// + public int ActiveInteractionCount { get; set; } + + /// + /// Gets the remaining interaction capacity. + /// + public int RemainingCapacity => Math.Max(0, Math.Max(1, Agent.MaxConcurrentInteractions) - ActiveInteractionCount); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentAvailabilityOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentAvailabilityOptions.cs new file mode 100644 index 000000000..e1800d661 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentAvailabilityOptions.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Defines the liveness and after-call recovery policy for Contact Center agents. +/// +public sealed class AgentAvailabilityOptions +{ + /// + /// Gets or sets the maximum age of a heartbeat that is considered live for routing. + /// + public TimeSpan HeartbeatTimeout { get; set; } = TimeSpan.FromSeconds(90); + + /// + /// Gets or sets the maximum time an agent may remain in after-call wrap-up before capacity is recovered. + /// + public TimeSpan MaximumWrapUpDuration { get; set; } = TimeSpan.FromMinutes(15); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentDesktopSnapshot.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentDesktopSnapshot.cs new file mode 100644 index 000000000..7e59b61f7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentDesktopSnapshot.cs @@ -0,0 +1,70 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the point-in-time state the agent desktop needs to render itself after an initial +/// connection or a reconnect. It combines the durable configuration with the +/// live so a reconnecting client can restore presence, queue membership, and +/// any in-flight reservation without replaying the event history. +/// +public sealed class AgentDesktopSnapshot +{ + /// + /// Gets or sets the identifier of the Orchard user the snapshot describes. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the display name shown for the agent. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets a value indicating whether an agent profile exists for the user. + /// + public bool HasProfile { get; set; } + + /// + /// Gets or sets the current presence status name, or when no profile exists. + /// + public string PresenceStatus { get; set; } + + /// + /// Gets or sets the optional reason code associated with the current presence status. + /// + public string PresenceReason { get; set; } + + /// + /// Gets or sets the pending presence status the system grants once in-flight routing completes. + /// + public string RequestedPresenceStatus { get; set; } + + /// + /// Gets or sets the identifier of the active reservation when the agent is reserved for an offer. + /// + public string ActiveReservationId { get; set; } + + /// + /// Gets or sets the queues the agent is currently signed in to. + /// + public IList QueueIds { get; set; } = []; + + /// + /// Gets or sets the dialer campaigns the agent is currently signed in to. + /// + public IList CampaignIds { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the agent currently has at least one live connection. + /// + public bool IsOnline { get; set; } + + /// + /// Gets or sets the UTC time the agent's client last sent a heartbeat. + /// + public DateTime? LastHeartbeatUtc { get; set; } + + /// + /// Gets or sets the authoritative server UTC time, used by the client to align local timers. + /// + public DateTime ServerTimeUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentEntitlementsChangedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentEntitlementsChangedEventData.cs new file mode 100644 index 000000000..cefeb62ef --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentEntitlementsChangedEventData.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes manager-owned agent entitlement changes and any live memberships removed by them. +/// +public sealed class AgentEntitlementsChangedEventData +{ + /// + /// Gets or sets the queues the agent is allowed to join. + /// + public IList AllowedQueueIds { get; set; } = []; + + /// + /// Gets or sets the campaigns the agent is allowed to join. + /// + public IList AllowedCampaignIds { get; set; } = []; + + /// + /// Gets or sets the live queue memberships removed by this change. + /// + public IList RemovedQueueIds { get; set; } = []; + + /// + /// Gets or sets the live campaign memberships removed by this change. + /// + public IList RemovedCampaignIds { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentPresenceChangedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentPresenceChangedEventData.cs new file mode 100644 index 000000000..9449431df --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentPresenceChangedEventData.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes an auditable agent presence transition and the memberships that were active after it. +/// +public sealed class AgentPresenceChangedEventData +{ + /// + /// Gets or sets the presence status before the transition. + /// + public AgentPresenceStatus PreviousStatus { get; set; } + + /// + /// Gets or sets the presence status after the transition. + /// + public AgentPresenceStatus CurrentStatus { get; set; } + + /// + /// Gets or sets the presence status requested for after active work completes. + /// + public AgentPresenceStatus? RequestedStatus { get; set; } + + /// + /// Gets or sets the optional reason associated with the current status. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the queues the agent is signed in to after the transition. + /// + public IList QueueIds { get; set; } = []; + + /// + /// Gets or sets the campaigns the agent is signed in to after the transition. + /// + public IList CampaignIds { get; set; } = []; + + /// + /// Gets or sets the UTC time the transition occurred. + /// + public DateTime ChangedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs new file mode 100644 index 000000000..02072f19d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs @@ -0,0 +1,105 @@ +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the Contact Center configuration and live presence for an Orchard user acting as an agent. +/// +public sealed class AgentProfile : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique name of the agent profile. + /// + public string Name { get; set; } + + /// + /// Gets or sets the identifier of the Orchard user this agent profile represents. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the user name of the Orchard user this agent profile represents. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the display name shown for the agent in supervisor and queue views. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the maximum number of concurrent voice interactions the agent can handle. + /// + public int MaxConcurrentInteractions { get; set; } = 1; + + /// + /// Gets or sets the current presence state of the agent. + /// + public AgentPresenceStatus PresenceStatus { get; set; } + + /// + /// Gets or sets the optional reason code associated with the current presence state. + /// + public string PresenceReason { get; set; } + + /// + /// Gets or sets the pending presence state that the system grants after in-flight routing completes. + /// + public AgentPresenceStatus? RequestedPresenceStatus { get; set; } + + /// + /// Gets or sets the UTC time the presence state last changed. + /// + public DateTime? PresenceChangedUtc { get; set; } + + /// + /// Gets or sets the UTC time the agent most recently received a routing assignment, used by round-robin routing. + /// + public DateTime? LastAssignedUtc { get; set; } + + /// + /// Gets or sets the queues the agent is signed in to and can receive work from. + /// + public IList QueueIds { get; set; } = []; + + /// + /// Gets or sets the dialer campaigns the agent is signed in to. + /// + public IList CampaignIds { get; set; } = []; + + /// + /// Gets or sets the manager-owned queue entitlements the agent is allowed to sign in to. This is the + /// authoritative allow-list enforced independently of , which only tracks the + /// agent's live sign-in state and can never be broadened by the agent beyond this set. + /// + public IList AllowedQueueIds { get; set; } = []; + + /// + /// Gets or sets the manager-owned dialer campaign entitlements the agent is allowed to sign in to. This + /// is the authoritative allow-list enforced independently of , which only tracks + /// the agent's live sign-in state and can never be broadened by the agent beyond this set. + /// + public IList AllowedCampaignIds { get; set; } = []; + + /// + /// Gets or sets the skills the agent can be routed for. + /// + public IList Skills { get; set; } = []; + + /// + /// Gets or sets the active reservation identifier when the agent is reserved for an offer. + /// + public string ActiveReservationId { get; set; } + + /// + /// Gets or sets the UTC time the agent profile was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the agent profile was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentSession.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentSession.cs new file mode 100644 index 000000000..36b4c9927 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentSession.cs @@ -0,0 +1,73 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the live, volatile real-time connection state of an agent. It is intentionally separate +/// from the administrator-owned : the profile holds durable configuration +/// (skills, capacity, queue membership) while the session tracks the agent's open SignalR connections, +/// heartbeat, and online status so routing can stop targeting an agent whose client has gone away. +/// +public sealed class AgentSession : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the identifier of the Orchard user this session belongs to. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the user name of the Orchard user this session belongs to. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the display name shown for the agent in supervisor and queue views. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the identifiers of the SignalR connections the agent currently has open. + /// + public IList ConnectionIds { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the agent currently has at least one live connection. + /// + public bool IsOnline { get; set; } + + /// + /// Gets or sets the queues the agent was signed in to when the session was last refreshed. + /// + public IList QueueIds { get; set; } = []; + + /// + /// Gets or sets the dialer campaigns the agent was signed in to when the session was last refreshed. + /// + public IList CampaignIds { get; set; } = []; + + /// + /// Gets or sets the UTC time the agent first connected for this session. + /// + public DateTime? ConnectedUtc { get; set; } + + /// + /// Gets or sets the UTC time the agent's client last sent a heartbeat. + /// + public DateTime? LastHeartbeatUtc { get; set; } + + /// + /// Gets or sets the UTC time the agent's last connection dropped. + /// + public DateTime? LastDisconnectedUtc { get; set; } + + /// + /// Gets or sets the UTC time the session was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the session was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentStateReasonCode.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentStateReasonCode.cs new file mode 100644 index 000000000..6797e0ed6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentStateReasonCode.cs @@ -0,0 +1,48 @@ +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a canonical agent state reason code. Reason codes give agents auditable, admin-defined +/// reasons for entering a not-ready or break presence state, and they map each reason to the underlying +/// the agent should be placed in. +/// +public sealed class AgentStateReasonCode : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique reason code name shown to agents and supervisors. + /// + public string Name { get; set; } + + /// + /// Gets or sets the reason code description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the presence state an agent enters when they select this reason code. + /// + public AgentPresenceStatus AppliesTo { get; set; } = AgentPresenceStatus.Break; + + /// + /// Gets or sets the relative order the reason code is listed in, lowest first. + /// + public int SortOrder { get; set; } + + /// + /// Gets or sets a value indicating whether the reason code can be selected by agents. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the UTC time the reason code was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the reason code was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AssistContext.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AssistContext.cs new file mode 100644 index 000000000..6fd680866 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AssistContext.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Provides the context an AI assist provider uses to summarize an interaction or suggest a disposition. +/// +public sealed class AssistContext +{ + /// + /// Gets or sets the interaction identifier the assistance relates to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the CRM activity identifier the assistance relates to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the subject content type of the activity, used to scope disposition suggestions. + /// + public string SubjectContentType { get; set; } + + /// + /// Gets or sets the conversation transcript, when available. + /// + public string Transcript { get; set; } + + /// + /// Gets or sets the agent notes captured for the interaction. + /// + public string Notes { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BaseVoiceVerificationOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BaseVoiceVerificationOptions.cs new file mode 100644 index 000000000..18f7e061f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BaseVoiceVerificationOptions.cs @@ -0,0 +1,39 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// The operator's declaration that the base-voice audio path has been verified for this deployment. +/// +/// +/// Bound from the CrestApps_ContactCenter:BaseVoiceVerification configuration section. Whether the +/// end-to-end WebRTC media path works — trusted certificates, TURN relay, direct ICE, restart drain, and a +/// measured capacity floor — is a property of a deployment and its infrastructure, not of the +/// capability code, so it cannot be proven in the application build. It is proven once against the reference +/// topology and then declared here, exactly as the deployment topology is declared in +/// . Until it is declared, a production host withholds readiness so +/// an unverified base-voice deployment never serves traffic. +/// +public sealed class BaseVoiceVerificationOptions +{ + /// + /// Gets or sets a value indicating whether the operator has performed and acknowledged the base-voice audio + /// verification step for this deployment. + /// + /// + /// Defaults to . In a production host environment an unacknowledged deployment fails + /// readiness closed rather than merely warning, because serving voice traffic from a deployment whose media + /// path was never proven is the failure this gate exists to prevent. Outside a production host environment + /// the gate only warns, so development and test hosts are not blocked. + /// + public bool AudioVerificationAcknowledged { get; set; } + + /// + /// Gets or sets a reference to the retained evidence of the base-voice audio verification, such as a link or + /// identifier for the captured proof run. + /// + /// + /// Required whenever is : an acknowledgment + /// that cites no retained evidence is rejected at startup, so the acknowledgment can never be a bare boolean + /// flip. It is surfaced in the readiness verdict so an operator can trace the acknowledgment to its evidence. + /// + public string AudioVerificationEvidenceReference { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Bridge.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Bridge.cs new file mode 100644 index 000000000..1792dcc7a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Bridge.cs @@ -0,0 +1,62 @@ +using System.Text.Json.Serialization; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the media topology that joins a call session's legs, together with the full membership +/// history of that topology. +/// +public sealed class Bridge +{ + /// + /// Gets or sets the provider identifier of the topology. Providers name this differently, so the value + /// is opaque here and is only ever compared for equality or presence. + /// + public string ProviderBridgeId { get; set; } + + /// + /// Gets or sets the shape of the topology. + /// + public BridgeKind Kind { get; set; } + + /// + /// Gets or sets the UTC time the topology was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the topology was destroyed. + /// + public DateTime? DestroyedUtc { get; set; } + + /// + /// Gets or sets the append-only membership history of the topology. + /// + public IList Participants { get; set; } = []; + + /// + /// Gets or sets the live participant count the provider reports, when it reports one. It is kept apart + /// from because a provider that publishes only a number cannot say who those + /// parties are, and inventing members to match a count would make the membership history a fiction. + /// + public int? ReportedParticipantCount { get; set; } + + /// + /// Gets the participants that are currently present on the topology. + /// + [JsonIgnore] + public IEnumerable ActiveParticipants + => Participants.Where(participant => participant.LeftUtc is null); + + /// + /// Gets the participants that were present on the topology at the given instant. + /// + /// The UTC instant to reconstruct membership for. + /// The participants whose membership window contains . + public IEnumerable ParticipantsAt(DateTime instant) + { + return Participants.Where(participant => + participant.JoinedUtc <= instant && + (participant.LeftUtc is null || participant.LeftUtc > instant)); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BridgeKind.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BridgeKind.cs new file mode 100644 index 000000000..42f16b15d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BridgeKind.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the shape of the media topology that joins a call session's legs. +/// +public enum BridgeKind +{ + /// + /// The topology shape has not been determined. + /// + Unknown, + + /// + /// Exactly two parties hear one another. + /// + TwoParty, + + /// + /// Three or more parties hear one another. + /// + Conference, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BridgeParticipant.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BridgeParticipant.cs new file mode 100644 index 000000000..3ce0f25e6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BridgeParticipant.cs @@ -0,0 +1,40 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents one party's membership of a bridge, bounded by the time it joined and the time it left. +/// Membership is append-only: a party that leaves keeps its record with set, so +/// who was present at any past instant can be reconstructed rather than inferred from a live count. +/// +public sealed class BridgeParticipant +{ + /// + /// Gets or sets the provider identifier of the participating leg. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the part this participant plays in the call. + /// + public CallPartyRole Role { get; set; } + + /// + /// Gets or sets the agent identifier when the participant is an agent or a supervisor. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the address of the participating party. + /// + public string Address { get; set; } + + /// + /// Gets or sets the UTC time the participant joined the bridge. + /// + public DateTime JoinedUtc { get; set; } + + /// + /// Gets or sets the UTC time the participant left the bridge, or while it is + /// still present. + /// + public DateTime? LeftUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BusinessHoursCalendar.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BusinessHoursCalendar.cs new file mode 100644 index 000000000..b8d7b4e38 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BusinessHoursCalendar.cs @@ -0,0 +1,50 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a reusable business-hours calendar that defines when queues route work and which dates are closed. +/// +public sealed class BusinessHoursCalendar : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique name of the calendar. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the calendar. + /// + public string Description { get; set; } + + /// + /// Gets or sets the time zone the weekly schedule and holidays are evaluated in. When empty, UTC is used. + /// + public string TimeZoneId { get; set; } + + /// + /// Gets or sets the per-day open windows that define the weekly schedule. + /// + public IList WeeklySchedule { get; set; } = []; + + /// + /// Gets or sets the dates the queue is closed all day regardless of the weekly schedule. + /// + public IList Holidays { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the calendar is enabled. Disabled calendars do not gate routing. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the UTC time the calendar was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the calendar was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BusinessHoursDay.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BusinessHoursDay.cs new file mode 100644 index 000000000..ed7c9de56 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/BusinessHoursDay.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the open window for a single day of the week within a business-hours calendar. +/// +public sealed class BusinessHoursDay +{ + /// + /// Gets or sets the day of the week this window applies to. + /// + public DayOfWeek Day { get; set; } + + /// + /// Gets or sets a value indicating whether the queue is open on this day. + /// + public bool IsOpen { get; set; } + + /// + /// Gets or sets the local time, in minutes from midnight, the open window starts. + /// + public int OpenMinute { get; set; } + + /// + /// Gets or sets the local time, in minutes from midnight (exclusive), the open window ends. + /// + public int CloseMinute { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallCommandResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallCommandResult.cs new file mode 100644 index 000000000..e20258e06 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallCommandResult.cs @@ -0,0 +1,67 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the result of a Contact Center call command such as accepting or declining an offered +/// interaction. It tells the caller whether the command succeeded and whether the agent's device must +/// still answer the media (for providers whose delivery model rings the agent's device directly). +/// +public sealed class CallCommandResult +{ + /// + /// Gets or sets a value indicating whether the command succeeded. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the human-readable outcome of the command. + /// + public string Reason { get; set; } + + /// + /// Gets or sets a value indicating whether the agent's device must answer the media after the + /// command. This is for agent-device-native providers, where the live call + /// already rings the agent's device, and for server-side ACD providers, + /// where the Contact Center already bridged the call to the agent. + /// + public bool RequiresDeviceAnswer { get; set; } + + /// + /// Gets or sets the identifier of the interaction the command applied to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the identifier of the call session the command applied to, when applicable. + /// + public string CallSessionId { get; set; } + + /// + /// Creates a successful result. + /// + /// The human-readable outcome. + /// Whether the agent's device must answer the media. + /// The successful result. + public static CallCommandResult Success(string reason, bool requiresDeviceAnswer) + { + return new CallCommandResult + { + Succeeded = true, + Reason = reason, + RequiresDeviceAnswer = requiresDeviceAnswer, + }; + } + + /// + /// Creates a failed result. + /// + /// The human-readable failure reason. + /// The failed result. + public static CallCommandResult Failure(string reason) + { + return new CallCommandResult + { + Succeeded = false, + Reason = reason, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlAuthorizationContext.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlAuthorizationContext.cs new file mode 100644 index 000000000..dbbba7b82 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlAuthorizationContext.cs @@ -0,0 +1,50 @@ +using System.Security.Claims; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes a call-control authorization decision request. +/// +public sealed class CallControlAuthorizationContext +{ + /// + /// Gets or sets who initiated the operation. Defaults to , which + /// requires a resolvable agent that owns the call. + /// + public CallControlInitiator Initiator { get; set; } + + /// + /// Gets or sets the authenticated principal invoking the command. + /// + public ClaimsPrincipal Principal { get; set; } + + /// + /// Gets or sets the Orchard user identifier invoking the command. + /// + public string UserId { get; set; } + + /// + /// Gets or sets the verb being authorized. + /// + public CallControlVerb Verb { get; set; } + + /// + /// Gets or sets the logical Contact Center interaction identifier supplied by the caller. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the optional provider name that must match the resolved call session. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the optional provider call identifier that must match the resolved call session. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets a value indicating whether the command is a supervisor operation. + /// + public bool SupervisorOperation { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlAuthorizationResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlAuthorizationResult.cs new file mode 100644 index 000000000..54fd15412 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlAuthorizationResult.cs @@ -0,0 +1,79 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes the outcome of the shared call-control authorization boundary. +/// +public sealed class CallControlAuthorizationResult +{ + /// + /// Gets or sets a value indicating whether the command is authorized. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the redacted failure reason safe to return to clients. + /// + public string FailureReason { get; set; } + + /// + /// Gets or sets the resolved agent profile identifier for the caller. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the resolved provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the resolved call session. + /// + public CallSession CallSession { get; set; } + + /// + /// Creates an authorized result. + /// + /// The resolved agent identifier. + /// The resolved call session. + /// The authorized result. + public static CallControlAuthorizationResult Success(string agentId, CallSession session) + { + return new CallControlAuthorizationResult + { + Succeeded = true, + AgentId = agentId, + ProviderCallId = session.ProviderCallId, + CallSession = session, + }; + } + + /// + /// Creates an authorized result for an operation that has no owning call session, such as a + /// system-initiated termination of a call the platform has not yet observed a provider event for. + /// + /// The resolved agent identifier, when one is known. + /// The server-resolved provider call identifier. + /// The authorized result. + public static CallControlAuthorizationResult Success(string agentId, string providerCallId) + { + return new CallControlAuthorizationResult + { + Succeeded = true, + AgentId = agentId, + ProviderCallId = providerCallId, + }; + } + + /// + /// Creates a redacted denial result. + /// + /// The denied result. + public static CallControlAuthorizationResult Denied() + { + return new CallControlAuthorizationResult + { + Succeeded = false, + FailureReason = "The requested call is not available.", + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlInitiator.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlInitiator.cs new file mode 100644 index 000000000..5c6ca5b5d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlInitiator.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies who initiated a call-control operation, which selects the authorization rule applied to it. +/// +/// +/// The default value is so that a context or durable payload which never sets an +/// initiator is authorized as an agent request and therefore fails closed without an owning agent. +/// +public enum CallControlInitiator +{ + /// + /// The operation was requested by an authenticated agent or supervisor and must pass an ownership check. + /// + Agent, + + /// + /// The operation was issued by the platform itself with no requesting principal, such as terminating an + /// inbound call that arrived at a closed entry point or an unroutable queue. + /// + System, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlVerb.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlVerb.cs new file mode 100644 index 000000000..fb2f79fc7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallControlVerb.cs @@ -0,0 +1,72 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Defines the call-control verbs that must pass through the shared authorization boundary. +/// +public enum CallControlVerb +{ + /// + /// Accepts or answers a ringing call. + /// + Accept, + + /// + /// Declines or rejects a ringing call. + /// + Decline, + + /// + /// Ends an active call. + /// + Hangup, + + /// + /// Places a call on hold. + /// + Hold, + + /// + /// Resumes a held call. + /// + Resume, + + /// + /// Mutes call media. + /// + Mute, + + /// + /// Unmutes call media. + /// + Unmute, + + /// + /// Sends DTMF digits. + /// + Dtmf, + + /// + /// Transfers a call. + /// + Transfer, + + /// + /// Merges calls into a conference. + /// + Merge, + + /// + /// Starts a new outbound dial. + /// + Dial, + + /// + /// Sends a ringing call to voicemail. + /// + Voicemail, + + /// + /// Engages a live call as a supervisor. + /// + SupervisorEngage, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallLeg.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallLeg.cs new file mode 100644 index 000000000..a07c6c285 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallLeg.cs @@ -0,0 +1,55 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents one live call leg belonging to a call session. A leg is a single party's connection; the +/// session's describes which legs currently hear one another. +/// +public sealed class CallLeg +{ + /// + /// Gets or sets the provider identifier of the leg. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the part this leg's party plays in the call. + /// + public CallPartyRole Role { get; set; } + + /// + /// Gets or sets the normalized lifecycle state of the leg. + /// + public CallLegStatus Status { get; set; } + + /// + /// Gets or sets the address of the party on the leg. + /// + public string Address { get; set; } + + /// + /// Gets or sets the agent identifier when the leg belongs to an agent or a supervisor. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the UTC time the leg was created. + /// + public DateTime StartedUtc { get; set; } + + /// + /// Gets or sets the UTC time the leg was answered. + /// + public DateTime? AnsweredUtc { get; set; } + + /// + /// Gets or sets the UTC time the leg ended. + /// + public DateTime? EndedUtc { get; set; } + + /// + /// Gets or sets the provider-neutral reason the leg ended. + /// + public HangupCause? HangupCause { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallLegStatus.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallLegStatus.cs new file mode 100644 index 000000000..7d3bb4345 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallLegStatus.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the normalized lifecycle state of a single call leg. +/// +public enum CallLegStatus +{ + /// + /// The provider has not reported a state for the leg. + /// + Unknown, + + /// + /// The leg is being originated toward its destination. + /// + Dialing, + + /// + /// The destination is alerting. + /// + Ringing, + + /// + /// The leg is answered and carrying media. + /// + Answered, + + /// + /// The leg is answered but its media is suspended. + /// + OnHold, + + /// + /// The leg has cleared normally. + /// + Ended, + + /// + /// The leg terminated without ever being answered. + /// + Failed, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallPartyRole.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallPartyRole.cs new file mode 100644 index 000000000..24c15cbc5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallPartyRole.cs @@ -0,0 +1,39 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the part a party plays in a call. The role is what makes a topology readable without +/// knowing a provider's naming: it distinguishes the customer from the handling agent, a consulted +/// destination, and a supervisor who is present but is not handling the work. +/// +public enum CallPartyRole +{ + /// + /// The party's role has not been determined. + /// + Unknown, + + /// + /// The external party the contact center is serving. + /// + Customer, + + /// + /// The agent handling the work. + /// + Agent, + + /// + /// A destination an agent consulted before completing or abandoning a transfer. + /// + Consult, + + /// + /// A supervisor engaged on the call who is not handling the work. + /// + Supervisor, + + /// + /// A party outside the contact center added to the call, such as a third-party conference member. + /// + External, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallRelationship.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallRelationship.cs new file mode 100644 index 000000000..cc3bc0323 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallRelationship.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Links a call session to another session so a transfer chain can be walked in either direction without +/// reading provider metadata strings. +/// +public sealed class CallRelationship +{ + /// + /// Gets or sets how the related session relates to this one. + /// + public CallRelationshipKind Kind { get; set; } + + /// + /// Gets or sets the identifier of the related call session. + /// + public string RelatedCallSessionId { get; set; } + + /// + /// Gets or sets the identifier of the related interaction. + /// + public string RelatedInteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier of the related session. + /// + public string RelatedProviderCallId { get; set; } + + /// + /// Gets or sets the UTC time the relationship was established. + /// + public DateTime EstablishedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallRelationshipKind.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallRelationshipKind.cs new file mode 100644 index 000000000..a98f7fb84 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallRelationshipKind.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies how one call session relates to another. +/// +public enum CallRelationshipKind +{ + /// + /// The related session handed this call over to this session. + /// + TransferredFrom, + + /// + /// This session handed the call over to the related session. + /// + TransferredTo, + + /// + /// The related session is the private consult placed from this session. + /// + ConsultOf, + + /// + /// The related session was merged into the same conference as this session. + /// + ConferencedWith, + + /// + /// This session is the callback that fulfills the related session. + /// + CallbackOf, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs new file mode 100644 index 000000000..dc4be1710 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs @@ -0,0 +1,319 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the Contact Center's business-oriented projection of a voice call. It maps a provider +/// call to an interaction, agent, and queue, and tracks the normalized call lifecycle and durations +/// without owning media execution, which remains the responsibility of the Telephony provider. +/// +public sealed class CallSession : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the identifier of the interaction this call session belongs to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the identifier of the CRM activity the call belongs to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the technical name of the provider that owns the call. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider-specific identifier of the call. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the delivery model used to deliver the call to the agent. + /// + public VoiceProviderDeliveryModel DeliveryModel { get; set; } + + /// + /// Gets or sets the direction of the call relative to the contact center. + /// + public InteractionDirection Direction { get; set; } + + /// + /// Gets or sets the normalized call state. + /// + [JsonInclude] + public VoiceCallState State { get; private set; } + + /// + /// Moves the call session to the specified state. + /// + /// The state to move to. + /// The session cannot reach the state from the one it is in. + public void TransitionTo(VoiceCallState state) + { + if (!CallSessionLifecycle.CanTransition(State, state)) + { + throw new InvalidStateTransitionException(nameof(CallSession), State, state); + } + + State = state; + } + + /// + /// Determines whether the call session can move to the specified state. + /// + /// The state to test. + /// when the transition is admitted; otherwise . + public bool CanTransitionTo(VoiceCallState state) + => CallSessionLifecycle.CanTransition(State, state); + + /// + /// Gets a value indicating whether the call has reached an outcome it cannot leave. + /// + [JsonIgnore] + public bool IsTerminal => CallSessionLifecycle.IsTerminal(State); + + /// + /// Records the state the provider reports the call is in. + /// + /// The state observed on the provider. + /// + /// The provider owns what the call is actually doing, so this is a report rather than a decision. Ordering + /// for that stream is enforced upstream by VoiceStreamOrdering, which refuses deliveries that would + /// move the call backwards; applying a second, narrower rule here would let this record disagree with the + /// interaction it is projected onto, which is the divergence CallStateMachinePropertyTests catches. + /// + public void MirrorProviderState(VoiceCallState state) + { + // Mirroring reports what the provider already did, so it does not consult the table. It still refuses to + // bring a terminal session back to life: a call that this system already recorded as over cannot start + // alerting again, and a late provider frame that reopened it would merge two calls into one history. + if (CallSessionLifecycle.IsTerminal(State) && !CallSessionLifecycle.IsTerminal(state)) + { + return; + } + + State = state; + } + + /// + /// Restores a state that was decided elsewhere, without consulting the lifecycle. + /// + /// The state to restore. + /// The same session, so it can be used at the end of an object initializer. + /// + /// This bypasses every transition rule and exists only so a test can arrange a state directly. Production code + /// must never call it: AggregateLifecycleArchitectureTests fails the build if any file under src/ + /// does, so the bypass cannot quietly become a shortcut. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public CallSession RestorePersistedState(VoiceCallState state) + { + State = state; + + return this; + } + + /// + /// Gets or sets the identifier of the agent connected to the call. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the active agent-session identifier that owns the live media leg for this call. + /// + public string AgentSessionId { get; set; } + + /// + /// Gets or sets the identifier of the queue the call was delivered from. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the legs that make up the call. A leg is one party's connection; + /// describes which of them currently hear one another. + /// + public IList Legs { get; set; } = []; + + /// + /// Gets or sets the media topology that joins the legs, including its full membership history. + /// + public Bridge Bridge { get; set; } + + /// + /// Gets or sets the bridges the call previously occupied, retained so membership at a past instant stays + /// reconstructible after the parties were moved to a different media topology. + /// + public IList PriorBridges { get; set; } = []; + + /// + /// Gets or sets the private consults placed from this call. + /// + public IList Consults { get; set; } = []; + + /// + /// Gets or sets the links from this call to the calls it was transferred from, transferred to, consulted, + /// or conferenced with. + /// + public IList Relationships { get; set; } = []; + + /// + /// Gets or sets the supervisor engagements recorded against this call, live and past. + /// + public IList MonitorSessions { get; set; } = []; + + /// + /// Gets or sets the provider recording identifier for the active or retained call recording. + /// + public string RecordingId { get; set; } + + /// + /// Gets or sets the durable provider-command identifier that last fenced a topology transition. + /// + public string DurableCommandId { get; set; } + + /// + /// Gets or sets the address of the calling party. + /// + public string FromAddress { get; set; } + + /// + /// Gets or sets the address of the called party. + /// + public string ToAddress { get; set; } + + /// + /// Gets or sets a value indicating whether the call is currently on hold. + /// + public bool IsOnHold { get; set; } + + /// + /// Gets or sets a value indicating whether the provider reports the call as muted. + /// + public bool IsMuted { get; set; } + + /// + /// Gets or sets the provider-reported recording state for the call. + /// + public RecordingState RecordingState { get; set; } + + /// + /// Gets or sets the provider recording reference for the call, when one exists. + /// + public string RecordingReference { get; set; } + + /// + /// Gets or sets the UTC time of the latest provider event applied to this call session. + /// + public DateTime? LastProviderEventUtc { get; set; } + + /// + /// Gets or sets the highest provider-supplied monotonic sequence number applied to this call + /// session. When a provider supplies monotonic sequence numbers this value is the authoritative + /// ordering high-water mark; deliveries at or below it are rejected as stale. It remains + /// for providers that only supply timestamps or idempotency keys. + /// + public long? HighWaterSequence { get; set; } + + /// + /// Gets or sets the UTC time the call session was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the call started dialing or ringing. + /// + public DateTime? StartedUtc { get; set; } + + /// + /// Gets or sets the UTC time the call was answered or connected. + /// + public DateTime? AnsweredUtc { get; set; } + + /// + /// Gets or sets the UTC time the call ended. + /// + public DateTime? EndedUtc { get; set; } + + /// + /// Gets or sets the provider-neutral reason the call ended. It is assigned whenever the session + /// reaches a terminal state so outbound compliance reporting and abandon analytics can distinguish + /// a normal clearing from a busy, unanswered, rejected, congested, abandoned, or machine-answered + /// call at the source rather than inferring it later. + /// + public HangupCause? HangupCause { get; set; } + + /// + /// Gets or sets the total seconds the call was connected (talk time). + /// + public double TalkSeconds { get; set; } + + /// + /// Gets or sets the total seconds the call spent on hold. + /// + public double HoldSeconds { get; set; } + + /// + /// Gets or sets provider-specific metadata retained for troubleshooting. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); + + /// + /// Gets or sets the UTC time the call session was last modified. + /// + public DateTime? ModifiedUtc { get; set; } + + /// + /// Gets a value indicating whether three or more parties currently hear one another. + /// + [JsonIgnore] + public bool IsConference => Bridge?.Kind == BridgeKind.Conference; + + /// + /// Gets the number of parties currently present on the media topology. The provider's own live count + /// wins when it publishes one, because a provider sees parties the platform never created a leg for. + /// + [JsonIgnore] + public int ParticipantCount + => Bridge?.ReportedParticipantCount ?? Bridge?.ActiveParticipants.Count() ?? 0; + + /// + /// Gets the supervisor engagement that is currently live, when there is one. + /// + [JsonIgnore] + public IEnumerable ActiveMonitorSessions + => MonitorSessions.Where(monitorSession => monitorSession.IsActive); + + /// + /// Gets the parties that were joined to any of the call's bridges at the given instant, including bridges + /// the call has since been moved off. + /// + /// The UTC instant to reconstruct membership for. + /// The participants that were joined at that instant. + public IEnumerable ParticipantsAt(DateTime instant) + { + foreach (var priorBridge in PriorBridges) + { + foreach (var participant in priorBridge.ParticipantsAt(instant)) + { + yield return participant; + } + } + + if (Bridge is null) + { + yield break; + } + + foreach (var participant in Bridge.ParticipantsAt(instant)) + { + yield return participant; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSessionLifecycle.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSessionLifecycle.cs new file mode 100644 index 000000000..f3aeb550f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSessionLifecycle.cs @@ -0,0 +1,131 @@ +using System.Collections.Frozen; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Declares which normalized call-state changes the domain admits for a . +/// +/// Provider streams are ordered by lifecycle phase, which groups several states together — dialing and ringing +/// are both alerting, connected and held are both established — so phase ordering cannot tell a legal edge from +/// an impossible one within a phase. A call that goes from planned to held has not regressed and would be +/// applied, and the durations computed from it would be reported as though the call had been answered. +/// +/// +public static class CallSessionLifecycle +{ + private static readonly FrozenDictionary> _transitions = + new Dictionary> + { + // Ended is admitted from every alerting state. Providers do not all distinguish why a call that was + // never answered stopped: some report NoAnswer or Rejected, others report a plain hangup. Refusing + // the plain hangup would leave the session alerting forever and the offer would never be released, + // which is a worse outcome than recording a less specific ending than the one that occurred. + // Connected is admitted from planned because a session is not always created before the call is up. + // On agent-device-native delivery the device answers and the provider reports the established call + // in the same round trip that creates the record, so the session is born connected. Held is still + // refused from planned: a call that was never answered cannot have been placed on hold, and that is + // the reading this table exists to reject. + [VoiceCallState.Planned] = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Dialing, + VoiceCallState.Ringing, + VoiceCallState.Connected, + VoiceCallState.Ended, + VoiceCallState.Canceled, + VoiceCallState.Failed, + ]), + [VoiceCallState.Dialing] = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Ringing, + VoiceCallState.Connected, + VoiceCallState.Ending, + VoiceCallState.Ended, + VoiceCallState.NoAnswer, + VoiceCallState.Rejected, + VoiceCallState.Canceled, + VoiceCallState.Failed, + ]), + [VoiceCallState.Ringing] = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Connected, + VoiceCallState.Ending, + VoiceCallState.Ended, + VoiceCallState.NoAnswer, + VoiceCallState.Rejected, + VoiceCallState.Canceled, + VoiceCallState.Failed, + ]), + // Ringing is admitted back from the established states because the customer leg and the agent leg + // share one session. A queue transfer keeps the customer up while the agent leg alerts the next + // agent, so the session alerts again without the call ever having dropped. No terminal state admits + // it, so this cannot resurrect a call that ended. + [VoiceCallState.Connected] = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Ringing, + VoiceCallState.OnHold, + VoiceCallState.Ending, + VoiceCallState.Ended, + VoiceCallState.Transferred, + VoiceCallState.Failed, + ]), + [VoiceCallState.OnHold] = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Ringing, + VoiceCallState.Connected, + VoiceCallState.Ending, + VoiceCallState.Ended, + VoiceCallState.Transferred, + VoiceCallState.Failed, + ]), + [VoiceCallState.Ending] = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Ended, + VoiceCallState.Transferred, + VoiceCallState.Failed, + ]), + + // Every outcome is final. A call that ended cannot start ringing again; that is a new call, and + // reusing the session for it would merge two calls into one history. + [VoiceCallState.Ended] = FrozenSet.Empty, + [VoiceCallState.Failed] = FrozenSet.Empty, + [VoiceCallState.NoAnswer] = FrozenSet.Empty, + [VoiceCallState.Rejected] = FrozenSet.Empty, + [VoiceCallState.Canceled] = FrozenSet.Empty, + [VoiceCallState.Transferred] = FrozenSet.Empty, + }.ToFrozenDictionary(); + + private static readonly FrozenSet _terminalStates = FrozenSet.ToFrozenSet( + [ + VoiceCallState.Ended, + VoiceCallState.Failed, + VoiceCallState.NoAnswer, + VoiceCallState.Rejected, + VoiceCallState.Canceled, + VoiceCallState.Transferred, + ]); + + /// + /// Determines whether a call session in one state may move to another. + /// + /// The state the call is in. + /// The state the call would move to. + /// when the transition is admitted; otherwise . + public static bool CanTransition(VoiceCallState from, VoiceCallState to) + { + if (from == to) + { + return true; + } + + return _transitions.TryGetValue(from, out var allowed) && allowed.Contains(to); + } + + /// + /// Determines whether a normalized call state is terminal. + /// + /// The state to inspect. + /// when the state is terminal; otherwise . + public static bool IsTerminal(VoiceCallState state) + => _terminalStates.Contains(state); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallbackRequest.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallbackRequest.cs new file mode 100644 index 000000000..ccbee8184 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallbackRequest.cs @@ -0,0 +1,97 @@ +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a scheduled callback: a request to dial a customer back at a chosen time. When due, it is +/// promoted into an outbound CRM activity for the dialer or an agent to handle. +/// +public sealed class CallbackRequest : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the destination number or address to call back. + /// + public string Destination { get; set; } + + /// + /// Gets or sets the content item identifier of the contact being called back. + /// + public string ContactContentItemId { get; set; } + + /// + /// Gets or sets the content type of the contact being called back. + /// + public string ContactContentType { get; set; } + + /// + /// Gets or sets the campaign the callback belongs to. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the queue the promoted activity is enqueued into, when set. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the identifier of the agent the callback is reserved for, when it is a personal callback. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the lifecycle status of the callback. + /// + public CallbackRequestStatus Status { get; set; } + + /// + /// Gets or sets the UTC time the callback was requested. + /// + public DateTime RequestedUtc { get; set; } + + /// + /// Gets or sets the UTC time the callback becomes due. + /// + public DateTime ScheduledUtc { get; set; } + + /// + /// Gets or sets the identifier of the activity created when the callback was promoted. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the number of dialing attempts made for the callback. + /// + public int Attempts { get; set; } + + /// + /// Gets or sets free-form notes recorded with the callback. + /// + public string Notes { get; set; } + + /// + /// Gets or sets the token identifying the worker that currently holds the promotion lease, when claimed. + /// + public string OwnerToken { get; set; } + + /// + /// Gets or sets the monotonic fence token incremented on every claim to reject stale writers. + /// + public long FenceToken { get; set; } + + /// + /// Gets or sets the UTC time the current promotion lease expires, when the callback is claimed. + /// + public DateTime? LeaseExpiresUtc { get; set; } + + /// + /// Gets or sets the UTC time the callback was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the callback was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ConsultCall.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ConsultCall.cs new file mode 100644 index 000000000..b5398d363 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ConsultCall.cs @@ -0,0 +1,63 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a private consult an agent placed before deciding whether to complete a warm transfer. +/// A consult is a first-class part of the topology rather than provider metadata, so a supervisor can +/// see that the customer is held while the agent talks to someone else, and reporting can distinguish a +/// completed warm transfer from an abandoned consult. +/// +public sealed class ConsultCall +{ + /// + /// Gets or sets the platform identifier of the consult. + /// + public string ConsultId { get; set; } + + /// + /// Gets or sets the provider identifier of the consult leg. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the agent that placed the consult. + /// + public string InitiatedByAgentId { get; set; } + + /// + /// Gets or sets the kind of destination that was consulted. + /// + public InteractionTransferTargetType TargetType { get; set; } + + /// + /// Gets or sets the identifier of the consulted destination: a queue id, agent id, external address, + /// or entry point id. + /// + public string TargetId { get; set; } + + /// + /// Gets or sets the resolved address of the consulted destination. + /// + public string TargetAddress { get; set; } + + /// + /// Gets or sets the lifecycle state of the consult. + /// + public ConsultCallStatus Status { get; set; } + + /// + /// Gets or sets the UTC time the consult was placed. + /// + public DateTime StartedUtc { get; set; } + + /// + /// Gets or sets the UTC time the consulting agent and the destination began talking. + /// + public DateTime? ConnectedUtc { get; set; } + + /// + /// Gets or sets the UTC time the consult ended. + /// + public DateTime? EndedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ConsultCallStatus.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ConsultCallStatus.cs new file mode 100644 index 000000000..ff9004162 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ConsultCallStatus.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the lifecycle state of a consult call placed before a warm transfer. +/// +public enum ConsultCallStatus +{ + /// + /// The consult has been requested but the destination has not been reached. + /// + Initiated, + + /// + /// The consult destination is alerting. + /// + Ringing, + + /// + /// The consulting agent and the destination are talking privately. + /// + Connected, + + /// + /// The consult ended by completing the transfer to the consulted destination. + /// + Completed, + + /// + /// The consulting agent abandoned the consult and returned to the customer. + /// + Cancelled, + + /// + /// The consult could not be established. + /// + Failed, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterComplianceOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterComplianceOptions.cs new file mode 100644 index 000000000..0dcea3a35 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterComplianceOptions.cs @@ -0,0 +1,14 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the tenant-level compliance configuration for outbound dialing. It is bound from the +/// CrestApps_ContactCenter:Compliance configuration section and validated on start. +/// +public sealed class ContactCenterComplianceOptions +{ + /// + /// Gets or sets the length, in minutes, of the rolling window used to measure the outbound abandonment + /// rate. Must be between 1 and 1440 minutes. + /// + public int AbandonmentRollingWindowMinutes { get; set; } = 30; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterCoordinationOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterCoordinationOptions.cs new file mode 100644 index 000000000..c6fb23f6a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterCoordinationOptions.cs @@ -0,0 +1,21 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// The distributed-lock timings the Contact Center coordinates inbound work with. These are deployment +/// characteristics rather than product constants: a node under heavier load, or one further from its database, +/// needs a longer lease before a peer may assume the holder died. +/// +public sealed class ContactCenterCoordinationOptions +{ + /// + /// Gets or sets how long a node waits to acquire the inbound routing lock for a call before giving up. A + /// caller that gives up does not route the call twice; it defers to the node that holds the lock. + /// + public TimeSpan InboundLockTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Gets or sets how long the inbound routing lock is held before it expires on its own, which bounds how + /// long a crashed node blocks routing for the same call. + /// + public TimeSpan InboundLockExpiration { get; set; } = TimeSpan.FromMinutes(1); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterDataCategory.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterDataCategory.cs new file mode 100644 index 000000000..53f2b9764 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterDataCategory.cs @@ -0,0 +1,49 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes the data-governance classification of a single Contact Center persisted data category, including +/// its privacy sensitivity, retention basis, erasure approach, and whether it references call recordings. +/// +public sealed class ContactCenterDataCategory +{ + /// + /// Gets the stable, machine-readable key that identifies this data category. + /// + public required string Key { get; init; } + + /// + /// Gets the human-readable display name for this data category. + /// + public required string DisplayName { get; init; } + + /// + /// Gets the privacy sensitivity classification for this data category. + /// + public required ContactCenterDataSensitivity Sensitivity { get; init; } + + /// + /// Gets a value indicating whether this data category references call recordings whose payload is stored in + /// an external provider or media store. + /// + public required bool ContainsRecordingReference { get; init; } + + /// + /// Gets the retention basis describing what governs how long this data category is kept. + /// + public required string RetentionBasis { get; init; } + + /// + /// Gets the erasure strategy that satisfies a right-to-be-forgotten request for this data category. + /// + public required ContactCenterErasureStrategy ErasureStrategy { get; init; } + + /// + /// Gets a description of the data category and the reasoning behind its classification. + /// + public required string Description { get; init; } + + /// + /// Gets a value indicating whether this data category holds personal data, derived from its sensitivity. + /// + public bool ContainsPersonalData => Sensitivity != ContactCenterDataSensitivity.NonPersonal; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterDataSensitivity.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterDataSensitivity.cs new file mode 100644 index 000000000..8e42b9e2c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterDataSensitivity.cs @@ -0,0 +1,25 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Classifies the privacy sensitivity of a Contact Center data category so retention, access, and erasure +/// obligations can be reasoned about per entity. +/// +public enum ContactCenterDataSensitivity +{ + /// + /// The category holds no personal data — only operational, aggregate, or configuration values. + /// + NonPersonal, + + /// + /// The category holds personal data that identifies or relates to an individual, such as a phone number + /// or an agent identity. + /// + Personal, + + /// + /// The category holds sensitive personal data whose exposure carries elevated risk, such as call + /// recordings or free-text notes that may capture special-category information. + /// + SensitivePersonal, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntityRetentionResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntityRetentionResult.cs new file mode 100644 index 000000000..f9a45c99d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntityRetentionResult.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Reports what one retention policy did during a retention cycle. +/// +public sealed class ContactCenterEntityRetentionResult +{ + /// + /// Gets or sets the technical name of the entity the policy purges. + /// + public string EntityName { get; set; } + + /// + /// Gets or sets a value indicating whether purging is enabled for this entity. When it is disabled the + /// entity's records are kept indefinitely and no cutoff was computed. + /// + public bool IsEnabled { get; set; } + + /// + /// Gets or sets the cutoff that was applied, or when purging is disabled. + /// + public DateTime? CutoffUtc { get; set; } + + /// + /// Gets or sets the number of records purged. + /// + public int PurgedCount { get; set; } + + /// + /// Gets or sets a value indicating whether the drain stopped before the entity was empty because the cycle + /// budget ran out or the cycle was canceled. It is the signal that the configured budget is too small for + /// the volume, which would otherwise be invisible. + /// + public bool WorkRemains { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntryPoint.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntryPoint.cs new file mode 100644 index 000000000..11211bd51 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntryPoint.cs @@ -0,0 +1,79 @@ +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents an inbound entry point: it maps one or more dialed numbers (DIDs) to a target queue, +/// gates the call by a business-hours calendar, and defines what happens while the entry point is closed. +/// +public sealed class ContactCenterEntryPoint : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique name of the entry point. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the entry point. + /// + public string Description { get; set; } + + /// + /// Gets or sets the dialed numbers (DIDs) served by this entry point. + /// + public IList DialedNumbers { get; set; } = []; + + /// + /// Gets or sets the identifier of the queue calls route to while the entry point is open. + /// + public string TargetQueueId { get; set; } + + /// + /// Gets or sets the priority assigned to calls entering through this entry point. + /// + public InteractionPriority Priority { get; set; } = InteractionPriority.Normal; + + /// + /// Gets or sets the identifier of the business-hours calendar that gates when the entry point is open. + /// When empty, the entry point is always open. + /// + public string BusinessHoursCalendarId { get; set; } + + /// + /// Gets or sets the action taken for calls while the entry point is closed. + /// + public EntryPointClosedAction ClosedAction { get; set; } = EntryPointClosedAction.HoldInQueue; + + /// + /// Gets or sets the identifier of the queue used when is + /// . + /// + public string OverflowQueueId { get; set; } + + /// + /// Gets or sets the greeting or announcement shown to the caller. + /// + public string WelcomeMessage { get; set; } + + /// + /// Gets or sets the message played when the entry point is closed. + /// + public string ClosedMessage { get; set; } + + /// + /// Gets or sets a value indicating whether the entry point is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the UTC time the entry point was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the entry point was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterErasureStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterErasureStrategy.cs new file mode 100644 index 000000000..0dc06e1ea --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterErasureStrategy.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes how a Contact Center data category satisfies an erasure or right-to-be-forgotten request. +/// +public enum ContactCenterErasureStrategy +{ + /// + /// No erasure action is required because the category holds no personal data. + /// + NotApplicable, + + /// + /// The record is removed automatically when it ages past its retention window; no per-subject erasure is + /// performed because the data is short-lived and bounded by retention. + /// + RetentionExpiry, + + /// + /// The personal fields are cleared while the record is kept so aggregate metrics and audit history remain + /// intact after the individual can no longer be identified. + /// + Anonymize, + + /// + /// The record is erased as part of erasing the parent interaction it belongs to, rather than on its own. + /// + CascadeWithInteraction, + + /// + /// The payload lives in an external system (a telephony provider or media store) and erasure is delegated + /// to that system; Contact Center only holds a reference and records the delegated request. + /// + ExternalStore, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetric.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetric.cs new file mode 100644 index 000000000..baa5d463f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetric.cs @@ -0,0 +1,41 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a daily count of a Contact Center domain event type. It is the projection that powers +/// historical volume reporting and is rebuildable from the durable event log. +/// +public sealed class ContactCenterEventMetric : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the day the events were counted for, formatted as yyyy-MM-dd (UTC). + /// + public string DateKey { get; set; } + + /// + /// Gets or sets the UTC date (midnight) the events were counted for, used for range queries. + /// + public DateTime Date { get; set; } + + /// + /// Gets or sets the domain event type being counted. + /// + public string EventType { get; set; } + + /// + /// Gets or sets the number of events of this type on this day. + /// + public long Count { get; set; } + + /// + /// Gets or sets the UTC time the metric was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the metric was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetricDelta.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetricDelta.cs new file mode 100644 index 000000000..dfef1d31a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetricDelta.cs @@ -0,0 +1,38 @@ +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents an unrolled contribution to a daily event count. Counting by reading the daily total, adding to +/// it and writing it back makes that one row a serialization point: every event of the same type on the same +/// day contends for it, and under optimistic concurrency the loser either fails or overwrites a count it never +/// saw. A contribution is instead appended and never updated, so concurrent writers do not meet, and the +/// contributions are folded into the daily total afterwards by a single roller. +/// +public sealed class ContactCenterEventMetricDelta : CatalogItem +{ + /// + /// Gets or sets the day the contribution counts toward, formatted as yyyy-MM-dd (UTC). + /// + public string DateKey { get; set; } + + /// + /// Gets or sets the UTC date (midnight) the contribution counts toward. + /// + public DateTime Date { get; set; } + + /// + /// Gets or sets the domain event type being counted. + /// + public string EventType { get; set; } + + /// + /// Gets or sets the number of events this contribution represents. + /// + public long Count { get; set; } + + /// + /// Gets or sets the UTC time the contribution was appended. + /// + public DateTime CreatedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterExternalDestination.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterExternalDestination.cs new file mode 100644 index 000000000..521e4c598 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterExternalDestination.cs @@ -0,0 +1,31 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a single server-side approved external transfer destination in the tenant catalog. +/// +public sealed class ContactCenterExternalDestination +{ + /// + /// Gets or sets the opaque stable identifier for this destination entry. + /// Callers supply this identifier when requesting an external transfer; the resolver + /// looks up the entry by this value to obtain the canonical E.164 address. + /// + public string Id { get; set; } + + /// + /// Gets or sets the operator-assigned display name for this destination. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the server-side canonical E.164 address used for the actual transfer. + /// This value is never supplied by callers; it is always taken from this stored entry. + /// + public string E164Address { get; set; } + + /// + /// Gets or sets a value indicating whether this destination is active. + /// Disabled entries are always denied even when a caller supplies the matching identifier. + /// + public bool Enabled { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterExternalTransferSettings.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterExternalTransferSettings.cs new file mode 100644 index 000000000..ece96f0a2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterExternalTransferSettings.cs @@ -0,0 +1,15 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Tenant-scoped site settings that hold the operator-curated catalog of approved external +/// transfer destinations. Stored via Orchard Core site settings so the catalog is isolated +/// per shell/tenant and never shared across tenants. +/// +public sealed class ContactCenterExternalTransferSettings +{ + /// + /// Gets or sets the list of approved external destinations configured for this tenant. + /// Only entries that are present and enabled are reachable via an external transfer. + /// + public List Destinations { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterHealthCheckOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterHealthCheckOptions.cs new file mode 100644 index 000000000..6cc24382a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterHealthCheckOptions.cs @@ -0,0 +1,67 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Configures the thresholds used by the Contact Center operational health checks. Defaults are tuned for a +/// single healthy node; operators raise them for larger deployments through configuration. +/// +public sealed class ContactCenterHealthCheckOptions +{ + /// + /// Gets or sets the dead-letter count at or above which a queue is reported as degraded. A single + /// dead-lettered message already signals a delivery failure requiring operator attention. + /// + public int DeadLetterDegradedThreshold { get; set; } = 1; + + /// + /// Gets or sets the dead-letter count at or above which a queue is reported as unhealthy. + /// + public int DeadLetterUnhealthyThreshold { get; set; } = 25; + + /// + /// Gets or sets the overdue backlog size at or above which a queue is reported as degraded. A sustained + /// overdue backlog indicates the background dispatcher is not keeping up. + /// + public int OverdueBacklogDegradedThreshold { get; set; } = 50; + + /// + /// Gets or sets the overdue backlog size at or above which a queue is reported as unhealthy. + /// + public int OverdueBacklogUnhealthyThreshold { get; set; } = 500; + + /// + /// Gets or sets a value indicating whether readiness may drain this node after consecutive failures of a + /// node-local store probe. + /// + /// + /// Disabled by default. When the store itself is down every node observes the same failure, so the gate + /// drains the whole fleet and a degraded dependency becomes a total outage. Enable it only where the load + /// balancer fails open once too few targets remain healthy, or where partial drain is preferable to partial + /// failure. When disabled the check performs no I/O. + /// + public bool EnableNodeServingGate { get; set; } + + /// + /// Gets or sets the number of consecutive failed store probes required before the node reports that it + /// cannot serve. Only used when is enabled. + /// + public int ConsecutiveFailuresBeforeUnready { get; set; } = 3; + + /// + /// Gets or sets the number of consecutive successful store probes required before a draining node reports + /// that it can serve again. Only used when is enabled. + /// + public int ConsecutiveSuccessesBeforeReady { get; set; } = 2; + + /// + /// Normalizes the configured thresholds so a lower unhealthy bound can never sit below its degraded bound. + /// + public void Normalize() + { + DeadLetterDegradedThreshold = Math.Max(1, DeadLetterDegradedThreshold); + DeadLetterUnhealthyThreshold = Math.Max(DeadLetterDegradedThreshold, DeadLetterUnhealthyThreshold); + OverdueBacklogDegradedThreshold = Math.Max(1, OverdueBacklogDegradedThreshold); + OverdueBacklogUnhealthyThreshold = Math.Max(OverdueBacklogDegradedThreshold, OverdueBacklogUnhealthyThreshold); + ConsecutiveFailuresBeforeUnready = Math.Max(1, ConsecutiveFailuresBeforeUnready); + ConsecutiveSuccessesBeforeReady = Math.Max(1, ConsecutiveSuccessesBeforeReady); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterMetricContribution.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterMetricContribution.cs new file mode 100644 index 000000000..7b49111fe --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterMetricContribution.cs @@ -0,0 +1,16 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a single unfolded contribution to a daily event count, read without loading the document that +/// holds it. A caller that has to account for every contribution waiting to be folded needs only the bucket it +/// counts toward, how much it counts, and a position it can resume a walk from. +/// +/// The document identifier, which is also the position a walk resumes from. +/// The day the contribution counts toward, formatted as yyyy-MM-dd. +/// The domain event type being counted. +/// The number of events this contribution represents. +public sealed record ContactCenterMetricContribution( + long DocumentId, + string DateKey, + string EventType, + long Count); diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs new file mode 100644 index 000000000..1c0489996 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs @@ -0,0 +1,72 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a durable outbox entry that tracks an event until every registered handler has completed. +/// The interaction event itself remains the immutable audit record; this message carries the mutable +/// delivery state so application restarts and transient failures cannot silently drop a domain event. +/// +public sealed class ContactCenterOutboxMessage : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the identifier of the this message retries. + /// + public string EventId { get; set; } + + /// + /// Gets or sets the canonical event type name, retained for diagnostics and filtering. + /// + public string EventType { get; set; } + + /// + /// Gets or sets the dispatch state of the message. + /// + public OutboxMessageStatus Status { get; set; } + + /// + /// Gets or sets the opaque token identifying the worker that owns the current claim. + /// + public string OwnerToken { get; set; } + + /// + /// Gets or sets the monotonically increasing fence token assigned to the current claim. + /// + public long FenceToken { get; set; } + + /// + /// Gets or sets the number of dispatch attempts made so far. + /// + public int AttemptCount { get; set; } + + /// + /// Gets or sets the UTC time the next dispatch attempt is due. + /// + public DateTime NextAttemptUtc { get; set; } + + /// + /// Gets or sets the message from the last failed dispatch attempt. + /// + public string LastError { get; set; } + + /// + /// Gets or sets the stable handler identifiers required to complete this message. + /// + public IList ExpectedHandlerIds { get; set; } = []; + + /// + /// Gets or sets the handler type names that have already processed the event successfully. + /// + public IList CompletedHandlerTypes { get; set; } = []; + + /// + /// Gets or sets the UTC time the message was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the message was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProcessedEvent.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProcessedEvent.cs new file mode 100644 index 000000000..912b71a21 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProcessedEvent.cs @@ -0,0 +1,26 @@ +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a durable marker recording that one handler already applied one durable event. It is the +/// idempotency ledger that lets an at-least-once handler dedupe a replayed event by its stable event id +/// instead of relying on in-memory state. +/// +public sealed class ContactCenterProcessedEvent : CatalogItem +{ + /// + /// Gets or sets the stable, versioned identifier of the handler that processed the event. + /// + public string HandlerId { get; set; } + + /// + /// Gets or sets the durable identifier of the event that was processed. + /// + public string EventId { get; set; } + + /// + /// Gets or sets the UTC time the handler recorded the event as processed. + /// + public DateTime ProcessedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProjectionCheckpoint.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProjectionCheckpoint.cs new file mode 100644 index 000000000..3a935d15b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProjectionCheckpoint.cs @@ -0,0 +1,37 @@ +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the durable replay cursor and version for one projection. It records how far a projection has +/// been rebuilt from the source-of-truth event log so a rebuild can resume and a version bump can force a +/// full replay when the projection logic changes. +/// +public sealed class ContactCenterProjectionCheckpoint : CatalogItem +{ + /// + /// Gets or sets the stable, versioned identifier of the projection the checkpoint belongs to. + /// + public string HandlerId { get; set; } + + /// + /// Gets or sets the projection logic version the checkpoint was last rebuilt with. + /// + public int Version { get; set; } + + /// + /// Gets or sets the occurrence time of the last event applied during the most recent rebuild. + /// + public DateTime LastOccurredUtc { get; set; } + + /// + /// Gets or sets the identifier of the last event applied during the most recent rebuild, used as a + /// stable tie-breaker when several events share the same occurrence time. + /// + public string LastEventId { get; set; } + + /// + /// Gets or sets the UTC time the projection was last fully rebuilt from the event log. + /// + public DateTime? RebuiltUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProjectionDrift.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProjectionDrift.cs new file mode 100644 index 000000000..cf0f8fdca --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterProjectionDrift.cs @@ -0,0 +1,28 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a single detected discrepancy between the count a projection stores and the count recomputed +/// from the source-of-truth event log for one day and event type. +/// +public sealed class ContactCenterProjectionDrift +{ + /// + /// Gets the day the discrepancy applies to, formatted as yyyy-MM-dd. + /// + public string DateKey { get; init; } + + /// + /// Gets the domain event type the discrepancy applies to. + /// + public string EventType { get; init; } + + /// + /// Gets the count recomputed from the event log. + /// + public long ExpectedCount { get; init; } + + /// + /// Gets the count currently stored in the projection. + /// + public long ActualCount { get; init; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRecordingSettings.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRecordingSettings.cs new file mode 100644 index 000000000..9910cef08 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRecordingSettings.cs @@ -0,0 +1,49 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Tenant-scoped site settings that describe the recording governance policy every voice interaction must satisfy. +/// Stored via Orchard Core site settings so the policy is isolated per shell/tenant and never shared across tenants. +/// +public sealed class ContactCenterRecordingSettings +{ + /// + /// The maximum number of retention days that can be configured, bounding the retention window so it can never + /// overflow the representable date range when a retention deadline is computed. + /// + public const int MaxRetentionDays = 36500; + + /// + /// Gets or sets a value indicating whether recording is permitted for this tenant. When disabled the + /// governance policy fails closed and no interaction may start recording regardless of provider capability. + /// + /// + /// Ships by default. Recording is a compliance-sensitive capability whose end-to-end + /// media path is only proven for a deployment by the base-voice audio verification step (see the base-voice + /// deployment acceptance gate), so an operator must consciously enable it after that proof passes rather than + /// have it on the moment the feature is enabled. + /// + public bool RecordingEnabled { get; set; } + + /// + /// Gets or sets the consent model that governs whether a call may be recorded for this tenant. + /// + public RecordingConsentModel ConsentModel { get; set; } = RecordingConsentModel.AllParties; + + /// + /// Gets or sets a value indicating whether explicit, recorded consent must be captured on the interaction + /// before recording may start. When enabled and consent has not been captured, the policy denies recording. + /// + public bool RequireExplicitConsent { get; set; } + + /// + /// Gets or sets the number of days a captured recording is retained before it becomes eligible for erasure. + /// A value of zero means no automatic retention window is applied and the recording is retained indefinitely. + /// + public int RetentionDays { get; set; } + + /// + /// Gets or sets a value indicating whether captured recordings begin under legal hold. A recording under legal + /// hold is exempt from retention-driven and subject-request erasure until the hold is released. + /// + public bool LegalHoldByDefault { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionOptions.cs new file mode 100644 index 000000000..5efef2225 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionOptions.cs @@ -0,0 +1,141 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Configures Contact Center data-governance retention windows. Every window is expressed in days and a value +/// of zero disables purging for that entity so its data is kept indefinitely. The floor settings can only make +/// retention more conservative (keep data longer); they never purge earlier than the configured window. +/// +public sealed class ContactCenterRetentionOptions +{ + /// + /// The number of records deleted per purge batch when none is configured. + /// + public const int DefaultPurgeBatchSize = 500; + + /// + /// The number of purge batches one retention cycle may run when none is configured. Together with + /// it lets a single cycle drain five million expired records. + /// + public const int DefaultMaxPurgeBatchesPerCycle = 10_000; + + /// + /// Gets or sets the number of records deleted per purge batch. Each batch is committed before the next is + /// read so a large drain never accumulates one unbounded transaction. Zero or less uses + /// . + /// + public int PurgeBatchSize { get; set; } = DefaultPurgeBatchSize; + + /// + /// Gets or sets the number of purge batches one retention cycle may run for each entity, so a large table + /// cannot starve the entities that drain after it. It + /// bounds how long a cycle can hold the tenant busy. When a cycle stops because this budget ran out it + /// says so in its report rather than truncating silently. Zero or less uses + /// . + /// + public int MaxPurgeBatchesPerCycle { get; set; } = DefaultMaxPurgeBatchesPerCycle; + + /// + /// Gets or sets the number of days to retain durable interaction events before they are purged. A value of + /// zero disables purging entirely so events are kept indefinitely. + /// + public int InteractionEventRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain completed interactions. Only interactions that have ended are + /// eligible; a live interaction is never purged no matter how long it has been running. + /// + public int InteractionRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain ended call sessions, measured from the time the call ended. + /// + public int CallSessionRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain queue items that have left the queue, measured from the time + /// they were dequeued rather than enqueued so a long wait does not shorten the window. + /// + public int QueueItemRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain reservations that have reached a terminal state, measured from + /// the time they settled. Neither creation nor expiry can serve as the age: an accepted reservation lives + /// for as long as the work does and keeps an expiry in the future. + /// + public int ActivityReservationRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain routing work state that has not been touched. There is no + /// terminal status to key on, so this window is measured from the last mutation alone. That is safe because + /// a purged work state is recreated and re-seeded from the CRM activity on next access. + /// + public int WorkStateRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain outbox messages that have been completed or dead-lettered. + /// + public int OutboxMessageRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain provider webhook inbox messages that have been completed or + /// dead-lettered. + /// + public int WebhookInboxMessageRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain provider commands that have reached a terminal state, + /// measured from completion because neither the retry time nor the lease time advances once a command has + /// finished. + /// + public int ProviderCommandRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain agent sessions, measured from the last heartbeat regardless of + /// whether the session still claims to be online, so sessions abandoned by a crashed node are collected. + /// + public int AgentSessionRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain callback requests that have reached a terminal status, + /// measured from the last modification rather than the scheduled time, because a callback booked far ahead + /// and then canceled keeps a scheduled time in the future. + /// + public int CallbackRequestRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain daily event metrics. + /// + public int EventMetricRetentionDays { get; set; } + + /// + /// Gets or sets the number of days to retain event deduplication markers. A marker may only be purged once + /// no redelivery of the same event can still arrive, so this window is raised to the outbox delivery + /// envelope described by when that envelope is longer. + /// Purging a marker early makes an already-processed event look new and lets its side effect run twice. + /// + public int ProcessedEventRetentionDays { get; set; } + + /// + /// Gets or sets the longest time, in days, a redelivery of the same event can still arrive. It is derived + /// from the outbox and webhook retry envelopes (maximum attempts multiplied by the maximum backoff) and + /// acts as a floor beneath and + /// , both of which suppress a redelivered event. + /// + public double ProcessedEventDeliveryEnvelopeDays { get; set; } + + /// + /// Gets or sets the minimum number of days the durable event log must remain rebuildable. Because purging + /// the event log destroys the ability to replay projections for the purged period, retention never purges + /// events younger than this horizon even when is shorter. This + /// guarantees projections can be rebuilt for at least this window. Zero applies no replay-horizon floor. + /// + public int ProjectionReplayHorizonDays { get; set; } + + /// + /// Gets or sets a legal-hold floor, in days, below which business records are never purged regardless of + /// the configured retention window. Raise it to satisfy a legal hold or regulatory minimum-retention + /// obligation. Zero applies no legal-hold floor. It applies to the records that carry customer interaction + /// history, not to the infrastructure tables that only carry delivery bookkeeping. + /// + public int LegalHoldMinimumDays { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionReport.cs new file mode 100644 index 000000000..c696ba044 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionReport.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Reports the outcome of one retention cycle across every registered policy. +/// +public sealed class ContactCenterRetentionReport +{ + /// + /// Gets the per-entity results, one for every registered policy including those whose purging is disabled. + /// + public IList Entities { get; } = []; + + /// + /// Gets the total number of records purged across every entity. + /// + public int TotalPurged => Entities.Sum(entity => entity.PurgedCount); + + /// + /// Gets a value indicating whether any entity still had expired records when the cycle stopped. A cycle + /// that ends with this set has not returned the database to steady state and the budget must be raised. + /// + public bool WorkRemains => Entities.Any(entity => entity.WorkRemains); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterSkill.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterSkill.cs new file mode 100644 index 000000000..8edee1c6e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterSkill.cs @@ -0,0 +1,35 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a routeable Contact Center capability that can be assigned to agents and required by queues. +/// +public sealed class ContactCenterSkill : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique skill name. + /// + public string Name { get; set; } + + /// + /// Gets or sets the skill description. + /// + public string Description { get; set; } + + /// + /// Gets or sets a value indicating whether the skill can be selected by agents and queues. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the UTC time the skill was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the skill was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyObservations.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyObservations.cs new file mode 100644 index 000000000..ce32bac64 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyObservations.cs @@ -0,0 +1,52 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// What the running deployment actually looks like, gathered once so the topology decision itself stays pure. +/// +public sealed class ContactCenterTopologyObservations +{ + /// + /// Gets the topology profile identifier the operator declared, if any. + /// + public string DeclaredProfileId { get; init; } + + /// + /// Gets a value indicating whether the host is running in a production environment. + /// + /// + /// Used only to reject an undeclared topology. A deployment that declares nothing cannot be checked against + /// anything, so tolerating that outside production and rejecting it inside production is what stops the + /// validator from being trivially bypassed by omitting configuration. + /// + public bool IsProductionHostEnvironment { get; init; } + + /// + /// Gets the configured Orchard database provider for this tenant. + /// + public string DatabaseProvider { get; init; } + + /// + /// Gets a value indicating whether the OrchardCore.Redis feature is enabled. + /// + public bool RedisFeatureEnabled { get; init; } + + /// + /// Gets a value indicating whether the OrchardCore.Redis.Lock feature is enabled. + /// + public bool RedisLockFeatureEnabled { get; init; } + + /// + /// Gets a value indicating whether the CrestApps.OrchardCore.SignalR.Redis backplane feature is enabled. + /// + public bool SignalRRedisBackplaneFeatureEnabled { get; init; } + + /// + /// Gets a value indicating whether the resolved distributed lock is process-local. + /// + /// + /// Observed from the resolved service rather than inferred from the enabled features, because a feature can + /// be enabled while the container still hands out the local implementation. The lock that is actually + /// injected is the one that decides whether two overlapping processes can enter the same critical section. + /// + public bool DistributedLockIsProcessLocal { get; init; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyOptions.cs new file mode 100644 index 000000000..27848e9d3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyOptions.cs @@ -0,0 +1,21 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// The operator-declared deployment topology for this tenant. +/// +/// +/// Bound from the CrestApps_ContactCenter:Topology configuration section. +/// +public sealed class ContactCenterTopologyOptions +{ + /// + /// Gets or sets the identifier of the declared topology profile. + /// + /// + /// Must match a profile in . When left unset the deployment is + /// treated as undeclared, which is tolerated outside a production host environment and rejected inside one: + /// a production deployment that never states which topology it is running cannot be validated against any + /// contract, so it must not be reported as ready. + /// + public string ProfileId { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyProfile.cs new file mode 100644 index 000000000..c704475f2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyProfile.cs @@ -0,0 +1,50 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// A deployment topology an operator may declare, and the infrastructure that topology requires. +/// +/// +/// This is the shipped, code-side mirror of the topologies array in +/// .github/contact-center/support-matrix.v1.json. The governance document is not deployed with the +/// product, so the running application cannot read it; without a shipped copy the support contract would be a +/// claim no deployment could check. A contract test asserts the two are identical, so the copy cannot drift +/// into a second, more permissive definition of what "production" means. +/// +public sealed class ContactCenterTopologyProfile +{ + /// + /// Gets the identifier an operator declares in configuration to select this topology. + /// + public required string Id { get; init; } + + /// + /// Gets a value indicating whether this topology is supported for production use. + /// + public required bool IsProduction { get; init; } + + /// + /// Gets the smallest number of application nodes this topology is certified for. + /// + public required int MinimumApplicationNodes { get; init; } + + /// + /// Gets the largest number of application nodes this topology is certified for. + /// + public required int MaximumApplicationNodes { get; init; } + + /// + /// Gets a value indicating whether this topology requires the Redis SignalR backplane. + /// + public required bool RequiresRedisBackplane { get; init; } + + /// + /// Gets a value indicating whether this topology requires Redis-backed distributed locking. + /// + public required bool RequiresRedisDistributedLock { get; init; } + + /// + /// Gets a value indicating whether this topology requires a shared relational database rather than a + /// file-backed one. + /// + public required bool RequiresSharedRelationalDatabase { get; init; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyProfiles.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyProfiles.cs new file mode 100644 index 000000000..9406c0196 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyProfiles.cs @@ -0,0 +1,95 @@ +using System.Collections.Frozen; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// The deployment topologies this release recognizes. +/// +/// +/// Kept in lockstep with .github/contact-center/support-matrix.v1.json by a contract test. Adding a +/// topology here without adding it there — or vice versa — fails the build, because a topology the product +/// accepts but the support contract does not publish is an unsupported deployment the product treats as +/// supported. +/// +public static class ContactCenterTopologyProfiles +{ + /// + /// The identifier of the single production topology this release earns: exactly one application node + /// running the full distributed contract. + /// + public const string SingleNodeDistributedId = "single-node-distributed"; + + /// + /// The identifier of the multi-node topology. It remains the architectural direction but is not + /// production-certified in this release, because multi-node capacity certification has not been earned. + /// + public const string SingleRegionMultiNodeId = "single-region-multi-node"; + + /// + /// The identifier of the development topology. It requires no distributed infrastructure and is never + /// supported for production use. + /// + public const string SingleNodeDevelopmentId = "single-node-development"; + + private static readonly FrozenDictionary _profiles = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + [SingleNodeDistributedId] = new ContactCenterTopologyProfile + { + Id = SingleNodeDistributedId, + IsProduction = true, + MinimumApplicationNodes = 1, + MaximumApplicationNodes = 1, + RequiresRedisBackplane = true, + RequiresRedisDistributedLock = true, + RequiresSharedRelationalDatabase = true, + }, + [SingleRegionMultiNodeId] = new ContactCenterTopologyProfile + { + Id = SingleRegionMultiNodeId, + IsProduction = false, + MinimumApplicationNodes = 2, + MaximumApplicationNodes = 4, + RequiresRedisBackplane = true, + RequiresRedisDistributedLock = true, + RequiresSharedRelationalDatabase = true, + }, + [SingleNodeDevelopmentId] = new ContactCenterTopologyProfile + { + Id = SingleNodeDevelopmentId, + IsProduction = false, + MinimumApplicationNodes = 1, + MaximumApplicationNodes = 1, + RequiresRedisBackplane = false, + RequiresRedisDistributedLock = false, + RequiresSharedRelationalDatabase = false, + }, + }.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets every recognized topology profile. + /// + public static IReadOnlyCollection All => _profiles.Values; + + /// + /// Finds the profile an operator declared. + /// + /// The declared profile identifier. + /// The matching profile, or when the identifier is not recognized. + /// + /// An unrecognized identifier deliberately returns rather than falling back to the + /// development profile. A typo in a production deployment must surface as a validation failure, not as a + /// silent downgrade to the topology with no requirements. + /// + public static ContactCenterTopologyProfile Find(string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return null; + } + + return _profiles.TryGetValue(id.Trim(), out var profile) + ? profile + : null; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyValidationResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyValidationResult.cs new file mode 100644 index 000000000..919226e4c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterTopologyValidationResult.cs @@ -0,0 +1,31 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// The verdict on whether this deployment satisfies the topology it declared. +/// +public sealed class ContactCenterTopologyValidationResult +{ + /// + /// Gets the topology profile identifier the operator declared, if any. + /// + public string DeclaredProfileId { get; init; } + + /// + /// Gets a value indicating whether the declared topology is a production topology. + /// + public bool IsProductionTopology { get; init; } + + /// + /// Gets the reasons the deployment does not satisfy the topology it declared. + /// + /// + /// Every missing component is reported, not only the first. An operator fixing one at a time across + /// successive deployments is the slowest possible way to reach a supported configuration. + /// + public IReadOnlyList Failures { get; init; } = []; + + /// + /// Gets a value indicating whether the deployment satisfies the topology it declared. + /// + public bool IsSatisfied => Failures.Count == 0; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterWorkState.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterWorkState.cs new file mode 100644 index 000000000..f6b813e7b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterWorkState.cs @@ -0,0 +1,134 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the routing-owned work state for a single CRM activity. Contact Center reservation, offer, +/// assignment and dialer transitions write this aggregate instead of the CRM activity, so a concurrent CRM +/// edit and a routing transition can never invalidate one another. +/// +public sealed class ContactCenterWorkState : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the identifier of the CRM activity this work state belongs to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the routing-owned assignment status of the activity. + /// + [JsonInclude] + public ActivityAssignmentStatus AssignmentStatus { get; private set; } + + /// + /// Moves the ContactCenterWorkState to the specified assignment status. + /// + /// The status to move to. + /// The ContactCenterWorkState cannot reach the status from the one it is in. + public void TransitionTo(ActivityAssignmentStatus status) + { + if (!WorkAssignmentLifecycle.CanTransition(AssignmentStatus, status)) + { + throw new InvalidStateTransitionException(nameof(ContactCenterWorkState), AssignmentStatus, status); + } + + AssignmentStatus = status; + } + + /// + /// Determines whether the ContactCenterWorkState can move to the specified status. + /// + /// The status to test. + /// when the transition is admitted; otherwise . + public bool CanTransitionTo(ActivityAssignmentStatus status) + => WorkAssignmentLifecycle.CanTransition(AssignmentStatus, status); + + /// + /// Adopts the routing status carried by the CRM activity this work state is seeded from. + /// + /// The assignment status the activity already carries. + /// + /// Seeding is not a transition. The work state is being created to mirror an activity that already holds a + /// routing status decided before this record existed, so there is no previous status to move from and no + /// edge to check. Treating it as a transition would refuse every activity that is already assigned. + /// + public void AdoptActivityAssignmentStatus(ActivityAssignmentStatus status) + => AssignmentStatus = status; + + /// + /// Restores a status that was decided elsewhere, without consulting the lifecycle. + /// + /// The status to restore. + /// The same ContactCenterWorkState, so it can be used at the end of an object initializer. + /// + /// This bypasses every transition rule and exists only so a test can arrange a state directly. Production code + /// must never call it: AggregateLifecycleArchitectureTests fails the build if any file under src/ + /// does, so the bypass cannot quietly become a shortcut. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public ContactCenterWorkState RestorePersistedAssignmentStatus(ActivityAssignmentStatus status) + { + AssignmentStatus = status; + + return this; + } + + /// + /// Gets or sets the identifier of the reservation currently holding the activity. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the user identifier of the agent the activity is reserved for. + /// + public string ReservedById { get; set; } + + /// + /// Gets or sets the user name of the agent the activity is reserved for. + /// + public string ReservedByUsername { get; set; } + + /// + /// Gets or sets the UTC time the activity was reserved. + /// + public DateTime? ReservedUtc { get; set; } + + /// + /// Gets or sets the UTC time the current reservation expires when it is not accepted. + /// + public DateTime? ReservationExpiresUtc { get; set; } + + /// + /// Gets or sets the user identifier of the agent the activity is assigned to. + /// + public string AssignedToId { get; set; } + + /// + /// Gets or sets the user name of the agent the activity is assigned to. + /// + public string AssignedToUsername { get; set; } + + /// + /// Gets or sets the UTC time the activity was assigned. + /// + public DateTime? AssignedToUtc { get; set; } + + /// + /// Gets or sets the number of outbound attempts the dialer has made for the activity. + /// + public int Attempts { get; set; } + + /// + /// Gets or sets the UTC time the work state was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the work state was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerAbandonmentEvaluation.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerAbandonmentEvaluation.cs new file mode 100644 index 000000000..060e434f5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerAbandonmentEvaluation.cs @@ -0,0 +1,82 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of evaluating a dialer profile against its rolling abandonment-rate cap. The +/// evaluation is auditable: it always carries whether dialing is permitted, whether the statistics were +/// available, the measured rate, and the sample size behind the decision. +/// +public sealed class DialerAbandonmentEvaluation +{ + private DialerAbandonmentEvaluation( + bool isPermitted, + bool statisticsAvailable, + double ratePercent, + long sampleSize, + string description) + { + IsPermitted = isPermitted; + StatisticsAvailable = statisticsAvailable; + RatePercent = ratePercent; + SampleSize = sampleSize; + Description = description; + } + + /// + /// Gets a value indicating whether outbound dialing is permitted under the abandonment policy. + /// + public bool IsPermitted { get; } + + /// + /// Gets a value indicating whether the rolling abandonment statistics were available for the decision. + /// + public bool StatisticsAvailable { get; } + + /// + /// Gets the measured rolling abandonment rate, expressed as a percentage of live-answered calls. + /// + public double RatePercent { get; } + + /// + /// Gets the number of live-answered calls the decision was measured against. + /// + public long SampleSize { get; } + + /// + /// Gets a human-readable, auditable description of the decision. + /// + public string Description { get; } + + /// + /// Creates a permitted evaluation. + /// + /// Whether the rolling statistics were available. + /// The measured abandonment rate percentage. + /// The number of live-answered calls measured. + /// An auditable description of why dialing is permitted. + /// A permitted . + public static DialerAbandonmentEvaluation Permitted( + bool statisticsAvailable, + double ratePercent, + long sampleSize, + string description) + { + return new DialerAbandonmentEvaluation(true, statisticsAvailable, ratePercent, sampleSize, description); + } + + /// + /// Creates a suppressed evaluation. + /// + /// Whether the rolling statistics were available. + /// The measured abandonment rate percentage. + /// The number of live-answered calls measured. + /// An auditable description of why dialing is suppressed. + /// A suppressed . + public static DialerAbandonmentEvaluation Suppressed( + bool statisticsAvailable, + double ratePercent, + long sampleSize, + string description) + { + return new DialerAbandonmentEvaluation(false, statisticsAvailable, ratePercent, sampleSize, description); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerAbandonmentStatistics.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerAbandonmentStatistics.cs new file mode 100644 index 000000000..5cfcbed92 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerAbandonmentStatistics.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the rolling outbound-dialing outcomes a statistics provider reports for a dialer profile so +/// the abandonment policy can decide whether the profile stays within its configured cap. +/// +public sealed class DialerAbandonmentStatistics +{ + /// + /// Gets or sets the number of calls a live person answered within the rolling window. This is the + /// denominator of the abandonment rate. + /// + public long LiveAnswers { get; set; } + + /// + /// Gets or sets the number of live-answered calls that were abandoned because no agent connected in + /// time within the rolling window. This is the numerator of the abandonment rate. + /// + public long AbandonedCalls { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs new file mode 100644 index 000000000..b748c8286 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs @@ -0,0 +1,135 @@ +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents an outbound dialing configuration that ties a campaign and queue to a dialing mode and provider. +/// +public sealed class DialerProfile : CatalogItem, INameAwareModel, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the unique name of the dialer profile. + /// + public string Name { get; set; } + + /// + /// Gets or sets the description of the dialer profile. + /// + public string Description { get; set; } + + /// + /// Gets or sets the CRM campaign whose activities are dialed. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the queue eligible agents sign in to for the campaign. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the dialing mode that controls pacing and agent reservation behavior. + /// + public DialerMode Mode { get; set; } = DialerMode.Preview; + + /// + /// Gets or sets the technical name of the Contact Center voice provider that places calls, or null for the default. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the number of calls placed per available agent for power dialing. + /// + public int CallsPerAgent { get; set; } = 1; + + /// + /// Gets or sets the maximum number of dialing attempts allowed per activity. + /// + public int MaxAttempts { get; set; } = 3; + + /// + /// Gets or sets the delay, in minutes, before a no-answer activity is retried. + /// + public int RetryDelayMinutes { get; set; } = 60; + + /// + /// Gets or sets the caller identifier presented to the customer when supported. + /// + public string CallerId { get; set; } + + /// + /// Gets or sets the ISO 3166-1 alpha-2 region a destination is read in when it carries no country + /// calling code. A campaign's activities are commonly imported in national format, and without a region + /// such a destination cannot be canonicalized, so it cannot be compared with a do-not-call registry or + /// matched to a regional calling calendar and the attempt is suppressed instead. + /// + public string DefaultRegionCode { get; set; } + + /// + /// Gets or sets a value indicating whether do-not-call and communication preferences suppress activities. + /// + public bool RespectDoNotCall { get; set; } = true; + + /// + /// Gets or sets a value indicating whether calls are restricted by business-hours calendars. + /// + public bool EnforceCallingWindow { get; set; } + + /// + /// Gets or sets a value indicating whether outbound dialing is gated by a rolling abandonment-rate cap. + /// The cap only constrains automated pacing modes, because manual and preview dialing bind an agent to + /// every call and cannot abandon a connected party. + /// + public bool EnforceAbandonmentCap { get; set; } + + /// + /// Gets or sets the maximum tolerated rolling abandonment rate, expressed as a percentage of calls a + /// live person answered. A value of 3 keeps abandonment at or below three percent. + /// + public double MaxAbandonmentRatePercent { get; set; } = 3; + + /// + /// Gets or sets the minimum number of live-answered calls that must accumulate in the rolling window + /// before the abandonment rate is enforced, avoiding volatile suppression on small samples. + /// + public int AbandonmentSampleFloor { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether an abandoned automated call plays a safe-harbor announcement + /// that identifies the caller instead of being dropped silently. + /// + public bool SafeHarborEnabled { get; set; } + + /// + /// Gets or sets the safe-harbor announcement played to a live party when no agent connects in time. + /// + public string SafeHarborMessage { get; set; } + + /// + /// Gets or sets the default business-hours calendar used to evaluate outbound calls. + /// + public string CallingCalendarId { get; set; } + + /// + /// Gets or sets region-specific business-hours calendar overrides keyed by ISO 3166-1 alpha-2 region code. + /// + public IDictionary RegionalCallingCalendarIds { get; set; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets or sets a value indicating whether the dialer profile is enabled. + /// + public bool Enabled { get; set; } = true; + + /// + /// Gets or sets the UTC time the dialer profile was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the dialer profile was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerSuppressionEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerSuppressionEventData.cs new file mode 100644 index 000000000..f1d06c21d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerSuppressionEventData.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the auditable payload recorded when the outbound compliance gate suppresses a dialing attempt. +/// +public sealed class DialerSuppressionEventData +{ + /// + /// Gets or sets the identifier of the dialer profile that governed the attempt. + /// + public string ProfileItemId { get; set; } + + /// + /// Gets or sets the identifier of the CRM activity that was suppressed. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the reason the attempt was suppressed. + /// + public DialerSuppressionReason Reason { get; set; } + + /// + /// Gets or sets a human-readable explanation of the suppression decision. + /// + public string Description { get; set; } + + /// + /// Gets or sets the destination that would have been dialed. + /// + public string Destination { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DispositionSuggestion.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DispositionSuggestion.cs new file mode 100644 index 000000000..82e65a7ee --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DispositionSuggestion.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents an AI-suggested disposition for an interaction. +/// +public sealed class DispositionSuggestion +{ + /// + /// Gets or sets the identifier of the suggested disposition. + /// + public string DispositionId { get; set; } + + /// + /// Gets or sets the display name of the suggested disposition. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the confidence of the suggestion, from 0 to 1. + /// + public double Confidence { get; set; } + + /// + /// Gets or sets a short rationale for the suggestion. + /// + public string Rationale { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/EntryPointRoutingPlan.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/EntryPointRoutingPlan.cs new file mode 100644 index 000000000..bad1592af --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/EntryPointRoutingPlan.cs @@ -0,0 +1,39 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the routing decision derived from an inbound entry point and its business-hours state. +/// +public sealed class EntryPointRoutingPlan +{ + /// + /// Gets or sets the matched entry point. + /// + public ContactCenterEntryPoint EntryPoint { get; set; } + + /// + /// Gets or sets a value indicating whether the entry point is currently open. + /// + public bool IsOpen { get; set; } + + /// + /// Gets or sets a value indicating whether the call should be enqueued. + /// + public bool ShouldQueue { get; set; } + + /// + /// Gets or sets the effective queue identifier the call should be enqueued into. + /// + public string TargetQueueId { get; set; } + + /// + /// Gets or sets the priority to assign to the queued call. + /// + public InteractionPriority Priority { get; set; } = InteractionPriority.Normal; + + /// + /// Gets or sets the action to apply while the entry point is closed. + /// + public EntryPointClosedAction ClosedAction { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs new file mode 100644 index 000000000..e88f0331c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs @@ -0,0 +1,47 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of routing an inbound voice event through the Contact Center. +/// +public sealed class InboundVoiceRoutingResult +{ + /// + /// Gets or sets a value indicating whether the inbound call was offered to an agent. + /// + public bool Routed { get; set; } + + /// + /// Gets or sets a value indicating whether the inbound call is waiting in a Contact Center queue. + /// + public bool Queued { get; set; } + + /// + /// Gets or sets the identifier of the interaction created for the inbound call. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the identifier of the CRM activity created for the inbound call. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the identifier of the queue the inbound call was placed in, when one was resolved. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the identifier of the user the inbound call was offered to, when an agent was reserved. + /// + public string AgentUserId { get; set; } + + /// + /// Gets or sets a human-readable explanation of the routing outcome, used for diagnostics. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the stable machine-readable reason code for a terminal routing outcome. + /// + public string ReasonCode { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs new file mode 100644 index 000000000..ea042a5b0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs @@ -0,0 +1,293 @@ +using System.ComponentModel; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.Entities; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a communication event associated with a CRM activity. The CRM activity remains the +/// universal work item; an interaction captures the technical communication history for one attempt. +/// +public sealed class Interaction : CatalogItem, IEntity, IModifiedUtcAwareModel +{ + /// + /// Gets or sets extensible Orchard entity metadata for the interaction. + /// + public JsonObject EntityProperties { get; set; } = []; + + JsonObject IEntity.Properties + { + get => EntityProperties; + } + + /// + /// Gets or sets the channel the interaction is conducted on. + /// + public InteractionChannel Channel { get; set; } + + /// + /// Gets or sets the direction of the interaction relative to the contact center. + /// + public InteractionDirection Direction { get; set; } + + /// + /// Gets the communication-session status of the interaction. It is changed only through + /// , so a status the lifecycle does not admit cannot be + /// recorded by any caller. + /// + [JsonInclude] + public InteractionStatus Status { get; private set; } + + /// + /// Gets or sets the identifier of the CRM activity this communication event belongs to. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the provider name that produced the communication event. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider interaction or call identifier. + /// + public string ProviderInteractionId { get; set; } + + /// + /// Gets or sets the provider call leg identifier when the channel has leg-level tracking. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the customer address used for the communication event. + /// + public string CustomerAddress { get; set; } + + /// + /// Gets or sets the Contact Center queue that handled the communication event, when applicable. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the identifier of the agent connected to the communication event. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the correlation identifier shared by every event and provider session of this interaction. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the recording reference when a provider or media store captures the interaction. + /// + public string RecordingReference { get; set; } + + /// + /// Gets or sets the recording state of the interaction. + /// + public RecordingState RecordingState { get; set; } + + /// + /// Gets or sets the UTC instant at which explicit party consent to record this interaction was captured, when + /// the tenant recording governance policy requires it. + /// + public DateTime? RecordingConsentCapturedUtc { get; set; } + + /// + /// Gets or sets the jurisdiction under which recording consent for this interaction was evaluated, when known. + /// + public string RecordingConsentJurisdiction { get; set; } + + /// + /// Gets or sets a value indicating whether the captured recording is under legal hold. A recording under legal + /// hold is exempt from retention-driven and subject-request erasure until the hold is released. + /// + public bool RecordingLegalHold { get; set; } + + /// + /// Gets or sets the UTC instant beyond which the captured recording becomes eligible for erasure, or + /// when the recording is retained indefinitely. + /// + public DateTime? RecordingRetainUntilUtc { get; set; } + + /// + /// Gets or sets the UTC instant at which the captured recording reference was erased at the orchestration layer + /// in response to a right-to-erasure request, or when the recording has not been erased. + /// + public DateTime? RecordingErasedUtc { get; set; } + + /// + /// Gets or sets the correlation identifier used by the provider webhook or callback, when different from . + /// + public string ProviderCorrelationId { get; set; } + + /// + /// Gets or sets provider or channel-specific metadata that should remain attached to the interaction history. + /// + public IDictionary TechnicalMetadata { get; set; } = new Dictionary(); + + /// + /// Gets or sets the queue transitions that occurred during the interaction. + /// + public IList QueueHistory { get; set; } = []; + + /// + /// Gets or sets the transfer attempts that occurred during the interaction. + /// + public IList TransferHistory { get; set; } = []; + + /// + /// Gets or sets the identifier of the user that created the interaction. + /// + public string CreatedById { get; set; } + + /// + /// Gets or sets the user name of the user that created the interaction. + /// + public string CreatedByUserName { get; set; } + + /// + /// Gets or sets the UTC time the interaction was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the interaction was last modified. + /// + public DateTime? ModifiedUtc { get; set; } + + /// + /// Gets or sets the UTC time work on the interaction started. + /// + public DateTime? StartedUtc { get; set; } + + /// + /// Gets or sets the UTC time the interaction was answered or connected. + /// + public DateTime? AnsweredUtc { get; set; } + + /// + /// Gets or sets the UTC time the interaction's communication session ended. + /// + public DateTime? EndedUtc { get; set; } + + /// + /// Gets or sets the UTC time after-call wrap-up started. + /// + public DateTime? WrapUpStartedUtc { get; set; } + + /// + /// Gets or sets the UTC time after-call wrap-up was completed. + /// + public DateTime? WrapUpCompletedUtc { get; set; } + + /// + /// Gets or sets the participants involved in the interaction. + /// + public IList Participants { get; set; } = []; + + /// + /// Moves the interaction to the specified communication-session status. + /// + /// The status to move to. + /// The interaction cannot reach the status from the one it is in. + public void TransitionTo(InteractionStatus status) + { + if (!InteractionLifecycle.CanTransition(Status, status)) + { + throw new InvalidStateTransitionException(nameof(Interaction), Status, status); + } + + Status = status; + } + + /// + /// Restores a communication-session status that was decided elsewhere, without consulting the lifecycle. + /// + /// The status to restore. + /// The same interaction, so it can be used at the end of an object initializer. + /// + /// This bypasses every transition rule and exists only so a test can put an interaction directly into the + /// state it wants to exercise. Production code must never call it: AggregateLifecycleArchitectureTests + /// fails the build if any file under src/ does, so the bypass cannot quietly become a shortcut. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public Interaction RestorePersistedStatus(InteractionStatus status) + { + Status = status; + + return this; + } + + /// + /// Mirrors the communication-session status implied by the authoritative call session. + /// + /// The status implied by the call session's current state. + /// + /// For a provider-backed voice interaction the call session is the authority on what the call is doing, and + /// the interaction's status is a projection of it rather than an independent decision. Ordering for that + /// stream is enforced upstream by VoiceStreamOrdering, which rejects deliveries that would move the + /// call backwards; re-deciding the same question here with a second, narrower rule would let the two records + /// disagree, which is the divergence CallStateMachinePropertyTests exists to catch. The lifecycle + /// table therefore governs the paths where this system decides the next status, not the ones where it is + /// reporting what a provider already did. + /// + public void MirrorSessionStatus(InteractionStatus status) + { + // Mirroring reports what the provider already did, so it does not consult the table. It still refuses to + // bring a settled interaction back to life: the interaction can settle on a path the call session never + // sees, such as an offer released after the customer abandoned, and a late provider frame that reopened + // it would put a finished conversation back into the agent's live work. + if (InteractionLifecycle.IsSettled(Status) && !InteractionLifecycle.IsSettled(status)) + { + return; + } + + Status = status; + } + + /// + /// Returns the interaction to routing so it can be offered again, clearing the agent it was offered to. + /// + /// The interaction's communication session has already settled. + /// + /// This names the one thing several call sites were each spelling out for themselves. It moves along the + /// declared backwards edge like any other transition, so it is refused once the session has settled: a + /// settled status has no outgoing edge, and re-offering a call that is over creates work for a conversation + /// nobody can join. + /// + public void Requeue() + { + TransitionTo(InteractionStatus.Created); + AgentId = null; + } + + /// + /// Returns the interaction to the alerting state so it can be offered to another agent. + /// + /// The interaction's communication session has already settled. + public void Reoffer() + { + TransitionTo(InteractionStatus.Ringing); + } + + /// + /// Determines whether the interaction can move to the specified communication-session status. + /// + /// The status to test. + /// when the transition is admitted; otherwise . + public bool CanTransitionTo(InteractionStatus status) + => InteractionLifecycle.CanTransition(Status, status); + + /// + /// Gets a value indicating whether the interaction's communication session has reached an outcome. + /// + [System.Text.Json.Serialization.JsonIgnore] + public bool IsSettled => InteractionLifecycle.IsSettled(Status); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs new file mode 100644 index 000000000..934eee2e9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs @@ -0,0 +1,110 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.Core.Models; +using OrchardCore.Entities; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a single durable Contact Center domain event. Interaction events form the auditable, +/// replayable history of everything that happens to an interaction across the contact center. +/// +public sealed class InteractionEvent : CatalogItem, IEntity +{ + /// + /// Gets or sets extensible Orchard entity metadata for the event. + /// + public JsonObject EntityProperties { get; set; } = []; + + JsonObject IEntity.Properties + { + get => EntityProperties; + } + + /// + /// Gets or sets the identifier of the interaction the event belongs to. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the canonical event type name. See . + /// + public string EventType { get; set; } + + /// + /// Gets or sets the schema version of the event payload. + /// + public int SchemaVersion { get; set; } = ContactCenterConstants.CurrentEventSchemaVersion; + + /// + /// Gets or sets the type of aggregate the event applies to, such as the interaction or a queue item. + /// + public string AggregateType { get; set; } + + /// + /// Gets or sets the identifier of the aggregate the event applies to. + /// + public string AggregateId { get; set; } + + /// + /// Gets or sets the correlation identifier shared by every event of the same interaction. + /// + public string CorrelationId { get; set; } + + /// + /// Gets or sets the identifier of the event that caused this event, when known. + /// + public string CausationId { get; set; } + + /// + /// Gets or sets the identifier of the actor that originated the event, or a system actor. + /// + public string ActorId { get; set; } + + /// + /// Gets or sets the name of the component that originated the event. See . + /// + public string SourceComponent { get; set; } + + /// + /// Gets or sets the UTC time the event occurred. + /// + public DateTime OccurredUtc { get; set; } + + /// + /// Gets or sets an optional idempotency key used to de-duplicate provider-originated events. + /// + public string IdempotencyKey { get; set; } + + /// + /// Gets or sets the serialized JSON payload of the event. + /// + public string Data { get; set; } + + /// + /// Deserializes the event payload into the specified type. + /// + /// The payload type to deserialize into. + /// The deserialized payload, or the default value when no payload is present. + public T GetData() + { + if (string.IsNullOrEmpty(Data)) + { + return default; + } + + return JsonSerializer.Deserialize(Data); + } + + /// + /// Serializes the specified payload and stores it as the event data. + /// + /// The payload type to serialize. + /// The payload to serialize. + public void SetData(T payload) + { + Data = payload is null + ? null + : JsonSerializer.Serialize(payload); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionLifecycle.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionLifecycle.cs new file mode 100644 index 000000000..53fe9a376 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionLifecycle.cs @@ -0,0 +1,119 @@ +using System.Collections.Frozen; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Declares which interaction status changes the domain admits. +/// +/// Provider streams were previously ordered by a lifecycle rank, which only answers whether a change moves the +/// call forward. Forward is not the same as legal: an interaction that was created and never rang can move to +/// without regressing anything, and a held call that was never answered is +/// a fiction that every report, wallboard and duration calculation downstream will treat as real. The table +/// below is the one place that says which edges exist, so the answer cannot differ between the ingestion path, +/// the agent workspace and the reporting projection. +/// +/// +public static class InteractionLifecycle +{ + private static readonly FrozenDictionary> _transitions = + new Dictionary> + { + // A provider that answers immediately reports Connected without an intermediate alert, and a call + // that is abandoned before it is offered anywhere settles straight from Created. Transferring is + // admitted from Created because a queued interaction that has not been offered to anyone can still + // be moved to a different queue, which is a transfer of the work rather than of a live conversation. + [InteractionStatus.Created] = FrozenSet.ToFrozenSet( + [ + InteractionStatus.Ringing, + InteractionStatus.Connected, + InteractionStatus.Transferring, + InteractionStatus.Ended, + InteractionStatus.Failed, + ]), + // Created and Ringing are reachable again from every unsettled status because an offer that is not + // taken has to become offerable again: a reservation that expires returns the interaction to routing, + // and a re-offer alerts the next agent. Neither edge leaves a settled status, so a conversation that + // is over still cannot be handed to anyone. + [InteractionStatus.Ringing] = FrozenSet.ToFrozenSet( + [ + InteractionStatus.Created, + InteractionStatus.Connected, + InteractionStatus.Transferring, + InteractionStatus.Ended, + InteractionStatus.Failed, + ]), + [InteractionStatus.Connected] = FrozenSet.ToFrozenSet( + [ + InteractionStatus.Created, + InteractionStatus.Ringing, + InteractionStatus.Held, + InteractionStatus.Transferring, + InteractionStatus.Conferenced, + InteractionStatus.Ended, + InteractionStatus.Failed, + ]), + [InteractionStatus.Held] = FrozenSet.ToFrozenSet( + [ + InteractionStatus.Created, + InteractionStatus.Ringing, + InteractionStatus.Connected, + InteractionStatus.Transferring, + InteractionStatus.Conferenced, + InteractionStatus.Ended, + InteractionStatus.Failed, + ]), + [InteractionStatus.Transferring] = FrozenSet.ToFrozenSet( + [ + InteractionStatus.Created, + InteractionStatus.Ringing, + InteractionStatus.Connected, + InteractionStatus.Held, + InteractionStatus.Conferenced, + InteractionStatus.Ended, + InteractionStatus.Failed, + ]), + [InteractionStatus.Conferenced] = FrozenSet.ToFrozenSet( + [ + InteractionStatus.Created, + InteractionStatus.Ringing, + InteractionStatus.Connected, + InteractionStatus.Held, + InteractionStatus.Transferring, + InteractionStatus.Ended, + InteractionStatus.Failed, + ]), + + // Settled statuses are final. A provider that redelivers a hangup after the call is already ended + // is answered by the same-status rule, not by an edge out of a settled state. + [InteractionStatus.Ended] = FrozenSet.Empty, + [InteractionStatus.Failed] = FrozenSet.Empty, + }.ToFrozenDictionary(); + + /// + /// Determines whether an interaction in one status may move to another. + /// + /// The status the interaction is in. + /// The status the interaction would move to. + /// when the transition is admitted; otherwise . + public static bool CanTransition(InteractionStatus from, InteractionStatus to) + { + // Re-applying the status an interaction already holds is not a transition. Provider streams redeliver, + // and refusing a redelivery that changes nothing would turn an at-least-once provider into an error. + if (from == to) + { + return true; + } + + return _transitions.TryGetValue(from, out var allowed) && allowed.Contains(to); + } + + /// + /// Determines whether an interaction status is settled, meaning its communication session has reached an + /// outcome and no further transition can move it. + /// + /// The status to inspect. + /// when the status is settled; otherwise . + public static bool IsSettled(InteractionStatus status) + => status == InteractionStatus.Ended || status == InteractionStatus.Failed; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionParticipant.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionParticipant.cs new file mode 100644 index 000000000..2eac7534e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionParticipant.cs @@ -0,0 +1,39 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a single participant in an interaction, such as the customer, an agent, or a supervisor. +/// +public sealed class InteractionParticipant +{ + /// + /// Gets or sets the role the participant plays in the interaction. + /// + public InteractionParticipantRole Role { get; set; } + + /// + /// Gets or sets the identifier of the participant, such as a user identifier for an agent. + /// + public string Identifier { get; set; } + + /// + /// Gets or sets the display name of the participant. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the address of the participant, such as a phone number or email. + /// + public string Address { get; set; } + + /// + /// Gets or sets the UTC time the participant joined the interaction. + /// + public DateTime? JoinedUtc { get; set; } + + /// + /// Gets or sets the UTC time the participant left the interaction. + /// + public DateTime? LeftUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionQueueHistoryEntry.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionQueueHistoryEntry.cs new file mode 100644 index 000000000..cde372f05 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionQueueHistoryEntry.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a queue transition recorded as part of an interaction's communication history. +/// +public sealed class InteractionQueueHistoryEntry +{ + /// + /// Gets or sets the queue identifier. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the UTC time the interaction entered the queue. + /// + public DateTime EnteredUtc { get; set; } + + /// + /// Gets or sets the UTC time the interaction left the queue. + /// + public DateTime? ExitedUtc { get; set; } + + /// + /// Gets or sets the reason the interaction left the queue. + /// + public string ExitReason { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionTransferHistoryEntry.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionTransferHistoryEntry.cs new file mode 100644 index 000000000..c3e7a23f9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionTransferHistoryEntry.cs @@ -0,0 +1,41 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a transfer attempt recorded as part of an interaction's communication history. +/// +public sealed class InteractionTransferHistoryEntry +{ + /// + /// Gets or sets the participant or agent that initiated the transfer. + /// + public string FromParticipantId { get; set; } + + /// + /// Gets or sets the transfer destination identifier. + /// + public string ToParticipantId { get; set; } + + /// + /// Gets or sets the transfer destination type recorded as a historical text snapshot of the + /// name at the time of the + /// transfer. This is an audit value for display only; the live topology exposes the typed target and this string is + /// never re-parsed back into the enum. + /// + public string TargetType { get; set; } + + /// + /// Gets or sets the UTC time the transfer was requested. + /// + public DateTime RequestedUtc { get; set; } + + /// + /// Gets or sets the UTC time the transfer completed or failed. + /// + public DateTime? CompletedUtc { get; set; } + + /// + /// Gets or sets the human-readable transfer result recorded as a historical text snapshot for audit display. It is + /// descriptive text, not a machine outcome, and is never re-parsed into a typed result. + /// + public string Result { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InvalidStateTransitionException.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InvalidStateTransitionException.cs new file mode 100644 index 000000000..053dc8b79 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InvalidStateTransitionException.cs @@ -0,0 +1,61 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Thrown when an aggregate is asked to move to a state it cannot reach from the state it is in. +/// +/// The transition is refused rather than recorded. A projection built from a state the domain does not admit +/// is worse than a failed operation, because nothing downstream can tell it apart from a real one: a call +/// that reports itself as held without ever having been answered is indistinguishable, to a report, a wallboard, +/// or a supervisor, from a call that genuinely is. +/// +/// +public sealed class InvalidStateTransitionException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The name of the aggregate that refused the transition. + /// The state the aggregate is in. + /// The state the aggregate was asked to move to. + public InvalidStateTransitionException(string aggregateName, object from, object to) + : base($"A {aggregateName} cannot move from '{from}' to '{to}'.") + { + AggregateName = aggregateName; + From = from; + To = to; + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the refused transition. + public InvalidStateTransitionException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the refused transition. + /// The exception that caused this one. + public InvalidStateTransitionException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// + /// Gets the name of the aggregate that refused the transition. + /// + public string AggregateName { get; } + + /// + /// Gets the state the aggregate was in when the transition was refused. + /// + public object From { get; } + + /// + /// Gets the state the aggregate was asked to move to. + /// + public object To { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/MonitorSession.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/MonitorSession.cs new file mode 100644 index 000000000..b636d63b9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/MonitorSession.cs @@ -0,0 +1,60 @@ +using System.Text.Json.Serialization; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a supervisor's live engagement with a call. Without it a supervisor's presence exists only +/// as a pair of past events, so nothing can answer who is listening to a call right now, stop an +/// engagement the supervisor's browser lost, or prevent the same supervisor engaging twice. +/// +public sealed class MonitorSession +{ + /// + /// Gets or sets the platform identifier of the engagement. + /// + public string MonitorSessionId { get; set; } + + /// + /// Gets or sets the supervisor's agent-profile identifier, when the supervisor has an agent profile. + /// This is the identifier that shares an identity space with . + /// + public string SupervisorAgentId { get; set; } + + /// + /// Gets or sets the supervisor's user identifier. A supervisor is always a user but is not always an + /// agent, so this is the identifier the engagement is matched on when it is started and stopped. + /// + public string SupervisorUserId { get; set; } + + /// + /// Gets or sets the identifier of the agent being monitored. + /// + public string TargetAgentId { get; set; } + + /// + /// Gets or sets the way the supervisor is engaged with the call. + /// + public MonitorMode Mode { get; set; } + + /// + /// Gets or sets the provider identifier of the supervisor's leg, when the provider reports one. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the UTC time the engagement started. + /// + public DateTime StartedUtc { get; set; } + + /// + /// Gets or sets the UTC time the engagement ended, or while it is live. + /// + public DateTime? EndedUtc { get; set; } + + /// + /// Gets a value indicating whether the engagement is still live. + /// + [JsonIgnore] + public bool IsActive => EndedUtc is null; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/OfferDeclinedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/OfferDeclinedEventData.cs new file mode 100644 index 000000000..ba4e73cd0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/OfferDeclinedEventData.cs @@ -0,0 +1,12 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the queue context required to continue routing after an agent declines an offer. +/// +public sealed class OfferDeclinedEventData +{ + /// + /// Gets or sets the queue whose next eligible work offer should be evaluated. + /// + public string QueueId { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/OutboxMessageStatus.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/OutboxMessageStatus.cs new file mode 100644 index 000000000..90fbd2475 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/OutboxMessageStatus.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the dispatch state of a Contact Center outbox message. +/// +public enum OutboxMessageStatus +{ + /// + /// The message is waiting to be retried after a failed handler dispatch. + /// + Pending, + + /// + /// The message is owned by a worker under a durable, expiring claim. + /// + Claimed, + + /// + /// Every registered handler completed and the message is safe to remove. + /// + Completed, + + /// + /// The message exhausted its retry budget and was set aside for manual inspection. + /// + DeadLettered, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderAnswerCommandRequest.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderAnswerCommandRequest.cs new file mode 100644 index 000000000..8e05bbe82 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderAnswerCommandRequest.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes the durable provider work required to connect an accepted inbound offer to its agent. +/// +public sealed class ProviderAnswerCommandRequest +{ + /// + /// Gets or sets the CRM activity identifier. + /// + public string ActivityId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the assigned agent profile identifier. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the Orchard user identifier represented by the agent profile. + /// + public string AgentUserId { get; set; } + + /// + /// Gets or sets the queue identifier that produced the offer. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets whether a definitive connect failure should return the work to inbound routing. + /// + public bool ReofferOnFailure { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCallActionCommandRequest.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCallActionCommandRequest.cs new file mode 100644 index 000000000..0466341cd --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCallActionCommandRequest.cs @@ -0,0 +1,53 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes a durable provider action for an existing call. +/// +public sealed class ProviderCallActionCommandRequest +{ + /// + /// Gets or sets who issued the action. Defaults to so a payload + /// that never declares an initiator is authorized as an agent request and fails closed without an owner. + /// + public CallControlInitiator Initiator { get; set; } + + /// + /// Gets or sets the CRM activity identifier. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the Contact Center interaction identifier. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the queue identifier that owned the unanswered offer. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the assigned agent profile identifier. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the Orchard user identifier represented by the assigned agent profile. + /// + public string AgentUserId { get; set; } + + /// + /// Gets or sets the provider call identifier. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets whether a definitive provider failure should return the live call to routing. + /// + public bool ReofferOnFailure { get; set; } + + /// + /// Gets or sets provider request metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommand.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommand.cs new file mode 100644 index 000000000..c617d1570 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommand.cs @@ -0,0 +1,138 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a durable, idempotent record of a single provider command. The command intent is persisted +/// before the provider is contacted so a lost response, restart, or retry can be reconciled instead of +/// blindly re-issued, protecting the customer from a duplicate action. +/// +public sealed class ProviderCommand : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the stable idempotency key that uniquely identifies this command. Exactly one command + /// exists per key within a tenant. + /// + public string CommandId { get; set; } + + /// + /// Gets or sets the canonical provider technical name the command targets. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the kind of provider operation the command represents. + /// + public ProviderCommandType CommandType { get; set; } + + /// + /// Gets or sets the current lifecycle status of the command. + /// + public ProviderCommandStatus Status { get; set; } + + /// + /// Gets or sets the monotonically increasing fence token. It increases on every claim so a stale worker + /// or delayed provider response carrying an older fence can be rejected safely. + /// + public long FenceToken { get; set; } + + /// + /// Gets or sets the opaque token identifying the worker that currently owns the command lease. + /// + public string OwnerToken { get; set; } + + /// + /// Gets or sets the UTC time the current claim lease expires. After this time another worker may reclaim + /// the command. + /// + public DateTime LeaseExpiresUtc { get; set; } + + /// + /// Gets or sets the CRM activity identifier this command relates to, when applicable. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the interaction identifier this command relates to, when applicable. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the reservation identifier this command was issued under, when applicable. It is retained + /// so a definitive failure or reconciliation can compensate the exact reservation rather than guessing. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets a value indicating whether the reservation associated with this command should be removed + /// from the queue when the command reaches a failure outcome. Defaults to for + /// backward-compatible Dial commands. Set to for commands such as Answer where the + /// reservation must remain in the queue so another agent can attempt the call. + /// + public bool RemoveReservationFromQueueOnFailure { get; set; } = true; + + /// + /// Gets or sets the dialer profile whose current compliance policy must be revalidated before recovering a + /// pending outbound command. + /// + public string DialerProfileId { get; set; } + + /// + /// Gets or sets the provider-assigned reference (for example a provider call identifier) captured once + /// the outcome is confirmed. + /// + public string ProviderReference { get; set; } + + /// + /// Gets or sets the serialized request payload retained so the command can be reconciled or replayed. + /// + public string RequestPayload { get; set; } + + /// + /// Gets or sets the number of dispatch attempts made so far. + /// + public int AttemptCount { get; set; } + + /// + /// Gets or sets the number of reconciliation attempts made so far. + /// + public int ReconcileCount { get; set; } + + /// + /// Gets or sets the UTC time the next dispatch or reconciliation attempt is due. + /// + public DateTime NextAttemptUtc { get; set; } + + /// + /// Gets or sets the reason or error captured from the most recent transition. + /// + public string LastError { get; set; } + + /// + /// Gets or sets the UTC time the command was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the command was dispatched to the provider, when applicable. + /// + public DateTime? SentUtc { get; set; } + + /// + /// Gets or sets the UTC time the command reached a terminal state, when applicable. + /// + public DateTime? CompletedUtc { get; set; } + + /// + /// Gets or sets the UTC time the command was last modified. + /// + public DateTime? ModifiedUtc { get; set; } + + /// + /// Gets a value indicating whether the command is in a terminal state and can no longer transition. + /// + public bool IsTerminal => Status is ProviderCommandStatus.Confirmed + or ProviderCommandStatus.Compensated + or ProviderCommandStatus.Failed; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandClaim.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandClaim.cs new file mode 100644 index 000000000..4787cfc5e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandClaim.cs @@ -0,0 +1,29 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the fenced, exclusive lease a worker acquires over a . The claim +/// must be presented to perform ownership-guarded transitions such as sending the command or reporting its +/// outcome. +/// +public sealed class ProviderCommandClaim +{ + /// + /// Gets or sets the stable idempotency key of the claimed command. + /// + public string CommandId { get; set; } + + /// + /// Gets or sets the fence token granted for this claim. It is rejected once a newer claim supersedes it. + /// + public long FenceToken { get; set; } + + /// + /// Gets or sets the opaque owner token identifying the worker that holds the lease. + /// + public string OwnerToken { get; set; } + + /// + /// Gets or sets the UTC time the lease expires. + /// + public DateTime LeaseExpiresUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandRegistration.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandRegistration.cs new file mode 100644 index 000000000..15e818ec3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandRegistration.cs @@ -0,0 +1,58 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes the intent required to register a new before the provider is +/// contacted. +/// +public sealed class ProviderCommandRegistration +{ + /// + /// Gets or sets the stable idempotency key that uniquely identifies the command. + /// + public string CommandId { get; set; } + + /// + /// Gets or sets the canonical provider technical name the command targets. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the kind of provider operation the command represents. + /// + public ProviderCommandType CommandType { get; set; } + + /// + /// Gets or sets the CRM activity identifier the command relates to, when applicable. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the interaction identifier the command relates to, when applicable. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the reservation identifier the command is issued under, when applicable. It is retained + /// so a definitive failure or reconciliation can compensate the exact reservation. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets a value indicating whether the reservation associated with this command should be removed + /// from the queue when the command reaches a failure outcome. Defaults to for + /// backward-compatible Dial commands. Set to for commands such as Answer where the + /// reservation must remain in the queue so another agent can attempt the call. + /// + public bool RemoveReservationFromQueueOnFailure { get; set; } = true; + + /// + /// Gets or sets the dialer profile whose current compliance policy must be revalidated before recovery + /// dispatches a pending command. + /// + public string DialerProfileId { get; set; } + + /// + /// Gets or sets the serialized request payload retained so the command can be reconciled or replayed. + /// + public string RequestPayload { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandStatus.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandStatus.cs new file mode 100644 index 000000000..e62f3a59d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandStatus.cs @@ -0,0 +1,55 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the durable lifecycle state of a provider command as it moves from creation through provider +/// execution, outcome confirmation, and terminal resolution. +/// +public enum ProviderCommandStatus +{ + /// + /// The command intent is persisted but has not yet been claimed by a worker for dispatch. + /// + Pending, + + /// + /// A worker holds an exclusive, fenced lease on the command and may send it to the provider. + /// + Claimed, + + /// + /// The command was dispatched to the provider and the outcome has not yet been confirmed. + /// + Sent, + + /// + /// The provider response was lost or ambiguous, so the outcome must be reconciled before any retry. + /// The command is never re-sent directly from this state. + /// + OutcomeUnknown, + + /// + /// The provider confirmed the command executed. This is a terminal success state. + /// + Confirmed, + + /// + /// Reconciliation proved the command did not execute (or must be undone) and compensation is in progress. + /// + Compensating, + + /// + /// Compensation completed. This is a terminal state. + /// + Compensated, + + /// + /// The command failed definitively. This is a terminal state. + /// + Failed, + + /// + /// The outcome could not be proven and automatic retry is suspended to avoid a duplicate customer action. + /// The command waits for reconciliation or an operator decision. + /// + Paused, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandType.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandType.cs new file mode 100644 index 000000000..abac26142 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderCommandType.cs @@ -0,0 +1,47 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the kind of provider operation a represents. +/// +public enum ProviderCommandType +{ + /// + /// An outbound dial request. This is the customer-action-risk command the state machine protects first. + /// + Dial, + + /// + /// A request to answer or connect an existing inbound call. + /// + Answer, + + /// + /// A request to end an active call. + /// + Hangup, + + /// + /// A request to transfer an active call. + /// + Transfer, + + /// + /// A request to place an active call on hold. + /// + Hold, + + /// + /// A request to resume a held call. + /// + Resume, + + /// + /// A request to reject an unanswered inbound call. + /// + Reject, + + /// + /// A request to send an unanswered inbound call to voicemail. + /// + SendToVoicemail, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookInboxMessage.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookInboxMessage.cs new file mode 100644 index 000000000..397c9b01a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookInboxMessage.cs @@ -0,0 +1,76 @@ +using CrestApps.Core; +using CrestApps.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents an authenticated provider webhook delivery retained until processing succeeds or is dead-lettered. +/// +public sealed class ProviderWebhookInboxMessage : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the canonical provider technical name. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the provider-scoped idempotency key. + /// + public string DeliveryId { get; set; } + + /// + /// Gets or sets the stable technical name of the payload handler. + /// + public string HandlerName { get; set; } + + /// + /// Gets or sets the normalized serialized payload. + /// + public string Payload { get; set; } + + /// + /// Gets or sets the durable processing status. + /// + public ProviderWebhookInboxStatus Status { get; set; } + + /// + /// Gets or sets the opaque token identifying the worker that owns the current claim. + /// + public string OwnerToken { get; set; } + + /// + /// Gets or sets the monotonically increasing fence token assigned to the current claim. + /// + public long FenceToken { get; set; } + + /// + /// Gets or sets the number of failed processing attempts. + /// + public int AttemptCount { get; set; } + + /// + /// Gets or sets the UTC time the next processing attempt is due. + /// + public DateTime NextAttemptUtc { get; set; } + + /// + /// Gets or sets the exception type from the last failed processing attempt. + /// + public string LastError { get; set; } + + /// + /// Gets or sets the UTC time the message was created. + /// + public DateTime CreatedUtc { get; set; } + + /// + /// Gets or sets the UTC time the message reached a terminal outcome: completed, becoming an idempotency + /// tombstone, or dead-lettered after exhausting its attempts. + /// + public DateTime? ProcessedUtc { get; set; } + + /// + /// Gets or sets the UTC time the message was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookInboxStatus.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookInboxStatus.cs new file mode 100644 index 000000000..f7fa5f78c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookInboxStatus.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the durable processing state of a provider webhook inbox message. +/// +public enum ProviderWebhookInboxStatus +{ + /// + /// The message is pending processing or retry. + /// + Pending, + + /// + /// The message is owned by a worker under a durable, expiring claim. + /// + Claimed, + + /// + /// The normalized payload completed and the message is retained as an idempotency tombstone. + /// + Completed, + + /// + /// The message exhausted its retry budget and requires operator intervention. + /// + DeadLettered, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookIngressOptions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookIngressOptions.cs new file mode 100644 index 000000000..bcb70b319 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderWebhookIngressOptions.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Configures tenant-local provider webhook rate and concurrency limits. +/// +public sealed class ProviderWebhookIngressOptions +{ + /// + /// Gets or sets the maximum number of provider webhook requests that may concurrently buffer or process. + /// + public int ConcurrencyPermitLimit { get; set; } = 8; + + /// + /// Gets or sets the number of authenticated webhook deliveries permitted per provider during each rate period. + /// + public int RatePermitLimit { get; set; } = 120; + + /// + /// Gets or sets the authenticated provider rate-limit replenishment period in seconds. + /// + public int RatePeriodSeconds { get; set; } = 60; + + /// + /// Gets or sets the maximum accepted age of a provider-signed event timestamp in seconds. + /// + public int MaximumDeliveryAgeSeconds { get; set; } = 900; + + /// + /// Gets or sets the maximum accepted future clock skew of a provider-signed event timestamp in seconds. + /// + public int MaximumFutureSkewSeconds { get; set; } = 120; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs new file mode 100644 index 000000000..9b0af8c00 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs @@ -0,0 +1,127 @@ +using System.ComponentModel; +using System.Text.Json.Serialization; +using CrestApps.Core; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a CRM activity enqueued and waiting for assignment to an agent. +/// +public sealed class QueueItem : CatalogItem, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the identifier of the queue that owns the item. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the identifier of the CRM activity the item represents. + /// + public string ActivityItemId { get; set; } + + /// + /// Gets or sets the routing priority of the item. Higher values are handled first. + /// + public InteractionPriority Priority { get; set; } = InteractionPriority.Normal; + + /// + /// Gets or sets the lifecycle status of the item. + /// + [JsonInclude] + public QueueItemStatus Status { get; private set; } + + /// + /// Moves the QueueItem to the specified lifecycle status. + /// + /// The status to move to. + /// The QueueItem cannot reach the status from the one it is in. + public void TransitionTo(QueueItemStatus status) + { + if (!QueueItemLifecycle.CanTransition(Status, status)) + { + throw new InvalidStateTransitionException(nameof(QueueItem), Status, status); + } + + Status = status; + } + + /// + /// Determines whether the QueueItem can move to the specified status. + /// + /// The status to test. + /// when the transition is admitted; otherwise . + public bool CanTransitionTo(QueueItemStatus status) + => QueueItemLifecycle.CanTransition(Status, status); + + /// + /// Gets a value indicating whether the item has left the queue for good. + /// + [JsonIgnore] + public bool IsSettled => QueueItemLifecycle.IsSettled(Status); + + /// + /// Restores a status that was decided elsewhere, without consulting the lifecycle. + /// + /// The status to restore. + /// The same QueueItem, so it can be used at the end of an object initializer. + /// + /// This bypasses every transition rule and exists only so a test can arrange a state directly. Production code + /// must never call it: AggregateLifecycleArchitectureTests fails the build if any file under src/ + /// does, so the bypass cannot quietly become a shortcut. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public QueueItem RestorePersistedStatus(QueueItemStatus status) + { + Status = status; + + return this; + } + + /// + /// Gets or sets the active reservation identifier when the item is reserved. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the user identifier of the agent who most recently owned the underlying activity, used as the + /// sticky-agent preference when the queue enables sticky routing. + /// + public string StickyAgentUserId { get; set; } + + /// + /// Gets or sets the identifier of the queue this item overflowed from, when it was moved by overflow handling. + /// + public string OverflowedFromQueueId { get; set; } + + /// + /// Gets or sets the queue identifiers this item has already visited through overflow routing. + /// + public IList OverflowHistory { get; set; } = []; + + /// + /// Gets or sets the agent assigned to the item. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the UTC time the item entered the queue. + /// + public DateTime EnqueuedUtc { get; set; } + + /// + /// Gets or sets the UTC time the item entered its current queue. + /// + public DateTime QueueEnteredUtc { get; set; } + + /// + /// Gets or sets the UTC time the item left the queue. + /// + public DateTime? DequeuedUtc { get; set; } + + /// + /// Gets or sets the UTC time the item was last modified. + /// + public DateTime? ModifiedUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItemLifecycle.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItemLifecycle.cs new file mode 100644 index 000000000..d3a3367f4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItemLifecycle.cs @@ -0,0 +1,73 @@ +using System.Collections.Frozen; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Declares which queue-item status changes the domain admits. +/// +/// A queue item can legitimately go backwards — a reservation that expires returns the item to the queue, and an +/// assignment that fails re-queues it — so this lifecycle cannot be expressed as a rank at all. What it must +/// refuse is an item leaving a settled status: an item that was completed and then re-enters the queue is handed +/// to a second agent for work that is already done. +/// +/// +public static class QueueItemLifecycle +{ + private static readonly FrozenDictionary> _transitions = + new Dictionary> + { + [QueueItemStatus.Waiting] = FrozenSet.ToFrozenSet( + [ + QueueItemStatus.Reserved, + QueueItemStatus.Assigned, + QueueItemStatus.Completed, + QueueItemStatus.Removed, + ]), + + // A reservation that is rejected, expires, or is cancelled returns the item to the queue for the + // next agent, which is why Reserved reaches Waiting. + [QueueItemStatus.Reserved] = FrozenSet.ToFrozenSet( + [ + QueueItemStatus.Waiting, + QueueItemStatus.Assigned, + QueueItemStatus.Completed, + QueueItemStatus.Removed, + ]), + + // An agent whose client disappears mid-assignment releases the item back to the queue rather than + // stranding it, so Assigned reaches Waiting too. + [QueueItemStatus.Assigned] = FrozenSet.ToFrozenSet( + [ + QueueItemStatus.Waiting, + QueueItemStatus.Completed, + QueueItemStatus.Removed, + ]), + [QueueItemStatus.Completed] = FrozenSet.Empty, + [QueueItemStatus.Removed] = FrozenSet.Empty, + }.ToFrozenDictionary(); + + /// + /// Determines whether a queue item in one status may move to another. + /// + /// The status the item is in. + /// The status the item would move to. + /// when the transition is admitted; otherwise . + public static bool CanTransition(QueueItemStatus from, QueueItemStatus to) + { + if (from == to) + { + return true; + } + + return _transitions.TryGetValue(from, out var allowed) && allowed.Contains(to); + } + + /// + /// Determines whether a queue-item status is settled, meaning the item has left the queue for good. + /// + /// The status to inspect. + /// when the status is settled; otherwise . + public static bool IsSettled(QueueItemStatus status) + => status == QueueItemStatus.Completed || status == QueueItemStatus.Removed; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingAccessedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingAccessedEventData.cs new file mode 100644 index 000000000..8c05ea1b3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingAccessedEventData.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the audit details recorded when a captured recording is accessed or retrieved. +/// +public sealed class RecordingAccessedEventData +{ + /// + /// Gets or sets the identifier of the actor that accessed the recording. + /// + public string ActorId { get; set; } + + /// + /// Gets or sets the stated purpose for accessing the recording. + /// + public string Purpose { get; set; } + + /// + /// Gets or sets the opaque recording reference that was accessed. + /// + public string RecordingReference { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingCommandResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingCommandResult.cs new file mode 100644 index 000000000..863395f5c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingCommandResult.cs @@ -0,0 +1,60 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of a recording state-change command. Recording is a release-critical mutation, so +/// an interrupted or timed-out command reports an explicit indeterminate outcome rather than silently +/// collapsing to success or failure. +/// +public sealed class RecordingCommandResult +{ + /// + /// Gets or sets a value indicating whether the recording state change was applied and recorded. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets a value indicating whether the provider may have executed the recording state change but + /// its outcome could not be observed. + /// + public bool OutcomeUnknown { get; set; } + + /// + /// Gets or sets an explanation of the outcome. + /// + public string Reason { get; set; } + + /// + /// Creates a successful result. + /// + /// The optional explanation. + /// A successful . + public static RecordingCommandResult Success(string reason = null) + { + return new RecordingCommandResult { Succeeded = true, Reason = reason }; + } + + /// + /// Creates a failed result for a recording state change that was definitely not applied. + /// + /// The failure reason. + /// A failed . + public static RecordingCommandResult Failure(string reason) + { + return new RecordingCommandResult { Succeeded = false, Reason = reason }; + } + + /// + /// Creates a result for a recording state change whose provider outcome could not be determined. + /// + /// The reason the outcome is unknown. + /// An indeterminate . + public static RecordingCommandResult Unknown(string reason) + { + return new RecordingCommandResult + { + Succeeded = false, + OutcomeUnknown = true, + Reason = reason, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingConsentModel.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingConsentModel.cs new file mode 100644 index 000000000..2e607784d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingConsentModel.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the consent model a tenant must satisfy before a voice interaction may be recorded. +/// +public enum RecordingConsentModel +{ + /// + /// Every party on the call must consent before recording is permitted (two-party or all-party jurisdictions). + /// + AllParties, + + /// + /// A single party's consent (the recording organization) is sufficient to permit recording. + /// + SingleParty, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingDeniedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingDeniedEventData.cs new file mode 100644 index 000000000..24f34ad9d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingDeniedEventData.cs @@ -0,0 +1,12 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the machine-readable reason a recording governance policy denied a request to start or resume recording. +/// +public sealed class RecordingDeniedEventData +{ + /// + /// Gets or sets the stable machine-readable reason code describing why recording was denied. + /// + public string DenyReasonCode { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasedEventData.cs new file mode 100644 index 000000000..6259caa5e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasedEventData.cs @@ -0,0 +1,24 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the audit details recorded when a captured recording reference is erased at the orchestration layer in +/// response to a right-to-erasure request. +/// +public sealed class RecordingErasedEventData +{ + /// + /// Gets or sets the identifier of the actor that requested erasure. + /// + public string ActorId { get; set; } + + /// + /// Gets or sets the stated reason for the erasure request. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the opaque recording reference that was erased, allowing the owning media store to delete the + /// underlying media. + /// + public string RecordingReference { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasureDecision.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasureDecision.cs new file mode 100644 index 000000000..5bb5772eb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasureDecision.cs @@ -0,0 +1,41 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of a right-to-erasure request against a captured recording, describing whether the +/// recording reference was erased and, when refused, the machine-readable reason. +/// +public sealed class RecordingErasureDecision +{ + /// + /// Gets a value indicating whether the recording reference was erased. + /// + public bool Erased { get; private init; } + + /// + /// Gets the stable machine-readable reason code describing why erasure was denied, or + /// when the recording was erased. + /// + public string DenyReasonCode { get; private init; } + + /// + /// Creates a decision that indicates the recording reference was erased. + /// + /// A decision that indicates erasure completed. + public static RecordingErasureDecision Erase() + => new() + { + Erased = true, + }; + + /// + /// Creates a decision that denies erasure with the specified reason code. + /// + /// The stable machine-readable reason code describing why erasure was denied. + /// A decision that denies erasure. + public static RecordingErasureDecision Deny(string denyReasonCode) + => new() + { + Erased = false, + DenyReasonCode = denyReasonCode, + }; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasureDeniedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasureDeniedEventData.cs new file mode 100644 index 000000000..894124b36 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingErasureDeniedEventData.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the machine-readable reason a recording governance policy denied a right-to-erasure request. +/// +public sealed class RecordingErasureDeniedEventData +{ + /// + /// Gets or sets the identifier of the actor that requested erasure. + /// + public string ActorId { get; set; } + + /// + /// Gets or sets the stable machine-readable reason code describing why erasure was denied. + /// + public string DenyReasonCode { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingGovernanceDecision.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingGovernanceDecision.cs new file mode 100644 index 000000000..eaad46d39 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingGovernanceDecision.cs @@ -0,0 +1,57 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of a recording governance evaluation, describing whether recording may proceed and, when +/// permitted, the retention and legal-hold metadata to stamp onto the interaction at capture time. +/// +public sealed class RecordingGovernanceDecision +{ + /// + /// Gets a value indicating whether recording is permitted to proceed. + /// + public bool Allowed { get; private init; } + + /// + /// Gets the stable machine-readable reason code describing why recording was denied, or + /// when recording is permitted. + /// + public string DenyReasonCode { get; private init; } + + /// + /// Gets the UTC instant beyond which a captured recording becomes eligible for erasure, or + /// when no retention window applies. Only meaningful when is . + /// + public DateTime? RetainUntilUtc { get; private init; } + + /// + /// Gets a value indicating whether a captured recording should begin under legal hold. Only meaningful when + /// is . + /// + public bool LegalHold { get; private init; } + + /// + /// Creates a decision that permits recording with the resolved retention and legal-hold metadata. + /// + /// The UTC instant beyond which the recording becomes eligible for erasure, or for indefinite retention. + /// Whether the captured recording should begin under legal hold. + /// A decision that permits recording. + public static RecordingGovernanceDecision Allow(DateTime? retainUntilUtc, bool legalHold) + => new() + { + Allowed = true, + RetainUntilUtc = retainUntilUtc, + LegalHold = legalHold, + }; + + /// + /// Creates a decision that denies recording with the specified reason code. + /// + /// The stable machine-readable reason code describing why recording was denied. + /// A decision that denies recording. + public static RecordingGovernanceDecision Deny(string denyReasonCode) + => new() + { + Allowed = false, + DenyReasonCode = denyReasonCode, + }; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingMediaDeletedEventData.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingMediaDeletedEventData.cs new file mode 100644 index 000000000..ecf2d616d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/RecordingMediaDeletedEventData.cs @@ -0,0 +1,24 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Carries the confirmation details recorded when the underlying recording media has actually been deleted from +/// the owning media store, following an erasure or retention request. This is the durable confirmed-deletion +/// receipt, distinct from the acceptance of the erasure request itself. +/// +public sealed class RecordingMediaDeletedEventData +{ + /// + /// Gets or sets the identifier of the actor that requested the erasure that led to this deletion. + /// + public string ActorId { get; set; } + + /// + /// Gets or sets the stated reason for the erasure that led to this deletion. + /// + public string Reason { get; set; } + + /// + /// Gets or sets the opaque recording reference whose media was deleted from the owning media store. + /// + public string RecordingReference { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ActivityProgressCounts.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ActivityProgressCounts.cs new file mode 100644 index 000000000..654ff87cd --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ActivityProgressCounts.cs @@ -0,0 +1,59 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the progress breakdown of a set of CRM activities used by the campaign summary and +/// subject inventory reports to show what is completed versus what is still pending. +/// +public sealed class ActivityProgressCounts +{ + /// + /// Gets or sets the total number of activities in the group. + /// + public long Total { get; set; } + + /// + /// Gets or sets the number of activities that reached a completed disposition. + /// + public long Completed { get; set; } + + /// + /// Gets or sets the number of activities that have not been started yet (pending work inventory). + /// + public long Pending { get; set; } + + /// + /// Gets or sets the number of activities that are actively being worked (reserved, dialing, or in progress). + /// + public long InProgress { get; set; } + + /// + /// Gets or sets the number of activities that failed. + /// + public long Failed { get; set; } + + /// + /// Gets or sets the number of activities that were cancelled or purged. + /// + public long Cancelled { get; set; } + + /// + /// Gets or sets the total number of contact attempts recorded across the group. + /// + public long TotalAttempts { get; set; } + + /// + /// Gets the fraction of activities that are completed, between 0 and 1. + /// + public double CompletionRate + { + get + { + if (Total <= 0) + { + return 0d; + } + + return (double)Completed / Total; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityReport.cs new file mode 100644 index 000000000..53525cbca --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityReport.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the agent productivity report: per-agent handled volume, talk time, and completed work +/// over a reporting period. +/// +public sealed class AgentProductivityReport +{ + /// + /// Gets or sets the inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc { get; set; } + + /// + /// Gets or sets the inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc { get; set; } + + /// + /// Gets or sets the per-agent productivity rows, ordered by handled volume. + /// + public IList Rows { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityRow.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityRow.cs new file mode 100644 index 000000000..c90190dba --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityRow.cs @@ -0,0 +1,62 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the productivity of a single agent over a reporting period. +/// +public sealed class AgentProductivityRow +{ + /// + /// Gets or sets the agent profile identifier. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the username used by the cached report display-name shape. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the resolved display name of the agent. + /// + public string DisplayName { get; set; } + + /// + /// Gets or sets the number of interactions the agent handled (answered). + /// + public long InteractionsHandled { get; set; } + + /// + /// Gets or sets the number of inbound interactions the agent handled. + /// + public long InboundHandled { get; set; } + + /// + /// Gets or sets the number of outbound interactions the agent handled. + /// + public long OutboundHandled { get; set; } + + /// + /// Gets or sets the total talk time, in seconds, across the agent's handled interactions. + /// + public double TotalTalkTimeSeconds { get; set; } + + /// + /// Gets or sets the total after-call wrap-up time, in seconds. + /// + public double TotalWrapUpTimeSeconds { get; set; } + + /// + /// Gets or sets the average after-call wrap-up time, in seconds. + /// + public double AverageWrapUpTimeSeconds { get; set; } + + /// + /// Gets or sets the average handle time, in seconds, across the agent's handled interactions. + /// + public double AverageHandleTimeSeconds { get; set; } + + /// + /// Gets or sets the number of CRM activities the agent completed. + /// + public long ActivitiesCompleted { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsDailyPoint.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsDailyPoint.cs new file mode 100644 index 000000000..ad6e36cf2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsDailyPoint.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the interaction volume for a single day in the call insights report. +/// +public sealed class CallInsightsDailyPoint +{ + /// + /// Gets or sets the UTC date the volumes are counted for. + /// + public DateOnly Date { get; set; } + + /// + /// Gets or sets the total number of interactions created on the day. + /// + public long Total { get; set; } + + /// + /// Gets or sets the number of interactions that were answered (connected to an agent) on the day. + /// + public long Answered { get; set; } + + /// + /// Gets or sets the number of inbound interactions that were abandoned before an agent answered on the day. + /// + public long Abandoned { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsReport.cs new file mode 100644 index 000000000..32dbce761 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsReport.cs @@ -0,0 +1,115 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the call insights report: interaction volume, outcomes, handle time, and daily trend +/// over a reporting period. +/// +public sealed class CallInsightsReport +{ + /// + /// Gets or sets the inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc { get; set; } + + /// + /// Gets or sets the inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc { get; set; } + + /// + /// Gets or sets the total number of interactions in the period. + /// + public long Total { get; set; } + + /// + /// Gets or sets the number of inbound interactions. + /// + public long Inbound { get; set; } + + /// + /// Gets or sets the number of outbound interactions. + /// + public long Outbound { get; set; } + + /// + /// Gets or sets the number of interactions that were answered (connected to an agent). + /// + public long Answered { get; set; } + + /// + /// Gets or sets the number of inbound interactions abandoned before an agent answered. + /// + public long Abandoned { get; set; } + + /// + /// Gets or sets the number of interactions that failed. + /// + public long Failed { get; set; } + + /// + /// Gets or sets the total talk time, in seconds, across all answered interactions. + /// + public double TotalTalkTimeSeconds { get; set; } + + /// + /// Gets or sets the total after-call wrap-up time, in seconds. + /// + public double TotalWrapUpTimeSeconds { get; set; } + + /// + /// Gets or sets the average handle time, in seconds, across all answered interactions. + /// + public double AverageHandleTimeSeconds { get; set; } + + /// + /// Gets or sets the average speed of answer, in seconds, across all answered interactions. + /// + public double AverageSpeedOfAnswerSeconds { get; set; } + + /// + /// Gets the answer rate (answered divided by total), between 0 and 1. + /// + public double AnswerRate + { + get + { + if (Total <= 0) + { + return 0d; + } + + return (double)Answered / Total; + } + } + + /// + /// Gets the abandonment rate (abandoned divided by inbound), between 0 and 1. + /// + public double AbandonmentRate + { + get + { + if (Inbound <= 0) + { + return 0d; + } + + return (double)Abandoned / Inbound; + } + } + + /// + /// Gets or sets the interaction volume grouped by channel. + /// + public IList ByChannel { get; set; } = []; + + /// + /// Gets or sets the interaction volume grouped by communication-session status. + /// + public IList ByStatus { get; set; } = []; + + /// + /// Gets or sets the daily interaction trend for the period. + /// + public IList Daily { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignGroupSummaryRow.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignGroupSummaryRow.cs new file mode 100644 index 000000000..c4b7ca5ef --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignGroupSummaryRow.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents activity progress aggregated across a campaign group. +/// +public sealed class CampaignGroupSummaryRow +{ + /// + /// Gets or sets the campaign group identifier. + /// + public string CampaignGroupId { get; set; } + + /// + /// Gets or sets the resolved campaign group name. + /// + public string CampaignGroupName { get; set; } + + /// + /// Gets or sets the activity progress counts. + /// + public ActivityProgressCounts Counts { get; set; } = new(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryReport.cs new file mode 100644 index 000000000..85c66b877 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryReport.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the campaign summary report: per-campaign completed-versus-pending progress across the +/// activity inventory in a reporting period. +/// +public sealed class CampaignSummaryReport +{ + /// + /// Gets or sets the inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc { get; set; } + + /// + /// Gets or sets the inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc { get; set; } + + /// + /// Gets or sets the per-campaign summary rows, ordered by total activities. + /// + public IList Rows { get; set; } = []; + + /// + /// Gets or sets the campaign-group summary rows. + /// + public IList GroupRows { get; set; } = []; + + /// + /// Gets or sets the combined progress counts across every campaign in the report. + /// + public ActivityProgressCounts Totals { get; set; } = new(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryRow.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryRow.cs new file mode 100644 index 000000000..93d5484c9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryRow.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the progress of a single campaign's activities: how many are completed versus how many +/// remain pending or in progress. +/// +public sealed class CampaignSummaryRow +{ + /// + /// Gets or sets the campaign identifier. An empty value represents activities with no campaign. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the resolved campaign name. + /// + public string CampaignName { get; set; } + + /// + /// Gets or sets the activity progress counts for the campaign. + /// + public ActivityProgressCounts Counts { get; set; } = new(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCount.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCount.cs new file mode 100644 index 000000000..bfd74e1db --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCount.cs @@ -0,0 +1,18 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents a single labeled count used by Contact Center report breakdowns such as volume by +/// channel, direction, status, or source. +/// +public sealed class ContactCenterReportCount +{ + /// + /// Gets or sets the human-readable label the count is grouped under. + /// + public string Label { get; set; } + + /// + /// Gets or sets the number of items in the group. + /// + public long Count { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCriteria.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCriteria.cs new file mode 100644 index 000000000..ba6af3de3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCriteria.cs @@ -0,0 +1,65 @@ +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Defines optional dimensions used to filter Contact Center and CRM report populations. +/// +public sealed class ContactCenterReportCriteria +{ + /// + /// Gets or sets the queue identifier used to filter interactions. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the queue-group identifier used to filter interactions by current queue membership. + /// + public string QueueGroupId { get; set; } + + /// + /// Gets or sets the queue identifiers resolved from the selected queue group's current membership. + /// + public IReadOnlySet QueueIds { get; set; } + + /// + /// Gets or sets the agent profile identifier used to filter interactions. + /// + public string AgentId { get; set; } + + /// + /// Gets or sets the campaign identifier used to filter CRM activities. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the campaign group identifier used to filter campaign reports. + /// + public string CampaignGroupId { get; set; } + + /// + /// Gets or sets the campaign identifiers resolved from the selected campaign group. + /// + public IReadOnlySet CampaignIds { get; set; } + + /// + /// Gets or sets the activity source used to filter CRM activities. + /// + public string ActivitySource { get; set; } + + /// + /// Gets or sets the channel used to filter interactions and CRM activities. + /// + public InteractionChannel? Channel { get; set; } + + /// + /// Gets or sets the interaction direction used to filter interactions. + /// + public InteractionDirection? Direction { get; set; } + + /// + /// Gets or sets the activity status used to filter CRM activities. + /// + public ActivityStatus? ActivityStatus { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueGroupUsageRow.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueGroupUsageRow.cs new file mode 100644 index 000000000..4a9355a30 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueGroupUsageRow.cs @@ -0,0 +1,47 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents queue usage aggregated across the queues that currently belong to one queue group. +/// +public sealed class QueueGroupUsageRow +{ + /// + /// Gets or sets the queue-group identifier. An empty value represents queues with no current group. + /// + public string QueueGroupId { get; set; } + + /// + /// Gets or sets the resolved queue-group name. + /// + public string QueueGroupName { get; set; } + + /// + /// Gets or sets the number of interactions routed through queues in the group. + /// + public long InteractionsHandled { get; set; } + + /// + /// Gets or sets the number of answered interactions. + /// + public long Answered { get; set; } + + /// + /// Gets or sets the number of abandoned inbound interactions. + /// + public long Abandoned { get; set; } + + /// + /// Gets or sets the average handle time, in seconds. + /// + public double AverageHandleTimeSeconds { get; set; } + + /// + /// Gets or sets the average speed of answer, in seconds. + /// + public double AverageSpeedOfAnswerSeconds { get; set; } + + /// + /// Gets or sets the current waiting depth across queues in the group. + /// + public int CurrentWaiting { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageReport.cs new file mode 100644 index 000000000..a7502de36 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageReport.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the queue usage report: per-queue handled volume and current waiting depth over a +/// reporting period. +/// +public sealed class QueueUsageReport +{ + /// + /// Gets or sets the inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc { get; set; } + + /// + /// Gets or sets the inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc { get; set; } + + /// + /// Gets or sets the per-queue usage rows, ordered by handled volume. + /// + public IList Rows { get; set; } = []; + + /// + /// Gets or sets usage aggregated by each queue's current queue-group membership. + /// + public IList GroupRows { get; set; } = []; + + /// + /// Gets or sets the combined usage across every queue in the filtered report. + /// + public QueueUsageTotals Totals { get; set; } = new(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageRow.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageRow.cs new file mode 100644 index 000000000..749a1b7a7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageRow.cs @@ -0,0 +1,63 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the usage of a single queue over a reporting period, combining historical handled volume +/// with the current live waiting depth. +/// +public sealed class QueueUsageRow +{ + /// + /// Gets or sets the queue identifier. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the resolved queue name. + /// + public string QueueName { get; set; } + + /// + /// Gets or sets the queue's current queue-group identifier. + /// + public string QueueGroupId { get; set; } + + /// + /// Gets or sets the resolved current queue-group name. + /// + public string QueueGroupName { get; set; } + + /// + /// Gets or sets the number of interactions that were routed through the queue in the period. + /// + public long InteractionsHandled { get; set; } + + /// + /// Gets or sets the number of the queue's interactions that were answered. + /// + public long Answered { get; set; } + + /// + /// Gets or sets the number of the queue's inbound interactions abandoned before an agent answered. + /// + public long Abandoned { get; set; } + + /// + /// Gets or sets the average handle time, in seconds, for the queue's answered interactions. + /// + public double AverageHandleTimeSeconds { get; set; } + + /// + /// Gets or sets the average speed of answer, in seconds, for the queue's answered interactions. + /// + public double AverageSpeedOfAnswerSeconds { get; set; } + + /// + /// Gets or sets the number of items currently waiting in the queue. + /// + public int CurrentWaiting { get; set; } + + /// + /// Gets or sets the service-level threshold, in seconds, configured for the queue. + /// + public int SlaThresholdSeconds { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageTotals.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageTotals.cs new file mode 100644 index 000000000..09074f47a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageTotals.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the combined queue usage across the complete filtered report population. +/// +public sealed class QueueUsageTotals +{ + /// + /// Gets or sets the number of interactions routed through queues. + /// + public long InteractionsHandled { get; set; } + + /// + /// Gets or sets the number of answered interactions. + /// + public long Answered { get; set; } + + /// + /// Gets or sets the number of abandoned inbound interactions. + /// + public long Abandoned { get; set; } + + /// + /// Gets or sets the average handle time, in seconds. + /// + public double AverageHandleTimeSeconds { get; set; } + + /// + /// Gets or sets the average speed of answer, in seconds. + /// + public double AverageSpeedOfAnswerSeconds { get; set; } + + /// + /// Gets or sets the current waiting depth across queues. + /// + public int CurrentWaiting { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryReport.cs new file mode 100644 index 000000000..fe96b8196 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryReport.cs @@ -0,0 +1,28 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the subject inventory report: per-subject completed-versus-pending progress across the +/// activity inventory in a reporting period. +/// +public sealed class SubjectInventoryReport +{ + /// + /// Gets or sets the inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc { get; set; } + + /// + /// Gets or sets the inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc { get; set; } + + /// + /// Gets or sets the per-subject inventory rows, ordered by total activities. + /// + public IList Rows { get; set; } = []; + + /// + /// Gets or sets the combined progress counts across every subject in the report. + /// + public ActivityProgressCounts Totals { get; set; } = new(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryRow.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryRow.cs new file mode 100644 index 000000000..951d80b0b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryRow.cs @@ -0,0 +1,18 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +/// +/// Represents the progress of a single subject type's activities: how many are completed versus how +/// many remain pending or in progress. +/// +public sealed class SubjectInventoryRow +{ + /// + /// Gets or sets the subject content type. An empty value represents activities with no subject. + /// + public string SubjectContentType { get; set; } + + /// + /// Gets or sets the activity progress counts for the subject. + /// + public ActivityProgressCounts Counts { get; set; } = new(); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ReservationLifecycle.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ReservationLifecycle.cs new file mode 100644 index 000000000..7cf06f05f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ReservationLifecycle.cs @@ -0,0 +1,62 @@ +using System.Collections.Frozen; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Declares which reservation status changes the domain admits. +/// +/// A reservation is a short-lived lock, and the whole value of the lock is that it resolves exactly once. The +/// races it exists to settle are real: an agent accepts at the same moment the timer expires, or a supervisor +/// cancels while the agent is rejecting. Letting an already-resolved reservation resolve again is what allows +/// the same activity to be handed to two agents. +/// +/// +public static class ReservationLifecycle +{ + private static readonly FrozenDictionary> _transitions = + new Dictionary> + { + [ReservationStatus.Pending] = FrozenSet.ToFrozenSet( + [ + ReservationStatus.Accepted, + ReservationStatus.Rejected, + ReservationStatus.Expired, + ReservationStatus.Canceled, + ]), + + // An accepted reservation can still be cancelled: the assignment it produced may be released before + // the agent starts work. Every other outcome is final. + [ReservationStatus.Accepted] = FrozenSet.ToFrozenSet( + [ + ReservationStatus.Canceled, + ]), + [ReservationStatus.Rejected] = FrozenSet.Empty, + [ReservationStatus.Expired] = FrozenSet.Empty, + [ReservationStatus.Canceled] = FrozenSet.Empty, + }.ToFrozenDictionary(); + + /// + /// Determines whether a reservation in one status may move to another. + /// + /// The status the reservation is in. + /// The status the reservation would move to. + /// when the transition is admitted; otherwise . + public static bool CanTransition(ReservationStatus from, ReservationStatus to) + { + if (from == to) + { + return true; + } + + return _transitions.TryGetValue(from, out var allowed) && allowed.Contains(to); + } + + /// + /// Determines whether a reservation status is resolved, meaning the lock is no longer held. + /// + /// The status to inspect. + /// when the reservation is resolved; otherwise . + public static bool IsResolved(ReservationStatus status) + => status != ReservationStatus.Pending; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SkillTag.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SkillTag.cs new file mode 100644 index 000000000..c24635b46 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SkillTag.cs @@ -0,0 +1,142 @@ +using System.Diagnostics.CodeAnalysis; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// The name of a routeable capability, in the one form the platform compares skills by. +/// +/// A skill only means anything when a queue that requires it and an agent that has it agree that they are +/// talking about the same skill. That agreement used to be an accident: the administration form trimmed the +/// names a queue asked for, nothing trimmed the names an agent was given through a recipe or an import, and +/// the routing strategy compared them case-insensitively but not otherwise. An agent whose skill was stored +/// as " Spanish" was quietly unroutable to every queue that required "Spanish", and nothing +/// anywhere reported a problem — the agent was simply never chosen. +/// +/// +public readonly record struct SkillTag +{ + private readonly string _value; + + private SkillTag(string value) + { + _value = value; + } + + /// + /// Gets the skill name as it should be displayed, or when this is the default value. + /// + public string Value => _value; + + /// + /// Gets a value indicating whether this instance names a skill. A default names none. + /// + public bool HasValue => _value is not null; + + /// + /// Creates a skill tag from a name. + /// + /// The skill name. Surrounding whitespace is not part of the name. + /// The skill tag. + /// The name is empty or contains only whitespace. + public static SkillTag Create(string name) + { + if (!TryCreate(name, out var skillTag)) + { + throw new ArgumentException("A skill name cannot be empty.", nameof(name)); + } + + return skillTag; + } + + /// + /// Attempts to create a skill tag from a name. + /// + /// The skill name. Surrounding whitespace is not part of the name. + /// The skill tag when the name is usable; otherwise the default value. + /// when the name is usable; otherwise, . + public static bool TryCreate(string name, out SkillTag skillTag) + { + skillTag = default; + + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + skillTag = new SkillTag(name.Trim()); + + return true; + } + + /// + /// Creates the distinct skill tags named by a sequence, discarding entries that name no skill. + /// + /// The skill names to read. A sequence names no skills. + /// The distinct skill tags, in the order they were first named. + public static IReadOnlyList CreateAll(IEnumerable names) + { + if (names is null) + { + return []; + } + + var skillTags = new List(); + var seen = new HashSet(); + + foreach (var name in names) + { + if (TryCreate(name, out var skillTag) && seen.Add(skillTag)) + { + skillTags.Add(skillTag); + } + } + + return skillTags; + } + + /// + /// Creates the distinct skill names of a sequence, in the form the platform stores them. + /// + /// The skill names to read. A sequence names no skills. + /// The distinct skill names. + public static IList NormalizeAll(IEnumerable names) + { + return CreateAll(names) + .Select(skillTag => skillTag.Value) + .ToArray(); + } + + /// + /// Determines whether this instance names the same skill as another. + /// + /// The skill tag to compare against. + /// when both name the same skill; otherwise, . + public bool Equals(SkillTag other) + { + return string.Equals(_value, other._value, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Returns a hash code that is equal for every spelling of the same skill name. + /// + /// The hash code. + public override int GetHashCode() + { + return _value is null + ? 0 + : StringComparer.OrdinalIgnoreCase.GetHashCode(_value); + } + + /// + /// Returns the skill name, or an empty string when this is the default value. + /// + /// The skill name. + public override string ToString() => _value ?? string.Empty; + + /// + /// Determines whether a value names a skill. + /// + /// The value to inspect. + /// when the value names a skill; otherwise, . + public static bool IsSkillName([NotNullWhen(true)] string name) => !string.IsNullOrWhiteSpace(name); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SupervisorEngagementResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SupervisorEngagementResult.cs new file mode 100644 index 000000000..c24fd488a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SupervisorEngagementResult.cs @@ -0,0 +1,57 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of a supervisor live-monitoring engagement. +/// +public sealed class SupervisorEngagementResult +{ + /// + /// Gets or sets a value indicating whether the engagement was accepted. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets a value indicating whether the provider may have executed the engagement but + /// its outcome could not be observed. + /// + public bool OutcomeUnknown { get; set; } + + /// + /// Gets or sets an explanation of the outcome. + /// + public string Reason { get; set; } + + /// + /// Creates a successful result. + /// + /// A successful . + public static SupervisorEngagementResult Success() + { + return new SupervisorEngagementResult { Succeeded = true }; + } + + /// + /// Creates a failed result. + /// + /// The failure reason. + /// A failed . + public static SupervisorEngagementResult Failure(string reason) + { + return new SupervisorEngagementResult { Succeeded = false, Reason = reason }; + } + + /// + /// Creates a result for an engagement whose provider outcome could not be determined. + /// + /// The reason the outcome is unknown. + /// An indeterminate . + public static SupervisorEngagementResult Unknown(string reason) + { + return new SupervisorEngagementResult + { + Succeeded = false, + OutcomeUnknown = true, + Reason = reason, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferDestinationResolutionResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferDestinationResolutionResult.cs new file mode 100644 index 000000000..5561175f5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferDestinationResolutionResult.cs @@ -0,0 +1,69 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes a resolved, provider-safe transfer destination. +/// +public sealed class TransferDestinationResolutionResult +{ + /// + /// Gets or sets a value indicating whether the destination is allowed. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the redacted failure reason. + /// + public string FailureReason { get; set; } + + /// + /// Gets or sets the destination type. + /// + public InteractionTransferTargetType TargetType { get; set; } + + /// + /// Gets or sets the provider-safe resolved destination. + /// + public string ResolvedTarget { get; set; } + + /// + /// Gets or sets the Orchard user id of the destination agent, when the destination is an agent, so a voice + /// provider can resolve that agent's live endpoint. It is for non-agent destinations. + /// + public string ProviderEndpointUserId { get; set; } + + /// + /// Creates a successful destination result. + /// + /// The destination type. + /// The provider-safe destination. + /// The destination agent's Orchard user id, for an agent destination. + /// The destination result. + public static TransferDestinationResolutionResult Success( + InteractionTransferTargetType targetType, + string resolvedTarget, + string providerEndpointUserId = null) + { + return new TransferDestinationResolutionResult + { + Succeeded = true, + TargetType = targetType, + ResolvedTarget = resolvedTarget, + ProviderEndpointUserId = providerEndpointUserId, + }; + } + + /// + /// Creates a denied destination result. + /// + /// The denied destination result. + public static TransferDestinationResolutionResult Denied() + { + return new TransferDestinationResolutionResult + { + Succeeded = false, + FailureReason = "The requested transfer destination is not available.", + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs new file mode 100644 index 000000000..f688cf0e7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs @@ -0,0 +1,45 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Describes a request to transfer a live interaction to a new destination. +/// +public sealed class TransferRequest +{ + /// + /// Gets or sets the identifier of the interaction being transferred. + /// + public string InteractionId { get; set; } + + /// + /// Gets or sets the transfer type (blind or consultative). + /// + public InteractionTransferType Type { get; set; } + + /// + /// Gets or sets the kind of destination the interaction is transferred to. + /// + public InteractionTransferTargetType TargetType { get; set; } + + /// + /// Gets or sets the destination identifier: a queue id, agent id, external address, or entry point id. + /// + public string TargetId { get; set; } + + /// + /// Gets or sets the identifier of the agent who initiated the transfer. + /// + public string InitiatedByAgentId { get; set; } + + /// + /// Gets or sets the Orchard user identifier of the agent who initiated the transfer. + /// + public string InitiatedByUserId { get; set; } + + /// + /// Gets or sets the authenticated principal used for transfer-destination RBAC. + /// + public ClaimsPrincipal Principal { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferResult.cs new file mode 100644 index 000000000..bed9ad863 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferResult.cs @@ -0,0 +1,58 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of a transfer request. +/// +public sealed class TransferResult +{ + /// + /// Gets or sets a value indicating whether the transfer was accepted and recorded. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets a value indicating whether the provider may have executed the transfer but its + /// outcome could not be observed. + /// + public bool OutcomeUnknown { get; set; } + + /// + /// Gets or sets an explanation of the outcome. + /// + public string Reason { get; set; } + + /// + /// Creates a successful result. + /// + /// The optional explanation. + /// A successful . + public static TransferResult Success(string reason = null) + { + return new TransferResult { Succeeded = true, Reason = reason }; + } + + /// + /// Creates a failed result. + /// + /// The failure reason. + /// A failed . + public static TransferResult Failure(string reason) + { + return new TransferResult { Succeeded = false, Reason = reason }; + } + + /// + /// Creates a result for a transfer whose provider outcome could not be determined. + /// + /// The reason the outcome is unknown. + /// An indeterminate . + public static TransferResult Unknown(string reason) + { + return new TransferResult + { + Succeeded = false, + OutcomeUnknown = true, + Reason = reason, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/WorkAssignmentLifecycle.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/WorkAssignmentLifecycle.cs new file mode 100644 index 000000000..19a247637 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/WorkAssignmentLifecycle.cs @@ -0,0 +1,85 @@ +using System.Collections.Frozen; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Declares which routing-owned assignment status changes the domain admits for a +/// . +/// +/// Unlike the communication lifecycles, this one is genuinely cyclic: work is released and becomes available +/// again, and that is normal rather than exceptional. What it must refuse is a status appearing without the step +/// that produces it — work that is in progress without ever having been assigned has no agent, and routing will +/// treat it as busy while nobody is working it. +/// +/// +public static class WorkAssignmentLifecycle +{ + private static readonly FrozenDictionary> _transitions = + new Dictionary> + { + [ActivityAssignmentStatus.Unassigned] = FrozenSet.ToFrozenSet( + [ + ActivityAssignmentStatus.Available, + ActivityAssignmentStatus.Reserved, + ActivityAssignmentStatus.Assigned, + ActivityAssignmentStatus.Released, + ]), + [ActivityAssignmentStatus.Available] = FrozenSet.ToFrozenSet( + [ + ActivityAssignmentStatus.Unassigned, + ActivityAssignmentStatus.Reserved, + ActivityAssignmentStatus.Assigned, + ActivityAssignmentStatus.Released, + ]), + + // A reservation that expires or is rejected puts the work back where routing can see it, so + // Reserved reaches Available and Unassigned as well as Assigned. + [ActivityAssignmentStatus.Reserved] = FrozenSet.ToFrozenSet( + [ + ActivityAssignmentStatus.Unassigned, + ActivityAssignmentStatus.Available, + ActivityAssignmentStatus.Assigned, + ActivityAssignmentStatus.Released, + ]), + [ActivityAssignmentStatus.Assigned] = FrozenSet.ToFrozenSet( + [ + ActivityAssignmentStatus.Unassigned, + ActivityAssignmentStatus.Available, + ActivityAssignmentStatus.InProgress, + ActivityAssignmentStatus.Released, + ]), + [ActivityAssignmentStatus.InProgress] = FrozenSet.ToFrozenSet( + [ + ActivityAssignmentStatus.Unassigned, + ActivityAssignmentStatus.Available, + ActivityAssignmentStatus.Released, + ]), + + // Released is not terminal. The same work is dialed again on a later cycle, and the healing service + // returns abandoned work to the pool, so released work must be able to become available again. + [ActivityAssignmentStatus.Released] = FrozenSet.ToFrozenSet( + [ + ActivityAssignmentStatus.Unassigned, + ActivityAssignmentStatus.Available, + ActivityAssignmentStatus.Reserved, + ActivityAssignmentStatus.Assigned, + ]), + }.ToFrozenDictionary(); + + /// + /// Determines whether work in one assignment status may move to another. + /// + /// The status the work is in. + /// The status the work would move to. + /// when the transition is admitted; otherwise . + public static bool CanTransition(ActivityAssignmentStatus from, ActivityAssignmentStatus to) + { + if (from == to) + { + return true; + } + + return _transitions.TryGetValue(from, out var allowed) && allowed.Contains(to); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Properties/AssemblyInfo.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Properties/AssemblyInfo.cs new file mode 100644 index 000000000..2a0d8854c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CrestApps.OrchardCore.Tests")] diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs new file mode 100644 index 000000000..27c045590 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs @@ -0,0 +1,296 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . It pairs the +/// highest-priority waiting item with the agent who has been available the longest (round robin by idle time). +/// Assignment for a queue is serialized with a per-queue distributed lock so that two nodes, or the +/// reservation-expiry background task running alongside an inbound call, cannot double-assign the same +/// item or agent. +/// +public sealed class ActivityAssignmentService : IActivityAssignmentService +{ + private static readonly TimeSpan _assignmentLockTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan _assignmentLockExpiration = TimeSpan.FromSeconds(30); + + private readonly IQueueItemManager _queueItemManager; + private readonly IAgentAvailabilityService _availabilityService; + private readonly IActivityQueueManager _queueManager; + private readonly IActivityRoutingService _routingService; + private readonly IActivityReservationService _reservationService; + private readonly IBusinessHoursService _businessHours; + private readonly IContactCenterEventPublisher _publisher; + private readonly IDistributedLock _distributedLock; + private readonly ISession _session; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The queue item manager. + /// The canonical agent availability service. + /// The queue manager. + /// The routing service. + /// The reservation service. + /// The business-hours service used to pause assignment while the queue is closed. + /// The Contact Center event publisher. + /// The distributed lock used to serialize assignment per queue. + /// The YesSql session used to persist each reservation before assigning more queue work. + /// The clock used to evaluate SLA aging and business hours. + /// The logger. + public ActivityAssignmentService( + IQueueItemManager queueItemManager, + IAgentAvailabilityService availabilityService, + IActivityQueueManager queueManager, + IActivityRoutingService routingService, + IActivityReservationService reservationService, + IBusinessHoursService businessHours, + IContactCenterEventPublisher publisher, + IDistributedLock distributedLock, + ISession session, + IClock clock, + ILogger logger) + { + _queueItemManager = queueItemManager; + _availabilityService = availabilityService; + _queueManager = queueManager; + _routingService = routingService; + _reservationService = reservationService; + _businessHours = businessHours; + _publisher = publisher; + _distributedLock = distributedLock; + _session = session; + _clock = clock; + _logger = logger; + } + + /// + public async Task AssignNextAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetQueueLockKey(queueId), + _assignmentLockTimeout, + _assignmentLockExpiration); + + if (!locked) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Skipped assigning the next Contact Center item for queue '{QueueId}' because its assignment lock was not acquired.", + queueId.SanitizeLogValue()); + } + + return null; + } + + await using var acquiredLock = locker; + + return await AssignNextCoreAsync(queueId, cancellationToken); + } + + /// + public async Task AssignQueueAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetQueueLockKey(queueId), + _assignmentLockTimeout, + _assignmentLockExpiration); + + if (!locked) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Skipped assigning Contact Center queue '{QueueId}' because its assignment lock was not acquired.", + queueId.SanitizeLogValue()); + } + + return 0; + } + + await using var acquiredLock = locker; + + var count = 0; + + while (await AssignNextCoreAsync(queueId, cancellationToken) is not null) + { + count++; + await _session.SaveChangesAsync(cancellationToken); + } + + return count; + } + + private async Task AssignNextCoreAsync(string queueId, CancellationToken cancellationToken) + { + var queue = await _queueManager.FindByIdAsync(queueId, cancellationToken); + + if (queue is null || !queue.Enabled) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Skipped Contact Center assignment for queue '{QueueId}' because the queue is {QueueState}.", + queueId.SanitizeLogValue(), + queue is null ? "missing" : "disabled"); + } + + return null; + } + + var now = _clock.UtcNow; + + if (!await _businessHours.IsOpenAsync(queue.BusinessHoursCalendarId, now, cancellationToken)) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Skipped Contact Center assignment for queue '{QueueId}' because its business hours are closed.", + queueId.SanitizeLogValue()); + } + + return null; + } + + var waiting = await _queueItemManager.ListWaitingAsync(queueId, cancellationToken); + var topItem = QueueItemPrioritizer.SelectNext(waiting, queue, now); + + if (topItem is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "No waiting Contact Center item is available for queue '{QueueId}'.", + queueId.SanitizeLogValue()); + } + + return null; + } + + var availability = await _availabilityService.ListForQueueAsync(queueId, cancellationToken); + var agents = availability.Select(entry => entry.Agent).ToArray(); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Evaluating Contact Center queue item '{QueueItemId}' for queue '{QueueId}' against {AvailableAgentCount} available agents.", + topItem.ItemId.SanitizeLogValue(), + queueId.SanitizeLogValue(), + agents.Length); + } + + var decision = await _routingService.SelectAgentAsync(queue, topItem, agents, cancellationToken); + + if (!decision.Succeeded || decision.Agent is null) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + var candidateSummary = string.Join( + "; ", + decision.Candidates.Select(candidate => + $"{candidate.Agent.ItemId.SanitizeLogValue()}: eligible={candidate.IsEligible}, reasonCount={candidate.Reasons.Count}")); + + _logger.LogWarning( + "Contact Center routing did not assign queue item '{QueueItemId}' from queue '{QueueId}'. Reason: {Reason}. Candidates: {CandidateSummary}", + topItem.ItemId.SanitizeLogValue(), + queueId.SanitizeLogValue(), + decision.Reason.SanitizeLogValue(), + candidateSummary); + } + + await PublishRoutingDecisionAsync(decision, cancellationToken); + + return null; + } + + var timeout = queue.ReservationTimeoutSeconds > 0 + ? queue.ReservationTimeoutSeconds + : 30; + + var reservation = await _reservationService.ReserveAsync(topItem, decision.Agent, timeout, cancellationToken); + + if (reservation is null) + { + decision.Succeeded = false; + decision.Reason = "The selected agent or queue item was no longer available when the reservation was created."; + + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Contact Center reservation creation lost a race for queue item '{QueueItemId}' and agent '{AgentId}' in queue '{QueueId}'.", + topItem.ItemId.SanitizeLogValue(), + decision.Agent.ItemId.SanitizeLogValue(), + queueId.SanitizeLogValue()); + } + } + else + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Reserved Contact Center queue item '{QueueItemId}' as reservation '{ReservationId}' for agent '{AgentId}' in queue '{QueueId}'.", + topItem.ItemId.SanitizeLogValue(), + reservation.ItemId.SanitizeLogValue(), + decision.Agent.ItemId.SanitizeLogValue(), + queueId.SanitizeLogValue()); + } + } + + await PublishRoutingDecisionAsync(decision, cancellationToken); + + return reservation; + } + + private Task PublishRoutingDecisionAsync(ActivityRoutingDecision decision, CancellationToken cancellationToken) + { + var data = new ActivityRoutingDecisionEventData + { + QueueId = decision.Queue?.ItemId, + QueueItemId = decision.QueueItem?.ItemId, + ActivityItemId = decision.QueueItem?.ActivityItemId, + SelectedAgentId = decision.Agent?.ItemId, + Succeeded = decision.Succeeded, + Reason = decision.Reason, + Candidates = decision.Candidates + .Select(candidate => new ActivityRoutingCandidateDecisionData + { + AgentId = candidate.Agent.ItemId, + UserId = candidate.Agent.UserId, + IsEligible = candidate.IsEligible, + Score = candidate.Score, + Reasons = [.. candidate.Reasons], + }) + .ToArray(), + }; + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.RoutingDecisionMade, + AggregateType = nameof(QueueItem), + AggregateId = decision.QueueItem?.ItemId, + ActorId = decision.Agent?.ItemId, + SourceComponent = ContactCenterConstants.Components.Routing, + }; + + interactionEvent.SetData(data); + + return _publisher.PublishAsync(interactionEvent, cancellationToken); + } + + private static string GetQueueLockKey(string queueId) + { + return $"ContactCenterQueueAssignment:{queueId}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueGroupManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueGroupManager.cs new file mode 100644 index 000000000..59f377852 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueGroupManager.cs @@ -0,0 +1,41 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityQueueGroupManager : CatalogManager, IActivityQueueGroupManager +{ + private readonly IActivityQueueGroupStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying queue-group store. + /// The catalog entry handlers for queue groups. + /// The logger instance. + public ActivityQueueGroupManager( + IActivityQueueGroupStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + var group = await _store.FindByNameAsync(name, cancellationToken); + + if (group is not null) + { + await LoadAsync(group, cancellationToken); + } + + return group; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueGroupStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueGroupStore.cs new file mode 100644 index 000000000..ec40da52b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueGroupStore.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ActivityQueueGroupStore : DocumentCatalog, IActivityQueueGroupStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ActivityQueueGroupStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return await Session.Query( + index => index.Name == name, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueManager.cs new file mode 100644 index 000000000..54c2a7930 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueManager.cs @@ -0,0 +1,54 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityQueueManager : CatalogManager, IActivityQueueManager +{ + private readonly IActivityQueueStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying queue store. + /// The catalog entry handlers for queues. + /// The logger instance. + public ActivityQueueManager( + IActivityQueueStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + var queue = await _store.FindByNameAsync(name, cancellationToken); + + if (queue is not null) + { + await LoadAsync(queue, cancellationToken); + } + + return queue; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var queues = await _store.ListEnabledAsync(cancellationToken); + + foreach (var queue in queues) + { + await LoadAsync(queue, cancellationToken); + } + + return queues; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs new file mode 100644 index 000000000..75e3e6c1d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs @@ -0,0 +1,248 @@ +using System.Data.Common; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityQueueService : IActivityQueueService +{ + private const int MaxEnqueueAttempts = 3; + + private readonly IQueueItemManager _queueItemManager; + private readonly IActivityQueueManager _queueManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IBusinessHoursService _businessHours; + private readonly IContactCenterEventPublisher _publisher; + private readonly ISession _session; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The queue item manager. + /// The queue manager. + /// The CRM activity manager. + /// The routing-owned work state service. + /// The business-hours service used to evaluate after-hours overflow. + /// The Contact Center event publisher. + /// The YesSql session used to make newly queued work visible to immediate routing queries. + /// The executor used to retry idempotent enqueue conflicts in a fresh scope. + /// The clock used to stamp queue times. + public ActivityQueueService( + IQueueItemManager queueItemManager, + IActivityQueueManager queueManager, + IOmnichannelActivityManager activityManager, + IContactCenterWorkStateService workStateService, + IBusinessHoursService businessHours, + IContactCenterEventPublisher publisher, + ISession session, + IContactCenterScopeExecutor scopeExecutor, + IClock clock) + { + _queueItemManager = queueItemManager; + _queueManager = queueManager; + _activityManager = activityManager; + _workStateService = workStateService; + _businessHours = businessHours; + _publisher = publisher; + _session = session; + _scopeExecutor = scopeExecutor; + _clock = clock; + } + + /// + public async Task EnqueueAsync(string activityItemId, string queueId, InteractionPriority? priority, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(activityItemId); + ArgumentException.ThrowIfNullOrEmpty(queueId); + + try + { + return await EnqueueCoreAsync(activityItemId, queueId, priority, cancellationToken); + } + catch (Exception exception) when (IsEnqueueConflict(exception)) + { + return await RetryEnqueueInFreshScopeAsync(activityItemId, queueId, priority, cancellationToken); + } + } + + private async Task EnqueueCoreAsync( + string activityItemId, + string queueId, + InteractionPriority? priority, + CancellationToken cancellationToken) + { + var existing = await _queueItemManager.FindByActivityIdAsync(activityItemId, cancellationToken); + + if (existing is not null && existing.Status is QueueItemStatus.Waiting or QueueItemStatus.Reserved or QueueItemStatus.Assigned) + { + return existing; + } + + var queue = await _queueManager.FindByIdAsync(queueId, cancellationToken); + var activity = await _activityManager.FindByIdAsync(activityItemId, cancellationToken); + var item = await _queueItemManager.NewAsync(cancellationToken: cancellationToken); + item.QueueId = queueId; + item.ActivityItemId = activityItemId; + item.Priority = priority ?? queue?.DefaultPriority ?? InteractionPriority.Normal; + item.TransitionTo(QueueItemStatus.Waiting); + var existingWorkState = activity is null + ? null + : await _workStateService.GetAsync(activity.ItemId, cancellationToken); + item.StickyAgentUserId = existingWorkState?.AssignedToId; + item.EnqueuedUtc = _clock.UtcNow; + item.QueueEnteredUtc = item.EnqueuedUtc; + + await _queueItemManager.CreateAsync(item, cancellationToken: cancellationToken); + + if (activity is not null) + { + await _workStateService.MutateAsync( + activity.ItemId, + workState => workState.TransitionTo(ActivityAssignmentStatus.Available), + cancellationToken); + } + + await _session.SaveChangesAsync(cancellationToken); + + await _publisher.PublishAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemAdded, + AggregateType = nameof(QueueItem), + AggregateId = item.ItemId, + SourceComponent = ContactCenterConstants.Components.Queues, + }, cancellationToken); + + return item; + } + + private async Task RetryEnqueueInFreshScopeAsync( + string activityItemId, + string queueId, + InteractionPriority? priority, + CancellationToken cancellationToken) + { + Exception lastException = null; + + for (var attempt = 2; attempt <= MaxEnqueueAttempts; attempt++) + { + QueueItem item = null; + + try + { + await _scopeExecutor.ExecuteAsync(async queueService => + { + item = await queueService.EnqueueAsync(activityItemId, queueId, priority, cancellationToken); + }); + + return item; + } + catch (Exception exception) when (IsEnqueueConflict(exception)) + { + lastException = exception; + } + } + + throw lastException ?? new ConcurrencyException(new Document()); + } + + private static bool IsEnqueueConflict(Exception exception) + { + return exception is ConcurrencyException or DbException; + } + + /// + public async Task DequeueAsync(QueueItem queueItem, QueueItemStatus status, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queueItem); + + queueItem.TransitionTo(status); + queueItem.DequeuedUtc = _clock.UtcNow; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + + await _publisher.PublishAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemDequeued, + AggregateType = nameof(QueueItem), + AggregateId = queueItem.ItemId, + SourceComponent = ContactCenterConstants.Components.Queues, + }, cancellationToken); + } + + /// + public async Task OverflowDueAsync(ActivityQueue queue, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queue); + + if (string.IsNullOrEmpty(queue.OverflowQueueId) || + string.Equals(queue.OverflowQueueId, queue.ItemId, StringComparison.Ordinal)) + { + return 0; + } + + var waiting = await _queueItemManager.ListWaitingAsync(queue.ItemId, cancellationToken); + + if (waiting.Count == 0) + { + return 0; + } + + var now = _clock.UtcNow; + + var closed = !string.IsNullOrEmpty(queue.BusinessHoursCalendarId) + && queue.AfterHoursAction == QueueAfterHoursAction.Overflow + && !await _businessHours.IsOpenAsync(queue.BusinessHoursCalendarId, now, cancellationToken); + + var moved = 0; + + foreach (var item in waiting) + { + var queueEnteredUtc = item.QueueEnteredUtc == default + ? item.EnqueuedUtc + : item.QueueEnteredUtc; + var overflowDueByWait = queue.OverflowAfterSeconds > 0 + && (now - queueEnteredUtc).TotalSeconds >= queue.OverflowAfterSeconds; + + if (!closed && !overflowDueByWait) + { + continue; + } + + if (item.OverflowHistory.Contains(queue.OverflowQueueId, StringComparer.Ordinal)) + { + continue; + } + + if (!item.OverflowHistory.Contains(queue.ItemId, StringComparer.Ordinal)) + { + item.OverflowHistory.Add(queue.ItemId); + } + + item.OverflowedFromQueueId = queue.ItemId; + item.QueueId = queue.OverflowQueueId; + item.QueueEnteredUtc = now; + await _queueItemManager.UpdateAsync(item, cancellationToken: cancellationToken); + + await _publisher.PublishAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemOverflowed, + AggregateType = nameof(QueueItem), + AggregateId = item.ItemId, + SourceComponent = ContactCenterConstants.Components.Queues, + }, cancellationToken); + + moved++; + } + + return moved; + } +} \ No newline at end of file diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueStore.cs new file mode 100644 index 000000000..9dcae0e78 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueStore.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ActivityQueueStore : DocumentCatalog, IActivityQueueStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ActivityQueueStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return await Session.Query( + index => index.Name == name, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var queues = await Session.Query( + index => index.Enabled, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return queues.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs new file mode 100644 index 000000000..ab00583ae --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs @@ -0,0 +1,82 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityReservationManager : CatalogManager, IActivityReservationManager +{ + private readonly IActivityReservationStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying reservation store. + /// The catalog entry handlers for reservations. + /// The logger instance. + public ActivityReservationManager( + IActivityReservationStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task> ListExpiredAsync(DateTime utcNow, CancellationToken cancellationToken = default) + { + var reservations = await _store.ListExpiredAsync(utcNow, cancellationToken); + + foreach (var reservation in reservations) + { + await LoadAsync(reservation, cancellationToken); + } + + return reservations; + } + + /// + public async Task FindPendingByAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + var reservation = await _store.FindPendingByAgentAsync(agentId, cancellationToken); + + if (reservation is not null) + { + await LoadAsync(reservation, cancellationToken); + } + + return reservation; + } + + /// + public async Task> ListActiveByAgentAsync( + string agentId, + CancellationToken cancellationToken = default) + { + var reservations = await _store.ListActiveByAgentAsync(agentId, cancellationToken); + + foreach (var reservation in reservations) + { + await LoadAsync(reservation, cancellationToken); + } + + return reservations; + } + + /// + public async Task> ListActiveByActivityAsync(string activityItemId, CancellationToken cancellationToken = default) + { + var reservations = await _store.ListActiveByActivityAsync(activityItemId, cancellationToken); + + foreach (var reservation in reservations) + { + await LoadAsync(reservation, cancellationToken); + } + + return reservations; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs new file mode 100644 index 000000000..a1d7f2171 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -0,0 +1,790 @@ +using System.Text.Json; +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using Microsoft.Extensions.Logging; +using OrchardCore; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityReservationService : IActivityReservationService +{ + private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan _lockExpiration = TimeSpan.FromSeconds(30); + + private readonly IActivityReservationManager _reservationManager; + private readonly IQueueItemManager _queueItemManager; + private readonly IAgentProfileManager _agentManager; + private readonly IAgentAvailabilityService _availabilityService; + private readonly IActivityQueueManager _queueManager; + private readonly IActivityQueueService _queueService; + private readonly IInteractionManager _interactionManager; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IContactCenterActivityWriter _activityWriter; + private readonly IContactCenterEventPublisher _publisher; + private readonly IProviderCommandStateService _providerCommandStateService; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IDistributedLock _distributedLock; + private readonly ISession _session; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The reservation manager. + /// The queue item manager. + /// The agent profile manager. + /// The canonical agent availability service. + /// The queue manager. + /// The queue service used for dequeue operations. + /// The interaction manager. + /// The routing-owned work state service. + /// The writer used to apply CRM activity lifecycle changes outside the routing transaction. + /// The Contact Center event publisher. + /// The optional durable provider-command service used for voice-specific timeout actions. + /// The executor used to wake provider-command processing after commit. + /// The distributed lock used to serialize agent and reservation transitions. + /// The YesSql session used to commit reservation state atomically. + /// The clock used to stamp reservation times. + /// The logger. + public ActivityReservationService( + IActivityReservationManager reservationManager, + IQueueItemManager queueItemManager, + IAgentProfileManager agentManager, + IAgentAvailabilityService availabilityService, + IActivityQueueManager queueManager, + IActivityQueueService queueService, + IInteractionManager interactionManager, + IContactCenterWorkStateService workStateService, + IContactCenterActivityWriter activityWriter, + IContactCenterEventPublisher publisher, + IEnumerable providerCommandStateServices, + IContactCenterScopeExecutor scopeExecutor, + IDistributedLock distributedLock, + ISession session, + IClock clock, + ILogger logger) + { + _reservationManager = reservationManager; + _queueItemManager = queueItemManager; + _agentManager = agentManager; + _availabilityService = availabilityService; + _queueManager = queueManager; + _queueService = queueService; + _interactionManager = interactionManager; + _workStateService = workStateService; + _activityWriter = activityWriter; + _publisher = publisher; + _providerCommandStateService = providerCommandStateServices.FirstOrDefault(); + _scopeExecutor = scopeExecutor; + _distributedLock = distributedLock; + _session = session; + _clock = clock; + _logger = logger; + } + + /// + public async Task ReserveAsync(QueueItem queueItem, AgentProfile agent, int timeoutSeconds, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queueItem); + ArgumentNullException.ThrowIfNull(agent); + + (var activityLocker, var activityLocked) = await _distributedLock.TryAcquireLockAsync( + GetActivityReservationLockKey(queueItem.ActivityItemId), + _lockTimeout, + _lockExpiration); + + if (!activityLocked) + { + return null; + } + + await using var acquiredActivityLock = activityLocker; + + (var agentLocker, var agentLocked) = await _distributedLock.TryAcquireLockAsync( + GetAgentReservationLockKey(agent.ItemId), + _lockTimeout, + _lockExpiration); + + if (!agentLocked) + { + return null; + } + + await using var acquiredAgentLock = agentLocker; + + var current = await _queueItemManager.FindByIdAsync(queueItem.ItemId, cancellationToken); + + if (current is null || current.Status != QueueItemStatus.Waiting) + { + return null; + } + + queueItem = current; + + // The availability service is the canonical authority for whether an agent may take work, and answering + // that question already costs a read of the agent profile and a count of that agent's active + // interactions. Reading the agent and counting again here would double the round trips this critical + // section holds two distributed locks across, in order to re-derive a decision that already has an + // owner. The locks carry a fixed expiration and are never renewed, so the length of this section is + // what decides how often it outruns its lease; what makes that survivable is the version check the + // commit below runs under, not the lease. + var availability = await _availabilityService.GetAsync(agent.ItemId, queueItem.QueueId, cancellationToken); + + if (availability?.Agent is null) + { + return null; + } + + agent = availability.Agent; + + if (!string.IsNullOrWhiteSpace(agent.ActiveReservationId)) + { + return null; + } + + var now = _clock.UtcNow; + var reservation = await _reservationManager.NewAsync(cancellationToken: cancellationToken); + reservation.ActivityItemId = queueItem.ActivityItemId; + reservation.QueueId = queueItem.QueueId; + reservation.QueueItemId = queueItem.ItemId; + reservation.AgentId = agent.ItemId; + reservation.TransitionTo(ReservationStatus.Pending); + reservation.CreatedUtc = now; + reservation.ExpiresUtc = now.AddSeconds(timeoutSeconds); + + await _reservationManager.CreateAsync(reservation, cancellationToken: cancellationToken); + + queueItem.TransitionTo(QueueItemStatus.Reserved); + queueItem.ReservationId = reservation.ItemId; + queueItem.AgentId = agent.ItemId; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + + if (!agent.RequestedPresenceStatus.HasValue && + agent.PresenceStatus is not AgentPresenceStatus.Available and not AgentPresenceStatus.Reserved and not AgentPresenceStatus.Busy and not AgentPresenceStatus.WrapUp) + { + agent.RequestedPresenceStatus = agent.PresenceStatus == AgentPresenceStatus.RequestBreak + ? AgentPresenceStatus.Break + : agent.PresenceStatus; + } + + agent.PresenceStatus = AgentPresenceStatus.Reserved; + agent.ActiveReservationId = reservation.ItemId; + agent.PresenceChangedUtc = now; + agent.LastAssignedUtc = now; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + + await _workStateService.MutateAsync(queueItem.ActivityItemId, workState => + { + workState.TransitionTo(ActivityAssignmentStatus.Reserved); + workState.ReservationId = reservation.ItemId; + workState.ReservedById = agent.UserId; + workState.ReservedByUsername = agent.UserName; + workState.ReservedUtc = now; + workState.ReservationExpiresUtc = reservation.ExpiresUtc; + }, cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.QueueItemReserved, reservation, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentReserved, reservation, cancellationToken); + + await CommitTransitionAsync( + queueItem.ActivityItemId, + agent.ItemId, + cancellationToken); + + return reservation; + } + + /// + public async Task AcceptAsync(string reservationId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetReservationLockKey(reservationId), + _lockTimeout, + _lockExpiration); + + if (!locked) + { + return null; + } + + await using var acquiredLock = locker; + + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || reservation.Status != ReservationStatus.Pending) + { + return null; + } + + (var agentLocker, var agentLocked) = await _distributedLock.TryAcquireLockAsync( + GetAgentReservationLockKey(reservation.AgentId), + _lockTimeout, + _lockExpiration); + + if (!agentLocked) + { + return null; + } + + await using var acquiredAgentLock = agentLocker; + + reservation.TransitionTo(ReservationStatus.Accepted); + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + + var queueItem = await _queueItemManager.FindByIdAsync(reservation.QueueItemId, cancellationToken); + + if (queueItem is not null) + { + queueItem.TransitionTo(QueueItemStatus.Assigned); + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + if (agent is not null) + { + agent.PresenceStatus = AgentPresenceStatus.Busy; + agent.ActiveReservationId = null; + agent.PresenceChangedUtc = _clock.UtcNow; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + } + + await _workStateService.MutateAsync(reservation.ActivityItemId, workState => + { + workState.TransitionTo(ActivityAssignmentStatus.Assigned); + workState.AssignedToId = agent?.UserId; + workState.AssignedToUsername = agent?.UserName; + workState.AssignedToUtc = _clock.UtcNow; + }, cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.QueueItemAssigned, reservation, cancellationToken); + + await CommitTransitionAsync( + reservation.ActivityItemId, + reservation.AgentId, + cancellationToken); + + return reservation; + } + + /// + public async Task RejectAsync(string reservationId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetReservationLockKey(reservationId), + _lockTimeout, + _lockExpiration); + + if (!locked) + { + return null; + } + + await using var acquiredLock = locker; + + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || + reservation.Status is not ReservationStatus.Pending and not ReservationStatus.Accepted) + { + return null; + } + + await ReleaseAsync(reservation, ReservationStatus.Rejected, cancellationToken); + + await CommitTransitionAsync( + reservation.ActivityItemId, + reservation.AgentId, + cancellationToken); + + return reservation; + } + + /// + public async Task CancelAsync(string reservationId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetReservationLockKey(reservationId), + _lockTimeout, + _lockExpiration); + + if (!locked) + { + return null; + } + + await using var acquiredLock = locker; + + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || reservation.Status != ReservationStatus.Pending) + { + return null; + } + + await ReleaseAsync(reservation, ReservationStatus.Canceled, cancellationToken); + + await CommitTransitionAsync( + reservation.ActivityItemId, + reservation.AgentId, + cancellationToken); + + return reservation; + } + + /// + public async Task CompensateAsync( + string reservationId, + bool removeFromQueue, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetReservationLockKey(reservationId), + _lockTimeout, + _lockExpiration); + + if (!locked) + { + return null; + } + + await using var acquiredLock = locker; + + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || + reservation.Status is not ReservationStatus.Pending and not ReservationStatus.Accepted) + { + return null; + } + + (var agentLocker, var agentLocked) = await _distributedLock.TryAcquireLockAsync( + GetAgentReservationLockKey(reservation.AgentId), + _lockTimeout, + _lockExpiration); + + if (!agentLocked) + { + return null; + } + + await using var acquiredAgentLock = agentLocker; + + var activeAgentReservations = await _reservationManager.ListActiveByAgentAsync( + reservation.AgentId, + cancellationToken); + var hasNewerAgentWork = activeAgentReservations.Any(candidate => + !string.Equals(candidate.ItemId, reservation.ItemId, StringComparison.Ordinal)); + var now = _clock.UtcNow; + var wasAccepted = reservation.Status == ReservationStatus.Accepted; + reservation.TransitionTo(ReservationStatus.Canceled); + + // This is the age settled reservations are purged by. Without it the row is never selected by retention. + reservation.ModifiedUtc = now; + + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + + var queueItem = await _queueItemManager.FindByIdAsync(reservation.QueueItemId, cancellationToken); + + if (queueItem is not null && + string.Equals(queueItem.ReservationId, reservation.ItemId, StringComparison.Ordinal)) + { + queueItem.ReservationId = null; + queueItem.AgentId = null; + + if (removeFromQueue) + { + await _queueService.DequeueAsync(queueItem, QueueItemStatus.Removed, cancellationToken); + } + else + { + queueItem.TransitionTo(QueueItemStatus.Waiting); + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + var agentReleased = false; + var ownsPendingReservation = !wasAccepted && + agent?.PresenceStatus == AgentPresenceStatus.Reserved && + string.Equals(agent.ActiveReservationId, reservation.ItemId, StringComparison.Ordinal); + var ownsAcceptedReservation = wasAccepted && + agent?.PresenceStatus == AgentPresenceStatus.Busy && + (string.Equals(agent.ActiveReservationId, reservation.ItemId, StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(agent.ActiveReservationId)); + + if (agent is not null && + !hasNewerAgentWork && + (ownsPendingReservation || ownsAcceptedReservation)) + { + agent.PresenceStatus = agent.RequestedPresenceStatus ?? AgentPresenceUtilities.ResolveDefaultReadyState(agent); + agent.RequestedPresenceStatus = null; + agent.ActiveReservationId = null; + agent.PresenceChangedUtc = now; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + agentReleased = true; + } + + await _workStateService.MutateAsync(reservation.ActivityItemId, workState => + { + if (!string.Equals(workState.ReservationId, reservation.ItemId, StringComparison.Ordinal)) + { + return; + } + + workState.TransitionTo(removeFromQueue + ? ActivityAssignmentStatus.Released + : ActivityAssignmentStatus.Available); + workState.ReservationId = null; + workState.ReservedById = null; + workState.ReservedByUsername = null; + workState.ReservedUtc = null; + workState.ReservationExpiresUtc = null; + workState.AssignedToId = null; + workState.AssignedToUsername = null; + workState.AssignedToUtc = null; + }, cancellationToken); + + if (agentReleased) + { + await PublishAsync(ContactCenterConstants.Events.AgentReleased, reservation, cancellationToken); + } + + await CommitTransitionAsync( + reservation.ActivityItemId, + reservation.AgentId, + cancellationToken); + + return reservation; + } + + /// + public async Task ExpireDueAsync(CancellationToken cancellationToken = default) + { + var now = _clock.UtcNow; + var expired = await _reservationManager.ListExpiredAsync(now, cancellationToken); + var count = 0; + + foreach (var candidate in expired) + { + cancellationToken.ThrowIfCancellationRequested(); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetReservationLockKey(candidate.ItemId), + _lockTimeout, + _lockExpiration); + + if (!locked) + { + continue; + } + + await using var acquiredLock = locker; + + var reservation = await _reservationManager.FindByIdAsync(candidate.ItemId, cancellationToken); + + if (reservation is null || + reservation.Status != ReservationStatus.Pending || + reservation.ExpiresUtc > now) + { + continue; + } + + await ReleaseAsync(reservation, ReservationStatus.Expired, cancellationToken); + await CommitTransitionAsync( + reservation.ActivityItemId, + reservation.AgentId, + cancellationToken); + count++; + } + + return count; + } + + private async Task ReleaseAsync(ActivityReservation reservation, ReservationStatus status, CancellationToken cancellationToken) + { + var now = _clock.UtcNow; + reservation.TransitionTo(status); + + // This is the age settled reservations are purged by. Without it the row is never selected by retention. + reservation.ModifiedUtc = now; + + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + + var queueItem = await _queueItemManager.FindByIdAsync(reservation.QueueItemId, cancellationToken); + + if (queueItem is not null && + !string.IsNullOrWhiteSpace(queueItem.ReservationId) && + !string.Equals(queueItem.ReservationId, reservation.ItemId, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Skipped releasing expired reservation '{ReservationId}' for activity '{ActivityItemId}' because queue item '{QueueItemId}' is now owned by newer reservation '{CurrentReservationId}'.", + reservation.ItemId.SanitizeLogValue(), + reservation.ActivityItemId.SanitizeLogValue(), + queueItem.ItemId.SanitizeLogValue(), + queueItem.ReservationId.SanitizeLogValue()); + + var obsoleteAgent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + if (obsoleteAgent is not null && + string.Equals(obsoleteAgent.ActiveReservationId, reservation.ItemId, StringComparison.Ordinal)) + { + obsoleteAgent.PresenceStatus = obsoleteAgent.RequestedPresenceStatus ?? AgentPresenceUtilities.ResolveDefaultReadyState(obsoleteAgent); + obsoleteAgent.RequestedPresenceStatus = null; + obsoleteAgent.ActiveReservationId = null; + obsoleteAgent.PresenceChangedUtc = now; + await _agentManager.UpdateAsync(obsoleteAgent, cancellationToken: cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentReleased, reservation, cancellationToken); + } + + return; + } + + var queue = !string.IsNullOrEmpty(reservation.QueueId) + ? await _queueManager.FindByIdAsync(reservation.QueueId, cancellationToken) + : null; + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + var interaction = await _interactionManager.FindByActivityIdAsync(reservation.ActivityItemId, cancellationToken); + var configuredUnansweredAction = status == ReservationStatus.Expired + ? queue?.UnansweredOfferAction ?? UnansweredOfferAction.Requeue + : UnansweredOfferAction.Requeue; + var unansweredAction = configuredUnansweredAction; + ProviderCommandRegistration providerCommand = null; + + if (unansweredAction is UnansweredOfferAction.Voicemail or UnansweredOfferAction.Reject) + { + if (interaction is null || + string.IsNullOrWhiteSpace(interaction.ProviderInteractionId) || + string.IsNullOrWhiteSpace(interaction.ProviderName) || + _providerCommandStateService is null) + { + _logger.LogWarning( + "The unanswered-offer action '{UnansweredOfferAction}' could not be persisted for activity '{ActivityItemId}' because provider command infrastructure or call identity is unavailable.", + unansweredAction, + interaction?.ActivityItemId.SanitizeLogValue()); + unansweredAction = UnansweredOfferAction.Requeue; + } + else + { + var commandId = IdGenerator.GenerateId(); + interaction.TechnicalMetadata[ContactCenterConstants.CommandMetadata.CommandId] = commandId; + providerCommand = new ProviderCommandRegistration + { + CommandId = commandId, + ProviderName = interaction.ProviderName, + CommandType = unansweredAction == UnansweredOfferAction.Voicemail + ? ProviderCommandType.SendToVoicemail + : ProviderCommandType.Reject, + ActivityItemId = reservation.ActivityItemId, + InteractionId = interaction.ItemId, + RemoveReservationFromQueueOnFailure = false, + RequestPayload = JsonSerializer.Serialize(new ProviderCallActionCommandRequest + { + Initiator = CallControlInitiator.System, + ActivityItemId = reservation.ActivityItemId, + InteractionId = interaction.ItemId, + QueueId = reservation.QueueId, + AgentId = reservation.AgentId, + AgentUserId = agent?.UserId, + ProviderCallId = interaction.ProviderInteractionId, + ReofferOnFailure = true, + Metadata = BuildOfferTimeoutMetadata(queue, agent), + }), + }; + } + } + + var requeue = unansweredAction == UnansweredOfferAction.Requeue; + + if (queueItem is not null) + { + queueItem.ReservationId = null; + queueItem.AgentId = null; + + if (requeue) + { + queueItem.TransitionTo(QueueItemStatus.Waiting); + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + else + { + queueItem.DequeuedUtc = now; + await _queueService.DequeueAsync(queueItem, QueueItemStatus.Removed, cancellationToken); + } + } + + if (agent is not null) + { + agent.PresenceStatus = agent.RequestedPresenceStatus ?? AgentPresenceUtilities.ResolveDefaultReadyState(agent); + agent.RequestedPresenceStatus = null; + agent.ActiveReservationId = null; + agent.PresenceChangedUtc = now; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + } + + await _workStateService.MutateAsync(reservation.ActivityItemId, workState => + { + workState.TransitionTo(requeue + ? ActivityAssignmentStatus.Available + : ActivityAssignmentStatus.Released); + workState.ReservationId = null; + workState.ReservedById = null; + workState.ReservedByUsername = null; + workState.ReservedUtc = null; + workState.ReservationExpiresUtc = null; + }, cancellationToken); + + if (!requeue) + { + var terminalStatus = unansweredAction == UnansweredOfferAction.Voicemail + ? ActivityStatus.Completed + : ActivityStatus.Cancelled; + + await _activityWriter.ScheduleUpdateAsync(reservation.ActivityItemId, activity => + { + activity.Status = terminalStatus; + activity.CompletedUtc = now; + }, cancellationToken); + } + + // Releasing an offer races the conversation ending. The customer can abandon while the offer is still + // ringing an agent, the provider event settles the interaction, and this sweep then arrives to return + // work that no longer exists to routing. Returning a settled interaction to routing is refused by the + // lifecycle, and this path runs from a background sweep that releases every due reservation, so letting + // that refusal escape would abandon the rest of the sweep over one call that had already hung up. The + // reservation, queue item, agent and work state are still released; only the re-offer is skipped. + if (interaction is not null && !interaction.IsSettled) + { + if (requeue) + { + interaction.Requeue(); + } + else if (providerCommand is not null) + { + interaction.Reoffer(); + interaction.EndedUtc = null; + interaction.AgentId = null; + interaction.TechnicalMetadata["unansweredOfferAction"] = unansweredAction.ToString(); + } + else + { + interaction.TransitionTo(InteractionStatus.Ended); + interaction.EndedUtc ??= now; + interaction.TechnicalMetadata["unansweredOfferAction"] = unansweredAction.ToString(); + } + + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + + await PublishAsync(ContactCenterConstants.Events.AgentReleased, reservation, cancellationToken); + + if (providerCommand is not null) + { + await _providerCommandStateService.RegisterAsync(providerCommand, cancellationToken); + _scopeExecutor.ScheduleAfterCommit(processor => + processor.DispatchAsync(providerCommand.CommandId, CancellationToken.None)); + } + } + + private async Task CommitTransitionAsync( + string activityItemId, + string agentId, + CancellationToken cancellationToken) + { + try + { + await _session.SaveChangesAsync(cancellationToken); + } + catch (ConcurrencyException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "A concurrent Contact Center operation won the compare-and-set transition for activity '{ActivityId}' and agent '{AgentId}'.", + activityItemId.SanitizeLogValue(), + agentId.SanitizeLogValue()); + } + + throw; + } + } + + private static Dictionary BuildOfferTimeoutMetadata(ActivityQueue queue, AgentProfile agent) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (queue is not null) + { + metadata["queueId"] = queue.ItemId; + + if (!string.IsNullOrWhiteSpace(queue.Name)) + { + metadata["queueName"] = queue.Name; + } + } + + if (agent is not null) + { + if (!string.IsNullOrWhiteSpace(agent.UserId)) + { + metadata["voicemailRecipientUserId"] = agent.UserId; + } + + if (!string.IsNullOrWhiteSpace(agent.UserName)) + { + metadata["voicemailRecipientUserName"] = agent.UserName; + } + + if (!string.IsNullOrWhiteSpace(agent.DisplayName)) + { + metadata["voicemailRecipientDisplayName"] = agent.DisplayName; + } + } + + return metadata; + } + + private Task PublishAsync(string eventType, ActivityReservation reservation, CancellationToken cancellationToken) + { + return _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + AggregateType = nameof(ActivityReservation), + AggregateId = reservation.ItemId, + ActorId = reservation.AgentId, + SourceComponent = ContactCenterConstants.Components.Queues, + }, cancellationToken); + } + + private static string GetAgentReservationLockKey(string agentId) + { + return $"ContactCenterAgentReservation:{agentId}"; + } + + private static string GetActivityReservationLockKey(string activityItemId) + { + return $"ContactCenterActivityReservation:{activityItemId}"; + } + + private static string GetReservationLockKey(string reservationId) + { + return $"ContactCenterReservation:{reservationId}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs new file mode 100644 index 000000000..97eae5fa6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs @@ -0,0 +1,78 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ActivityReservationStore : DocumentCatalog, IActivityReservationStore +{ + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ActivityReservationStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task> ListExpiredAsync(DateTime utcNow, CancellationToken cancellationToken = default) + { + var reservations = await Session.Query( + index => index.Status == ReservationStatus.Pending && index.ExpiresUtc <= utcNow, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return reservations.ToArray(); + } + + /// + public async Task FindPendingByAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + return await Session.Query( + index => index.AgentId == agentId && index.Status == ReservationStatus.Pending, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListActiveByAgentAsync( + string agentId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + var reservations = await Session.Query( + index => index.AgentId == agentId && + (index.Status == ReservationStatus.Pending || index.Status == ReservationStatus.Accepted), + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return reservations.ToArray(); + } + + /// + public async Task> ListActiveByActivityAsync(string activityItemId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(activityItemId); + + var reservations = await Session.Query( + index => index.ActivityItemId == activityItemId && + (index.Status == ReservationStatus.Pending || index.Status == ReservationStatus.Accepted), + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return reservations.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityRoutingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityRoutingService.cs new file mode 100644 index 000000000..5b810ea30 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityRoutingService.cs @@ -0,0 +1,81 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Applies registered routing strategies and returns an explainable routing decision. +/// +public sealed class ActivityRoutingService : IActivityRoutingService +{ + private readonly IEnumerable _strategies; + + /// + /// Initializes a new instance of the class. + /// + /// The routing strategies to apply. + public ActivityRoutingService(IEnumerable strategies) + { + _strategies = strategies; + } + + /// + public async Task SelectAgentAsync( + ActivityQueue queue, + QueueItem queueItem, + IEnumerable agents, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queue); + ArgumentNullException.ThrowIfNull(queueItem); + + var candidates = agents?.Select(agent => new ActivityRoutingCandidate(agent)).ToList() ?? []; + var context = new ActivityRoutingContext(queue, queueItem, candidates); + + foreach (var strategy in _strategies.OrderBy(strategy => strategy.Order)) + { + await strategy.ApplyAsync(context, cancellationToken); + } + + if (candidates.Count == 0) + { + return CreateNoMatchDecision(queue, queueItem, candidates, "No agents are currently available for this queue."); + } + + var selected = candidates + .Where(candidate => candidate.IsEligible) + .OrderByDescending(candidate => candidate.Score) + .ThenBy(candidate => candidate.Agent.PresenceChangedUtc ?? DateTime.MaxValue) + .FirstOrDefault(); + + if (selected is null) + { + return CreateNoMatchDecision(queue, queueItem, candidates, "No available agent matched the queue routing policy."); + } + + return new ActivityRoutingDecision + { + Succeeded = true, + Queue = queue, + QueueItem = queueItem, + Agent = selected.Agent, + Reason = "Selected the highest-scoring eligible agent.", + Candidates = candidates, + }; + } + + private static ActivityRoutingDecision CreateNoMatchDecision( + ActivityQueue queue, + QueueItem queueItem, + IList candidates, + string reason) + { + return new ActivityRoutingDecision + { + Succeeded = false, + Queue = queue, + QueueItem = queueItem, + Reason = reason, + Candidates = candidates, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentAvailabilityRecoveryService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentAvailabilityRecoveryService.cs new file mode 100644 index 000000000..18fe04a6e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentAvailabilityRecoveryService.cs @@ -0,0 +1,101 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Recovers agent capacity when after-call work is orphaned or exceeds the configured deadline. +/// +public sealed class AgentAvailabilityRecoveryService : IAgentAvailabilityRecoveryService +{ + private readonly IAgentProfileManager _agentManager; + private readonly IInteractionManager _interactionManager; + private readonly IAgentPresenceManager _presenceManager; + private readonly AgentAvailabilityOptions _options; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager. + /// The interaction manager. + /// The agent presence manager. + /// The availability policy. + /// The clock. + /// The logger. + public AgentAvailabilityRecoveryService( + IAgentProfileManager agentManager, + IInteractionManager interactionManager, + IAgentPresenceManager presenceManager, + IOptions options, + IClock clock, + ILogger logger) + { + _agentManager = agentManager; + _interactionManager = interactionManager; + _presenceManager = presenceManager; + _options = options.Value; + _clock = clock; + _logger = logger; + } + + /// + public async Task RecoverAsync(CancellationToken cancellationToken = default) + { + var agents = await _agentManager.ListByPresenceAsync(AgentPresenceStatus.WrapUp, cancellationToken); + var recovered = 0; + + foreach (var agent in agents) + { + cancellationToken.ThrowIfCancellationRequested(); + + var interactions = await _interactionManager.ListPendingWrapUpsByAgentAsync(agent.ItemId, cancellationToken); + + if (interactions.Any(interaction => + interaction.WrapUpStartedUtc.HasValue && + interaction.WrapUpStartedUtc.Value + _options.MaximumWrapUpDuration > _clock.UtcNow)) + { + continue; + } + + foreach (var interaction in interactions) + { + interaction.WrapUpCompletedUtc = _clock.UtcNow; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + + AgentProfile updated; + + try + { + updated = await _presenceManager.CompleteWorkAsync(agent.ItemId, cancellationToken); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning( + ex, + "Skipped availability recovery for contended Contact Center agent '{AgentId}'.", + agent.ItemId.SanitizeLogValue()); + + continue; + } + + if (updated is null) + { + continue; + } + + recovered++; + _logger.LogWarning( + "Recovered expired or orphaned after-call work for Contact Center agent '{AgentId}'.", + agent.ItemId.SanitizeLogValue()); + } + + return recovered; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentAvailabilityService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentAvailabilityService.cs new file mode 100644 index 000000000..945b5076c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentAvailabilityService.cs @@ -0,0 +1,140 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Computes routing availability from administrative presence, live sessions, queue opt-in, and capacity. +/// +public sealed class AgentAvailabilityService : IAgentAvailabilityService +{ + private readonly IAgentProfileManager _agentManager; + private readonly IAgentSessionManager _sessionManager; + private readonly IInteractionManager _interactionManager; + private readonly AgentAvailabilityOptions _options; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager. + /// The live agent session manager. + /// The interaction manager. + /// The availability policy. + /// The clock. + public AgentAvailabilityService( + IAgentProfileManager agentManager, + IAgentSessionManager sessionManager, + IInteractionManager interactionManager, + IOptions options, + IClock clock) + { + _agentManager = agentManager; + _sessionManager = sessionManager; + _interactionManager = interactionManager; + _options = options.Value; + _clock = clock; + } + + /// + public async Task GetAsync( + string agentId, + string queueId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + ArgumentException.ThrowIfNullOrEmpty(queueId); + + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is null || + agent.PresenceStatus != AgentPresenceStatus.Available || + !AgentEntitlementUtilities.HasQueueEntitlement(agent, queueId)) + { + return null; + } + + var session = await _sessionManager.FindByUserIdAsync(agent.UserId, cancellationToken); + var liveAfter = _clock.UtcNow - _options.HeartbeatTimeout; + + if (session is null || + !session.IsOnline || + session.ConnectionIds.Count == 0 || + !session.LastHeartbeatUtc.HasValue || + session.LastHeartbeatUtc.Value < liveAfter || + !session.QueueIds.Contains(queueId, StringComparer.OrdinalIgnoreCase)) + { + return null; + } + + var activeInteractionCount = await _interactionManager.CountActiveByAgentAsync(agentId, cancellationToken); + + if (activeInteractionCount >= Math.Max(1, agent.MaxConcurrentInteractions)) + { + return null; + } + + return new AgentAvailability + { + Agent = agent, + LastHeartbeatUtc = session.LastHeartbeatUtc.Value, + ActiveInteractionCount = activeInteractionCount, + }; + } + + /// + public async Task> ListForQueueAsync( + string queueId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + var agents = await _agentManager.ListAvailableForQueueAsync(queueId, cancellationToken); + + if (agents.Count == 0) + { + return []; + } + + var agentsByUserId = agents + .Where(agent => !string.IsNullOrWhiteSpace(agent.UserId)) + .ToDictionary(agent => agent.UserId, StringComparer.Ordinal); + var sessions = await _sessionManager.ListByUserIdsAsync(agentsByUserId.Keys.ToArray(), cancellationToken); + var activeCounts = await _interactionManager.CountActiveByAgentIdsAsync( + agents.Select(agent => agent.ItemId).ToArray(), + cancellationToken); + var liveAfter = _clock.UtcNow - _options.HeartbeatTimeout; + var available = new List(agents.Count); + + foreach (var session in sessions) + { + if (!session.IsOnline || + session.ConnectionIds.Count == 0 || + !session.LastHeartbeatUtc.HasValue || + session.LastHeartbeatUtc.Value < liveAfter || + !session.QueueIds.Contains(queueId, StringComparer.OrdinalIgnoreCase) || + !agentsByUserId.TryGetValue(session.UserId, out var agent)) + { + continue; + } + + activeCounts.TryGetValue(agent.ItemId, out var activeInteractionCount); + + if (activeInteractionCount >= Math.Max(1, agent.MaxConcurrentInteractions)) + { + continue; + } + + available.Add(new AgentAvailability + { + Agent = agent, + LastHeartbeatUtc = session.LastHeartbeatUtc.Value, + ActiveInteractionCount = activeInteractionCount, + }); + } + + return available; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentEntitlementDeniedException.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentEntitlementDeniedException.cs new file mode 100644 index 000000000..0138a9e63 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentEntitlementDeniedException.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// The exception thrown when an agent attempts to sign in to, or remain a member of, every requested +/// queue and campaign without holding a manager-owned entitlement for any of them. +/// +public sealed class AgentEntitlementDeniedException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The Orchard user identifier that was denied. + public AgentEntitlementDeniedException(string userId) + : base("You are not entitled to sign in to any of the requested queues or campaigns. Ask a manager to grant queue or campaign entitlements first.") + { + UserId = userId; + } + + /// + /// Gets the Orchard user identifier that was denied. + /// + public string UserId { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentEntitlementUtilities.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentEntitlementUtilities.cs new file mode 100644 index 000000000..b4e4b31dc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentEntitlementUtilities.cs @@ -0,0 +1,106 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides case-insensitive helpers that keep an agent's live queue and campaign membership constrained +/// to the manager-owned entitlements granted on the agent's profile. Every consumer that reads or writes +/// or for authorization purposes +/// should use these helpers instead of comparing the raw lists directly, so entitlement enforcement stays +/// consistent across sign-in, membership updates, and routing. +/// +public static class AgentEntitlementUtilities +{ + /// + /// Trims, removes blanks from, and de-duplicates a list of identifiers using a case-insensitive comparison. + /// + /// The identifiers to normalize. + /// The normalized, de-duplicated list of identifiers. + public static IList NormalizeIds(IEnumerable ids) + { + if (ids is null) + { + return []; + } + + return ids + .Where(id => !string.IsNullOrWhiteSpace(id)) + .Select(id => id.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + /// + /// Filters the requested identifiers down to the subset that is present in the allowed identifiers, + /// using a case-insensitive comparison. Any requested identifier that is not entitled is silently + /// dropped, so callers can rely on the result never exceeding the granted entitlements. + /// + /// The identifiers being requested, for example from a sign-in form. + /// The manager-owned entitlements the caller is allowed to select from. + /// The entitled subset of . + public static IList FilterEntitled(IEnumerable requestedIds, IEnumerable allowedIds) + { + var allowed = new HashSet(NormalizeIds(allowedIds), StringComparer.OrdinalIgnoreCase); + + return NormalizeIds(requestedIds) + .Where(allowed.Contains) + .ToList(); + } + + /// + /// Determines, using a case-insensitive comparison, whether the agent is entitled to the queue and is + /// currently signed in to it. Fails closed: an agent with no queue entitlements is never entitled, + /// regardless of what contains. + /// + /// The agent profile to check. + /// The queue identifier. + /// when the agent is entitled to and signed in to the queue. + public static bool HasQueueEntitlement(AgentProfile profile, string queueId) + { + ArgumentNullException.ThrowIfNull(profile); + + if (string.IsNullOrEmpty(queueId)) + { + return false; + } + + return profile.QueueIds?.Contains(queueId, StringComparer.OrdinalIgnoreCase) == true && + profile.AllowedQueueIds?.Contains(queueId, StringComparer.OrdinalIgnoreCase) == true; + } + + /// + /// Returns the queues the agent is both entitled to and currently signed in to, using a + /// case-insensitive comparison. This is the effective routing membership: the intersection of + /// and . Fails closed, + /// so an agent with no queue entitlements produces an empty result regardless of live sign-in state. + /// + /// The agent profile to inspect. + /// The normalized, de-duplicated set of entitled and signed-in queue identifiers. + public static IList GetEntitledQueueIds(AgentProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + + return FilterEntitled(profile.QueueIds, profile.AllowedQueueIds); + } + + /// + /// Determines, using a case-insensitive comparison, whether the agent is entitled to the dialer + /// campaign and is currently signed in to it. Fails closed: an agent with no campaign entitlements is + /// never entitled, regardless of what contains. + /// + /// The agent profile to check. + /// The dialer campaign identifier. + /// when the agent is entitled to and signed in to the campaign. + public static bool HasCampaignEntitlement(AgentProfile profile, string campaignId) + { + ArgumentNullException.ThrowIfNull(profile); + + if (string.IsNullOrEmpty(campaignId)) + { + return false; + } + + return profile.CampaignIds?.Contains(campaignId, StringComparer.OrdinalIgnoreCase) == true && + profile.AllowedCampaignIds?.Contains(campaignId, StringComparer.OrdinalIgnoreCase) == true; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs new file mode 100644 index 000000000..bb56d39ba --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -0,0 +1,627 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class AgentPresenceManagerService : IAgentPresenceManager +{ + private static readonly TimeSpan _signInLockTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan _signInLockExpiration = TimeSpan.FromMinutes(1); + + private readonly IAgentProfileManager _agentManager; + private readonly IAgentSessionManager _sessionManager; + private readonly IAgentWorkStateHealingService _agentWorkStateHealingService; + private readonly IContactCenterEventPublisher _publisher; + private readonly IDistributedLock _distributedLock; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager. + /// The optional real-time agent session managers. + /// The optional agent state healers. + /// The Contact Center event publisher. + /// The distributed lock used to serialize sign-in updates. + /// The clock used to stamp presence changes. + /// The logger. + public AgentPresenceManagerService( + IAgentProfileManager agentManager, + IEnumerable sessionManagers, + IEnumerable agentWorkStateHealingServices, + IContactCenterEventPublisher publisher, + IDistributedLock distributedLock, + IClock clock, + ILogger logger) + { + _agentManager = agentManager; + _sessionManager = sessionManagers.FirstOrDefault(); + _agentWorkStateHealingService = agentWorkStateHealingServices.FirstOrDefault(); + _publisher = publisher; + _distributedLock = distributedLock; + _clock = clock; + _logger = logger; + } + + /// + public async Task SignInAsync(string userId, IEnumerable queueIds, IEnumerable campaignIds, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + var selectedQueueIds = queueIds?.Distinct().ToList() ?? []; + var selectedCampaignIds = campaignIds?.Distinct().ToList() ?? []; + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Signing a Contact Center agent in to {QueueCount} queues and {CampaignCount} campaigns.", + selectedQueueIds.Count, + selectedCampaignIds.Count); + } + + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is not null && _agentWorkStateHealingService is not null) + { + await _agentWorkStateHealingService.HealForResetAsync(profile.ItemId, cancellationToken); + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(userId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{userId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + profile = await _agentManager.NewAsync(cancellationToken: cancellationToken); + profile.UserId = userId; + profile.Name = userId; + } + + var entitledQueueIds = AgentEntitlementUtilities.FilterEntitled(selectedQueueIds, profile.AllowedQueueIds); + var entitledCampaignIds = AgentEntitlementUtilities.FilterEntitled(selectedCampaignIds, profile.AllowedCampaignIds); + + if (entitledQueueIds.Count == 0 && entitledCampaignIds.Count == 0) + { + throw new AgentEntitlementDeniedException(userId); + } + + var previousStatus = profile.PresenceStatus; + + profile.QueueIds = entitledQueueIds; + profile.CampaignIds = entitledCampaignIds; + profile.PresenceStatus = AgentPresenceStatus.Available; + profile.RequestedPresenceStatus = null; + profile.PresenceChangedUtc = _clock.UtcNow; + profile.ActiveReservationId = null; + + await SaveAsync(profile, cancellationToken); + await SyncSessionMembershipAsync(userId, profile.QueueIds, profile.CampaignIds, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentSignedIn, profile, previousStatus, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Completed Contact Center sign-in for agent '{AgentId}' and user '{UserId}' with presence '{PresenceStatus}'.", + profile.ItemId.SanitizeLogValue(), + userId.SanitizeLogValue(), + profile.PresenceStatus); + } + + return profile; + } + + /// + public async Task UpdateMembershipsAsync( + string userId, + IEnumerable queueIds, + IEnumerable campaignIds, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(userId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{userId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + return null; + } + + var entitledQueueIds = AgentEntitlementUtilities.FilterEntitled(queueIds, profile.AllowedQueueIds); + var entitledCampaignIds = AgentEntitlementUtilities.FilterEntitled(campaignIds, profile.AllowedCampaignIds); + + if (entitledQueueIds.Count == 0 && entitledCampaignIds.Count == 0) + { + throw new AgentEntitlementDeniedException(userId); + } + + var previousStatus = profile.PresenceStatus; + + profile.QueueIds = entitledQueueIds; + profile.CampaignIds = entitledCampaignIds; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await SyncSessionMembershipAsync(userId, profile.QueueIds, profile.CampaignIds, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentSignedIn, profile, previousStatus, cancellationToken); + + return profile; + } + + /// + public async Task SignOutAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Skipped Contact Center sign-out for user '{UserId}' because no agent profile exists.", userId.SanitizeLogValue()); + } + + return null; + } + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Signing Contact Center agent '{AgentId}' for user '{UserId}' out of {QueueCount} queues and {CampaignCount} campaigns.", + profile.ItemId.SanitizeLogValue(), + userId.SanitizeLogValue(), + profile.QueueIds.Count, + profile.CampaignIds.Count); + } + + if (_agentWorkStateHealingService is not null) + { + await _agentWorkStateHealingService.HealForResetAsync(profile.ItemId, cancellationToken); + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(userId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{userId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + return null; + } + + var previousStatus = profile.PresenceStatus; + + profile.PresenceStatus = AgentPresenceStatus.Offline; + profile.PresenceReason = null; + profile.RequestedPresenceStatus = null; + profile.PresenceChangedUtc = _clock.UtcNow; + profile.QueueIds = []; + profile.CampaignIds = []; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await SyncSessionMembershipAsync(userId, profile.QueueIds, profile.CampaignIds, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentSignedOut, profile, previousStatus, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation("Completed Contact Center sign-out for agent '{AgentId}'.", profile.ItemId.SanitizeLogValue()); + } + + return profile; + } + + /// + public async Task SetPresenceAsync(string userId, AgentPresenceStatus status, string reason, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + profile = await _agentManager.NewAsync(cancellationToken: cancellationToken); + profile.UserId = userId; + profile.Name = userId; + } + else if (_agentWorkStateHealingService is not null && !CanApplyPresenceNow(profile)) + { + // The agent is parked in an on-call presence state (Reserved/Busy/WrapUp or holding a reservation). + // Reconcile against provider truth before deferring the requested change so a call that no longer + // exists on the provider cannot leave the agent stuck and unable to return to a ready state. Live + // provider-backed calls are preserved by the healer, so a genuine in-progress call still defers the + // change as before. + await _agentWorkStateHealingService.HealForResetAsync(profile.ItemId, cancellationToken); + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(userId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{userId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + profile = await _agentManager.NewAsync(cancellationToken: cancellationToken); + profile.UserId = userId; + profile.Name = userId; + } + + var previousStatus = profile.PresenceStatus; + + if (status == AgentPresenceStatus.RequestBreak) + { + profile.RequestedPresenceStatus = AgentPresenceStatus.Break; + + if (CanApplyPresenceNow(profile)) + { + profile.PresenceStatus = AgentPresenceStatus.Break; + profile.RequestedPresenceStatus = null; + } + } + else if (CanApplyPresenceNow(profile)) + { + profile.PresenceStatus = status; + profile.RequestedPresenceStatus = null; + } + else + { + profile.RequestedPresenceStatus = status; + } + + profile.PresenceReason = reason; + profile.PresenceChangedUtc = _clock.UtcNow; + + await SaveAsync(profile, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentPresenceChanged, profile, previousStatus, cancellationToken); + + return profile; + } + + /// + public async Task StartWrapUpAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + var profile = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (profile is null) + { + return null; + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(profile.UserId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{profile.UserId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + profile = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (profile is null) + { + return null; + } + + if (profile.PresenceStatus == AgentPresenceStatus.WrapUp && + string.IsNullOrWhiteSpace(profile.ActiveReservationId)) + { + return profile; + } + + var previousStatus = profile.PresenceStatus; + + profile.PresenceStatus = AgentPresenceStatus.WrapUp; + profile.ActiveReservationId = null; + profile.PresenceChangedUtc = _clock.UtcNow; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentPresenceChanged, profile, previousStatus, cancellationToken); + + return profile; + } + + /// + public async Task CompleteWorkAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + var profile = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (profile is null) + { + return null; + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(profile.UserId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{profile.UserId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + profile = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (profile is null) + { + return null; + } + + if (profile.PresenceStatus is not AgentPresenceStatus.Busy and not AgentPresenceStatus.WrapUp || + !string.IsNullOrWhiteSpace(profile.ActiveReservationId)) + { + return null; + } + + var previousStatus = profile.PresenceStatus; + + profile.PresenceStatus = profile.RequestedPresenceStatus ?? AgentPresenceUtilities.ResolveDefaultReadyState(profile); + profile.RequestedPresenceStatus = null; + profile.ActiveReservationId = null; + profile.PresenceChangedUtc = _clock.UtcNow; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentPresenceChanged, profile, previousStatus, cancellationToken); + + return profile; + } + + /// + public async Task UpdateEntitlementsAsync( + string agentId, + IEnumerable allowedQueueIds, + IEnumerable allowedCampaignIds, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + var profile = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (profile is null) + { + return null; + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + AgentProfileLock.GetKey(profile.UserId), + _signInLockTimeout, + _signInLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent profile for user '{profile.UserId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + profile = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (profile is null) + { + return null; + } + + profile.AllowedQueueIds = AgentEntitlementUtilities.NormalizeIds(allowedQueueIds); + profile.AllowedCampaignIds = AgentEntitlementUtilities.NormalizeIds(allowedCampaignIds); + + var previousQueueIds = profile.QueueIds.ToList(); + var previousCampaignIds = profile.CampaignIds.ToList(); + var prunedQueueIds = AgentEntitlementUtilities.FilterEntitled(profile.QueueIds, profile.AllowedQueueIds); + var prunedCampaignIds = AgentEntitlementUtilities.FilterEntitled(profile.CampaignIds, profile.AllowedCampaignIds); + + var membershipChanged = !prunedQueueIds.SequenceEqual(profile.QueueIds, StringComparer.OrdinalIgnoreCase) || + !prunedCampaignIds.SequenceEqual(profile.CampaignIds, StringComparer.OrdinalIgnoreCase); + + profile.QueueIds = prunedQueueIds; + profile.CampaignIds = prunedCampaignIds; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + var removedQueueIds = previousQueueIds + .Except(profile.QueueIds, StringComparer.OrdinalIgnoreCase) + .ToList(); + var removedCampaignIds = previousCampaignIds + .Except(profile.CampaignIds, StringComparer.OrdinalIgnoreCase) + .ToList(); + + await PublishEntitlementsChangedAsync( + profile, + removedQueueIds, + removedCampaignIds, + cancellationToken); + + if (membershipChanged) + { + await SyncSessionMembershipAsync(profile.UserId, profile.QueueIds, profile.CampaignIds, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Pruned unauthorized Contact Center live queue or campaign membership for agent '{AgentId}' after manager entitlement changes.", + profile.ItemId.SanitizeLogValue()); + } + } + + return profile; + } + + private static bool CanApplyPresenceNow(AgentProfile profile) + { + return string.IsNullOrEmpty(profile.ActiveReservationId) && + profile.PresenceStatus is not AgentPresenceStatus.Reserved and not AgentPresenceStatus.Busy and not AgentPresenceStatus.WrapUp; + } + + private async Task SaveAsync(AgentProfile profile, CancellationToken cancellationToken) + { + var existing = await _agentManager.FindByIdAsync(profile.ItemId, cancellationToken); + + if (existing is null) + { + await _agentManager.CreateAsync(profile, cancellationToken: cancellationToken); + } + else + { + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + } + } + + private async Task SyncSessionMembershipAsync( + string userId, + IEnumerable queueIds, + IEnumerable campaignIds, + CancellationToken cancellationToken) + { + if (_sessionManager is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipped Contact Center live-session membership synchronization for user '{UserId}' because no session manager is registered.", + userId.SanitizeLogValue()); + } + + return; + } + + var session = await _sessionManager.FindByUserIdAsync(userId, cancellationToken); + + if (session is null) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "No live Contact Center agent session exists for user '{UserId}'; profile memberships were saved but no connected session was updated.", + userId.SanitizeLogValue()); + } + + return; + } + + session.QueueIds = queueIds?.Distinct().ToList() ?? []; + session.CampaignIds = campaignIds?.Distinct().ToList() ?? []; + session.ModifiedUtc = _clock.UtcNow; + + await _sessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Synchronized Contact Center session '{SessionId}' for user '{UserId}' with {QueueCount} queues and {CampaignCount} campaigns.", + session.ItemId.SanitizeLogValue(), + userId.SanitizeLogValue(), + session.QueueIds.Count, + session.CampaignIds.Count); + } + } + + private Task PublishAsync( + string eventType, + AgentProfile profile, + AgentPresenceStatus previousStatus, + CancellationToken cancellationToken) + { + var interactionEvent = new InteractionEvent + { + EventType = eventType, + AggregateType = nameof(AgentProfile), + AggregateId = profile.ItemId, + ActorId = profile.UserId, + SourceComponent = ContactCenterConstants.Components.Agents, + }; + + interactionEvent.SetData(new AgentPresenceChangedEventData + { + PreviousStatus = previousStatus, + CurrentStatus = profile.PresenceStatus, + RequestedStatus = profile.RequestedPresenceStatus, + Reason = profile.PresenceReason, + QueueIds = profile.QueueIds.ToList(), + CampaignIds = profile.CampaignIds.ToList(), + ChangedUtc = profile.PresenceChangedUtc ?? _clock.UtcNow, + }); + + return _publisher.PublishAsync(interactionEvent, cancellationToken); + } + + private Task PublishEntitlementsChangedAsync( + AgentProfile profile, + IEnumerable removedQueueIds, + IEnumerable removedCampaignIds, + CancellationToken cancellationToken) + { + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.AgentEntitlementsChanged, + AggregateType = nameof(AgentProfile), + AggregateId = profile.ItemId, + ActorId = ContactCenterConstants.SystemActor, + SourceComponent = ContactCenterConstants.Components.Agents, + }; + + interactionEvent.SetData(new AgentEntitlementsChangedEventData + { + AllowedQueueIds = profile.AllowedQueueIds.ToList(), + AllowedCampaignIds = profile.AllowedCampaignIds.ToList(), + RemovedQueueIds = removedQueueIds.ToList(), + RemovedCampaignIds = removedCampaignIds.ToList(), + }); + + return _publisher.PublishAsync(interactionEvent, cancellationToken); + } + +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceUtilities.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceUtilities.cs new file mode 100644 index 000000000..5f8d0830e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceUtilities.cs @@ -0,0 +1,16 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +internal static class AgentPresenceUtilities +{ + public static AgentPresenceStatus ResolveDefaultReadyState(AgentProfile profile) + { + ArgumentNullException.ThrowIfNull(profile); + + return profile.QueueIds.Count > 0 || profile.CampaignIds.Count > 0 + ? AgentPresenceStatus.Available + : AgentPresenceStatus.Offline; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileLock.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileLock.cs new file mode 100644 index 000000000..6e505f25e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileLock.cs @@ -0,0 +1,9 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +internal static class AgentProfileLock +{ + public static string GetKey(string userId) + { + return $"ContactCenterAgentProfile:{userId}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileManager.cs new file mode 100644 index 000000000..23d623bf3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileManager.cs @@ -0,0 +1,70 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class AgentProfileManager : CatalogManager, IAgentProfileManager +{ + private readonly IAgentProfileStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying agent profile store. + /// The catalog entry handlers for agent profiles. + /// The logger instance. + public AgentProfileManager( + IAgentProfileStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + var profile = await _store.FindByUserIdAsync(userId, cancellationToken); + + if (profile is not null) + { + await LoadAsync(profile, cancellationToken); + } + + return profile; + } + + /// + public async Task> ListAvailableForQueueAsync(string queueId, CancellationToken cancellationToken = default) + { + var profiles = await _store.ListAvailableForQueueAsync(queueId, cancellationToken); + + foreach (var profile in profiles) + { + await LoadAsync(profile, cancellationToken); + } + + return profiles; + } + + /// + public async Task> ListByPresenceAsync( + AgentPresenceStatus presenceStatus, + CancellationToken cancellationToken = default) + { + var profiles = await _store.ListByPresenceAsync(presenceStatus, cancellationToken); + + foreach (var profile in profiles) + { + await LoadAsync(profile, cancellationToken); + } + + return profiles; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileStore.cs new file mode 100644 index 000000000..b99508043 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileStore.cs @@ -0,0 +1,62 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class AgentProfileStore : DocumentCatalog, IAgentProfileStore +{ + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public AgentProfileStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + return await Session.Query( + index => index.UserId == userId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListAvailableForQueueAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + var normalizedQueueId = queueId.ToLowerInvariant(); + var available = await Session.Query( + index => index.QueueId == normalizedQueueId && index.PresenceStatus == AgentPresenceStatus.Available, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return available.ToArray(); + } + + /// + public async Task> ListByPresenceAsync( + AgentPresenceStatus presenceStatus, + CancellationToken cancellationToken = default) + { + return (await Session.Query( + index => index.PresenceStatus == presenceStatus, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken)).ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionManager.cs new file mode 100644 index 000000000..9d72c97a7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionManager.cs @@ -0,0 +1,70 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of that delegates storage to +/// and loads entries through catalog handlers. +/// +public sealed class AgentSessionManager : CatalogManager, IAgentSessionManager +{ + private readonly IAgentSessionStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying agent session store. + /// The catalog entry handlers for agent sessions. + /// The logger instance. + public AgentSessionManager( + IAgentSessionStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + var session = await _store.FindByUserIdAsync(userId, cancellationToken); + + if (session is not null) + { + await LoadAsync(session, cancellationToken); + } + + return session; + } + + /// + public async Task> ListStaleAsync(DateTime heartbeatCutoffUtc, CancellationToken cancellationToken = default) + { + var sessions = await _store.ListStaleAsync(heartbeatCutoffUtc, cancellationToken); + + foreach (var session in sessions) + { + await LoadAsync(session, cancellationToken); + } + + return sessions; + } + + /// + public async Task> ListByUserIdsAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default) + { + var sessions = await _store.ListByUserIdsAsync(userIds, cancellationToken); + + foreach (var session in sessions) + { + await LoadAsync(session, cancellationToken); + } + + return sessions; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionService.cs new file mode 100644 index 000000000..99264eb46 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionService.cs @@ -0,0 +1,319 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . Session writes are +/// serialized per user with a distributed lock so concurrent connects, disconnects, and the stale +/// cleanup pass cannot corrupt the connection list. +/// +public sealed class AgentSessionService : IAgentSessionService +{ + /// + /// The number of seconds without a heartbeat after which a session is considered abandoned. + /// + public const int StaleThresholdSeconds = 90; + + private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan _lockExpiration = TimeSpan.FromSeconds(30); + + private readonly IAgentSessionManager _sessionManager; + private readonly IAgentProfileManager _agentManager; + private readonly IAgentPresenceManager _presenceManager; + private readonly IDistributedLock _distributedLock; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The agent session manager. + /// The agent profile manager. + /// The agent presence manager used to sign out abandoned sessions. + /// The distributed lock used to serialize per-user session writes. + /// The scope executor used to commit heartbeat stamps in their own unit of work. + /// The clock used to stamp session activity. + public AgentSessionService( + IAgentSessionManager sessionManager, + IAgentProfileManager agentManager, + IAgentPresenceManager presenceManager, + IDistributedLock distributedLock, + IContactCenterScopeExecutor scopeExecutor, + IClock clock) + { + _sessionManager = sessionManager; + _agentManager = agentManager; + _presenceManager = presenceManager; + _distributedLock = distributedLock; + _scopeExecutor = scopeExecutor; + _clock = clock; + } + + /// + public async Task ConnectAsync(string userId, string connectionId, string userName, string displayName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + ArgumentException.ThrowIfNullOrEmpty(connectionId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync(GetLockKey(userId), _lockTimeout, _lockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The Contact Center agent session for user '{userId}' is currently being updated."); + } + + await using var acquiredLock = locker; + + var now = _clock.UtcNow; + var session = await _sessionManager.FindByUserIdAsync(userId, cancellationToken); + var isNew = session is null; + + if (isNew) + { + session = await _sessionManager.NewAsync(cancellationToken: cancellationToken); + session.UserId = userId; + session.CreatedUtc = now; + session.ConnectedUtc = now; + } + + if (!session.ConnectionIds.Contains(connectionId)) + { + session.ConnectionIds.Add(connectionId); + } + + session.ConnectedUtc ??= now; + session.IsOnline = session.ConnectionIds.Count > 0; + session.LastHeartbeatUtc = now; + session.ModifiedUtc = now; + + if (!string.IsNullOrEmpty(userName)) + { + session.UserName = userName; + } + + if (!string.IsNullOrEmpty(displayName)) + { + session.DisplayName = displayName; + } + + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is not null) + { + session.QueueIds = AgentEntitlementUtilities.FilterEntitled(profile.QueueIds, profile.AllowedQueueIds); + session.CampaignIds = AgentEntitlementUtilities.FilterEntitled(profile.CampaignIds, profile.AllowedCampaignIds); + + if (string.IsNullOrEmpty(session.DisplayName)) + { + session.DisplayName = profile.DisplayName; + } + } + + if (isNew) + { + await _sessionManager.CreateAsync(session, cancellationToken: cancellationToken); + } + else + { + await _sessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + } + + return session; + } + + /// + public async Task DisconnectAsync(string userId, string connectionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + ArgumentException.ThrowIfNullOrEmpty(connectionId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync(GetLockKey(userId), _lockTimeout, _lockExpiration); + + if (!locked) + { + return null; + } + + await using var acquiredLock = locker; + + var session = await _sessionManager.FindByUserIdAsync(userId, cancellationToken); + + if (session is null) + { + return null; + } + + session.ConnectionIds.Remove(connectionId); + session.IsOnline = session.ConnectionIds.Count > 0; + session.ModifiedUtc = _clock.UtcNow; + + if (!session.IsOnline) + { + session.LastDisconnectedUtc = _clock.UtcNow; + } + + await _sessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + + return session; + } + + /// + public async Task HeartbeatAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + // A heartbeat rewrites the whole session document to move one timestamp, and it arrives from every + // connected agent on a timer, so it is by far the most frequent write this service performs — and the + // document it writes also carries the connection list that connect and disconnect maintain. Two things + // therefore have to hold, and they pull against each other. The heartbeat must not overwrite a + // connection list it read before a concurrent connect committed, which is what the store's + // document-version check exists to prevent; and losing that check must not surface to the agent, whose + // hub call would fail on a timer for a write that carries no information the agent needs. + // + // Neither a lock nor a second read on the ambient unit of work delivers this. Writes here are staged, + // not committed: the version check runs when the shell scope commits, which is after any lock this + // method could take has been released, so two heartbeats can serialize perfectly against each other and + // still collide at commit. And a second read on the ambient session is answered from its identity map, + // so it returns the instance already read rather than the row a concurrent connect committed. + // + // The stamp is applied in its own unit of work instead. A child scope has its own session, so the read + // genuinely reflects what is committed and the connection list written back is the current one, and it + // commits before returning, so a lost version check is raised here rather than thrown at the agent. + var stampedUtc = _clock.UtcNow; + AgentSession stamped = null; + + try + { + await _scopeExecutor.ExecuteAsync(async manager => + { + var current = await manager.FindByUserIdAsync(userId, cancellationToken); + + if (current is null) + { + return; + } + + current.LastHeartbeatUtc = stampedUtc; + current.ModifiedUtc = stampedUtc; + + await manager.UpdateAsync(current, cancellationToken: cancellationToken); + + stamped = current; + }); + } + catch (ConcurrencyException) + { + // Losing the version check means another writer (connect, disconnect, the cleanup pass, or a + // membership sync) committed a newer version of this session while the stamp was in flight. + // Retrying is wrong: connect and the cleanup pass already carry a newer heartbeat, so a retry + // would write an older timestamp over a newer one. Disconnect and membership sync do not advance + // the heartbeat, so a heartbeat lost to one of them records no liveness — that is tolerated + // because neither fires on a timer and the stale threshold spans several heartbeat intervals. + stamped = null; + } + + // Read back on the caller's unit of work, so the caller is told what that unit of work sees, including + // when the stamp lost its race or the session had already been signed out. + return stamped is null + ? await _sessionManager.FindByUserIdAsync(userId, cancellationToken) + : stamped; + } + + /// + public async Task BuildSnapshotAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + var session = await _sessionManager.FindByUserIdAsync(userId, cancellationToken); + + var snapshot = new AgentDesktopSnapshot + { + UserId = userId, + ServerTimeUtc = _clock.UtcNow, + }; + + if (session is not null) + { + snapshot.IsOnline = session.IsOnline; + snapshot.LastHeartbeatUtc = session.LastHeartbeatUtc; + snapshot.DisplayName = session.DisplayName; + } + + if (profile is not null) + { + snapshot.HasProfile = true; + snapshot.PresenceStatus = profile.PresenceStatus.ToString(); + snapshot.PresenceReason = profile.PresenceReason; + snapshot.RequestedPresenceStatus = profile.RequestedPresenceStatus?.ToString(); + snapshot.ActiveReservationId = profile.ActiveReservationId; + snapshot.QueueIds = AgentEntitlementUtilities.FilterEntitled(profile.QueueIds, profile.AllowedQueueIds); + snapshot.CampaignIds = AgentEntitlementUtilities.FilterEntitled(profile.CampaignIds, profile.AllowedCampaignIds); + + if (string.IsNullOrEmpty(snapshot.DisplayName)) + { + snapshot.DisplayName = profile.DisplayName; + } + } + + if (string.IsNullOrEmpty(snapshot.DisplayName)) + { + snapshot.DisplayName = userId; + } + + return snapshot; + } + + /// + public async Task ExpireStaleAsync(CancellationToken cancellationToken = default) + { + var cutoff = _clock.UtcNow.AddSeconds(-StaleThresholdSeconds); + var stale = await _sessionManager.ListStaleAsync(cutoff, cancellationToken); + var count = 0; + + foreach (var candidate in stale) + { + if (string.IsNullOrEmpty(candidate.UserId)) + { + continue; + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync(GetLockKey(candidate.UserId), _lockTimeout, _lockExpiration); + + if (!locked) + { + continue; + } + + await using var acquiredLock = locker; + + var session = await _sessionManager.FindByUserIdAsync(candidate.UserId, cancellationToken); + + if (session is null || (session.LastHeartbeatUtc.HasValue && session.LastHeartbeatUtc.Value >= cutoff)) + { + continue; + } + + var profile = await _agentManager.FindByUserIdAsync(session.UserId, cancellationToken); + + if (profile is not null && profile.PresenceStatus != AgentPresenceStatus.Offline) + { + await _presenceManager.SignOutAsync(session.UserId, cancellationToken); + } + + await _sessionManager.DeleteAsync(session, cancellationToken); + count++; + } + + return count; + } + + private static string GetLockKey(string userId) + { + return $"ContactCenterAgentSession:{userId}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionStore.cs new file mode 100644 index 000000000..bea1f327a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentSessionStore.cs @@ -0,0 +1,115 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; +using YesSql.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class AgentSessionStore : DocumentCatalog, IAgentSessionStore +{ + private const int QueryBatchSize = 500; + + /// + /// The maximum number of stale sessions a single cleanup pass reads. + /// + public const int MaxStaleSessionsPerPass = 500; + + /// + /// Gets a value indicating that agent session updates use YesSql document-version concurrency checks so + /// concurrent connect, heartbeat, and disconnect operations cannot lose active-session state. A losing + /// writer observes a instead of silently overwriting a newer commit. + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public AgentSessionStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + return await Session.Query( + index => index.UserId == userId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.IsOnline) + .ThenByDescending(index => index.LastHeartbeatUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListStaleAsync(DateTime heartbeatCutoffUtc, CancellationToken cancellationToken = default) + { + // Read the index alone rather than the documents, and order the read explicitly. A YesSql document + // query always groups by document identity, and no ordering over the index columns can satisfy that + // grouping, so bounding one makes the engine materialize and sort every stale session before it can + // honor the limit: the cost of a pass would still grow with the whole backlog rather than with the page + // it takes. Worse, a bound on a document query is not free to add — YesSql supplies an ordering by + // document identity when a page is asked for and none is given, so the sort appears whether or not it + // is wanted. An index query carries no such grouping, so ordering by the heartbeat time is answered by + // the retention index that leads with it and the limit stops the read early. + // + // Bounded on purpose. The caller takes a distributed lock, re-reads and deletes for every session this + // returns, so the cost of one cleanup pass is set by the size of the page. An unbounded read makes a + // single incident — a deployment restart that drops every connection at once — hand the pass every + // session in the tenant, and it runs on a schedule, so the pass would still be working when the next one + // starts. The oldest heartbeats come first, so consecutive passes drain the backlog instead of + // re-reading the same page, and what is not expired now is expired by the next pass a minute later, + // which is already the resolution this cleanup has. + var candidates = await Session.QueryIndex( + index => index.IsOnline && index.LastHeartbeatUtc < heartbeatCutoffUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.LastHeartbeatUtc) + .Take(MaxStaleSessionsPerPass) + .ListAsync(cancellationToken); + + var userIds = candidates + .Select(candidate => candidate.UserId) + .Where(userId => !string.IsNullOrEmpty(userId)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (userIds.Length == 0) + { + return []; + } + + return await ListByUserIdsAsync(userIds, cancellationToken); + } + + /// + public async Task> ListByUserIdsAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(userIds); + + if (userIds.Count == 0) + { + return []; + } + + var sessions = new List(); + + foreach (var userIdBatch in userIds.Chunk(QueryBatchSize)) + { + sessions.AddRange(await Session.Query( + index => index.UserId.IsIn(userIdBatch), + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken)); + } + + return sessions; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentStateReasonCodeManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentStateReasonCodeManager.cs new file mode 100644 index 000000000..b8f46adea --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentStateReasonCodeManager.cs @@ -0,0 +1,54 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class AgentStateReasonCodeManager : CatalogManager, IAgentStateReasonCodeManager +{ + private readonly IAgentStateReasonCodeStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying reason code store. + /// The catalog entry handlers for reason codes. + /// The logger instance. + public AgentStateReasonCodeManager( + IAgentStateReasonCodeStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + var reasonCode = await _store.FindByNameAsync(name, cancellationToken); + + if (reasonCode is not null) + { + await LoadAsync(reasonCode, cancellationToken); + } + + return reasonCode; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var reasonCodes = await _store.ListEnabledAsync(cancellationToken); + + foreach (var reasonCode in reasonCodes) + { + await LoadAsync(reasonCode, cancellationToken); + } + + return reasonCodes; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentStateReasonCodeStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentStateReasonCodeStore.cs new file mode 100644 index 000000000..4f255c35a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentStateReasonCodeStore.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class AgentStateReasonCodeStore : DocumentCatalog, IAgentStateReasonCodeStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public AgentStateReasonCodeStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return await Session.Query( + index => index.Name == name, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var reasonCodes = await Session.Query( + index => index.Enabled, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.SortOrder) + .ThenBy(index => index.Name) + .ListAsync(cancellationToken); + + return reasonCodes.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs new file mode 100644 index 000000000..5f40348ee --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs @@ -0,0 +1,318 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Repairs inconsistent agent routing state after restarts, partial failures, or user-initiated resets so +/// stale queue assignments cannot keep blocking future inbound routing. +/// +public sealed class AgentWorkStateHealingService : IAgentWorkStateHealingService +{ + private readonly IAgentProfileManager _agentManager; + private readonly IActivityReservationManager _reservationManager; + private readonly IActivityReservationService _reservationService; + private readonly IQueueItemManager _queueItemManager; + private readonly IInteractionManager _interactionManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IServiceProvider _serviceProvider; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager. + /// The reservation manager. + /// The reservation service. + /// The queue item manager. + /// The interaction manager. + /// The activity manager. + /// The routing-owned work state service. + /// The service provider used to lazily resolve provider synchronization, which breaks the container cycle through and tolerates tenants that have not enabled the Voice feature. + /// The clock. + /// The logger. + public AgentWorkStateHealingService( + IAgentProfileManager agentManager, + IActivityReservationManager reservationManager, + IActivityReservationService reservationService, + IQueueItemManager queueItemManager, + IInteractionManager interactionManager, + IOmnichannelActivityManager activityManager, + IContactCenterWorkStateService workStateService, + IServiceProvider serviceProvider, + IClock clock, + ILogger logger) + { + _agentManager = agentManager; + _reservationManager = reservationManager; + _reservationService = reservationService; + _queueItemManager = queueItemManager; + _interactionManager = interactionManager; + _activityManager = activityManager; + _workStateService = workStateService; + _serviceProvider = serviceProvider; + _clock = clock; + _logger = logger; + } + + /// + public async Task HealForResetAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is null) + { + return 0; + } + + return await HealAsync( + agent, + forceCancelReservation: true, + releaseAssignedWork: true, + cancellationToken); + } + + /// + public async Task HealForAvailabilityAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is null) + { + return 0; + } + + return await HealAsync( + agent, + forceCancelReservation: false, + releaseAssignedWork: agent.PresenceStatus == AgentPresenceStatus.Available, + cancellationToken); + } + + private async Task HealAsync( + AgentProfile agent, + bool forceCancelReservation, + bool releaseAssignedWork, + CancellationToken cancellationToken) + { + var healed = 0; + healed += await HealPendingReservationAsync(agent, forceCancelReservation, cancellationToken); + agent = await _agentManager.FindByIdAsync(agent.ItemId, cancellationToken) ?? agent; + healed += await HealActiveInteractionAsync(agent, releaseAssignedWork, cancellationToken); + + return healed; + } + + private async Task HealPendingReservationAsync(AgentProfile agent, bool forceCancel, CancellationToken cancellationToken) + { + var pendingReservation = !string.IsNullOrWhiteSpace(agent.ActiveReservationId) + ? await _reservationManager.FindByIdAsync(agent.ActiveReservationId, cancellationToken) + : await _reservationManager.FindPendingByAgentAsync(agent.ItemId, cancellationToken); + + if (pendingReservation is null) + { + if (string.IsNullOrWhiteSpace(agent.ActiveReservationId)) + { + return 0; + } + + agent.ActiveReservationId = null; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + + return 1; + } + + if (pendingReservation.Status != ReservationStatus.Pending) + { + if (!string.Equals(agent.ActiveReservationId, pendingReservation.ItemId, StringComparison.Ordinal)) + { + return 0; + } + + agent.ActiveReservationId = null; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + + return 1; + } + + var queueItem = await _queueItemManager.FindByIdAsync(pendingReservation.QueueItemId, cancellationToken); + var activity = await _activityManager.FindByIdAsync(pendingReservation.ActivityItemId, cancellationToken); + var interaction = await _interactionManager.FindByActivityIdAsync(pendingReservation.ActivityItemId, cancellationToken); + var reservationExpired = pendingReservation.ExpiresUtc <= _clock.UtcNow; + var queueItemInvalid = queueItem is null || + queueItem.Status != QueueItemStatus.Reserved || + !string.Equals(queueItem.ReservationId, pendingReservation.ItemId, StringComparison.Ordinal) || + !string.Equals(queueItem.AgentId, agent.ItemId, StringComparison.Ordinal); + var activityInvalid = activity is null; + var interactionInvalid = + activity?.Source == ActivitySources.Inbound + ? interaction is null || + interaction.Status != InteractionStatus.Ringing || + !string.Equals(interaction.AgentId, agent.ItemId, StringComparison.Ordinal) + : interaction is not null && + (interaction.Status != InteractionStatus.Ringing || + !string.Equals(interaction.AgentId, agent.ItemId, StringComparison.Ordinal)); + + if (!forceCancel && !reservationExpired && !queueItemInvalid && !activityInvalid && !interactionInvalid) + { + return 0; + } + + _logger.LogWarning( + "Canceling stale pending reservation '{ReservationId}' for agent '{AgentId}'. ForceCancel={ForceCancel}, Expired={Expired}, QueueItemInvalid={QueueItemInvalid}, ActivityInvalid={ActivityInvalid}, InteractionInvalid={InteractionInvalid}.", + pendingReservation.ItemId.SanitizeLogValue(), + agent.ItemId.SanitizeLogValue(), + forceCancel, + reservationExpired, + queueItemInvalid, + activityInvalid, + interactionInvalid); + + await _reservationService.CancelAsync(pendingReservation.ItemId, cancellationToken); + + return 1; + } + + private async Task HealActiveInteractionAsync(AgentProfile agent, bool releaseAssignedWork, CancellationToken cancellationToken) + { + var interaction = await _interactionManager.FindActiveByAgentAsync(agent.ItemId, cancellationToken); + + if (interaction is null) + { + return 0; + } + + var isProviderBacked = !string.IsNullOrWhiteSpace(interaction.ProviderName) && + !string.IsNullOrWhiteSpace(interaction.ProviderInteractionId); + + if (isProviderBacked) + { + var previousStatus = interaction.Status; + + // Resolved lazily on purpose. Constructor-injecting this service would close a real container + // cycle: IAgentPresenceManager -> IAgentWorkStateHealingService -> IProviderCallStateSynchronizationService + // -> IProviderVoiceEventService -> IAgentPresenceManager. It is also registered by the Voice feature + // while this service is registered by Queues, so a Queues-only tenant legitimately has no + // implementation. GetService (not GetRequiredService) keeps that tenant working instead of throwing: + // without a voice provider there is no provider truth to reconcile against, so the reconciliation + // step is skipped and the provider-backed guards below preserve the interaction rather than + // mutating it on stale information. + var synchronizationService = _serviceProvider.GetService(); + + if (synchronizationService is null) + { + _logger.LogWarning( + "Interaction '{InteractionId}' is provider-backed but no {ServiceName} is registered, so provider truth cannot be reconciled. Enable the Voice feature to restore provider-state healing.", + interaction.ItemId.SanitizeLogValue(), + nameof(IProviderCallStateSynchronizationService)); + } + else + { + interaction = await synchronizationService.RefreshInteractionAsync(interaction, cancellationToken); + + if (interaction.Status is InteractionStatus.Ended or InteractionStatus.Failed) + { + return interaction.Status == previousStatus ? 0 : 1; + } + } + } + + if (interaction.Status == InteractionStatus.Ringing) + { + if (isProviderBacked) + { + // Provider truth did not confirm this call ended, so it is either still ringing on the provider + // or the provider was momentarily unreachable. Never requeue a provider-backed call from here: + // requeuing would either yank a live ringing call away from the agent or resurrect a dead call + // in an endless offer loop. The periodic provider-truth reconciliation releases it once the + // provider confirms the call no longer exists. + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Leaving provider-backed ringing interaction '{InteractionId}' for agent '{AgentId}' under provider control; reconciliation releases it once the provider confirms it ended.", + interaction.ItemId.SanitizeLogValue(), + agent.ItemId.SanitizeLogValue()); + } + + return 0; + } + + _logger.LogWarning( + "Clearing stale ringing interaction '{InteractionId}' for agent '{AgentId}' because no active reservation remains.", + interaction.ItemId.SanitizeLogValue(), + agent.ItemId.SanitizeLogValue()); + + await RequeueInteractionAsync(interaction, cancellationToken); + + return 1; + } + + if (!releaseAssignedWork || + interaction.Status is not (InteractionStatus.Connected or InteractionStatus.Held or InteractionStatus.Transferring or InteractionStatus.Conferenced) || + string.IsNullOrWhiteSpace(interaction.QueueId)) + { + return 0; + } + + if (isProviderBacked) + { + _logger.LogWarning( + "Preserving provider-backed active interaction '{InteractionId}' for agent '{AgentId}' while the agent is being reset or marked available.", + interaction.ItemId.SanitizeLogValue(), + agent.ItemId.SanitizeLogValue()); + + return 0; + } + + _logger.LogWarning( + "Releasing stale assigned interaction '{InteractionId}' for agent '{AgentId}' because the agent is being reset or marked available while the interaction is still active.", + interaction.ItemId.SanitizeLogValue(), + agent.ItemId.SanitizeLogValue()); + + await RequeueInteractionAsync(interaction, cancellationToken); + + return 1; + } + + private async Task RequeueInteractionAsync(Interaction interaction, CancellationToken cancellationToken) + { + var queueItem = await _queueItemManager.FindByActivityIdAsync(interaction.ActivityItemId, cancellationToken); + + if (queueItem is not null && + queueItem.Status is QueueItemStatus.Reserved or QueueItemStatus.Assigned) + { + queueItem.TransitionTo(QueueItemStatus.Waiting); + queueItem.ReservationId = null; + queueItem.AgentId = null; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + + interaction.Requeue(); + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + await _workStateService.MutateAsync(interaction.ActivityItemId, workState => + { + workState.TransitionTo(ActivityAssignmentStatus.Available); + workState.AssignedToId = null; + workState.AssignedToUsername = null; + workState.AssignedToUtc = null; + workState.ReservationId = null; + workState.ReservedById = null; + workState.ReservedByUsername = null; + workState.ReservedUtc = null; + workState.ReservationExpiresUtc = null; + }, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AnswerProviderCommandTypeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AnswerProviderCommandTypeExecutor.cs new file mode 100644 index 000000000..f1ebe00f9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AnswerProviderCommandTypeExecutor.cs @@ -0,0 +1,548 @@ +using System.Globalization; +using System.Text.Json; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Handles provider-command execution for commands. +/// It connects the accepted inbound call through the live provider when available, falls back to the +/// default telephony facade when no voice provider is resolved, and projects the resulting call state +/// onto the interaction and call session models. +/// +public sealed class AnswerProviderCommandTypeExecutor : IProviderCommandTypeExecutor +{ + private const string OwnerMetadataKey = "providerCommandOwner"; + + private static readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly ITelephonyService _telephonyService; + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly ICallControlAuthorizationService _callControlAuthorizationService; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The resolver used to locate the live voice provider. + /// The fallback telephony service used when no live voice provider exists. + /// The manager used to load and update the interaction projection. + /// The manager used to load and update the call session projection. + /// The Contact Center event publisher. + /// The clock used to stamp UTC projections. + /// The shared call-control authorization boundary. + public AnswerProviderCommandTypeExecutor( + IContactCenterVoiceProviderResolver voiceProviderResolver, + ITelephonyService telephonyService, + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IContactCenterEventPublisher publisher, + IClock clock, + ICallControlAuthorizationService callControlAuthorizationService) + { + _voiceProviderResolver = voiceProviderResolver; + _telephonyService = telephonyService; + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _callControlAuthorizationService = callControlAuthorizationService; + _publisher = publisher; + _clock = clock; + } + + /// + public ProviderCommandType CommandType => ProviderCommandType.Answer; + + /// + public async Task CanDispatchAsync(ProviderCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + if (command.CommandType != ProviderCommandType.Answer || + string.IsNullOrWhiteSpace(command.ProviderName) || + string.IsNullOrWhiteSpace(command.RequestPayload)) + { + return false; + } + + var request = TryDeserializeRequest(command.RequestPayload); + + if (request is null || + string.IsNullOrWhiteSpace(request.ActivityId) || + string.IsNullOrWhiteSpace(request.InteractionId) || + string.IsNullOrWhiteSpace(request.ProviderCallId) || + string.IsNullOrWhiteSpace(request.AgentId) || + string.IsNullOrWhiteSpace(request.AgentUserId) || + string.IsNullOrWhiteSpace(request.QueueId)) + { + return false; + } + + var interaction = await _interactionManager.FindByIdAsync(request.InteractionId, cancellationToken); + + if (interaction is null || IsTerminal(interaction.Status) || + !string.Equals(interaction.ProviderInteractionId, request.ProviderCallId, StringComparison.Ordinal)) + { + return false; + } + + var session = await _callSessionManager.FindByInteractionIdAsync(request.InteractionId, cancellationToken); + + if (session is null || IsTerminal(session.State) || + !string.Equals(session.ProviderCallId, request.ProviderCallId, StringComparison.Ordinal)) + { + return false; + } + + var authorization = await _callControlAuthorizationService.AuthorizeAsync(new CallControlAuthorizationContext + { + UserId = request.AgentUserId, + Verb = CallControlVerb.Accept, + InteractionId = request.InteractionId, + ProviderName = command.ProviderName, + ProviderCallId = request.ProviderCallId, + }, cancellationToken); + + return authorization.Succeeded; + } + + /// + public async Task ExecuteAsync( + ProviderCommand command, + ProviderCommandClaim claim, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(claim); + + var request = DeserializeRequest(command); + + if (request is null) + { + throw new JsonException("The provider command request payload could not be deserialized."); + } + + if (string.IsNullOrWhiteSpace(request.ProviderCallId)) + { + throw new InvalidOperationException("The provider answer request is missing a provider call identifier."); + } + + if (string.IsNullOrWhiteSpace(request.InteractionId) || + string.IsNullOrWhiteSpace(request.ActivityId) || + string.IsNullOrWhiteSpace(request.AgentId) || + string.IsNullOrWhiteSpace(request.AgentUserId) || + string.IsNullOrWhiteSpace(request.QueueId)) + { + throw new InvalidOperationException("The provider answer request is incomplete."); + } + + var provider = _voiceProviderResolver.Get(command.ProviderName); + + var authorization = await _callControlAuthorizationService.AuthorizeAsync(new CallControlAuthorizationContext + { + UserId = request.AgentUserId, + Verb = CallControlVerb.Accept, + InteractionId = request.InteractionId, + ProviderName = command.ProviderName, + ProviderCallId = request.ProviderCallId, + }, cancellationToken); + + if (!authorization.Succeeded) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + OutcomeUnknown = true, + ProviderName = command.ProviderName, + ProviderCallId = request.ProviderCallId, + ErrorMessage = authorization.FailureReason, + }; + } + + if (provider is IContactCenterVoiceCallControlProvider callControlProvider) + { + var providerRequest = CreateProviderConnectRequest(request, claim); + var providerResult = await callControlProvider.ConnectToAgentAsync(providerRequest, cancellationToken); + + return NormalizeProviderResult( + providerResult, + ResolveProviderName(command.ProviderName, provider.TechnicalName), + request.ProviderCallId); + } + + if (_telephonyService is null) + { + throw new InvalidOperationException("No telephony service is available to answer the inbound call."); + } + + var callReference = CreateCallReference(request, claim); + var telephonyResult = await _telephonyService.AnswerAsync(callReference, cancellationToken); + + return ConvertTelephonyResult(telephonyResult, command.ProviderName, request.ProviderCallId); + } + + /// + public async Task ProjectSuccessAsync( + ProviderCommand command, + ContactCenterVoiceProviderResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(result); + + var request = DeserializeRequest(command); + + if (request is null) + { + throw new JsonException("The provider command request payload could not be deserialized."); + } + + var providerName = ResolveProviderName(command.ProviderName, result.ProviderName); + + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + if (interaction is not null) + { + interaction.AgentId = request.AgentId; + interaction.QueueId = request.QueueId; + interaction.ProviderName = providerName; + + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + + var session = await _callSessionManager.FindByInteractionIdAsync(command.InteractionId, cancellationToken); + + if (session is not null) + { + session.AgentId = request.AgentId; + session.QueueId = request.QueueId; + session.ProviderName = providerName; + + // The provider reports the leg it created for the agent through the neutral result contract, so the + // agent becomes a real party on the topology instead of a name on the session. Without this the + // bridge would only ever show the customer and nothing could reconstruct who was talking to whom. + // The caller can hang up while the agent is being connected, and this projection runs after that + // provider round-trip. A terminal session has already ended every leg and closed its bridge, so + // adding the agent now would contradict the record and be refused at persist time, failing the + // whole accept rather than the one late join that caused it. + if (!string.IsNullOrEmpty(result.ProviderLegId) && !IsTerminal(session.State)) + { + var now = _clock.UtcNow; + + CallTopologyProjector.UpsertLeg( + session, + result.ProviderLegId, + CallPartyRole.Agent, + CallLegStatus.Answered, + now, + agentId: request.AgentId); + + CallTopologyProjector.EnsureBridge(session, session.Bridge?.ProviderBridgeId, now); + CallTopologyProjector.Join(session, result.ProviderLegId, CallPartyRole.Agent, now, request.AgentId); + } + + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + } + } + + /// + public async Task ProjectFailureAsync(ProviderCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + var request = DeserializeRequest(command); + + if (request is null) + { + throw new JsonException("The provider command request payload could not be deserialized."); + } + + var now = _clock.UtcNow; + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + if (interaction is not null) + { + // The caller can hang up while the agent is being connected, and this projection runs after that + // provider round-trip, so a gone call is the most likely reason the answer failed in the first + // place. A settled interaction has already recorded its outcome; overwriting it with the failure of + // an answer nobody was waiting for would replace the real ending with an artifact of the retry. + if (!interaction.IsSettled) + { + if (request.ReofferOnFailure) + { + interaction.Reoffer(); + } + else + { + interaction.TransitionTo(InteractionStatus.Failed); + } + + interaction.EndedUtc = request.ReofferOnFailure ? null : now; + } + + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + + var session = await _callSessionManager.FindByInteractionIdAsync(command.InteractionId, cancellationToken); + + if (session is not null && !CallSessionLifecycle.IsTerminal(session.State)) + { + session.TransitionTo(request.ReofferOnFailure + ? VoiceCallState.Ringing + : VoiceCallState.Ended); + session.EndedUtc = request.ReofferOnFailure ? null : now; + + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + } + + if (request.ReofferOnFailure) + { + await PublishAsync( + ContactCenterConstants.Events.OfferRequeued, + nameof(ActivityReservation), + string.IsNullOrWhiteSpace(command.ReservationId) ? command.ActivityItemId : command.ReservationId, + request.AgentId, + command.CommandId, + command.InteractionId, + cancellationToken, + new OfferDeclinedEventData + { + QueueId = request.QueueId, + }); + + return; + } + + await PublishAsync( + ContactCenterConstants.Events.CallEnded, + nameof(Interaction), + command.InteractionId, + request.AgentId, + command.CommandId, + command.InteractionId, + cancellationToken); + } + + /// + public async Task ProjectOutcomeUnknownAsync( + ProviderCommand command, + string errorCode, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + var request = DeserializeRequest(command); + + if (request is null) + { + throw new JsonException("The provider command request payload could not be deserialized."); + } + + var providerErrorCode = string.IsNullOrWhiteSpace(errorCode) + ? "provider_answer_unknown" + : errorCode; + + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + if (interaction is not null) + { + interaction.TechnicalMetadata["providerErrorCode"] = providerErrorCode; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + + var session = await _callSessionManager.FindByInteractionIdAsync(command.InteractionId, cancellationToken); + + if (session is not null) + { + session.Metadata["providerErrorCode"] = providerErrorCode; + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + } + } + + private static ContactCenterConnectRequest CreateProviderConnectRequest( + ProviderAnswerCommandRequest request, + ProviderCommandClaim claim) + { + var connectRequest = new ContactCenterConnectRequest + { + ActivityId = request.ActivityId, + InteractionId = request.InteractionId, + ProviderCallId = request.ProviderCallId, + AgentId = request.AgentId, + AgentUserId = request.AgentUserId, + QueueId = request.QueueId, + }; + + StampMetadata(connectRequest.Metadata, claim); + + return connectRequest; + } + + private static CallReference CreateCallReference( + ProviderAnswerCommandRequest request, + ProviderCommandClaim claim) + { + var callReference = new CallReference + { + CallId = request.ProviderCallId, + Metadata = new Dictionary(), + }; + + StampMetadata(callReference.Metadata, claim); + + return callReference; + } + + private static void StampMetadata(IDictionary metadata, ProviderCommandClaim claim) + { + metadata[ContactCenterConstants.CommandMetadata.CommandId] = claim.CommandId; + metadata[ContactCenterConstants.CommandMetadata.FenceToken] = claim.FenceToken.ToString(CultureInfo.InvariantCulture); + metadata[OwnerMetadataKey] = claim.OwnerToken; + } + + private static void StampMetadata(IDictionary metadata, ProviderCommandClaim claim) + { + metadata[ContactCenterConstants.CommandMetadata.CommandId] = claim.CommandId; + metadata[ContactCenterConstants.CommandMetadata.FenceToken] = claim.FenceToken.ToString(CultureInfo.InvariantCulture); + metadata[OwnerMetadataKey] = claim.OwnerToken; + } + + private static ContactCenterVoiceProviderResult ConvertTelephonyResult( + TelephonyResult result, + string providerName, + string providerCallId) + { + if (result is null) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + OutcomeUnknown = true, + ProviderName = providerName, + ProviderCallId = providerCallId, + ErrorMessage = "The telephony service did not return a result.", + }; + } + + return new ContactCenterVoiceProviderResult + { + Succeeded = result.Succeeded, + OutcomeUnknown = result.OutcomeUnknown, + ProviderName = providerName, + ProviderCallId = result.Call?.CallId ?? providerCallId, + ErrorMessage = result.Error, + }; + } + + private static ContactCenterVoiceProviderResult NormalizeProviderResult( + ContactCenterVoiceProviderResult result, + string providerName, + string providerCallId) + { + if (result is null) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + OutcomeUnknown = true, + ProviderName = providerName, + ProviderCallId = providerCallId, + ErrorMessage = "The provider did not return a result.", + }; + } + + if (string.IsNullOrWhiteSpace(result.ProviderName)) + { + result.ProviderName = providerName; + } + + if (string.IsNullOrWhiteSpace(result.ProviderCallId)) + { + result.ProviderCallId = providerCallId; + } + + return result; + } + + private static string ResolveProviderName(string commandProviderName, string resultProviderName) + { + return string.IsNullOrWhiteSpace(resultProviderName) + ? commandProviderName + : resultProviderName; + } + + private static ProviderAnswerCommandRequest TryDeserializeRequest(string requestPayload) + { + if (string.IsNullOrWhiteSpace(requestPayload)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(requestPayload, _serializerOptions); + } + catch (JsonException) + { + return null; + } + } + + private static ProviderAnswerCommandRequest DeserializeRequest(ProviderCommand command) + { + return TryDeserializeRequest(command.RequestPayload); + } + + private static bool IsTerminal(InteractionStatus status) + { + return status is InteractionStatus.Ended or InteractionStatus.Failed; + } + + private static bool IsTerminal(VoiceCallState state) + { + return state is VoiceCallState.Ended or + VoiceCallState.Failed or + VoiceCallState.NoAnswer or + VoiceCallState.Rejected or + VoiceCallState.Canceled or + VoiceCallState.Transferred; + } + + private Task PublishAsync( + string eventType, + string aggregateType, + string aggregateId, + string actorId, + string commandId, + string interactionId, + CancellationToken cancellationToken, + object data = null) + { + var interactionEvent = new InteractionEvent + { + EventType = eventType, + InteractionId = interactionId, + AggregateType = aggregateType, + AggregateId = aggregateId, + ActorId = actorId, + SourceComponent = ContactCenterConstants.Components.Voice, + IdempotencyKey = ContactCenterClaimKeys.BuildProviderDomainEventIdempotencyKey(commandId, eventType), + }; + + if (data is not null) + { + interactionEvent.SetData(data); + } + + return _publisher.PublishAsync(interactionEvent, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/BusinessHoursCalendarManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/BusinessHoursCalendarManager.cs new file mode 100644 index 000000000..8a0f3ca78 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/BusinessHoursCalendarManager.cs @@ -0,0 +1,54 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class BusinessHoursCalendarManager : CatalogManager, IBusinessHoursCalendarManager +{ + private readonly IBusinessHoursCalendarStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying calendar store. + /// The catalog entry handlers for calendars. + /// The logger instance. + public BusinessHoursCalendarManager( + IBusinessHoursCalendarStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + var calendar = await _store.FindByNameAsync(name, cancellationToken); + + if (calendar is not null) + { + await LoadAsync(calendar, cancellationToken); + } + + return calendar; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var calendars = await _store.ListEnabledAsync(cancellationToken); + + foreach (var calendar in calendars) + { + await LoadAsync(calendar, cancellationToken); + } + + return calendars; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/BusinessHoursCalendarStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/BusinessHoursCalendarStore.cs new file mode 100644 index 000000000..554682e37 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/BusinessHoursCalendarStore.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class BusinessHoursCalendarStore : DocumentCatalog, IBusinessHoursCalendarStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public BusinessHoursCalendarStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return await Session.Query( + index => index.Name == name, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var calendars = await Session.Query( + index => index.Enabled, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return calendars.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallControlAuthorizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallControlAuthorizationService.cs new file mode 100644 index 000000000..122b8b189 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallControlAuthorizationService.cs @@ -0,0 +1,173 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Default implementation of the shared fail-closed call-control authorization boundary. +/// +public sealed class CallControlAuthorizationService : ICallControlAuthorizationService +{ + private static readonly HashSet _systemInitiatedVerbs = + [ + CallControlVerb.Decline, + CallControlVerb.Voicemail, + ]; + + private readonly IAgentProfileManager _agentManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IInteractionManager _interactionManager; + private readonly ISupervisorQueueAuthorizationService _supervisorQueueAuthorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager used to resolve the caller. + /// The call session manager used to resolve logical calls. + /// The interaction manager used to resolve system-initiated calls. + /// The supervisor queue authorization service. + public CallControlAuthorizationService( + IAgentProfileManager agentManager, + ICallSessionManager callSessionManager, + IInteractionManager interactionManager, + ISupervisorQueueAuthorizationService supervisorQueueAuthorizationService) + { + _agentManager = agentManager; + _callSessionManager = callSessionManager; + _interactionManager = interactionManager; + _supervisorQueueAuthorizationService = supervisorQueueAuthorizationService; + } + + /// + public async Task AuthorizeAsync( + CallControlAuthorizationContext context, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Initiator == CallControlInitiator.System) + { + return await AuthorizeSystemAsync(context, cancellationToken); + } + + if (string.IsNullOrWhiteSpace(context.UserId) || + string.IsNullOrWhiteSpace(context.InteractionId)) + { + return CallControlAuthorizationResult.Denied(); + } + + var agent = await _agentManager.FindByUserIdAsync(context.UserId, cancellationToken); + + if (agent is null) + { + return CallControlAuthorizationResult.Denied(); + } + + var session = await _callSessionManager.FindByInteractionIdAsync(context.InteractionId, cancellationToken); + + if (session is null || IsTerminal(session.State) || string.IsNullOrWhiteSpace(session.ProviderCallId)) + { + return CallControlAuthorizationResult.Denied(); + } + + if (!string.IsNullOrWhiteSpace(context.ProviderName) && + !string.Equals(session.ProviderName, context.ProviderName, StringComparison.Ordinal)) + { + return CallControlAuthorizationResult.Denied(); + } + + if (!string.IsNullOrWhiteSpace(context.ProviderCallId) && + !string.Equals(session.ProviderCallId, context.ProviderCallId, StringComparison.Ordinal)) + { + return CallControlAuthorizationResult.Denied(); + } + + if (context.SupervisorOperation) + { + return await _supervisorQueueAuthorizationService.IsAuthorizedAsync( + context.Principal, + context.UserId, + session.QueueId, + cancellationToken) + ? CallControlAuthorizationResult.Success(agent.ItemId, session) + : CallControlAuthorizationResult.Denied(); + } + + if (!string.Equals(session.AgentId, agent.ItemId, StringComparison.Ordinal)) + { + return CallControlAuthorizationResult.Denied(); + } + + return CallControlAuthorizationResult.Success(agent.ItemId, session); + } + + private async Task AuthorizeSystemAsync( + CallControlAuthorizationContext context, + CancellationToken cancellationToken) + { + // A system-initiated operation has no requesting principal, so the ownership check that protects an + // agent request cannot apply. It is restricted to the terminal verbs the platform actually issues so a + // future caller cannot reach a privileged verb by declaring itself the system, and it must never claim + // supervisor privilege, which is only ever granted to a resolved principal. + if (context.SupervisorOperation || + !_systemInitiatedVerbs.Contains(context.Verb) || + string.IsNullOrWhiteSpace(context.InteractionId)) + { + return CallControlAuthorizationResult.Denied(); + } + + // The provider call identifier is resolved from the interaction rather than the call session, because a + // call terminated at a closed entry point or an unroutable queue is rejected before any provider event + // has been ingested and therefore has no session yet. + var interaction = await _interactionManager.FindByIdAsync(context.InteractionId, cancellationToken); + + if (interaction is null || + IsTerminal(interaction.Status) || + string.IsNullOrWhiteSpace(interaction.ProviderInteractionId)) + { + return CallControlAuthorizationResult.Denied(); + } + + if (!string.IsNullOrWhiteSpace(context.ProviderName) && + !string.Equals(interaction.ProviderName, context.ProviderName, StringComparison.Ordinal)) + { + return CallControlAuthorizationResult.Denied(); + } + + if (!string.IsNullOrWhiteSpace(context.ProviderCallId) && + !string.Equals(interaction.ProviderInteractionId, context.ProviderCallId, StringComparison.Ordinal)) + { + return CallControlAuthorizationResult.Denied(); + } + + var session = await _callSessionManager.FindByInteractionIdAsync(context.InteractionId, cancellationToken); + + if (session is not null) + { + if (IsTerminal(session.State) || string.IsNullOrWhiteSpace(session.ProviderCallId)) + { + return CallControlAuthorizationResult.Denied(); + } + + return CallControlAuthorizationResult.Success(session.AgentId, session); + } + + return CallControlAuthorizationResult.Success(interaction.AgentId, interaction.ProviderInteractionId); + } + + private static bool IsTerminal(InteractionStatus status) + { + return status is InteractionStatus.Ended or InteractionStatus.Failed; + } + + private static bool IsTerminal(VoiceCallState state) + { + return state is VoiceCallState.Ended or + VoiceCallState.Failed or + VoiceCallState.NoAnswer or + VoiceCallState.Rejected or + VoiceCallState.Canceled or + VoiceCallState.Transferred; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs new file mode 100644 index 000000000..65390d575 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs @@ -0,0 +1,71 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of that delegates storage to +/// and loads entries through catalog handlers. +/// +public sealed class CallSessionManager : CatalogManager, ICallSessionManager +{ + private readonly ICallSessionStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying call session store. + /// The catalog entry handlers for call sessions. + /// The logger instance. + public CallSessionManager( + ICallSessionStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByProviderCallIdAsync(string providerCallId, CancellationToken cancellationToken = default) + { + var session = await _store.FindByProviderCallIdAsync(providerCallId, cancellationToken); + + if (session is not null) + { + await LoadAsync(session, cancellationToken); + } + + return session; + } + + /// + public async Task FindByProviderCallIdAsync( + string providerName, + string providerCallId, + CancellationToken cancellationToken = default) + { + var session = await _store.FindByProviderCallIdAsync(providerName, providerCallId, cancellationToken); + + if (session is not null) + { + await LoadAsync(session, cancellationToken); + } + + return session; + } + + /// + public async Task FindByInteractionIdAsync(string interactionId, CancellationToken cancellationToken = default) + { + var session = await _store.FindByInteractionIdAsync(interactionId, cancellationToken); + + if (session is not null) + { + await LoadAsync(session, cancellationToken); + } + + return session; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs new file mode 100644 index 000000000..985db2eb9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs @@ -0,0 +1,188 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class CallSessionStore : DocumentCatalog, ICallSessionStore +{ + /// + /// Gets a value indicating that call session updates use YesSql document-version concurrency checks so + /// concurrent provider-event ingestion cannot lose or reverse a high-water/state update. A losing writer + /// observes a instead of silently overwriting a newer commit. + /// + protected override bool CheckConcurrency => true; + + /// + protected override ValueTask SavingAsync(CallSession record) + { + ValidateTopology(record); + + return ValueTask.CompletedTask; + } + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public CallSessionStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByProviderCallIdAsync(string providerCallId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerCallId); + + return await Session.Query( + index => index.ProviderCallId == providerCallId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task FindByProviderCallIdAsync( + string providerName, + string providerCallId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + ArgumentException.ThrowIfNullOrEmpty(providerCallId); + + return await Session.Query( + index => index.ProviderName == providerName && + index.ProviderCallId == providerCallId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task FindByInteractionIdAsync(string interactionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(interactionId); + + return await Session.Query( + index => index.InteractionId == interactionId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task CountActiveAsync(CancellationToken cancellationToken = default) + { + return await Session.Query( + index => index.EndedUtc == null, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + private static void ValidateTopology(CallSession record) + { + if (!string.IsNullOrEmpty(record.AgentSessionId) && + string.IsNullOrEmpty(record.AgentId)) + { + throw new InvalidOperationException("A Contact Center call session cannot claim an agent session without an owning agent."); + } + + foreach (var leg in record.Legs) + { + if (leg.EndedUtc.HasValue && leg.EndedUtc.Value < leg.StartedUtc) + { + throw new InvalidOperationException("A Contact Center call leg cannot end before it started."); + } + } + + if (record.Bridge is not null) + { + ValidateBridge(record.Bridge); + } + + foreach (var priorBridge in record.PriorBridges) + { + ValidateBridge(priorBridge); + + // A bridge is retained only because the parties were moved off it, so it is closed by definition. + // Retaining an open one would let two bridges each claim to hold the call's live membership. + if (!priorBridge.DestroyedUtc.HasValue) + { + throw new InvalidOperationException("A retained Contact Center bridge must have been destroyed."); + } + } + + foreach (var monitorSession in record.MonitorSessions) + { + if (string.IsNullOrEmpty(monitorSession.SupervisorUserId)) + { + throw new InvalidOperationException("A Contact Center monitor session cannot exist without a supervisor."); + } + + // Both sides of this comparison are agent-profile identifiers. Comparing the supervisor's user + // identifier here would make the guard unfalsifiable, because the two live in different spaces. + if (!string.IsNullOrEmpty(monitorSession.SupervisorAgentId) && + string.Equals(monitorSession.SupervisorAgentId, record.AgentId, StringComparison.Ordinal)) + { + throw new InvalidOperationException("A Contact Center call session supervisor cannot monitor their own agent leg."); + } + + if (monitorSession.EndedUtc.HasValue && monitorSession.EndedUtc.Value < monitorSession.StartedUtc) + { + throw new InvalidOperationException("A Contact Center monitor session cannot end before it started."); + } + } + + // A supervisor engaged twice on one call would produce two live legs the platform cannot tell apart, + // so a stop would release an arbitrary one and leave the other listening. + var duplicateSupervisor = record.MonitorSessions + .Where(monitorSession => monitorSession.IsActive) + .GroupBy(monitorSession => monitorSession.SupervisorUserId, StringComparer.Ordinal) + .Any(group => group.Count() > 1); + + if (duplicateSupervisor) + { + throw new InvalidOperationException("A Contact Center supervisor cannot hold more than one live engagement on the same call."); + } + + foreach (var consult in record.Consults) + { + if (string.IsNullOrEmpty(consult.InitiatedByAgentId)) + { + throw new InvalidOperationException("A Contact Center consult cannot exist without the agent that placed it."); + } + + var isTerminal = consult.Status is ConsultCallStatus.Completed + or ConsultCallStatus.Cancelled + or ConsultCallStatus.Failed; + + if (isTerminal && !consult.EndedUtc.HasValue) + { + throw new InvalidOperationException("A finished Contact Center consult must record when it ended."); + } + } + } + + private static void ValidateBridge(Bridge bridge) + { + foreach (var participant in bridge.Participants) + { + if (participant.LeftUtc.HasValue && participant.LeftUtc.Value < participant.JoinedUtc) + { + throw new InvalidOperationException("A Contact Center bridge participant cannot leave before it joined."); + } + } + + if (bridge.DestroyedUtc.HasValue && + bridge.Participants.Any(participant => participant.LeftUtc is null)) + { + throw new InvalidOperationException("A destroyed Contact Center bridge cannot retain a participant that never left."); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallTopologyProjector.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallTopologyProjector.cs new file mode 100644 index 000000000..a42d5c79a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallTopologyProjector.cs @@ -0,0 +1,647 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Applies live call topology transitions to a . This is the only place in the +/// product that mutates legs, bridges, and bridge membership, so the rules that keep membership history +/// append-only and internally consistent exist once instead of in every caller. +/// +public static class CallTopologyProjector +{ + /// + /// Records a leg on the session, or updates the leg already recorded under the same provider identifier. + /// + /// The call session. + /// The provider identifier of the leg. + /// The part the leg's party plays in the call. + /// The normalized lifecycle state of the leg. + /// The current UTC time, used as the start time of a leg seen for the first time. + /// The optional address of the party on the leg. + /// The optional agent identifier when the leg belongs to an agent or a supervisor. + /// The recorded leg, or when no leg identifier was supplied. + public static CallLeg UpsertLeg( + CallSession session, + string providerLegId, + CallPartyRole role, + CallLegStatus status, + DateTime utcNow, + string address = null, + string agentId = null) + { + ArgumentNullException.ThrowIfNull(session); + + if (string.IsNullOrEmpty(providerLegId)) + { + return null; + } + + var leg = FindLeg(session, providerLegId); + + if (leg is null) + { + leg = new CallLeg + { + ProviderLegId = providerLegId, + StartedUtc = utcNow, + }; + + session.Legs.Add(leg); + } + + leg.Status = status; + + if (role != CallPartyRole.Unknown) + { + leg.Role = role; + } + + if (!string.IsNullOrEmpty(address)) + { + leg.Address = address; + } + + if (!string.IsNullOrEmpty(agentId)) + { + leg.AgentId = agentId; + } + + if (status == CallLegStatus.Answered && !leg.AnsweredUtc.HasValue) + { + leg.AnsweredUtc = utcNow; + } + + return leg; + } + + /// + /// Marks a leg as finished and removes its party from the bridge it was on. + /// + /// The call session. + /// The provider identifier of the leg. + /// The UTC time the leg ended. + /// The provider-neutral reason the leg ended. + public static void EndLeg( + CallSession session, + string providerLegId, + DateTime endedUtc, + HangupCause? hangupCause = null) + { + ArgumentNullException.ThrowIfNull(session); + + var leg = FindLeg(session, providerLegId); + + if (leg is null) + { + return; + } + + EndLegCore(session, leg, endedUtc, hangupCause); + } + + /// + /// Ends every leg the session still believes is up. + /// + /// + /// A call that has reached a terminal state takes every one of its legs with it. Ending only the leg the + /// provider named would leave the remaining legs live forever, because a terminal session accepts no + /// further provider deliveries that could close them. + /// + /// The call session. + /// The UTC time the legs ended. + public static void EndRemainingLegs(CallSession session, DateTime endedUtc) + { + ArgumentNullException.ThrowIfNull(session); + + foreach (var leg in session.Legs) + { + if (leg is null || leg.EndedUtc.HasValue) + { + continue; + } + + EndLegCore(session, leg, endedUtc, hangupCause: null); + } + } + + /// + /// Closes every supervisor engagement the session still believes is live. + /// + /// + /// Stopping an engagement is refused once the call is terminal, so an engagement that outlives its call can + /// never be closed by the supervisor who opened it. Left alone it would report a supervisor as listening to + /// a call that ended, which is the same "nothing can say who is on this call" failure the topology exists to + /// remove, only inverted. + /// + /// The call session. + /// The UTC time the engagements ended. + public static void EndRemainingMonitorSessions(CallSession session, DateTime endedUtc) + { + ArgumentNullException.ThrowIfNull(session); + + foreach (var monitorSession in session.MonitorSessions) + { + if (monitorSession is null || monitorSession.EndedUtc.HasValue) + { + continue; + } + + monitorSession.EndedUtc = endedUtc < monitorSession.StartedUtc + ? monitorSession.StartedUtc + : endedUtc; + + if (!string.IsNullOrEmpty(monitorSession.ProviderLegId)) + { + Leave(session, monitorSession.ProviderLegId, endedUtc); + } + } + } + + private static void EndLegCore( + CallSession session, + CallLeg leg, + DateTime endedUtc, + HangupCause? hangupCause) + { + // A leg that never reached an answered state failed rather than cleared, which is what lets abandon + // and no-answer reporting tell the two apart without re-reading the provider's own vocabulary. + leg.Status = leg.AnsweredUtc.HasValue + ? CallLegStatus.Ended + : CallLegStatus.Failed; + + // A provider may stamp a hangup behind the state change that preceded it, and the leg's start may come + // from a different clock than its end. A leg that ends before it starts is not a fact, so the end is + // clamped rather than persisted as an inversion the store would reject. + leg.EndedUtc ??= endedUtc < leg.StartedUtc + ? leg.StartedUtc + : endedUtc; + leg.HangupCause ??= hangupCause; + + Leave(session, leg.ProviderLegId, endedUtc); + } + + /// + /// Ensures the session has a bridge under the given provider identifier, creating one when it does not. + /// + /// The call session. + /// The provider identifier of the media topology. + /// The UTC time the bridge was observed. + /// The session's bridge. + public static Bridge EnsureBridge(CallSession session, string providerBridgeId, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(session); + + // A new provider topology identifier means the parties were moved to different media, so the previous + // membership window is closed and retained rather than continued under the new identifier. + if (session.Bridge is not null && + !string.IsNullOrEmpty(providerBridgeId) && + !string.IsNullOrEmpty(session.Bridge.ProviderBridgeId) && + !string.Equals(session.Bridge.ProviderBridgeId, providerBridgeId, StringComparison.Ordinal)) + { + DestroyBridge(session, utcNow); + + session.PriorBridges.Add(session.Bridge); + session.Bridge = null; + } + + if (session.Bridge is null) + { + session.Bridge = new Bridge + { + ProviderBridgeId = providerBridgeId, + Kind = BridgeKind.TwoParty, + CreatedUtc = utcNow, + }; + + return session.Bridge; + } + + if (string.IsNullOrEmpty(session.Bridge.ProviderBridgeId)) + { + session.Bridge.ProviderBridgeId = providerBridgeId; + } + + return session.Bridge; + } + + /// + /// Records a party joining the session's bridge. + /// + /// The call session. + /// The provider identifier of the joining leg. + /// The part the joining party plays in the call. + /// The UTC time the party joined. + /// The optional agent identifier when the party is an agent or a supervisor. + /// The optional address of the joining party. + public static void Join( + CallSession session, + string providerLegId, + CallPartyRole role, + DateTime joinedUtc, + string agentId = null, + string address = null) + { + ArgumentNullException.ThrowIfNull(session); + + // A destroyed bridge is a closed membership window. Admitting a live participant to it would both + // contradict the record and violate the store's rule that a destroyed bridge retains no live member, + // which would fail the whole delivery rather than the one late join that caused it. + if (string.IsNullOrEmpty(providerLegId) || + session.Bridge is null || + session.Bridge.DestroyedUtc.HasValue) + { + return; + } + + if (FindActiveParticipant(session.Bridge, providerLegId) is not null) + { + return; + } + + session.Bridge.Participants.Add(new BridgeParticipant + { + ProviderLegId = providerLegId, + Role = role, + AgentId = agentId, + Address = address, + JoinedUtc = joinedUtc, + }); + + RefreshKind(session.Bridge); + } + + /// + /// Records a party leaving the session's bridge. The membership record is retained with its end time so + /// the bridge's membership at a past instant stays reconstructible. + /// + /// The call session. + /// The provider identifier of the leaving leg. + /// The UTC time the party left. + public static void Leave(CallSession session, string providerLegId, DateTime leftUtc) + { + ArgumentNullException.ThrowIfNull(session); + + if (string.IsNullOrEmpty(providerLegId) || session.Bridge is null) + { + return; + } + + var participant = FindActiveParticipant(session.Bridge, providerLegId); + + if (participant is null) + { + return; + } + + participant.LeftUtc = leftUtc < participant.JoinedUtc + ? participant.JoinedUtc + : leftUtc; + + RefreshKind(session.Bridge); + } + + /// + /// Closes the session's bridge and every membership still open on it. + /// + /// The call session. + /// The UTC time the bridge was destroyed. + public static void DestroyBridge(CallSession session, DateTime destroyedUtc) + { + ArgumentNullException.ThrowIfNull(session); + + if (session.Bridge is null) + { + return; + } + + foreach (var participant in session.Bridge.Participants) + { + if (participant.LeftUtc is null) + { + participant.LeftUtc = destroyedUtc < participant.JoinedUtc + ? participant.JoinedUtc + : destroyedUtc; + } + } + + session.Bridge.DestroyedUtc ??= destroyedUtc; + session.Bridge.ReportedParticipantCount = null; + } + + /// + /// Applies the conference flag and participant count a provider reports. + /// + /// + /// A provider that publishes only a number cannot say who those parties are. The number is therefore + /// stored as the provider's reported count and is never turned into invented membership records, so the + /// membership history stays a record of parties the platform actually observed. + /// + /// The call session. + /// The provider's conference flag, when it published one. + /// The provider's live participant count, when it published one. + /// The UTC time the report was observed. + public static void ApplyReportedParticipation( + CallSession session, + bool? isConference, + int? participantCount, + DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(session); + + if (!isConference.HasValue && !participantCount.HasValue) + { + return; + } + + var bridge = EnsureBridge(session, session.Bridge?.ProviderBridgeId, utcNow); + + if (participantCount.HasValue) + { + bridge.ReportedParticipantCount = Math.Max(0, participantCount.Value); + } + + if (isConference.HasValue) + { + bridge.Kind = isConference.Value + ? BridgeKind.Conference + : BridgeKind.TwoParty; + + return; + } + + bridge.Kind = bridge.ReportedParticipantCount >= 3 + ? BridgeKind.Conference + : BridgeKind.TwoParty; + } + + /// + /// Records a link from this session to another call. + /// + /// The call session. + /// How the related call relates to this one. + /// The UTC time the relationship was established. + /// The optional identifier of the related call session. + /// The optional identifier of the related interaction. + /// The optional provider call identifier of the related call. + public static void Relate( + CallSession session, + CallRelationshipKind kind, + DateTime establishedUtc, + string relatedCallSessionId = null, + string relatedInteractionId = null, + string relatedProviderCallId = null) + { + ArgumentNullException.ThrowIfNull(session); + + if (string.IsNullOrEmpty(relatedCallSessionId) && + string.IsNullOrEmpty(relatedInteractionId) && + string.IsNullOrEmpty(relatedProviderCallId)) + { + return; + } + + var existing = session.Relationships.FirstOrDefault(relationship => + relationship.Kind == kind && + string.Equals(relationship.RelatedCallSessionId, relatedCallSessionId, StringComparison.Ordinal) && + string.Equals(relationship.RelatedInteractionId, relatedInteractionId, StringComparison.Ordinal) && + string.Equals(relationship.RelatedProviderCallId, relatedProviderCallId, StringComparison.Ordinal)); + + if (existing is not null) + { + return; + } + + session.Relationships.Add(new CallRelationship + { + Kind = kind, + RelatedCallSessionId = relatedCallSessionId, + RelatedInteractionId = relatedInteractionId, + RelatedProviderCallId = relatedProviderCallId, + EstablishedUtc = establishedUtc, + }); + } + + /// + /// Records a consult the agent placed before deciding whether to complete a warm transfer. Calling this + /// again with the same consult identifier advances the existing consult instead of adding a second one. + /// + /// The call session that owns the consult. + /// The platform identifier of the consult. + /// The agent that placed the consult. + /// The kind of destination that was consulted. + /// The identifier of the consulted destination. + /// The resolved address of the consulted destination. + /// The UTC time the consult was placed. + /// The provider identifier of the consult leg when the provider reports one. + /// The consult that was created or advanced. + public static ConsultCall StartConsult( + CallSession session, + string consultId, + string initiatedByAgentId, + InteractionTransferTargetType targetType, + string targetId, + string targetAddress, + DateTime startedUtc, + string providerLegId = null) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrEmpty(consultId); + + var consult = FindConsult(session, consultId); + + if (consult is null) + { + consult = new ConsultCall + { + ConsultId = consultId, + StartedUtc = startedUtc, + Status = ConsultCallStatus.Initiated, + }; + + session.Consults.Add(consult); + } + + consult.InitiatedByAgentId = initiatedByAgentId; + consult.TargetType = targetType; + consult.TargetId = targetId; + consult.TargetAddress = targetAddress; + + if (!string.IsNullOrEmpty(providerLegId)) + { + consult.ProviderLegId = providerLegId; + + UpsertLeg(session, providerLegId, CallPartyRole.Consult, CallLegStatus.Dialing, startedUtc, targetAddress); + } + + return consult; + } + + /// + /// Advances a consult to a new lifecycle state. Terminal states stamp the end time, and a consult that + /// reaches a terminal state also ends the consult leg so no leg is left dangling. + /// + /// The call session that owns the consult. + /// The platform identifier of the consult. + /// The state the consult moved to. + /// The UTC time the transition was observed. + public static void AdvanceConsult( + CallSession session, + string consultId, + ConsultCallStatus status, + DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(session); + + var consult = FindConsult(session, consultId); + + if (consult is null) + { + return; + } + + consult.Status = status; + + if (status == ConsultCallStatus.Connected && consult.ConnectedUtc is null) + { + consult.ConnectedUtc = utcNow; + } + + if (status is ConsultCallStatus.Completed or ConsultCallStatus.Cancelled or ConsultCallStatus.Failed) + { + consult.EndedUtc ??= utcNow; + + if (!string.IsNullOrEmpty(consult.ProviderLegId)) + { + EndLeg(session, consult.ProviderLegId, utcNow); + Leave(session, consult.ProviderLegId, utcNow); + } + } + } + + /// + /// Opens a supervisor engagement on the session. Barge places the supervisor in the conversation itself so + /// the topology gains a party; listening and whispering do not, because the supervisor hears the call without + /// being one of the parties on it. + /// + /// The call session being supervised. + /// The platform identifier of the engagement. + /// The user identifier of the supervisor that engaged. + /// + /// The supervisor's agent-profile identifier when the supervisor has one, so the engagement stays joinable + /// against the agent it targets. A supervisor without an agent profile has none. + /// + /// The engagement mode. + /// The UTC time the engagement started. + /// The provider identifier of the supervisor leg when the provider reports one. + /// The recorded engagement. + public static MonitorSession StartMonitorSession( + CallSession session, + string monitorSessionId, + string supervisorUserId, + string supervisorAgentId, + MonitorMode mode, + DateTime startedUtc, + string providerLegId = null) + { + ArgumentNullException.ThrowIfNull(session); + ArgumentException.ThrowIfNullOrEmpty(monitorSessionId); + + var monitorSession = new MonitorSession + { + MonitorSessionId = monitorSessionId, + SupervisorUserId = supervisorUserId, + SupervisorAgentId = supervisorAgentId, + TargetAgentId = session.AgentId, + Mode = mode, + ProviderLegId = providerLegId, + StartedUtc = startedUtc, + }; + + session.MonitorSessions.Add(monitorSession); + + if (mode == MonitorMode.Barge && !string.IsNullOrEmpty(providerLegId)) + { + EnsureBridge(session, session.Bridge?.ProviderBridgeId, startedUtc); + Join(session, providerLegId, CallPartyRole.Supervisor, startedUtc, supervisorAgentId); + } + + return monitorSession; + } + + /// + /// Closes the live supervisor engagement a supervisor holds on the session and releases the supervisor's + /// bridge membership when the engagement had placed them on the bridge. + /// + /// The call session being supervised. + /// The user identifier of the supervisor whose engagement ends. + /// The UTC time the engagement ended. + /// when a live engagement was found and closed. + public static bool EndMonitorSession(CallSession session, string supervisorUserId, DateTime endedUtc) + { + ArgumentNullException.ThrowIfNull(session); + + var live = session.ActiveMonitorSessions.FirstOrDefault(monitorSession => + string.Equals(monitorSession.SupervisorUserId, supervisorUserId, StringComparison.Ordinal)); + + if (live is null) + { + return false; + } + + live.EndedUtc = endedUtc; + + if (!string.IsNullOrEmpty(live.ProviderLegId)) + { + Leave(session, live.ProviderLegId, endedUtc); + } + + return true; + } + + private static ConsultCall FindConsult(CallSession session, string consultId) + { + if (string.IsNullOrEmpty(consultId)) + { + return null; + } + + return session.Consults.FirstOrDefault(consult => + string.Equals(consult.ConsultId, consultId, StringComparison.Ordinal)); + } + + private static CallLeg FindLeg(CallSession session, string providerLegId) + { + if (string.IsNullOrEmpty(providerLegId)) + { + return null; + } + + return session.Legs.FirstOrDefault(leg => + string.Equals(leg.ProviderLegId, providerLegId, StringComparison.Ordinal)); + } + + private static BridgeParticipant FindActiveParticipant(Bridge bridge, string providerLegId) + { + return bridge.Participants.FirstOrDefault(participant => + participant.LeftUtc is null && + string.Equals(participant.ProviderLegId, providerLegId, StringComparison.Ordinal)); + } + + private static void RefreshKind(Bridge bridge) + { + // A provider that publishes its own live count has already decided the shape; the topology is only + // inferred from observed membership when the provider is not reporting one. + if (bridge.ReportedParticipantCount.HasValue) + { + return; + } + + var active = bridge.Participants.Count(participant => participant.LeftUtc is null); + + bridge.Kind = active >= 3 + ? BridgeKind.Conference + : BridgeKind.TwoParty; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestManager.cs new file mode 100644 index 000000000..42eeba6d3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestManager.cs @@ -0,0 +1,41 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class CallbackRequestManager : CatalogManager, ICallbackRequestManager +{ + private readonly ICallbackRequestStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying callback store. + /// The catalog entry handlers for callbacks. + /// The logger instance. + public CallbackRequestManager( + ICallbackRequestStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task> ListDueAsync(DateTime utcNow, int maxCount, CancellationToken cancellationToken = default) + { + var callbacks = await _store.ListDueAsync(utcNow, maxCount, cancellationToken); + + foreach (var callback in callbacks) + { + await LoadAsync(callback, cancellationToken); + } + + return callbacks; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestStore.cs new file mode 100644 index 000000000..bbd7deda1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestStore.cs @@ -0,0 +1,41 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class CallbackRequestStore : DocumentCatalog, ICallbackRequestStore +{ + private const int DefaultBatchSize = 100; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public CallbackRequestStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task> ListDueAsync(DateTime utcNow, int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? DefaultBatchSize : maxCount; + var callbacks = await Session.Query( + index => index.Status == CallbackRequestStatus.Pending && + index.ScheduledUtc <= utcNow && + (index.LeaseExpiresUtc == null || index.LeaseExpiresUtc <= utcNow), + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.ScheduledUtc) + .Take(take) + .ListAsync(cancellationToken); + + return callbacks.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackService.cs new file mode 100644 index 000000000..bc12feaeb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackService.cs @@ -0,0 +1,186 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class CallbackService : ICallbackService +{ + /// + /// The maximum number of due callbacks promoted in one background pass. + /// + public const int MaxBatchSize = 100; + + private static readonly TimeSpan _promotionLease = TimeSpan.FromMinutes(5); + + private readonly ICallbackRequestManager _callbackManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IActivityQueueService _queueService; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The callback manager. + /// The CRM activity manager. + /// The routing-owned work state service. + /// The queue service used to enqueue promoted callbacks. + /// The Contact Center event publisher. + /// The clock used to stamp callback times. + public CallbackService( + ICallbackRequestManager callbackManager, + IOmnichannelActivityManager activityManager, + IContactCenterWorkStateService workStateService, + IActivityQueueService queueService, + IContactCenterEventPublisher publisher, + IClock clock) + { + _callbackManager = callbackManager; + _activityManager = activityManager; + _workStateService = workStateService; + _queueService = queueService; + _publisher = publisher; + _clock = clock; + } + + /// + public async Task ScheduleAsync(CallbackRequest callback, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callback); + + var now = _clock.UtcNow; + + if (callback.RequestedUtc == default) + { + callback.RequestedUtc = now; + } + + if (callback.ScheduledUtc == default) + { + callback.ScheduledUtc = now; + } + + callback.Status = CallbackRequestStatus.Pending; + + await _callbackManager.CreateAsync(callback, cancellationToken: cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.CallbackScheduled, callback, cancellationToken); + + return callback; + } + + /// + public async Task PromoteDueAsync(CancellationToken cancellationToken = default) + { + var now = _clock.UtcNow; + var due = await _callbackManager.ListDueAsync(now, MaxBatchSize, cancellationToken); + var count = 0; + + foreach (var callback in due) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!await TryClaimAsync(callback, cancellationToken)) + { + continue; + } + + var activity = await CreateActivityAsync(callback, cancellationToken); + callback.ActivityItemId = activity.ItemId; + callback.Status = CallbackRequestStatus.Scheduled; + + // This is the age settled callbacks are purged by. Without it the row is never selected by retention. + callback.ModifiedUtc = now; + + callback.Attempts++; + callback.OwnerToken = null; + callback.LeaseExpiresUtc = null; + + await _callbackManager.UpdateAsync(callback, cancellationToken: cancellationToken); + + if (!string.IsNullOrEmpty(callback.QueueId)) + { + await _queueService.EnqueueAsync(activity.ItemId, callback.QueueId, priority: null, cancellationToken); + } + + await PublishAsync(ContactCenterConstants.Events.CallbackPromoted, callback, cancellationToken); + + count++; + } + + return count; + } + + private async Task TryClaimAsync(CallbackRequest callback, CancellationToken cancellationToken) + { + var now = _clock.UtcNow; + + if (callback.Status != CallbackRequestStatus.Pending || + (callback.LeaseExpiresUtc.HasValue && callback.LeaseExpiresUtc.Value > now)) + { + return false; + } + + callback.OwnerToken = Guid.NewGuid().ToString("N"); + callback.FenceToken++; + callback.LeaseExpiresUtc = now.Add(_promotionLease); + + try + { + await _callbackManager.UpdateAsync(callback, cancellationToken: cancellationToken); + } + catch (ConcurrencyException) + { + return false; + } + + return true; + } + + private async Task CreateActivityAsync(CallbackRequest callback, CancellationToken cancellationToken) + { + var now = _clock.UtcNow; + var activity = await _activityManager.NewAsync(cancellationToken: cancellationToken); + activity.Kind = ActivityKind.Call; + activity.Source = ActivitySources.Callback; + activity.InteractionType = ActivityInteractionType.Manual; + activity.PreferredDestination = callback.Destination; + activity.CampaignId = callback.CampaignId; + activity.ContactContentItemId = callback.ContactContentItemId; + activity.ContactContentType = callback.ContactContentType; + + // The callback row is purged once it settles, so anything recorded only there has to move with the work. + activity.Notes = callback.Notes; + activity.Status = ActivityStatus.NotStated; + activity.ScheduledUtc = now; + activity.CreatedUtc = now; + + await _activityManager.CreateAsync(activity, cancellationToken: cancellationToken); + + await _workStateService.MutateAsync( + activity.ItemId, + workState => workState.TransitionTo(ActivityAssignmentStatus.Available), + cancellationToken); + + return activity; + } + + private Task PublishAsync(string eventType, CallbackRequest callback, CancellationToken cancellationToken) + { + return _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + AggregateType = nameof(CallbackRequest), + AggregateId = callback.ItemId, + SourceComponent = ContactCenterConstants.Components.Dialer, + }, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CapacityRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CapacityRoutingStrategy.cs new file mode 100644 index 000000000..b861fde31 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CapacityRoutingStrategy.cs @@ -0,0 +1,54 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Rejects agents that are already handling their maximum number of concurrent interactions so that +/// routing never offers new work to an agent who is at capacity. +/// +public sealed class CapacityRoutingStrategy : IActivityRoutingStrategy +{ + private readonly IInteractionManager _interactionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager used to count active agent interactions. + public CapacityRoutingStrategy(IInteractionManager interactionManager) + { + _interactionManager = interactionManager; + } + + /// + public int Order => 20; + + /// + public async ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + foreach (var candidate in context.Candidates) + { + if (!candidate.IsEligible) + { + continue; + } + + var capacity = candidate.Agent.MaxConcurrentInteractions > 0 + ? candidate.Agent.MaxConcurrentInteractions + : 1; + + var activeCount = await _interactionManager.CountActiveByAgentAsync(candidate.Agent.ItemId, cancellationToken); + + if (activeCount >= capacity) + { + candidate.IsEligible = false; + candidate.AddReason($"At capacity ({activeCount}/{capacity} active interactions)."); + } + else + { + candidate.AddReason($"Has spare capacity ({activeCount}/{capacity} active interactions)."); + } + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterActivityDialerContributor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterActivityDialerContributor.cs new file mode 100644 index 000000000..41e5e2dd4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterActivityDialerContributor.cs @@ -0,0 +1,77 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Contributes Contact Center dialer profiles and queueing to Omnichannel activity management. +/// +public sealed class ContactCenterActivityDialerContributor : IActivityDialerContributor +{ + private readonly IDialerProfileManager _dialerProfileManager; + private readonly IActivityQueueService _activityQueueService; + + /// + /// Initializes a new instance of the class. + /// + /// The dialer profile manager. + /// The activity queue service. + public ContactCenterActivityDialerContributor( + IDialerProfileManager dialerProfileManager, + IActivityQueueService activityQueueService) + { + _dialerProfileManager = dialerProfileManager; + _activityQueueService = activityQueueService; + } + + /// + public async Task> GetProfilesAsync( + CancellationToken cancellationToken = default) + { + var profiles = await _dialerProfileManager.GetAllAsync(cancellationToken); + + return profiles.Select(CreateDescriptor); + } + + /// + public async Task FindByIdAsync( + string profileId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(profileId); + + var profile = await _dialerProfileManager.FindByIdAsync(profileId, cancellationToken); + + return profile is null ? null : CreateDescriptor(profile); + } + + /// + public async Task EnqueueAsync( + string activityId, + ActivityDialerProfileDescriptor profile, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(activityId); + ArgumentNullException.ThrowIfNull(profile); + ArgumentException.ThrowIfNullOrWhiteSpace(profile.RoutingTargetId); + + await _activityQueueService.EnqueueAsync( + activityId, + profile.RoutingTargetId, + priority: null, + cancellationToken); + } + + private static ActivityDialerProfileDescriptor CreateDescriptor(DialerProfile profile) + { + return new ActivityDialerProfileDescriptor + { + ProfileId = profile.ItemId, + DisplayName = profile.Name ?? profile.ItemId, + ActivitySource = DialerActivitySourceHelper.GetActivitySource(profile.Mode), + CampaignId = profile.CampaignId, + RoutingTargetId = profile.QueueId, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterActivityWriter.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterActivityWriter.cs new file mode 100644 index 000000000..c15d7ada1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterActivityWriter.cs @@ -0,0 +1,138 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.Extensions.Logging; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterActivityWriter : IContactCenterActivityWriter +{ + private const int MaxActivityWriteAttempts = 3; + + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly ISession _session; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The CRM activity manager. + /// The executor used to defer and retry the write outside the routing scope. + /// The YesSql session used to commit the write on its own. + /// The logger. + public ContactCenterActivityWriter( + IOmnichannelActivityManager activityManager, + IContactCenterScopeExecutor scopeExecutor, + ISession session, + ILogger logger) + { + _activityManager = activityManager; + _scopeExecutor = scopeExecutor; + _session = session; + _logger = logger; + } + + /// + public Task ScheduleUpdateAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutate); + + if (string.IsNullOrEmpty(activityItemId)) + { + return Task.CompletedTask; + } + + if (_scopeExecutor.ScheduleAfterCommit( + writer => writer.UpdateAsync(activityItemId, mutate, CancellationToken.None))) + { + return Task.CompletedTask; + } + + // Without a shell scope there is nothing to defer to, so the mutation joins the caller's session and + // is committed by whoever owns it, exactly as it was before the write was moved out of routing. + return ApplyAsync(activityItemId, mutate, save: false, cancellationToken); + } + + /// + public async Task UpdateAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutate); + + if (string.IsNullOrEmpty(activityItemId)) + { + return; + } + + try + { + await ApplyAsync(activityItemId, mutate, save: true, cancellationToken); + } + catch (ConcurrencyException) + { + await RetryInFreshScopeAsync(activityItemId, mutate, cancellationToken); + } + } + + private async Task ApplyAsync( + string activityItemId, + Action mutate, + bool save, + CancellationToken cancellationToken) + { + var activity = await _activityManager.FindByIdAsync(activityItemId, cancellationToken); + + if (activity is null) + { + return; + } + + mutate(activity); + + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + + if (save) + { + await _session.SaveChangesAsync(cancellationToken); + } + } + + private async Task RetryInFreshScopeAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken) + { + Exception lastException = null; + + for (var attempt = 2; attempt <= MaxActivityWriteAttempts; attempt++) + { + try + { + await _scopeExecutor.ExecuteAsync(writer => + writer.UpdateAsync(activityItemId, mutate, cancellationToken)); + + return; + } + catch (ConcurrencyException exception) + { + lastException = exception; + } + } + + _logger.LogWarning( + lastException, + "Unable to apply a Contact Center write to the CRM activity '{ActivityItemId}' after {Attempts} attempts.", + activityItemId.SanitizeLogValue(), + MaxActivityWriteAttempts); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterAssistService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterAssistService.cs new file mode 100644 index 000000000..4f2e4d094 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterAssistService.cs @@ -0,0 +1,59 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterAssistService : IContactCenterAssistService +{ + private readonly IEnumerable _providers; + + /// + /// Initializes a new instance of the class. + /// + /// The registered assist providers. + public ContactCenterAssistService(IEnumerable providers) + { + _providers = providers; + } + + /// + public bool IsAvailable => _providers.Any(); + + /// + public async Task SuggestDispositionAsync(AssistContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + foreach (var provider in _providers.OrderBy(provider => provider.Order)) + { + var suggestion = await provider.SuggestDispositionAsync(context, cancellationToken); + + if (suggestion is not null) + { + return suggestion; + } + } + + return null; + } + + /// + public async Task SummarizeAsync(AssistContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + foreach (var provider in _providers.OrderBy(provider => provider.Order)) + { + var summary = await provider.SummarizeAsync(context, cancellationToken); + + if (!string.IsNullOrEmpty(summary)) + { + return summary; + } + } + + return null; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs new file mode 100644 index 000000000..2ceef3a8c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs @@ -0,0 +1,363 @@ +using System.Text.Json; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . It performs +/// offer acceptance, media connection, and state advancement as one server-side transaction so the +/// orchestration state and the provider media state stay consistent. +/// +public sealed class ContactCenterCallCommandService : IContactCenterCallCommandService +{ + private readonly IActivityReservationService _reservationService; + private readonly IActivityReservationManager _reservationManager; + private readonly IInteractionManager _interactionManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IDialerProfileManager _dialerProfileManager; + private readonly IDialerAttemptService _dialerAttemptService; + private readonly IAgentProfileManager _agentManager; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly ICallSessionManager _callSessionManager; + private readonly IProviderCommandStateService _providerCommandStateService; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The reservation service used to accept or reject the offer. + /// The reservation manager used to validate offer ownership before state changes. + /// The interaction manager used to advance the interaction. + /// The activity manager used to resolve non-media work offers. + /// The optional dialer profile managers. + /// The optional dialer attempt services. + /// The agent profile manager used to resolve the reserved agent. + /// The voice provider resolver used to determine the delivery model. + /// The call session manager used to project the voice session. + /// The service used to persist server-side answer intent. + /// The executor used for post-commit command processing and isolated compensation. + /// The Contact Center event publisher. + /// The clock used to stamp times. + public ContactCenterCallCommandService( + IActivityReservationService reservationService, + IActivityReservationManager reservationManager, + IInteractionManager interactionManager, + IOmnichannelActivityManager activityManager, + IEnumerable dialerProfileManagers, + IEnumerable dialerAttemptServices, + IAgentProfileManager agentManager, + IContactCenterVoiceProviderResolver voiceProviderResolver, + ICallSessionManager callSessionManager, + IProviderCommandStateService providerCommandStateService, + IContactCenterScopeExecutor scopeExecutor, + IContactCenterEventPublisher publisher, + IClock clock) + { + _reservationService = reservationService; + _reservationManager = reservationManager; + _interactionManager = interactionManager; + _activityManager = activityManager; + _dialerProfileManager = dialerProfileManagers.FirstOrDefault(); + _dialerAttemptService = dialerAttemptServices.FirstOrDefault(); + _agentManager = agentManager; + _voiceProviderResolver = voiceProviderResolver; + _callSessionManager = callSessionManager; + _providerCommandStateService = providerCommandStateService; + _scopeExecutor = scopeExecutor; + _publisher = publisher; + _clock = clock; + } + + /// + public async Task AcceptInboundOfferAsync(string reservationId, string agentUserId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + ArgumentException.ThrowIfNullOrEmpty(agentUserId); + + var reservation = await FindAuthorizedPendingReservationAsync(reservationId, agentUserId, cancellationToken); + + if (reservation is null) + { + return CallCommandResult.Failure("The offer is no longer available."); + } + + var interaction = await _interactionManager.FindByActivityIdAsync(reservation.ActivityItemId, cancellationToken); + + if (interaction is null) + { + var activity = await _activityManager.FindByIdAsync(reservation.ActivityItemId, cancellationToken); + + if (activity?.Source == ActivitySources.PreviewDial && + _dialerProfileManager is not null && + _dialerAttemptService is not null && + !string.IsNullOrWhiteSpace(activity.CampaignId)) + { + var profile = await _dialerProfileManager.FindByCampaignAsync(activity.CampaignId, cancellationToken); + + if (profile is not null) + { + return await _dialerAttemptService.TryDialAsync(profile, reservation, cancellationToken) + ? CallCommandResult.Success("The outbound call was started.", requiresDeviceAnswer: false) + : CallCommandResult.Failure("The outbound call could not be started."); + } + } + + reservation = await _reservationService.AcceptAsync(reservationId, cancellationToken); + + return reservation is null + ? CallCommandResult.Failure("The offer is no longer available.") + : CallCommandResult.Success("The work was accepted.", requiresDeviceAnswer: false); + } + + if (interaction.Status is InteractionStatus.Ended or InteractionStatus.Failed) + { + return CallCommandResult.Failure("The offer is no longer available."); + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + var provider = _voiceProviderResolver.Get(interaction.ProviderName); + var hasProvider = provider is not null; + var deliveryModel = hasProvider + ? provider.DeliveryModel + : VoiceProviderDeliveryModel.ServerSideAcd; + var requiresDeviceAnswer = hasProvider && + deliveryModel == VoiceProviderDeliveryModel.AgentDeviceNative && + interaction.Status != InteractionStatus.Connected; + + reservation = await _reservationService.AcceptAsync(reservationId, cancellationToken); + + if (reservation is null) + { + return CallCommandResult.Failure("The offer is no longer available."); + } + + var now = _clock.UtcNow; + + interaction.AgentId = reservation.AgentId; + interaction.QueueId ??= reservation.QueueId; + + if (interaction.Status != InteractionStatus.Connected) + { + interaction.TransitionTo(InteractionStatus.Ringing); + interaction.StartedUtc ??= now; + } + else + { + interaction.TransitionTo(InteractionStatus.Connected); + interaction.StartedUtc ??= now; + interaction.AnsweredUtc ??= now; + } + + var commandId = deliveryModel == VoiceProviderDeliveryModel.ServerSideAcd + ? IdGenerator.GenerateId() + : null; + + if (commandId is not null) + { + interaction.TechnicalMetadata[ContactCenterConstants.CommandMetadata.CommandId] = commandId; + } + + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + var session = await EnsureSessionAsync( + interaction, + reservation, + deliveryModel, + interaction.Status == InteractionStatus.Connected + ? VoiceCallState.Connected + : VoiceCallState.Ringing, + interaction.Status == InteractionStatus.Connected, + now, + cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.OfferAccepted, interaction.ItemId, reservation.AgentId, cancellationToken); + + if (interaction.Status == InteractionStatus.Connected) + { + await PublishAsync(ContactCenterConstants.Events.CallConnected, interaction.ItemId, reservation.AgentId, cancellationToken); + } + + if (deliveryModel == VoiceProviderDeliveryModel.ServerSideAcd) + { + try + { + await _providerCommandStateService.RegisterAsync(new ProviderCommandRegistration + { + CommandId = commandId, + ProviderName = interaction.ProviderName, + CommandType = ProviderCommandType.Answer, + ActivityItemId = reservation.ActivityItemId, + InteractionId = interaction.ItemId, + ReservationId = reservation.ItemId, + RemoveReservationFromQueueOnFailure = false, + RequestPayload = JsonSerializer.Serialize(new ProviderAnswerCommandRequest + { + ActivityId = reservation.ActivityItemId, + InteractionId = interaction.ItemId, + ProviderCallId = interaction.ProviderInteractionId, + AgentId = reservation.AgentId, + AgentUserId = agent?.UserId, + QueueId = reservation.QueueId, + ReofferOnFailure = true, + }), + }, cancellationToken); + } + catch + { + await _scopeExecutor.ExecuteAsync(service => + service.CompensateAsync(reservation.ItemId, false, CancellationToken.None)); + + throw; + } + + _scopeExecutor.ScheduleAfterCommit(processor => + processor.DispatchAsync(commandId, CancellationToken.None)); + } + + return new CallCommandResult + { + Succeeded = true, + Reason = "The offer was accepted.", + RequiresDeviceAnswer = requiresDeviceAnswer, + InteractionId = interaction.ItemId, + CallSessionId = session.ItemId, + }; + } + + /// + public async Task DeclineInboundOfferAsync(string reservationId, string agentUserId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + ArgumentException.ThrowIfNullOrEmpty(agentUserId); + + var reservation = await FindAuthorizedPendingReservationAsync(reservationId, agentUserId, cancellationToken); + + if (reservation is null) + { + return CallCommandResult.Failure("The offer is no longer available."); + } + + reservation = await _reservationService.RejectAsync(reservationId, cancellationToken); + + if (reservation is null) + { + return CallCommandResult.Failure("The offer is no longer available."); + } + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.OfferDeclined, + AggregateType = nameof(ActivityReservation), + AggregateId = reservation.ItemId, + ActorId = reservation.AgentId, + SourceComponent = ContactCenterConstants.Components.Voice, + }; + + interactionEvent.SetData(new OfferDeclinedEventData + { + QueueId = reservation.QueueId, + }); + + await _publisher.PublishAsync(interactionEvent, cancellationToken); + + return CallCommandResult.Success("The offer was declined.", requiresDeviceAnswer: false); + } + + private async Task FindAuthorizedPendingReservationAsync( + string reservationId, + string agentUserId, + CancellationToken cancellationToken) + { + var agent = await _agentManager.FindByUserIdAsync(agentUserId, cancellationToken); + + if (agent is null) + { + return null; + } + + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || + reservation.Status != ReservationStatus.Pending || + !string.Equals(reservation.AgentId, agent.ItemId, StringComparison.Ordinal)) + { + return null; + } + + return reservation; + } + + private async Task EnsureSessionAsync( + Interaction interaction, + ActivityReservation reservation, + VoiceProviderDeliveryModel deliveryModel, + VoiceCallState state, + bool answered, + DateTime now, + CancellationToken cancellationToken) + { + var session = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (session is null) + { + session = await _callSessionManager.NewAsync(cancellationToken: cancellationToken); + session.InteractionId = interaction.ItemId; + session.ActivityItemId = reservation.ActivityItemId; + session.ProviderName = interaction.ProviderName; + session.ProviderCallId = interaction.ProviderInteractionId; + session.Direction = interaction.Direction; + session.DeliveryModel = deliveryModel; + session.FromAddress = interaction.CustomerAddress; + session.QueueId = reservation.QueueId; + session.AgentId = reservation.AgentId; + session.TransitionTo(state); + session.CreatedUtc = now; + session.StartedUtc = now; + + if (answered) + { + session.AnsweredUtc = now; + } + + await _callSessionManager.CreateAsync(session, cancellationToken: cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.CallSessionCreated, interaction.ItemId, reservation.AgentId, cancellationToken); + + return session; + } + + session.TransitionTo(state); + session.AgentId = reservation.AgentId; + session.StartedUtc ??= now; + + if (answered) + { + session.AnsweredUtc ??= now; + } + + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + + return session; + } + + private Task PublishAsync(string eventType, string interactionId, string actorId, CancellationToken cancellationToken) + { + return _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + InteractionId = interactionId, + AggregateType = nameof(Interaction), + AggregateId = interactionId, + ActorId = actorId, + SourceComponent = ContactCenterConstants.Components.Voice, + }, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterClaimKeys.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterClaimKeys.cs new file mode 100644 index 000000000..fcce3a345 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterClaimKeys.cs @@ -0,0 +1,91 @@ +using System.Security.Cryptography; +using System.Text; +using CrestApps.OrchardCore.Telephony.Core.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Builds the portable, non-null claim keys used to enforce database uniqueness for call sessions and +/// interaction events. The same computation is shared by the YesSql index providers and the upgrade +/// migrations so backfilled and freshly indexed rows resolve to identical keys. +/// +public static class ContactCenterClaimKeys +{ + /// + /// Builds the canonical provider-call claim key. + /// + /// The canonical provider technical name. + /// The provider call identifier. + /// The item identifier used as the non-null fallback when there is no provider call identifier. + /// + /// {providerName}|{providerCallId} when is present; otherwise + /// so sessions without a provider call cannot collide. + /// + public static string BuildProviderCallClaim(string providerName, string providerCallId, string itemId) + { + if (string.IsNullOrEmpty(providerCallId)) + { + return itemId; + } + + return $"{providerName}|{providerCallId}"; + } + + /// + /// Builds the event idempotency claim key. + /// + /// The provider-supplied idempotency key, when present. + /// The item identifier used as the non-null fallback when there is no idempotency key. + /// + /// when it is present; otherwise so events + /// without an idempotency key cannot collide. + /// + public static string BuildEventIdempotencyClaim(string idempotencyKey, string itemId) + { + return string.IsNullOrEmpty(idempotencyKey) + ? itemId + : idempotencyKey; + } + + /// + /// Builds the provider-scoped idempotency key for a normalized provider voice event. + /// + /// The canonical provider technical name. + /// The provider-supplied raw delivery idempotency key. + /// + /// A bounded, collision-resistant key when both a canonical provider and a raw key are present, so + /// identical raw delivery identifiers from different providers cannot collide or exceed the database + /// idempotency column limit; otherwise the raw unchanged (including + /// or empty), preserving non-provider domain-event idempotency semantics. + /// + public static string BuildProviderEventIdempotencyKey(string providerName, string idempotencyKey) + { + return VoiceIngressKeys.BuildEventIdempotencyKey(providerName, idempotencyKey); + } + + /// + /// Builds the bounded idempotency key for one domain event projected from a provider event. + /// + /// The bounded provider-event idempotency key. + /// The canonical Contact Center event type. + /// + /// A bounded, collision-resistant key for the provider event and projected event type, or the provider + /// event key unchanged when either value is absent. + /// + public static string BuildProviderDomainEventIdempotencyKey(string providerEventKey, string eventType) + { + if (string.IsNullOrEmpty(providerEventKey) || string.IsNullOrEmpty(eventType)) + { + return providerEventKey; + } + + return BuildHashedKey("provider-domain-event:v1:", $"{providerEventKey}\n{eventType}"); + } + + private static string BuildHashedKey(string prefix, string value) + { + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + + return $"{prefix}{Convert.ToHexString(hash)}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterDataGovernanceCatalog.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterDataGovernanceCatalog.cs new file mode 100644 index 000000000..c9781641f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterDataGovernanceCatalog.cs @@ -0,0 +1,215 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the authoritative, code-level classification of every persisted Contact Center data category. It is +/// the single source of truth that backs the data-governance documentation, so retention, access-audit, and +/// erasure decisions reference one registry rather than drifting prose. +/// +public static class ContactCenterDataGovernanceCatalog +{ + private static readonly IReadOnlyList _categories = + [ + new ContactCenterDataCategory + { + Key = "interaction-event", + DisplayName = "Interaction event log", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "InteractionEventRetentionDays, floored by ProjectionReplayHorizonDays and LegalHoldMinimumDays.", + ErasureStrategy = ContactCenterErasureStrategy.RetentionExpiry, + Description = "The durable, append-only source of truth for interaction history and the projection-rebuild source. It may reference customer and agent identifiers, so it is personal and bounded by the retention window and its replay/legal-hold floors.", + }, + new ContactCenterDataCategory + { + Key = "interaction", + DisplayName = "Interaction", + Sensitivity = ContactCenterDataSensitivity.SensitivePersonal, + ContainsRecordingReference = true, + RetentionBasis = "Retained for the life of the interaction record; erasure clears the customer address and delegates recording erasure to the external store.", + ErasureStrategy = ContactCenterErasureStrategy.Anonymize, + Description = "The communication history for a single work attempt. It holds the customer address and a recording reference and recording state, so it is sensitive personal data; erasure anonymizes the personal fields while retaining the record for metrics.", + }, + new ContactCenterDataCategory + { + Key = "call-session", + DisplayName = "Call session", + Sensitivity = ContactCenterDataSensitivity.SensitivePersonal, + ContainsRecordingReference = true, + RetentionBasis = "Retained for the life of the call-session record; erasure clears the from/to addresses and delegates recording erasure to the external store.", + ErasureStrategy = ContactCenterErasureStrategy.Anonymize, + Description = "The voice-channel state for a call. It holds the caller and callee addresses and a recording reference and recording state, so it is sensitive personal data.", + }, + new ContactCenterDataCategory + { + Key = "callback-request", + DisplayName = "Callback request", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "Retained until promoted to an outbound activity or expired; free-text notes may carry additional personal data.", + ErasureStrategy = ContactCenterErasureStrategy.Anonymize, + Description = "A queued request to call a customer back. It holds the callback address and free-form notes, so it is personal data.", + }, + new ContactCenterDataCategory + { + Key = "agent-session", + DisplayName = "Agent session", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "Retained for staffing and adherence reporting; bounded by the agent-session reporting window.", + ErasureStrategy = ContactCenterErasureStrategy.Anonymize, + Description = "The presence and state history for an agent. It identifies an individual agent, so it is personal data used for adherence and staffing metrics.", + }, + new ContactCenterDataCategory + { + Key = "agent-profile", + DisplayName = "Agent profile", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "Managed with the agent's account lifecycle; removed or anonymized when the agent is deprovisioned.", + ErasureStrategy = ContactCenterErasureStrategy.Anonymize, + Description = "The routing configuration bound to an agent identity (skills, queues, entitlements). It identifies an individual agent, so it is personal configuration data.", + }, + new ContactCenterDataCategory + { + Key = "outbox-message", + DisplayName = "Event outbox message", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "Short-lived; deleted on successful dispatch and dead-lettered messages are purged after inspection.", + ErasureStrategy = ContactCenterErasureStrategy.RetentionExpiry, + Description = "A durable in-flight domain event awaiting dispatch. Its payload can embed personal data, so it is treated as personal and is bounded by rapid dispatch and dead-letter cleanup.", + }, + new ContactCenterDataCategory + { + Key = "provider-inbox-message", + DisplayName = "Provider webhook inbox message", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "Short-lived; deleted after successful processing and dead-lettered messages are purged after inspection.", + ErasureStrategy = ContactCenterErasureStrategy.RetentionExpiry, + Description = "A durable inbound provider event awaiting processing. Its payload can embed personal data such as caller identifiers, so it is treated as personal and is bounded by rapid processing.", + }, + new ContactCenterDataCategory + { + Key = "provider-command", + DisplayName = "Provider command", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Short-lived; deleted after the command completes or exhausts retries.", + ErasureStrategy = ContactCenterErasureStrategy.RetentionExpiry, + Description = "A leased, fenced outbound instruction to a telephony provider keyed by opaque call and command identifiers, holding no personal data of its own.", + }, + new ContactCenterDataCategory + { + Key = "queue-item", + DisplayName = "Queue item", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Transient; removed when the work item leaves the queue.", + ErasureStrategy = ContactCenterErasureStrategy.CascadeWithInteraction, + Description = "A waiting work item that references an activity and interaction by opaque identifier. It holds no personal data itself and is erased with its interaction.", + }, + new ContactCenterDataCategory + { + Key = "activity-reservation", + DisplayName = "Activity reservation", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Transient; removed when the reservation is accepted, declined, or expires.", + ErasureStrategy = ContactCenterErasureStrategy.RetentionExpiry, + Description = "A short-lived offer of a work item to an agent, keyed by opaque activity and agent claim identifiers, holding no personal data.", + }, + new ContactCenterDataCategory + { + Key = "work-state", + DisplayName = "Work state", + Sensitivity = ContactCenterDataSensitivity.Personal, + ContainsRecordingReference = false, + RetentionBasis = "Retained while the work item is routable; erased with the activity it routes.", + ErasureStrategy = ContactCenterErasureStrategy.CascadeWithInteraction, + Description = "The authoritative routing state for a work item, holding its assignment status, reservation, attempt count, and the identity of the agent who reserved or was assigned it. The agent identity makes it personal data.", + }, + new ContactCenterDataCategory + { + Key = "event-metric", + DisplayName = "Event metric", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Durable aggregate snapshot retained independently of the event log so it survives event purge.", + ErasureStrategy = ContactCenterErasureStrategy.NotApplicable, + Description = "Per-day, per-event-type aggregate counts. It contains no personal data and is retained as the durable metrics snapshot that the projection rebuild reconciles.", + }, + new ContactCenterDataCategory + { + Key = "event-metric-contribution", + DisplayName = "Event metric contribution", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Drained continuously into the event metric aggregate; retained only for contributions the roller could not fold.", + ErasureStrategy = ContactCenterErasureStrategy.NotApplicable, + Description = "A single appended contribution to a per-day, per-event-type count, waiting to be folded into the aggregate. It names an event type and a day and nothing about who the event concerned, so it contains no personal data.", + }, + new ContactCenterDataCategory + { + Key = "projection-checkpoint", + DisplayName = "Projection checkpoint", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Operational; one row per projection, updated in place.", + ErasureStrategy = ContactCenterErasureStrategy.NotApplicable, + Description = "The replay cursor, logic version, and last-rebuild time for a projection. It contains no personal data.", + }, + new ContactCenterDataCategory + { + Key = "processed-event", + DisplayName = "Processed-event ledger", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Operational de-duplication ledger; bounded by the idempotency window.", + ErasureStrategy = ContactCenterErasureStrategy.RetentionExpiry, + Description = "A per-handler record of processed event identifiers used for idempotency. It holds only opaque handler and event identifiers.", + }, + new ContactCenterDataCategory + { + Key = "configuration", + DisplayName = "Routing and dialing configuration", + Sensitivity = ContactCenterDataSensitivity.NonPersonal, + ContainsRecordingReference = false, + RetentionBasis = "Administrator-managed; retained until changed or removed by an administrator.", + ErasureStrategy = ContactCenterErasureStrategy.NotApplicable, + Description = "Tenant configuration such as queues, queue groups, skills, entry points, dialer profiles, agent state reason codes, business-hours calendars, and queue memberships. It holds no personal data of end customers.", + }, + ]; + + /// + /// Gets the classified Contact Center data categories. + /// + public static IReadOnlyList Categories => _categories; + + /// + /// Attempts to find the data category with the specified key. + /// + /// The stable key of the data category to find. + /// When this method returns, contains the matching category when found; otherwise . + /// when a matching category exists; otherwise . + public static bool TryGet(string key, out ContactCenterDataCategory category) + { + ArgumentException.ThrowIfNullOrEmpty(key); + + foreach (var candidate in _categories) + { + if (string.Equals(candidate.Key, key, StringComparison.Ordinal)) + { + category = candidate; + + return true; + } + } + + category = null; + + return false; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointManager.cs new file mode 100644 index 000000000..088a091fa --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointManager.cs @@ -0,0 +1,54 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterEntryPointManager : CatalogManager, IContactCenterEntryPointManager +{ + private readonly IContactCenterEntryPointStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying entry point store. + /// The catalog entry handlers for entry points. + /// The logger instance. + public ContactCenterEntryPointManager( + IContactCenterEntryPointStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + var entryPoint = await _store.FindByNameAsync(name, cancellationToken); + + if (entryPoint is not null) + { + await LoadAsync(entryPoint, cancellationToken); + } + + return entryPoint; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var entryPoints = await _store.ListEnabledAsync(cancellationToken); + + foreach (var entryPoint in entryPoints) + { + await LoadAsync(entryPoint, cancellationToken); + } + + return entryPoints; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointStore.cs new file mode 100644 index 000000000..dd5a7f140 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointStore.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterEntryPointStore : DocumentCatalog, IContactCenterEntryPointStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterEntryPointStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return await Session.Query( + index => index.Name == name, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var entryPoints = await Session.Query( + index => index.Enabled, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return entryPoints.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEventDeduplicationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEventDeduplicationService.cs new file mode 100644 index 000000000..b8c97a20b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEventDeduplicationService.cs @@ -0,0 +1,65 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-backed implementation of . The +/// reservation marker is staged in the ambient session so it commits atomically with the handler effect, +/// and a composite unique index over HandlerId and EventId collapses concurrent duplicates +/// to a single durable reservation. +/// +public sealed class ContactCenterEventDeduplicationService : IContactCenterEventDeduplicationService +{ + private readonly ISession _session; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to read and stage reservation markers. + /// The clock used to stamp the processed time. + public ContactCenterEventDeduplicationService( + ISession session, + IClock clock) + { + _session = session; + _clock = clock; + } + + /// + public async Task TryBeginAsync(string handlerId, string eventId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(handlerId); + ArgumentException.ThrowIfNullOrEmpty(eventId); + + var existing = await _session + .Query( + index => index.HandlerId == handlerId && index.EventId == eventId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + + if (existing is not null) + { + return false; + } + + var marker = new ContactCenterProcessedEvent + { + ItemId = IdGenerator.GenerateId(), + HandlerId = handlerId, + EventId = eventId, + ProcessedUtc = _clock.UtcNow, + }; + + await _session.SaveAsync( + marker, + collection: ContactCenterConstants.CollectionName, + cancellationToken: cancellationToken); + + return true; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEventDispatchContext.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEventDispatchContext.cs new file mode 100644 index 000000000..09ff19fcf --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEventDispatchContext.cs @@ -0,0 +1,52 @@ +using CrestApps.Core.Support; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Dispatches a persisted Contact Center event from an isolated post-commit scope. +/// +public sealed class ContactCenterEventDispatchContext +{ + private readonly IInteractionEventStore _eventStore; + private readonly IContactCenterOutbox _outbox; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction event store. + /// The Contact Center outbox. + /// The logger. + public ContactCenterEventDispatchContext( + IInteractionEventStore eventStore, + IContactCenterOutbox outbox, + ILogger logger) + { + _eventStore = eventStore; + _outbox = outbox; + _logger = logger; + } + + /// + /// Dispatches the persisted event with the specified identifier. + /// + /// The event identifier. + public async Task DispatchAsync(string eventId) + { + ArgumentException.ThrowIfNullOrEmpty(eventId); + + var interactionEvent = await _eventStore.FindByIdAsync(eventId); + + if (interactionEvent is null) + { + _logger.LogWarning( + "Skipped deferred Contact Center event dispatch because event '{EventId}' no longer exists.", + eventId.SanitizeLogValue()); + + return; + } + + await _outbox.DispatchAsync(interactionEvent); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricDateKey.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricDateKey.cs new file mode 100644 index 000000000..e1f133303 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricDateKey.cs @@ -0,0 +1,40 @@ +using System.Globalization; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Formats and parses the day key that identifies a daily event metric bucket. +/// +public static class ContactCenterMetricDateKey +{ + /// + /// The format the day key is written in. + /// + public const string Format = "yyyy-MM-dd"; + + /// + /// Formats the supplied instant's UTC date as a day key. + /// + /// The instant to take the date of. + /// The day key. + public static string From(DateTime occurredUtc) + { + return occurredUtc.Date.ToString(Format, CultureInfo.InvariantCulture); + } + + /// + /// Parses a day key back into the UTC midnight it identifies. + /// + /// The day key to parse. + /// The UTC date the key identifies. + /// The key is not a valid day key. + public static DateTime Parse(string dateKey) + { + if (!DateTime.TryParseExact(dateKey, Format, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date)) + { + throw new FormatException($"The value '{dateKey}' is not a valid '{Format}' day key."); + } + + return DateTime.SpecifyKind(date, DateTimeKind.Utc); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricDeltaStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricDeltaStore.cs new file mode 100644 index 000000000..895c1623a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricDeltaStore.cs @@ -0,0 +1,77 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterMetricDeltaStore : DocumentCatalog, IContactCenterMetricDeltaStore +{ + /// + /// The maximum number of unfolded contributions a single reader will add to the rolled-up totals. + /// + public const int MaxPendingContributionsPerRead = 10_000; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterMetricDeltaStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task> ListBatchAsync(int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? 500 : maxCount; + + // Deliberately unordered. The document query groups by document identity, so the engine cannot satisfy + // an ordering over the contribution columns from that grouping and instead materializes and sorts every + // waiting contribution before returning one batch. The roller folds whatever it is handed and deletes + // exactly those rows, so no order is needed to be correct. + var deltas = await Session.Query( + collection: ContactCenterConstants.CollectionName) + .Take(take) + .ListAsync(cancellationToken); + + return deltas.ToArray(); + } + + /// + public async Task> ListByDateRangeAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + // Bounded on purpose. This runs on the request path, and the cap is the same volume one roller run + // drains, so a backlog large enough to be truncated here is already a backlog the reader cannot report + // exactly. Truncating under-reports transiently, which is the behaviour a lagging roller already has. + var deltas = await Session.Query( + index => index.Date >= fromUtc && index.Date <= toUtc, + collection: ContactCenterConstants.CollectionName) + .Take(MaxPendingContributionsPerRead) + .ListAsync(cancellationToken); + + return deltas.ToArray(); + } + + /// + public async Task> ListContributionsAfterAsync(long afterDocumentId, int count, CancellationToken cancellationToken = default) + { + // Read from the index rather than through the documents. Everything the caller needs is already on the + // index, and an index query carries no grouping by document identity, so the ordering this walk depends + // on is answered from the identifier the rows are already keyed by instead of by sorting the table. + var rows = await Session.QueryIndex( + index => index.DocumentId > afterDocumentId, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.DocumentId) + .Take(count) + .ListAsync(cancellationToken); + + return rows + .Select(row => new ContactCenterMetricContribution(row.DocumentId, row.DateKey, row.EventType, row.Count)) + .ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricRollupService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricRollupService.cs new file mode 100644 index 000000000..81ff35350 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricRollupService.cs @@ -0,0 +1,107 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterMetricRollupService : IContactCenterMetricRollupService +{ + private const int BatchSize = 500; + private const int MaxBatchesPerRun = 20; + + private readonly IContactCenterMetricDeltaStore _deltaStore; + private readonly IContactCenterMetricStore _metricStore; + private readonly ISession _session; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The store holding the appended contributions. + /// The store holding the daily totals. + /// The YesSql session, used to commit each batch on its own. + /// The clock used to stamp the totals. + public ContactCenterMetricRollupService( + IContactCenterMetricDeltaStore deltaStore, + IContactCenterMetricStore metricStore, + ISession session, + IClock clock) + { + _deltaStore = deltaStore; + _metricStore = metricStore; + _session = session; + _clock = clock; + } + + /// + public async Task RollupAsync(CancellationToken cancellationToken = default) + { + var folded = 0; + + for (var batch = 0; batch < MaxBatchesPerRun; batch++) + { + var deltas = await _deltaStore.ListBatchAsync(BatchSize, cancellationToken); + + if (deltas.Count == 0) + { + break; + } + + foreach (var group in deltas.GroupBy(delta => (delta.DateKey, delta.EventType))) + { + await AddAsync(group.Key.DateKey, group.Key.EventType, group.Sum(delta => delta.Count), cancellationToken); + } + + // Only the contributions this batch actually read are removed. Deleting by predicate instead would + // also remove anything appended between the read and the delete, which would be counted by nobody. + foreach (var delta in deltas) + { + await _deltaStore.DeleteAsync(delta, cancellationToken); + } + + // Each batch is committed on its own so a long fold never accumulates one unbounded transaction, + // and so an interrupted run keeps the batches it had already folded. + await _session.SaveChangesAsync(cancellationToken); + + folded += deltas.Count; + + if (deltas.Count < BatchSize) + { + break; + } + } + + return folded; + } + + private async Task AddAsync(string dateKey, string eventType, long count, CancellationToken cancellationToken) + { + var metric = await _metricStore.FindAsync(dateKey, eventType, cancellationToken); + + if (metric is null) + { + await _metricStore.CreateAsync( + new ContactCenterEventMetric + { + ItemId = IdGenerator.GenerateId(), + DateKey = dateKey, + Date = ContactCenterMetricDateKey.Parse(dateKey), + EventType = eventType, + Count = count, + CreatedUtc = _clock.UtcNow, + }, + cancellationToken); + + return; + } + + metric.Count += count; + metric.ModifiedUtc = _clock.UtcNow; + + await _metricStore.UpdateAsync(metric, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricStore.cs new file mode 100644 index 000000000..1c1cecd26 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricStore.cs @@ -0,0 +1,57 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterMetricStore : DocumentCatalog, IContactCenterMetricStore +{ + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterMetricStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindAsync(string dateKey, string eventType, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(dateKey); + ArgumentException.ThrowIfNullOrEmpty(eventType); + + return await Session.Query( + index => index.DateKey == dateKey && index.EventType == eventType, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListByDateRangeAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + var metrics = await Session.Query( + index => index.Date >= fromUtc && index.Date <= toUtc, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return metrics.ToArray(); + } + + /// + public async Task> ListAllAsync(CancellationToken cancellationToken = default) + { + var metrics = await Session.Query( + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return metrics.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsProjectionMaintenanceService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsProjectionMaintenanceService.cs new file mode 100644 index 000000000..0effe4d9a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsProjectionMaintenanceService.cs @@ -0,0 +1,282 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterMetricsProjectionMaintenanceService : IContactCenterMetricsProjectionMaintenanceService +{ + private const int PageSize = 500; + + private readonly IInteractionEventStore _eventStore; + private readonly IContactCenterMetricStore _metricStore; + private readonly IContactCenterMetricDeltaStore _deltaStore; + private readonly IContactCenterProjectionCheckpointStore _checkpointStore; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The source-of-truth event log store. + /// The daily metric projection store. + /// The store holding contributions that have not been folded into the totals yet. + /// The projection replay checkpoint store. + /// The clock used to stamp metric and checkpoint times. + public ContactCenterMetricsProjectionMaintenanceService( + IInteractionEventStore eventStore, + IContactCenterMetricStore metricStore, + IContactCenterMetricDeltaStore deltaStore, + IContactCenterProjectionCheckpointStore checkpointStore, + IClock clock) + { + _eventStore = eventStore; + _metricStore = metricStore; + _deltaStore = deltaStore; + _checkpointStore = checkpointStore; + _clock = clock; + } + + /// + public async Task RebuildAsync(CancellationToken cancellationToken = default) + { + var recomputed = await RecomputeAsync(cancellationToken); + + // A contribution that is still waiting is a count the log already reports and the totals do not, so it is + // subtracted here and added back when it is folded. That is what stops a rebuild from counting it twice. + // + // The contributions are read after the log rather than before, because folding them first, or reading + // them first, makes an event recorded between the two reads counted by the recompute and folded again + // on top of it. Reading the log first removes that case. + // + // It does not make the rebuild exact, and the remaining cases are not all in one direction. A rebuild + // cannot read the log, the contributions and the totals in one snapshot, and two of the gaps it cannot + // close leave a total that is high rather than short. A contribution is appended by the projection + // handler, which runs in a post-commit scope and is redelivered by the outbox, so an event is in the log + // for a window before its contribution exists at all: the recompute counts that event, nothing is + // subtracted for it, and folding the contribution afterwards adds it a second time. Document + // identifiers are also allocated before the transaction that commits them, so a contribution can become + // visible below a position the walk has already passed and is missed for the same reason. Both settle by + // themselves in the sense that a rebuild run once the projection is settled — the outbox drained and the + // roller caught up — writes exactly the log; neither is silent, because the next drift check reports the + // difference. A rebuild run against live traffic is a repair that converges, not a snapshot that is + // exact, and the difference matters to an operator reading the number immediately afterwards. + var pending = await ReadPendingContributionsAsync(cancellationToken); + + var stored = await _metricStore.ListAllAsync(cancellationToken); + var remaining = stored.ToDictionary(metric => (metric.DateKey, metric.EventType)); + + var changes = 0; + var now = _clock.UtcNow; + + foreach (var bucket in recomputed.Counts) + { + var target = Math.Max(0, bucket.Value - pending.GetValueOrDefault(bucket.Key)); + + if (remaining.TryGetValue(bucket.Key, out var metric)) + { + remaining.Remove(bucket.Key); + + if (metric.Count != target) + { + metric.Count = target; + metric.ModifiedUtc = now; + await _metricStore.UpdateAsync(metric, cancellationToken); + changes++; + } + + continue; + } + + var created = new ContactCenterEventMetric + { + ItemId = IdGenerator.GenerateId(), + DateKey = bucket.Key.DateKey, + Date = ContactCenterMetricDateKey.Parse(bucket.Key.DateKey), + EventType = bucket.Key.EventType, + Count = target, + CreatedUtc = now, + }; + + await _metricStore.CreateAsync(created, cancellationToken); + changes++; + } + + foreach (var orphan in remaining.Values) + { + await _metricStore.DeleteAsync(orphan, cancellationToken); + changes++; + } + + await AdvanceCheckpointAsync(recomputed, now, cancellationToken); + + return changes; + } + + /// + public async Task> DetectDriftAsync(CancellationToken cancellationToken = default) + { + var recomputed = await RecomputeAsync(cancellationToken); + + var stored = await _metricStore.ListAllAsync(cancellationToken); + var storedByKey = stored.ToDictionary(metric => (metric.DateKey, metric.EventType), metric => metric.Count); + + // A contribution that has not been folded yet is part of the projection, so it is added to the stored + // total before the comparison. Detecting drift stays a read: it reports what the projection holds, it + // does not repair it, and it must not commit the ambient unit of work of whoever asked. + var pending = await ReadPendingContributionsAsync(cancellationToken); + + foreach (var contribution in pending) + { + storedByKey[contribution.Key] = storedByKey.GetValueOrDefault(contribution.Key) + contribution.Value; + } + + var drifts = new List(); + + foreach (var bucket in recomputed.Counts) + { + var actual = storedByKey.GetValueOrDefault(bucket.Key); + + if (actual != bucket.Value) + { + drifts.Add(new ContactCenterProjectionDrift + { + DateKey = bucket.Key.DateKey, + EventType = bucket.Key.EventType, + ExpectedCount = bucket.Value, + ActualCount = actual, + }); + } + } + + foreach (var entry in storedByKey) + { + if (!recomputed.Counts.ContainsKey(entry.Key)) + { + drifts.Add(new ContactCenterProjectionDrift + { + DateKey = entry.Key.DateKey, + EventType = entry.Key.EventType, + ExpectedCount = 0, + ActualCount = entry.Value, + }); + } + } + + return drifts; + } + + private async Task> ReadPendingContributionsAsync(CancellationToken cancellationToken) + { + var pending = new Dictionary<(string DateKey, string EventType), long>(); + var afterDocumentId = 0L; + + while (true) + { + var page = await _deltaStore.ListContributionsAfterAsync(afterDocumentId, PageSize, cancellationToken); + + if (page.Count == 0) + { + break; + } + + foreach (var contribution in page) + { + var key = (contribution.DateKey, contribution.EventType); + + pending[key] = pending.GetValueOrDefault(key) + contribution.Count; + afterDocumentId = Math.Max(afterDocumentId, contribution.DocumentId); + } + + if (page.Count < PageSize) + { + break; + } + } + + return pending; + } + + private async Task RecomputeAsync(CancellationToken cancellationToken) + { + var counts = new Dictionary<(string DateKey, string EventType), long>(); + var lastOccurredUtc = default(DateTime); + var lastEventId = string.Empty; + + var skip = 0; + + while (true) + { + var page = await _eventStore.ListOrderedPageAsync(skip, PageSize, cancellationToken); + + if (page.Count == 0) + { + break; + } + + foreach (var interactionEvent in page) + { + lastOccurredUtc = interactionEvent.OccurredUtc; + lastEventId = interactionEvent.ItemId; + + // Mirror the live projection: events without a type are not counted, and events without a + // real occurrence time are skipped because the live path substitutes the wall clock, which + // cannot be reproduced deterministically during a replay. + if (string.IsNullOrEmpty(interactionEvent.EventType) || interactionEvent.OccurredUtc == default) + { + continue; + } + + var dateKey = ContactCenterMetricDateKey.From(interactionEvent.OccurredUtc); + var key = (dateKey, interactionEvent.EventType); + + counts[key] = counts.GetValueOrDefault(key) + 1; + } + + if (page.Count < PageSize) + { + break; + } + + skip += page.Count; + } + + return new RecomputeResult(counts, lastOccurredUtc, lastEventId); + } + + private async Task AdvanceCheckpointAsync(RecomputeResult recomputed, DateTime rebuiltUtc, CancellationToken cancellationToken) + { + var checkpoint = await _checkpointStore.FindByHandlerAsync(ContactCenterConstants.MetricsProjectionHandlerId, cancellationToken); + + if (checkpoint is null) + { + checkpoint = new ContactCenterProjectionCheckpoint + { + ItemId = IdGenerator.GenerateId(), + HandlerId = ContactCenterConstants.MetricsProjectionHandlerId, + Version = ContactCenterConstants.MetricsProjectionVersion, + LastOccurredUtc = recomputed.LastOccurredUtc, + LastEventId = recomputed.LastEventId, + RebuiltUtc = rebuiltUtc, + }; + + await _checkpointStore.CreateAsync(checkpoint, cancellationToken); + + return; + } + + checkpoint.Version = ContactCenterConstants.MetricsProjectionVersion; + checkpoint.LastOccurredUtc = recomputed.LastOccurredUtc; + checkpoint.LastEventId = recomputed.LastEventId; + checkpoint.RebuiltUtc = rebuiltUtc; + + await _checkpointStore.UpdateAsync(checkpoint, cancellationToken); + } + + private sealed record RecomputeResult( + Dictionary<(string DateKey, string EventType), long> Counts, + DateTime LastOccurredUtc, + string LastEventId); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsService.cs new file mode 100644 index 000000000..596a674c2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsService.cs @@ -0,0 +1,85 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterMetricsService : IContactCenterMetricsService +{ + private readonly IContactCenterMetricStore _store; + private readonly IContactCenterMetricDeltaStore _deltaStore; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The metric store holding the rolled-up daily totals. + /// The store the individual contributions are appended to. + /// The clock used to stamp metric times. + public ContactCenterMetricsService( + IContactCenterMetricStore store, + IContactCenterMetricDeltaStore deltaStore, + IClock clock) + { + _store = store; + _deltaStore = deltaStore; + _clock = clock; + } + + /// + public async Task RecordAsync(string eventType, DateTime occurredUtc, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(eventType)) + { + return; + } + + var effectiveUtc = occurredUtc == default ? _clock.UtcNow : occurredUtc; + var date = effectiveUtc.Date; + + // The contribution is appended rather than added to the day's total in place. Reading the total, + // incrementing it and writing it back makes that one row a serialization point at any real event rate: + // every writer of the same event type on the same day contends for it, and under the store's optimistic + // concurrency the loser either fails the whole request or overwrites a count it never read. An append + // has no reader to be stale, no row to contend for, and no constraint two writers can both violate. + await _deltaStore.CreateAsync( + new ContactCenterEventMetricDelta + { + ItemId = IdGenerator.GenerateId(), + DateKey = ContactCenterMetricDateKey.From(date), + Date = date, + EventType = eventType, + Count = 1, + CreatedUtc = _clock.UtcNow, + }, + cancellationToken); + } + + /// + public async Task> GetSummaryAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default) + { + var fromUtc = fromDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc); + var toUtc = toDate.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc); + + var metrics = await _store.ListByDateRangeAsync(fromUtc, toUtc, cancellationToken); + + var summary = metrics + .GroupBy(metric => metric.EventType, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Sum(metric => metric.Count), StringComparer.Ordinal); + + // Contributions that have not been folded yet are still real events, so a reader adds them to the + // totals. Without this a summary read a moment after the traffic it describes would report a number + // that is behind by however much the roller has not caught up on. + var pending = await _deltaStore.ListByDateRangeAsync(fromUtc, toUtc, cancellationToken); + + foreach (var group in pending.GroupBy(delta => delta.EventType, StringComparer.Ordinal)) + { + summary[group.Key] = summary.GetValueOrDefault(group.Key) + group.Sum(delta => delta.Count); + } + + return summary; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMonitoringService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMonitoringService.cs new file mode 100644 index 000000000..fb4993a48 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMonitoringService.cs @@ -0,0 +1,378 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterMonitoringService : IContactCenterMonitoringService +{ + private static readonly MonitorMode[] _monitorModes = + [ + MonitorMode.Monitor, + MonitorMode.Whisper, + MonitorMode.Barge, + ]; + + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly ICallControlAuthorizationService _callControlAuthorizationService; + private readonly IContactCenterEventPublisher _publisher; + private readonly ITelephonyCommandExecutor _commandExecutor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The call session manager that owns the live call topology. + /// The voice provider resolver used to check monitoring capabilities. + /// The Contact Center event publisher. + /// The executor that provides a bounded server-owned provider-operation token. + /// The shared call-control authorization boundary. + /// The clock used to stamp engagement times. + public ContactCenterMonitoringService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IContactCenterVoiceProviderResolver voiceProviderResolver, + IContactCenterEventPublisher publisher, + ITelephonyCommandExecutor commandExecutor, + ICallControlAuthorizationService callControlAuthorizationService, + IClock clock) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _voiceProviderResolver = voiceProviderResolver; + _callControlAuthorizationService = callControlAuthorizationService; + _publisher = publisher; + _commandExecutor = commandExecutor; + _clock = clock; + } + + /// + public async Task> GetAvailableModesAsync( + string interactionId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(interactionId)) + { + return []; + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null) + { + return []; + } + + var provider = _voiceProviderResolver.Get(interaction.ProviderName); + + if (provider is not IContactCenterVoiceMonitoringProvider || + string.IsNullOrEmpty(interaction.ProviderInteractionId)) + { + return []; + } + + return _monitorModes + .Where(mode => provider.Capabilities.HasFlag(ResolveCapability(mode))) + .ToArray(); + } + + /// + public async Task EngageAsync( + string interactionId, + string supervisorId, + ClaimsPrincipal principal, + MonitorMode mode, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(interactionId)) + { + return SupervisorEngagementResult.Failure("An interaction is required."); + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null) + { + return SupervisorEngagementResult.Failure("The interaction could not be found."); + } + + var authorization = await _callControlAuthorizationService.AuthorizeAsync(new CallControlAuthorizationContext + { + Principal = principal, + UserId = supervisorId, + Verb = CallControlVerb.SupervisorEngage, + InteractionId = interaction.ItemId, + ProviderName = interaction.ProviderName, + ProviderCallId = interaction.ProviderInteractionId, + SupervisorOperation = true, + }, cancellationToken); + + if (!authorization.Succeeded) + { + return SupervisorEngagementResult.Failure(authorization.FailureReason); + } + + var providerCallId = authorization.ProviderCallId; + + var provider = _voiceProviderResolver.Get(interaction.ProviderName); + var capability = ResolveCapability(mode); + + if (provider is not IContactCenterVoiceMonitoringProvider monitoringProvider || + !provider.Capabilities.HasFlag(capability) || + string.IsNullOrEmpty(providerCallId)) + { + return SupervisorEngagementResult.Failure($"The voice provider does not support the '{mode}' engagement."); + } + + var callSession = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + // A supervisor already engaged on this call would gain a second live leg the platform cannot tell + // apart from the first, so a later stop would release an arbitrary one and leave the other listening. + if (callSession is not null && + callSession.ActiveMonitorSessions.Any(monitorSession => + string.Equals(monitorSession.SupervisorUserId, supervisorId, StringComparison.Ordinal))) + { + return SupervisorEngagementResult.Failure("The supervisor is already engaged on this call."); + } + + // Supervising one's own call is refused here rather than at persist time. The provider engage command + // runs before the engagement is recorded, so leaving this to the store's invariant would bring up a + // real snoop or barge channel and then throw, stranding a supervisor leg nothing can later stop. + if (callSession is not null && + !string.IsNullOrEmpty(authorization.AgentId) && + string.Equals(authorization.AgentId, callSession.AgentId, StringComparison.Ordinal)) + { + return SupervisorEngagementResult.Failure("A supervisor cannot engage on their own call."); + } + + try + { + var providerResult = await _commandExecutor.ExecuteAsync(commandCancellationToken => + monitoringProvider.EngageAsync(new ContactCenterVoiceMonitoringRequest + { + InteractionId = interaction.ItemId, + ProviderCallId = providerCallId, + SupervisorId = supervisorId, + Mode = mode, + }, commandCancellationToken)); + + if (providerResult?.Succeeded != true || providerResult.OutcomeUnknown) + { + return SupervisorEngagementResult.Failure( + providerResult?.ErrorMessage ?? $"The voice provider did not confirm the '{mode}' engagement."); + } + + await RecordEngagementStartedAsync( + callSession, + supervisorId, + authorization.AgentId, + mode, + providerResult.ProviderLegId, + cancellationToken); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.SupervisorMonitorStarted, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = supervisorId, + SourceComponent = ContactCenterConstants.Components.RealTime, + }; + + interactionEvent.SetData(new Dictionary + { + ["mode"] = mode.ToString(), + ["supervisorId"] = supervisorId, + }); + + await _publisher.PublishAsync(interactionEvent, CancellationToken.None); + + return SupervisorEngagementResult.Success(); + } + catch (TimeoutException) + { + return SupervisorEngagementResult.Unknown( + $"The voice provider did not confirm the '{mode}' engagement before the server timeout; the provider outcome is unknown."); + } + catch (OperationCanceledException) + { + return SupervisorEngagementResult.Unknown( + $"The '{mode}' engagement was interrupted before the provider outcome could be confirmed."); + } + } + + /// + /// Engages a live interaction as a supervisor using the requested mode when the provider supports it. + /// + /// The interaction identifier. + /// The supervisor performing the engagement. + /// The engagement mode. + /// The token to monitor for cancellation requests. + /// The engagement result. + public Task EngageAsync( + string interactionId, + string supervisorId, + MonitorMode mode, + CancellationToken cancellationToken = default) + { + return EngageAsync(interactionId, supervisorId, null, mode, cancellationToken); + } + + /// + public async Task StopEngagementAsync( + string interactionId, + string supervisorId, + ClaimsPrincipal principal, + MonitorMode mode, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(interactionId)) + { + return SupervisorEngagementResult.Failure("An interaction is required."); + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null) + { + return SupervisorEngagementResult.Failure("The interaction could not be found."); + } + + var authorization = await _callControlAuthorizationService.AuthorizeAsync(new CallControlAuthorizationContext + { + Principal = principal, + UserId = supervisorId, + Verb = CallControlVerb.SupervisorEngage, + InteractionId = interaction.ItemId, + ProviderName = interaction.ProviderName, + ProviderCallId = interaction.ProviderInteractionId, + SupervisorOperation = true, + }, cancellationToken); + + if (!authorization.Succeeded) + { + return SupervisorEngagementResult.Failure(authorization.FailureReason); + } + + var providerCallId = authorization.ProviderCallId; + + var provider = _voiceProviderResolver.Get(interaction.ProviderName); + + if (provider is not IContactCenterVoiceMonitoringProvider monitoringProvider || + string.IsNullOrEmpty(providerCallId)) + { + return SupervisorEngagementResult.Failure($"The voice provider cannot stop the '{mode}' engagement."); + } + + try + { + var providerResult = await _commandExecutor.ExecuteAsync(commandCancellationToken => + monitoringProvider.StopAsync(new ContactCenterVoiceMonitoringRequest + { + InteractionId = interaction.ItemId, + ProviderCallId = providerCallId, + SupervisorId = supervisorId, + Mode = mode, + }, commandCancellationToken)); + + if (providerResult?.Succeeded != true || providerResult.OutcomeUnknown) + { + return SupervisorEngagementResult.Failure( + providerResult?.ErrorMessage ?? $"The voice provider did not confirm stopping the '{mode}' engagement."); + } + + await RecordEngagementStoppedAsync(interaction.ItemId, supervisorId, cancellationToken); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.SupervisorMonitorStopped, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = supervisorId, + SourceComponent = ContactCenterConstants.Components.RealTime, + }; + + interactionEvent.SetData(new Dictionary + { + ["mode"] = mode.ToString(), + ["supervisorId"] = supervisorId, + }); + + await _publisher.PublishAsync(interactionEvent, CancellationToken.None); + + return SupervisorEngagementResult.Success(); + } + catch (TimeoutException) + { + return SupervisorEngagementResult.Unknown( + $"The voice provider did not confirm stopping the '{mode}' engagement before the server timeout; the provider outcome is unknown."); + } + catch (OperationCanceledException) + { + return SupervisorEngagementResult.Unknown( + $"Stopping the '{mode}' engagement was interrupted before the provider outcome could be confirmed."); + } + } + + private async Task RecordEngagementStartedAsync( + CallSession callSession, + string supervisorUserId, + string supervisorAgentId, + MonitorMode mode, + string providerLegId, + CancellationToken cancellationToken) + { + if (callSession is null) + { + return; + } + + CallTopologyProjector.StartMonitorSession( + callSession, + IdGenerator.GenerateId(), + supervisorUserId, + supervisorAgentId, + mode, + _clock.UtcNow, + providerLegId); + + await _callSessionManager.UpdateAsync(callSession, cancellationToken: cancellationToken); + } + + private async Task RecordEngagementStoppedAsync( + string interactionId, + string supervisorUserId, + CancellationToken cancellationToken) + { + var callSession = await _callSessionManager.FindByInteractionIdAsync(interactionId, cancellationToken); + + if (callSession is null || !CallTopologyProjector.EndMonitorSession(callSession, supervisorUserId, _clock.UtcNow)) + { + return; + } + + await _callSessionManager.UpdateAsync(callSession, cancellationToken: cancellationToken); + } + + private static ContactCenterVoiceProviderCapabilities ResolveCapability(MonitorMode mode) + { + return mode switch + { + MonitorMode.Monitor => ContactCenterVoiceProviderCapabilities.Monitor, + MonitorMode.Whisper => ContactCenterVoiceProviderCapabilities.Whisper, + MonitorMode.Barge => ContactCenterVoiceProviderCapabilities.Barge, + _ => ContactCenterVoiceProviderCapabilities.Monitor, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs new file mode 100644 index 000000000..6e16eed33 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs @@ -0,0 +1,565 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Telemetry; +using Microsoft.Extensions.Logging; +using OrchardCore; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation. Every event is persisted as a +/// pending before dispatch. Registered +/// instances are tracked individually and incomplete handlers are +/// redelivered with exponential back-off. +/// +public sealed class ContactCenterOutbox : IContactCenterOutbox +{ + /// + /// The maximum number of dispatch attempts before a message is dead-lettered. + /// + public const int MaxAttempts = 10; + + /// + /// The maximum number of due messages processed in a single retry pass. + /// + public const int MaxBatchSize = 100; + + private const int BaseBackoffSeconds = 30; + private const int MaxBackoffSeconds = 1800; + private static readonly TimeSpan _claimLease = TimeSpan.FromMinutes(5); + + private readonly IReadOnlyList _handlers; + private readonly IContactCenterOutboxStore _outboxStore; + private readonly IInteractionEventStore _eventStore; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IContactCenterFeatureWorkManager _workManager; + private readonly ISession _session; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The registered Contact Center event handlers. + /// The durable outbox message store. + /// The durable interaction event store used to reload events for retry. + /// The executor used to isolate each due message in a fresh child scope. + /// The feature work manager used to fence dispatch during feature quiescence. + /// The tenant session used to commit claims before handler execution. + /// The clock used to schedule retries. + /// The logger instance. + public ContactCenterOutbox( + IEnumerable handlers, + IContactCenterOutboxStore outboxStore, + IInteractionEventStore eventStore, + IContactCenterScopeExecutor scopeExecutor, + IContactCenterFeatureWorkManager workManager, + ISession session, + IClock clock, + ILogger logger) + { + _handlers = ValidateHandlers(handlers); + _outboxStore = outboxStore; + _eventStore = eventStore; + _scopeExecutor = scopeExecutor; + _workManager = workManager; + _session = session; + _clock = clock; + _logger = logger; + } + + private static IReadOnlyList ValidateHandlers(IEnumerable handlers) + { + ArgumentNullException.ThrowIfNull(handlers); + + var materialized = handlers as IReadOnlyList ?? handlers.ToArray(); + var seenHandlerIds = new HashSet(StringComparer.Ordinal); + + foreach (var handler in materialized) + { + var handlerId = handler.HandlerId; + + if (string.IsNullOrWhiteSpace(handlerId)) + { + throw new InvalidOperationException( + $"The Contact Center event handler '{handler.GetType().FullName}' must expose a non-empty stable HandlerId."); + } + + if (!Enum.IsDefined(handler.ReplaySafety) || handler.ReplaySafety == ContactCenterHandlerReplaySafety.Unspecified) + { + throw new InvalidOperationException( + $"The Contact Center event handler '{handlerId}' must declare an explicit ReplaySafety contract because outbox delivery is at-least-once."); + } + + if (!seenHandlerIds.Add(handlerId)) + { + throw new InvalidOperationException( + $"The Contact Center event handler id '{handlerId}' is registered by more than one handler. Handler ids must be unique so a failed handler is never skipped by another handler that shares its id."); + } + } + + return materialized; + } + + /// + public async Task EnqueueAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + await GetOrCreateMessageAsync(interactionEvent, cancellationToken); + } + + /// + public async Task DispatchAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + var message = await GetOrCreateMessageAsync(interactionEvent, cancellationToken); + using var workLease = _workManager.TryEnter(ContactCenterConstants.Feature.Area); + + if (workLease is null || !await TryClaimAsync(message, cancellationToken)) + { + return; + } + + var ownerToken = message.OwnerToken; + var fenceToken = message.FenceToken; + (var firstError, var handlerUnavailable) = await RunHandlersAsync(interactionEvent, message, cancellationToken); + await SettleInFreshScopeAsync( + message, + ownerToken, + fenceToken, + firstError, + handlerUnavailable, + cancellationToken); + + if (firstError is not null) + { + _logger.LogWarning( + "Scheduled Contact Center event '{EventType}' ({EventId}) for retry after a handler failure: {Error}", + interactionEvent.EventType, + interactionEvent.ItemId.SanitizeLogValue(), + firstError.SanitizeLogValue()); + } + } + + /// + public async Task DispatchDueAsync(CancellationToken cancellationToken = default) + { + using var workLease = _workManager.TryEnter(ContactCenterConstants.Feature.Area); + + if (workLease is null) + { + return 0; + } + + var now = _clock.UtcNow; + var due = await _outboxStore.ListDueAsync(now, MaxBatchSize, cancellationToken); + var redelivered = 0; + + foreach (var message in due) + { + cancellationToken.ThrowIfCancellationRequested(); + var messageId = message.ItemId; + + try + { + var completed = false; + + // Isolate every due message in its own fresh Orchard child scope and YesSql session so a + // poison message or a canceled session can never poison the remaining batch. + await _scopeExecutor.ExecuteAsync(async outbox => + { + if (await outbox.DispatchDueMessageAsync(messageId, cancellationToken)) + { + completed = true; + } + }); + + if (completed) + { + redelivered++; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + // The message's isolated scope failed (for example an optimistic-concurrency loss committed + // by another worker). The message stays pending and is reprocessed by the next pass in a + // fresh scope, so continue draining the rest of the batch instead of blocking it. + _logger.LogWarning( + "Isolated dispatch of Contact Center outbox message '{MessageId}' failed with {ExceptionType}; the message stays pending for the next pass.", + messageId.SanitizeLogValue(), + ex.GetType().Name); + } + } + + ContactCenterDiagnostics.RecordOutboxRedelivered(redelivered); + + return redelivered; + } + + /// + public async Task DispatchDueMessageAsync(string messageId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(messageId); + + var message = await _outboxStore.FindByIdAsync(messageId, cancellationToken); + + if (message is null || !await TryClaimAsync(message, cancellationToken)) + { + return false; + } + + var ownerToken = message.OwnerToken; + var fenceToken = message.FenceToken; + + if (string.IsNullOrEmpty(message.EventId)) + { + await DeadLetterAsync(message, "The outbox message has no event reference.", cancellationToken); + + return false; + } + + var interactionEvent = await _eventStore.FindByIdAsync(message.EventId, cancellationToken); + + if (interactionEvent is null) + { + await DeadLetterAsync(message, "The referenced event no longer exists.", cancellationToken); + + return false; + } + + (var firstError, var handlerUnavailable) = await RunHandlersAsync(interactionEvent, message, cancellationToken); + + return await SettleInFreshScopeAsync( + message, + ownerToken, + fenceToken, + firstError, + handlerUnavailable, + cancellationToken); + } + + /// + public async Task DispatchHandlerAsync( + InteractionEvent interactionEvent, + string handlerId, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + ArgumentException.ThrowIfNullOrEmpty(handlerId); + + var handler = _handlers.FirstOrDefault(candidate => + string.Equals(candidate.HandlerId, handlerId, StringComparison.Ordinal)); + + if (handler is null) + { + throw new InvalidOperationException( + $"The Contact Center event handler '{handlerId}' is not registered in the isolated dispatch scope."); + } + + await handler.HandleAsync(interactionEvent, cancellationToken); + } + + /// + public async Task SettleClaimAsync( + string messageId, + string ownerToken, + long fenceToken, + IReadOnlyCollection completedHandlerIds, + string error, + bool handlerUnavailable, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(messageId); + ArgumentException.ThrowIfNullOrEmpty(ownerToken); + ArgumentNullException.ThrowIfNull(completedHandlerIds); + + var message = await _outboxStore.FindByIdAsync(messageId, cancellationToken); + + if (message is null || + message.Status != OutboxMessageStatus.Claimed || + !string.Equals(message.OwnerToken, ownerToken, StringComparison.Ordinal) || + message.FenceToken != fenceToken) + { + throw new ConcurrencyException(new Document()); + } + + message.CompletedHandlerTypes = completedHandlerIds.ToArray(); + + if (error is null) + { + await CompleteAsync(message, cancellationToken); + + return true; + } + + await ScheduleRetryAsync(message, error, !handlerUnavailable, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return false; + } + + private async Task GetOrCreateMessageAsync( + InteractionEvent interactionEvent, + CancellationToken cancellationToken) + { + var existing = await _outboxStore.FindByEventIdAsync(interactionEvent.ItemId, cancellationToken); + + if (existing is not null) + { + return existing; + } + + var now = _clock.UtcNow; + var message = new ContactCenterOutboxMessage + { + ItemId = IdGenerator.GenerateId(), + EventId = interactionEvent.ItemId, + EventType = interactionEvent.EventType, + Status = OutboxMessageStatus.Pending, + ExpectedHandlerIds = _handlers.Select(handler => handler.HandlerId).ToArray(), + NextAttemptUtc = now, + CreatedUtc = now, + ModifiedUtc = now, + }; + + await _outboxStore.CreateAsync(message, cancellationToken); + + return message; + } + + private async Task TryClaimAsync( + ContactCenterOutboxMessage message, + CancellationToken cancellationToken) + { + var now = _clock.UtcNow; + + if ((message.Status != OutboxMessageStatus.Pending && + message.Status != OutboxMessageStatus.Claimed) || + message.NextAttemptUtc > now) + { + return false; + } + + message.Status = OutboxMessageStatus.Claimed; + message.OwnerToken = Guid.NewGuid().ToString("N"); + message.FenceToken++; + message.NextAttemptUtc = now.Add(_claimLease); + message.ModifiedUtc = now; + await _outboxStore.UpdateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return true; + } + + private async Task<(string Error, bool HandlerUnavailable)> RunHandlersAsync( + InteractionEvent interactionEvent, + ContactCenterOutboxMessage message, + CancellationToken cancellationToken) + { + string firstError = null; + var handlerFailed = false; + var handlerUnavailable = false; + var persistedCompleted = message.CompletedHandlerTypes.ToHashSet(StringComparer.Ordinal); + var completedHandlerIds = new HashSet(persistedCompleted, StringComparer.Ordinal); + var expectedHandlerIds = message.ExpectedHandlerIds?.Count > 0 + ? message.ExpectedHandlerIds + : _handlers.Select(handler => handler.HandlerId).ToArray(); + + foreach (var handlerId in expectedHandlerIds) + { + var handler = _handlers.FirstOrDefault(candidate => + string.Equals(candidate.HandlerId, handlerId, StringComparison.Ordinal)); + + if (handler is null) + { + handlerUnavailable = true; + firstError ??= $"The required Contact Center event handler '{handlerId}' is not available in the current feature shell."; + + continue; + } + + if (TryResolveCompletedCheckpoint(handler, handlerId, persistedCompleted, out var legacyCheckpoint)) + { + if (legacyCheckpoint is not null) + { + completedHandlerIds.Remove(legacyCheckpoint); + } + + completedHandlerIds.Add(handlerId); + + continue; + } + + try + { + // Commit each handler effect and its replay marker in an isolated child scope. A handler + // exception rolls back that unit without canceling this message scope, allowing the retry + // checkpoint to be persisted and later messages to continue. + await _scopeExecutor.ExecuteAsync(outbox => + outbox.DispatchHandlerAsync(interactionEvent, handlerId, cancellationToken)); + + completedHandlerIds.Add(handlerId); + } + catch (Exception ex) + { + handlerFailed = true; + firstError ??= ex.Message; + + _logger.LogError( + ex, + "An error occurred while handling the Contact Center event '{EventType}' for interaction '{InteractionId}' in handler '{Handler}'.", + interactionEvent.EventType, + interactionEvent.InteractionId.SanitizeLogValue(), + handlerId); + } + } + + // Persist the checkpoint using stable handler ids, repairing any legacy CLR-name/index entries so + // completed handlers are never replayed after a rename, reorder, or assembly version change. + message.CompletedHandlerTypes = completedHandlerIds.ToArray(); + + return (firstError, handlerUnavailable && !handlerFailed); + } + + private async Task SettleInFreshScopeAsync( + ContactCenterOutboxMessage message, + string ownerToken, + long fenceToken, + string error, + bool handlerUnavailable, + CancellationToken cancellationToken) + { + var completed = false; + + await _scopeExecutor.ExecuteAsync(async outbox => + { + completed = await outbox.SettleClaimAsync( + message.ItemId, + ownerToken, + fenceToken, + message.CompletedHandlerTypes.ToArray(), + error, + handlerUnavailable, + cancellationToken); + }); + + return completed; + } + + private static bool TryResolveCompletedCheckpoint( + IContactCenterEventHandler handler, + string handlerId, + HashSet persistedCompleted, + out string legacyCheckpoint) + { + legacyCheckpoint = null; + + if (!string.IsNullOrEmpty(handlerId) && persistedCompleted.Contains(handlerId)) + { + return true; + } + + // Deploy-safe legacy alias: pre-versioned checkpoints stored "{AssemblyQualifiedName}:{index}". + // Treat a handler whose runtime type matches such a legacy entry as already completed. + var legacyTypeName = handler.GetType().FullName; + + if (string.IsNullOrEmpty(legacyTypeName)) + { + return false; + } + + foreach (var entry in persistedCompleted) + { + if (entry.StartsWith(legacyTypeName + ",", StringComparison.Ordinal) || + entry.StartsWith(legacyTypeName + ":", StringComparison.Ordinal)) + { + legacyCheckpoint = entry; + + return true; + } + } + + return false; + } + + private async Task ScheduleRetryAsync( + ContactCenterOutboxMessage message, + string error, + bool countAttempt, + CancellationToken cancellationToken) + { + if (countAttempt) + { + message.AttemptCount++; + } + + message.LastError = error; + message.ModifiedUtc = _clock.UtcNow; + message.OwnerToken = null; + + if (message.AttemptCount >= MaxAttempts) + { + message.Status = OutboxMessageStatus.DeadLettered; + ContactCenterDiagnostics.RecordOutboxDeadLettered("retry_exhausted"); + + _logger.LogError( + "Dead-lettered Contact Center event '{EventType}' ({EventId}) after {Attempts} failed dispatch attempts: {Error}", + message.EventType, + message.EventId.SanitizeLogValue(), + message.AttemptCount, + error.SanitizeLogValue()); + } + else + { + message.Status = OutboxMessageStatus.Pending; + message.NextAttemptUtc = _clock.UtcNow.Add(GetBackoff(Math.Max(1, message.AttemptCount))); + } + + await _outboxStore.UpdateAsync(message, cancellationToken); + } + + private async Task CompleteAsync( + ContactCenterOutboxMessage message, + CancellationToken cancellationToken) + { + // Removal is a single unit of work. Persisting the completed status first and deleting afterwards detaches + // the message from the session identity map when the intermediate save commits, so the delete then fails and + // the row survives as a completed message nothing ever reaps. Nothing consumes the completed status as + // domain state either: the only readers count it as a store-reachability probe. If this transaction fails + // the message stays claimed and claim expiry redelivers it. The per-handler checkpoint is not persisted on + // this path, so that redelivery replays every handler and is safe only because handlers are required to be + // idempotent; the checkpoint exists to avoid replay after a *failed* dispatch, which is persisted. + await _outboxStore.DeleteAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + } + + private async Task DeadLetterAsync(ContactCenterOutboxMessage message, string reason, CancellationToken cancellationToken) + { + message.Status = OutboxMessageStatus.DeadLettered; + message.LastError = reason; + message.ModifiedUtc = _clock.UtcNow; + message.OwnerToken = null; + ContactCenterDiagnostics.RecordOutboxDeadLettered("unrecoverable"); + + await _outboxStore.UpdateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + _logger.LogError("Dead-lettered Contact Center outbox message '{MessageId}': {Reason}", message.ItemId.SanitizeLogValue(), reason); + } + + private static TimeSpan GetBackoff(int attempt) + { + var exponent = Math.Min(attempt - 1, 30); + var seconds = Math.Min(BaseBackoffSeconds * Math.Pow(2, exponent), MaxBackoffSeconds); + + return TimeSpan.FromSeconds(seconds); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs new file mode 100644 index 000000000..2e9a2836d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs @@ -0,0 +1,71 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterOutboxStore : DocumentCatalog, IContactCenterOutboxStore +{ + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterOutboxStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByEventIdAsync(string eventId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(eventId); + + return await Session.Query( + index => index.EventId == eventId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListDueAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? 100 : maxCount; + + var due = await Session.Query( + index => (index.Status == OutboxMessageStatus.Pending || index.Status == OutboxMessageStatus.Claimed) && + index.NextAttemptUtc <= nowUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.NextAttemptUtc) + .Take(take) + .ListAsync(cancellationToken); + + return due.ToArray(); + } + + /// + public async Task CountByStatusAsync(OutboxMessageStatus status, CancellationToken cancellationToken = default) + { + return await Session.Query( + index => index.Status == status, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task CountOverdueAsync(DateTime nowUtc, CancellationToken cancellationToken = default) + { + return await Session.Query( + index => (index.Status == OutboxMessageStatus.Pending || index.Status == OutboxMessageStatus.Claimed) && + index.NextAttemptUtc <= nowUtc, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterProcessedEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterProcessedEventStore.cs new file mode 100644 index 000000000..82016da53 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterProcessedEventStore.cs @@ -0,0 +1,22 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterProcessedEventStore : DocumentCatalog, IContactCenterProcessedEventStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterProcessedEventStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterProjectionCheckpointStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterProjectionCheckpointStore.cs new file mode 100644 index 000000000..bcca5bcd6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterProjectionCheckpointStore.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterProjectionCheckpointStore : DocumentCatalog, IContactCenterProjectionCheckpointStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterProjectionCheckpointStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByHandlerAsync(string handlerId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(handlerId); + + return await Session.Query( + index => index.HandlerId == handlerId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRecordingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRecordingService.cs new file mode 100644 index 000000000..ee7be86d6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRecordingService.cs @@ -0,0 +1,236 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterRecordingService : IContactCenterRecordingService +{ + private readonly IInteractionManager _interactionManager; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly IContactCenterEventPublisher _publisher; + private readonly ITelephonyCommandExecutor _commandExecutor; + private readonly IRecordingGovernancePolicy _governancePolicy; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The voice provider resolver. + /// The Contact Center event publisher. + /// The executor that provides a bounded server-owned provider-operation token. + /// The recording governance policy that gates recording and resolves retention metadata. + public ContactCenterRecordingService( + IInteractionManager interactionManager, + IContactCenterVoiceProviderResolver voiceProviderResolver, + IContactCenterEventPublisher publisher, + ITelephonyCommandExecutor commandExecutor, + IRecordingGovernancePolicy governancePolicy) + { + _interactionManager = interactionManager; + _voiceProviderResolver = voiceProviderResolver; + _publisher = publisher; + _commandExecutor = commandExecutor; + _governancePolicy = governancePolicy; + } + + /// + public Task StartAsync(string interactionId, CancellationToken cancellationToken = default) + { + return SetStateAsync(interactionId, RecordingState.Recording, ContactCenterConstants.Events.RecordingStarted, cancellationToken); + } + + /// + public Task PauseAsync(string interactionId, CancellationToken cancellationToken = default) + { + return SetStateAsync(interactionId, RecordingState.Paused, ContactCenterConstants.Events.RecordingPaused, cancellationToken); + } + + /// + public Task ResumeAsync(string interactionId, CancellationToken cancellationToken = default) + { + return SetStateAsync(interactionId, RecordingState.Recording, ContactCenterConstants.Events.RecordingResumed, cancellationToken); + } + + /// + public Task StopAsync(string interactionId, CancellationToken cancellationToken = default) + { + return SetStateAsync(interactionId, RecordingState.Stopped, ContactCenterConstants.Events.RecordingStopped, cancellationToken); + } + + private async Task SetStateAsync(string interactionId, RecordingState state, string eventType, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(interactionId)) + { + return RecordingCommandResult.Failure("An interaction is required."); + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null || interaction.RecordingState == state) + { + return RecordingCommandResult.Failure("The interaction could not be found or is already in the requested recording state."); + } + + var previousState = interaction.RecordingState; + + var provider = _voiceProviderResolver.Get(interaction.ProviderName); + + if (provider is not IContactCenterVoiceRecordingProvider recordingProvider || + !provider.Capabilities.HasFlag(ContactCenterVoiceProviderCapabilities.Recording) || + string.IsNullOrEmpty(interaction.ProviderInteractionId)) + { + return RecordingCommandResult.Failure("The voice provider does not support recording for this interaction."); + } + + // Recording governance gates only the transition into an actively-recording state (start and resume); a + // pause or stop must never be blocked by policy so the tenant can always halt capture. + RecordingGovernanceDecision governanceDecision = null; + + if (state == RecordingState.Recording) + { + governanceDecision = await _governancePolicy.EvaluateStartAsync(interaction, cancellationToken); + + if (!governanceDecision.Allowed) + { + var deniedEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.RecordingDenied, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = interaction.AgentId, + SourceComponent = ContactCenterConstants.Components.Interactions, + }; + + deniedEvent.SetData(new RecordingDeniedEventData + { + DenyReasonCode = governanceDecision.DenyReasonCode, + }); + + await _publisher.PublishAsync(deniedEvent, CancellationToken.None); + + return RecordingCommandResult.Failure(governanceDecision.DenyReasonCode ?? "Recording was denied by policy."); + } + } + + ContactCenterVoiceProviderResult providerResult; + + try + { + providerResult = await _commandExecutor.ExecuteAsync(commandCancellationToken => + recordingProvider.SetRecordingStateAsync(new ContactCenterVoiceRecordingRequest + { + InteractionId = interaction.ItemId, + ProviderCallId = interaction.ProviderInteractionId, + State = state, + }, commandCancellationToken)); + } + catch (TelephonyCommandNotAdmittedException) + { + // Refused before the provider was contacted: the recording state is definitely unchanged. + return RecordingCommandResult.Failure("The recording command was refused because the application is stopping."); + } + catch (TimeoutException) + { + return RecordingCommandResult.Unknown("The recording command exceeded the server-owned timeout; its provider outcome is unknown."); + } + catch (OperationCanceledException) + { + return RecordingCommandResult.Unknown("The recording command was interrupted after dispatch; its provider outcome is unknown."); + } + + if (providerResult is null || (!providerResult.Succeeded && !providerResult.OutcomeUnknown)) + { + return RecordingCommandResult.Failure("The voice provider did not apply the recording state change."); + } + + if (providerResult.OutcomeUnknown) + { + return RecordingCommandResult.Unknown("The voice provider could not confirm the recording state change."); + } + + interaction.RecordingState = state; + + // Determine initial capture from the persisted pre-transition state rather than the calling entry point, + // so invoking Start on an already paused interaction (or any resume) cannot re-stamp the capture-time + // retention window or newly raise legal hold. Only a transition into Recording from a state that was never + // actively capturing (None, or a fully Stopped prior session) counts as an initial capture. + var isInitialCapture = state == RecordingState.Recording && + previousState != RecordingState.Recording && + previousState != RecordingState.Paused; + + if (isInitialCapture) + { + ApplyGovernanceMetadata(interaction, governanceDecision); + } + + PersistRecordingMetadata(interaction, providerResult.Metadata); + await _interactionManager.UpdateAsync(interaction, cancellationToken: CancellationToken.None); + + await _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = interaction.AgentId, + SourceComponent = ContactCenterConstants.Components.Interactions, + }, CancellationToken.None); + + return RecordingCommandResult.Success(); + } + + private static void ApplyGovernanceMetadata(Interaction interaction, RecordingGovernanceDecision decision) + { + if (decision is null) + { + return; + } + + // Stamp the capture-time retention window and legal-hold flag. This runs only on the initial capture + // transition, so a later resume can neither reset the retention deadline to a resume-relative value nor + // raise legal hold on a recording that did not begin under one. + interaction.RecordingRetainUntilUtc = decision.RetainUntilUtc; + + if (decision.LegalHold) + { + interaction.RecordingLegalHold = true; + } + } + + private static void PersistRecordingMetadata(Interaction interaction, IDictionary metadata) + { + if (metadata is null || metadata.Count == 0) + { + return; + } + + // Persist every provider-supplied recording field onto the interaction so the durable retrieval path, + // format, and duration survive beyond the transient provider call (closing DIST-8, which previously + // discarded this metadata). The interaction document is tenant-scoped, so the metadata stays isolated. + interaction.TechnicalMetadata ??= new Dictionary(); + + foreach (var entry in metadata) + { + interaction.TechnicalMetadata[entry.Key] = entry.Value; + } + + // The recording reference is the canonical retrieval handle, so prefer the durable storage reference and + // fall back to the provider recording name when only the name is reported. + if (metadata.TryGetValue(ContactCenterConstants.RecordingMetadata.StorageReference, out var storageReference) && + !string.IsNullOrWhiteSpace(storageReference)) + { + interaction.RecordingReference = storageReference; + } + else if (metadata.TryGetValue(ContactCenterConstants.RecordingMetadata.ProviderRecordingId, out var recordingName) && + !string.IsNullOrWhiteSpace(recordingName)) + { + interaction.RecordingReference = recordingName; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterReportingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterReportingService.cs new file mode 100644 index 000000000..f2788f957 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterReportingService.cs @@ -0,0 +1,973 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of by aggregating the +/// interaction history and the CRM activity inventory over a reporting period. +/// +public sealed class ContactCenterReportingService : IContactCenterReportingService +{ + private readonly ISession _session; + private readonly IActivityQueueGroupManager _queueGroupManager; + private readonly IActivityQueueManager _queueManager; + private readonly IQueueItemManager _queueItemManager; + private readonly IAgentProfileManager _agentManager; + private readonly ICatalogManager _campaignManager; + private readonly ICatalogManager _campaignGroupManager; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session used to query interactions and activities. + /// The queue-group manager used to resolve current catalog membership. + /// The queue manager used to resolve queue names and settings. + /// The queue item manager used to read live waiting depth. + /// The agent profile manager used to resolve agent names. + /// The campaign manager used to resolve campaign names. + /// The campaign group manager used to aggregate campaign reports. + public ContactCenterReportingService( + ISession session, + IActivityQueueGroupManager queueGroupManager, + IActivityQueueManager queueManager, + IQueueItemManager queueItemManager, + IAgentProfileManager agentManager, + ICatalogManager campaignManager, + ICatalogManager campaignGroupManager) + { + _session = session; + _queueGroupManager = queueGroupManager; + _queueManager = queueManager; + _queueItemManager = queueItemManager; + _agentManager = agentManager; + _campaignManager = campaignManager; + _campaignGroupManager = campaignGroupManager; + } + + /// + public Task GetCallInsightsAsync( + DateTime fromUtc, + DateTime toUtc, + CancellationToken cancellationToken = default) + { + return GetCallInsightsAsync(fromUtc, toUtc, criteria: null, cancellationToken); + } + + /// + public async Task GetCallInsightsAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default) + { + var interactions = await QueryInteractionsAsync(fromUtc, toUtc, cancellationToken); + await ApplyCurrentQueueGroupCriteriaAsync(criteria, cancellationToken); + + return BuildCallInsights(fromUtc, toUtc, FilterInteractions(interactions, criteria)); + } + + /// + public Task GetAgentProductivityAsync( + DateTime fromUtc, + DateTime toUtc, + CancellationToken cancellationToken = default) + { + return GetAgentProductivityAsync(fromUtc, toUtc, criteria: null, cancellationToken); + } + + /// + public async Task GetAgentProductivityAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default) + { + var interactions = await QueryInteractionsAsync(fromUtc, toUtc, cancellationToken); + await ApplyCurrentQueueGroupCriteriaAsync(criteria, cancellationToken); + var agents = (await _agentManager.GetAllAsync(cancellationToken)).ToArray(); + var filteredAgents = string.IsNullOrEmpty(criteria?.AgentId) + ? agents + : agents.Where(agent => agent.ItemId == criteria.AgentId).ToArray(); + var completedByUser = await QueryCompletedActivitiesByUserAsync( + fromUtc, + toUtc, + criteria, + filteredAgents, + cancellationToken); + + return BuildAgentProductivity(fromUtc, toUtc, FilterInteractions(interactions, criteria), completedByUser, filteredAgents); + } + + /// + public Task GetQueueUsageAsync( + DateTime fromUtc, + DateTime toUtc, + CancellationToken cancellationToken = default) + { + return GetQueueUsageAsync(fromUtc, toUtc, criteria: null, cancellationToken); + } + + /// + public async Task GetQueueUsageAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default) + { + var interactions = await QueryInteractionsAsync(fromUtc, toUtc, cancellationToken); + var queues = (await _queueManager.GetAllAsync(cancellationToken)).ToArray(); + var queueGroups = (await _queueGroupManager.GetAllAsync(cancellationToken)).ToArray(); + ApplyCurrentQueueGroupCriteria(criteria, queues); + var filteredQueues = FilterQueues(queues, criteria); + + var waitingByQueue = new Dictionary(StringComparer.Ordinal); + + foreach (var queue in filteredQueues) + { + waitingByQueue[queue.ItemId] = await _queueItemManager.CountWaitingAsync(queue.ItemId, cancellationToken); + } + + return BuildQueueUsage( + fromUtc, + toUtc, + FilterInteractions(interactions, criteria), + filteredQueues, + queueGroups, + waitingByQueue); + } + + /// + public Task GetCampaignSummaryAsync( + DateTime fromUtc, + DateTime toUtc, + CancellationToken cancellationToken = default) + { + return GetCampaignSummaryAsync(fromUtc, toUtc, criteria: null, cancellationToken); + } + + /// + public async Task GetCampaignSummaryAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default) + { + var activities = await QueryActivityIndexesAsync(fromUtc, toUtc, cancellationToken); + var campaigns = await _campaignManager.GetAllAsync(cancellationToken); + var campaignGroups = await _campaignGroupManager.GetAllAsync(cancellationToken); + + var names = new Dictionary(StringComparer.Ordinal); + var campaignGroupIds = new Dictionary(StringComparer.Ordinal); + var campaignGroupNames = campaignGroups.ToDictionary(group => group.ItemId, group => group.DisplayText, StringComparer.Ordinal); + + foreach (var campaign in campaigns) + { + if (!string.IsNullOrWhiteSpace(campaign.ItemId) && !string.IsNullOrWhiteSpace(campaign.DisplayText)) + { + names[campaign.ItemId] = campaign.DisplayText; + } + + campaignGroupIds[campaign.ItemId] = campaign.CampaignGroupId; + } + + ApplyCurrentCampaignGroupCriteria(criteria, campaigns); + + return BuildCampaignSummary( + fromUtc, + toUtc, + FilterActivities(activities, criteria), + names, + campaignGroupIds, + campaignGroupNames); + } + + /// + public Task GetSubjectInventoryAsync( + DateTime fromUtc, + DateTime toUtc, + CancellationToken cancellationToken = default) + { + return GetSubjectInventoryAsync(fromUtc, toUtc, criteria: null, cancellationToken); + } + + /// + public async Task GetSubjectInventoryAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default) + { + var activities = await QueryActivityIndexesAsync(fromUtc, toUtc, cancellationToken); + var campaigns = string.IsNullOrEmpty(criteria?.CampaignGroupId) + ? [] + : await _campaignManager.GetAllAsync(cancellationToken); + + ApplyCurrentCampaignGroupCriteria(criteria, campaigns); + + return BuildSubjectInventory(fromUtc, toUtc, FilterActivities(activities, criteria)); + } + + /// + /// Applies the supplied report criteria to an interaction population. + /// + /// The interactions to filter. + /// The optional report criteria. + /// The filtered interactions. + public static IReadOnlyList FilterInteractions( + IReadOnlyList interactions, + ContactCenterReportCriteria criteria) + { + if (criteria is null) + { + return interactions; + } + + return interactions + .Where(interaction => string.IsNullOrEmpty(criteria.QueueId) || interaction.QueueId == criteria.QueueId) + .Where(interaction => criteria.QueueIds is null || criteria.QueueIds.Contains(interaction.QueueId ?? string.Empty)) + .Where(interaction => string.IsNullOrEmpty(criteria.AgentId) || interaction.AgentId == criteria.AgentId) + .Where(interaction => !criteria.Channel.HasValue || interaction.Channel == criteria.Channel.Value) + .Where(interaction => !criteria.Direction.HasValue || interaction.Direction == criteria.Direction.Value) + .ToArray(); + } + + /// + /// Resolves a selected queue group to queue identifiers using the queues' current catalog membership. + /// + /// The optional report criteria to update. + /// The current queue catalog. + public static void ApplyCurrentQueueGroupCriteria( + ContactCenterReportCriteria criteria, + IReadOnlyList queues) + { + if (string.IsNullOrEmpty(criteria?.QueueGroupId)) + { + return; + } + + criteria.QueueIds = queues + .Where(queue => string.Equals(queue.QueueGroupId, criteria.QueueGroupId, StringComparison.Ordinal)) + .Select(queue => queue.ItemId) + .ToHashSet(StringComparer.Ordinal); + } + + /// + /// Applies the supplied report criteria to a CRM activity population. + /// + /// The activity indexes to filter. + /// The optional report criteria. + /// The filtered activity indexes. + public static IReadOnlyList FilterActivities( + IReadOnlyList activities, + ContactCenterReportCriteria criteria) + { + if (criteria is null) + { + return activities; + } + + var channel = criteria.Channel switch + { + InteractionChannel.Voice => OmnichannelConstants.Channels.Phone, + InteractionChannel.Sms => OmnichannelConstants.Channels.Sms, + InteractionChannel.Email => OmnichannelConstants.Channels.Email, + InteractionChannel.Chat => InteractionChannel.Chat.ToString(), + _ => null, + }; + + return activities + .Where(activity => string.IsNullOrEmpty(criteria.CampaignId) || activity.CampaignId == criteria.CampaignId) + .Where(activity => criteria.CampaignIds is null || criteria.CampaignIds.Contains(activity.CampaignId ?? string.Empty)) + .Where(activity => string.IsNullOrEmpty(criteria.ActivitySource) || activity.Source == criteria.ActivitySource) + .Where(activity => string.IsNullOrEmpty(channel) || string.Equals(activity.Channel, channel, StringComparison.OrdinalIgnoreCase)) + .Where(activity => !criteria.ActivityStatus.HasValue || activity.Status == criteria.ActivityStatus.Value) + .ToArray(); + } + + internal static void ApplyCurrentCampaignGroupCriteria( + ContactCenterReportCriteria criteria, + IEnumerable campaigns) + { + if (string.IsNullOrEmpty(criteria?.CampaignGroupId)) + { + return; + } + + criteria.CampaignIds = campaigns + .Where(campaign => campaign.CampaignGroupId == criteria.CampaignGroupId) + .Select(campaign => campaign.ItemId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + private static IReadOnlyList FilterQueues( + IReadOnlyList queues, + ContactCenterReportCriteria criteria) + { + if (criteria is null) + { + return queues; + } + + return queues + .Where(queue => string.IsNullOrEmpty(criteria.QueueId) || queue.ItemId == criteria.QueueId) + .Where(queue => criteria.QueueIds is null || criteria.QueueIds.Contains(queue.ItemId)) + .ToArray(); + } + + internal static CallInsightsReport BuildCallInsights(DateTime fromUtc, DateTime toUtc, IReadOnlyList interactions) + { + var report = new CallInsightsReport + { + FromUtc = fromUtc, + ToUtc = toUtc, + Total = interactions.Count, + }; + + var talkTimeTotal = 0d; + var wrapUpTimeTotal = 0d; + var answerSpeedTotal = 0d; + var answeredWithHandleTime = 0L; + + var channelCounts = new Dictionary(); + var statusCounts = new Dictionary(); + var dailyPoints = new Dictionary(); + + foreach (var interaction in interactions) + { + if (interaction.Direction == InteractionDirection.Inbound) + { + report.Inbound++; + } + else + { + report.Outbound++; + } + + var answered = interaction.AnsweredUtc.HasValue; + var abandoned = !answered && interaction.Direction == InteractionDirection.Inbound && interaction.Status == InteractionStatus.Ended; + + if (answered) + { + report.Answered++; + answerSpeedTotal += Math.Max(0d, (interaction.AnsweredUtc.Value - interaction.CreatedUtc).TotalSeconds); + + if (interaction.EndedUtc.HasValue && interaction.EndedUtc.Value >= interaction.AnsweredUtc.Value) + { + talkTimeTotal += (interaction.EndedUtc.Value - interaction.AnsweredUtc.Value).TotalSeconds; + answeredWithHandleTime++; + } + + wrapUpTimeTotal += GetWrapUpSeconds(interaction); + } + + if (abandoned) + { + report.Abandoned++; + } + + if (interaction.Status == InteractionStatus.Failed) + { + report.Failed++; + } + + channelCounts[interaction.Channel] = channelCounts.GetValueOrDefault(interaction.Channel) + 1; + statusCounts[interaction.Status] = statusCounts.GetValueOrDefault(interaction.Status) + 1; + + var day = DateOnly.FromDateTime(interaction.CreatedUtc); + + if (!dailyPoints.TryGetValue(day, out var point)) + { + point = new CallInsightsDailyPoint { Date = day }; + dailyPoints[day] = point; + } + + point.Total++; + + if (answered) + { + point.Answered++; + } + + if (abandoned) + { + point.Abandoned++; + } + } + + report.TotalTalkTimeSeconds = talkTimeTotal; + report.TotalWrapUpTimeSeconds = wrapUpTimeTotal; + report.AverageHandleTimeSeconds = answeredWithHandleTime > 0 ? (talkTimeTotal + wrapUpTimeTotal) / answeredWithHandleTime : 0d; + report.AverageSpeedOfAnswerSeconds = report.Answered > 0 ? answerSpeedTotal / report.Answered : 0d; + + report.ByChannel = channelCounts + .OrderByDescending(entry => entry.Value) + .Select(entry => new ContactCenterReportCount { Label = entry.Key.ToString(), Count = entry.Value }) + .ToList(); + + report.ByStatus = statusCounts + .OrderByDescending(entry => entry.Value) + .Select(entry => new ContactCenterReportCount { Label = entry.Key.ToString(), Count = entry.Value }) + .ToList(); + + report.Daily = dailyPoints.Values + .OrderBy(point => point.Date) + .ToList(); + + return report; + } + + internal static AgentProductivityReport BuildAgentProductivity( + DateTime fromUtc, + DateTime toUtc, + IReadOnlyList interactions, + IReadOnlyDictionary completedByUser, + IReadOnlyList agents) + { + var stats = new Dictionary(StringComparer.Ordinal); + + foreach (var interaction in interactions) + { + if (!interaction.AnsweredUtc.HasValue || string.IsNullOrEmpty(interaction.AgentId)) + { + continue; + } + + if (!stats.TryGetValue(interaction.AgentId, out var row)) + { + row = new AgentProductivityRow { AgentId = interaction.AgentId }; + stats[interaction.AgentId] = row; + } + + row.InteractionsHandled++; + + if (interaction.Direction == InteractionDirection.Inbound) + { + row.InboundHandled++; + } + else + { + row.OutboundHandled++; + } + + if (interaction.EndedUtc.HasValue && interaction.EndedUtc.Value >= interaction.AnsweredUtc.Value) + { + row.TotalTalkTimeSeconds += (interaction.EndedUtc.Value - interaction.AnsweredUtc.Value).TotalSeconds; + } + + row.TotalWrapUpTimeSeconds += GetWrapUpSeconds(interaction); + } + + foreach (var agent in agents) + { + var completed = !string.IsNullOrEmpty(agent.UserId) && completedByUser.TryGetValue(agent.UserId, out var count) + ? count + : 0L; + + stats.TryGetValue(agent.ItemId, out var row); + + if (row is null && completed == 0) + { + continue; + } + + row ??= new AgentProductivityRow { AgentId = agent.ItemId }; + row.UserName = agent.UserName; + row.DisplayName = ResolveAgentName(agent); + row.ActivitiesCompleted = completed; + + stats[agent.ItemId] = row; + } + + foreach (var row in stats.Values) + { + if (string.IsNullOrEmpty(row.DisplayName)) + { + row.DisplayName = row.AgentId; + } + + row.AverageWrapUpTimeSeconds = row.InteractionsHandled > 0 ? row.TotalWrapUpTimeSeconds / row.InteractionsHandled : 0d; + row.AverageHandleTimeSeconds = row.InteractionsHandled > 0 + ? (row.TotalTalkTimeSeconds + row.TotalWrapUpTimeSeconds) / row.InteractionsHandled + : 0d; + } + + return new AgentProductivityReport + { + FromUtc = fromUtc, + ToUtc = toUtc, + Rows = stats.Values + .OrderByDescending(row => row.InteractionsHandled) + .ThenByDescending(row => row.ActivitiesCompleted) + .ToList(), + }; + } + + internal static QueueUsageReport BuildQueueUsage( + DateTime fromUtc, + DateTime toUtc, + IReadOnlyList interactions, + IReadOnlyList queues, + IReadOnlyList queueGroups, + IReadOnlyDictionary waitingByQueue) + { + var byQueue = new Dictionary(StringComparer.Ordinal); + var byGroup = new Dictionary(StringComparer.Ordinal); + var queuesById = queues.ToDictionary(queue => queue.ItemId, StringComparer.Ordinal); + var queueGroupNames = queueGroups.ToDictionary(group => group.ItemId, group => group.Name, StringComparer.Ordinal); + var totals = new QueueUsageAccumulator(); + + foreach (var interaction in interactions) + { + var queueId = interaction.QueueId ?? string.Empty; + + if (!byQueue.TryGetValue(queueId, out var queueAccumulator)) + { + queueAccumulator = new QueueUsageAccumulator(); + byQueue[queueId] = queueAccumulator; + } + + var queueGroupId = queuesById.TryGetValue(queueId, out var queue) + ? queue.QueueGroupId ?? string.Empty + : string.Empty; + + if (!byGroup.TryGetValue(queueGroupId, out var groupAccumulator)) + { + groupAccumulator = new QueueUsageAccumulator(); + byGroup[queueGroupId] = groupAccumulator; + } + + AccumulateInteraction(queueAccumulator, interaction); + AccumulateInteraction(groupAccumulator, interaction); + AccumulateInteraction(totals, interaction); + } + + var report = new QueueUsageReport + { + FromUtc = fromUtc, + ToUtc = toUtc, + }; + + foreach (var queue in queues) + { + byQueue.TryGetValue(queue.ItemId, out var accumulator); + var waiting = waitingByQueue.GetValueOrDefault(queue.ItemId); + var queueGroupId = queue.QueueGroupId ?? string.Empty; + + if (!byGroup.TryGetValue(queueGroupId, out var groupAccumulator)) + { + groupAccumulator = new QueueUsageAccumulator(); + byGroup[queueGroupId] = groupAccumulator; + } + + groupAccumulator.CurrentWaiting += waiting; + totals.CurrentWaiting += waiting; + + if (accumulator is null && waiting == 0) + { + continue; + } + + queueGroupNames.TryGetValue(queueGroupId, out var queueGroupName); + report.Rows.Add(BuildQueueRow( + queue.ItemId, + queue.Name ?? queue.ItemId, + queueGroupId, + queueGroupName, + queue.SlaThresholdSeconds, + waiting, + accumulator)); + byQueue.Remove(queue.ItemId); + } + + foreach (var entry in byQueue) + { + var name = string.IsNullOrEmpty(entry.Key) ? null : entry.Key; + report.Rows.Add(BuildQueueRow(entry.Key, name, null, null, 0, 0, entry.Value)); + } + + report.Rows = report.Rows + .OrderByDescending(row => row.InteractionsHandled) + .ThenByDescending(row => row.CurrentWaiting) + .ToList(); + + report.GroupRows = byGroup + .Where(entry => entry.Value.Handled > 0 || entry.Value.CurrentWaiting > 0) + .Select(entry => + { + queueGroupNames.TryGetValue(entry.Key, out var queueGroupName); + + return BuildQueueGroupRow(entry.Key, queueGroupName, entry.Value); + }) + .OrderByDescending(row => row.InteractionsHandled) + .ThenByDescending(row => row.CurrentWaiting) + .ToList(); + report.Totals = BuildQueueTotals(totals); + + return report; + } + + internal static CampaignSummaryReport BuildCampaignSummary( + DateTime fromUtc, + DateTime toUtc, + IReadOnlyList activities, + IReadOnlyDictionary campaignNames, + IReadOnlyDictionary campaignGroupIds, + IReadOnlyDictionary campaignGroupNames) + { + var report = new CampaignSummaryReport + { + FromUtc = fromUtc, + ToUtc = toUtc, + }; + + foreach (var group in activities.GroupBy(activity => activity.CampaignId ?? string.Empty, StringComparer.Ordinal)) + { + var counts = BuildCounts(group); + + report.Rows.Add(new CampaignSummaryRow + { + CampaignId = group.Key, + CampaignName = string.IsNullOrEmpty(group.Key) + ? null + : campaignNames.GetValueOrDefault(group.Key), + Counts = counts, + }); + + Accumulate(report.Totals, counts); + } + + report.Rows = report.Rows + .OrderByDescending(row => row.Counts.Total) + .ToList(); + + foreach (var group in activities.GroupBy( + activity => ResolveCampaignGroupId(activity.CampaignId, campaignGroupIds), + StringComparer.Ordinal)) + { + report.GroupRows.Add(new CampaignGroupSummaryRow + { + CampaignGroupId = group.Key, + CampaignGroupName = string.IsNullOrEmpty(group.Key) + ? null + : campaignGroupNames.GetValueOrDefault(group.Key), + Counts = BuildCounts(group), + }); + } + + report.GroupRows = report.GroupRows + .OrderByDescending(row => row.Counts.Total) + .ToList(); + + return report; + } + + private static string ResolveCampaignGroupId( + string campaignId, + IReadOnlyDictionary campaignGroupIds) + { + return !string.IsNullOrEmpty(campaignId) && + campaignGroupIds.TryGetValue(campaignId, out var campaignGroupId) + ? campaignGroupId ?? string.Empty + : string.Empty; + } + + internal static SubjectInventoryReport BuildSubjectInventory( + DateTime fromUtc, + DateTime toUtc, + IReadOnlyList activities) + { + var report = new SubjectInventoryReport + { + FromUtc = fromUtc, + ToUtc = toUtc, + }; + + foreach (var group in activities.GroupBy(activity => activity.SubjectContentType ?? string.Empty, StringComparer.Ordinal)) + { + var counts = BuildCounts(group); + + report.Rows.Add(new SubjectInventoryRow + { + SubjectContentType = group.Key, + Counts = counts, + }); + + Accumulate(report.Totals, counts); + } + + report.Rows = report.Rows + .OrderByDescending(row => row.Counts.Total) + .ToList(); + + return report; + } + + private async Task> QueryInteractionsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken) + { + var interactions = await _session.Query( + index => index.CreatedUtc >= fromUtc && index.CreatedUtc <= toUtc, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return interactions.ToArray(); + } + + private async Task> QueryActivityIndexesAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken) + { + var activities = await _session.QueryIndex( + index => index.CreatedUtc >= fromUtc && index.CreatedUtc <= toUtc, + collection: OmnichannelConstants.CollectionName) + .ListAsync(cancellationToken); + + return activities.ToArray(); + } + + private async Task> QueryCompletedActivitiesByUserAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + IReadOnlyList agents, + CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(criteria?.QueueId) || + !string.IsNullOrEmpty(criteria?.QueueGroupId) || + criteria?.Direction.HasValue == true) + { + return new Dictionary(StringComparer.Ordinal); + } + + var completed = await _session.QueryIndex( + index => index.Status == ActivityStatus.Completed && index.CompletedUtc >= fromUtc && index.CompletedUtc <= toUtc, + collection: OmnichannelConstants.CollectionName) + .ListAsync(cancellationToken); + + var result = new Dictionary(StringComparer.Ordinal); + var allowedUserIds = agents + .Where(agent => !string.IsNullOrEmpty(agent.UserId)) + .Select(agent => agent.UserId) + .ToHashSet(StringComparer.Ordinal); + + foreach (var activity in FilterActivities(completed.ToArray(), criteria)) + { + var userId = activity.AssignedToId; + + if (string.IsNullOrEmpty(userId) || !allowedUserIds.Contains(userId)) + { + continue; + } + + result[userId] = result.GetValueOrDefault(userId) + 1; + } + + return result; + } + + private async Task ApplyCurrentQueueGroupCriteriaAsync( + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(criteria?.QueueGroupId)) + { + return; + } + + var queues = (await _queueManager.GetAllAsync(cancellationToken)).ToArray(); + ApplyCurrentQueueGroupCriteria(criteria, queues); + } + + private static QueueUsageRow BuildQueueRow( + string queueId, + string queueName, + string queueGroupId, + string queueGroupName, + int slaThresholdSeconds, + int currentWaiting, + QueueUsageAccumulator accumulator) + { + var row = new QueueUsageRow + { + QueueId = queueId, + QueueName = queueName, + QueueGroupId = queueGroupId, + QueueGroupName = queueGroupName, + SlaThresholdSeconds = slaThresholdSeconds, + CurrentWaiting = currentWaiting, + }; + + if (accumulator is not null) + { + row.InteractionsHandled = accumulator.Handled; + row.Answered = accumulator.Answered; + row.Abandoned = accumulator.Abandoned; + row.AverageHandleTimeSeconds = accumulator.AnsweredWithHandleTime > 0 ? accumulator.TalkTimeTotal / accumulator.AnsweredWithHandleTime : 0d; + row.AverageSpeedOfAnswerSeconds = accumulator.Answered > 0 ? accumulator.AnswerSpeedTotal / accumulator.Answered : 0d; + } + + return row; + } + + private static QueueGroupUsageRow BuildQueueGroupRow( + string queueGroupId, + string queueGroupName, + QueueUsageAccumulator accumulator) + { + return new QueueGroupUsageRow + { + QueueGroupId = queueGroupId, + QueueGroupName = queueGroupName, + InteractionsHandled = accumulator.Handled, + Answered = accumulator.Answered, + Abandoned = accumulator.Abandoned, + AverageHandleTimeSeconds = accumulator.AnsweredWithHandleTime > 0 + ? accumulator.TalkTimeTotal / accumulator.AnsweredWithHandleTime + : 0d, + AverageSpeedOfAnswerSeconds = accumulator.Answered > 0 + ? accumulator.AnswerSpeedTotal / accumulator.Answered + : 0d, + CurrentWaiting = accumulator.CurrentWaiting, + }; + } + + private static QueueUsageTotals BuildQueueTotals(QueueUsageAccumulator accumulator) + { + return new QueueUsageTotals + { + InteractionsHandled = accumulator.Handled, + Answered = accumulator.Answered, + Abandoned = accumulator.Abandoned, + AverageHandleTimeSeconds = accumulator.AnsweredWithHandleTime > 0 + ? accumulator.TalkTimeTotal / accumulator.AnsweredWithHandleTime + : 0d, + AverageSpeedOfAnswerSeconds = accumulator.Answered > 0 + ? accumulator.AnswerSpeedTotal / accumulator.Answered + : 0d, + CurrentWaiting = accumulator.CurrentWaiting, + }; + } + + private static void AccumulateInteraction(QueueUsageAccumulator accumulator, Interaction interaction) + { + accumulator.Handled++; + + if (interaction.AnsweredUtc.HasValue) + { + accumulator.Answered++; + accumulator.AnswerSpeedTotal += Math.Max(0d, (interaction.AnsweredUtc.Value - interaction.CreatedUtc).TotalSeconds); + + if (interaction.EndedUtc.HasValue && interaction.EndedUtc.Value >= interaction.AnsweredUtc.Value) + { + accumulator.TalkTimeTotal += (interaction.EndedUtc.Value - interaction.AnsweredUtc.Value).TotalSeconds; + accumulator.AnsweredWithHandleTime++; + } + } + else if (interaction.Direction == InteractionDirection.Inbound && interaction.Status == InteractionStatus.Ended) + { + accumulator.Abandoned++; + } + } + + private static ActivityProgressCounts BuildCounts(IEnumerable activities) + { + var counts = new ActivityProgressCounts(); + + foreach (var activity in activities) + { + counts.Total++; + counts.TotalAttempts += Math.Max(0, activity.Attempts); + + switch (activity.Status) + { + case ActivityStatus.Completed: + counts.Completed++; + + break; + case ActivityStatus.Failed: + counts.Failed++; + + break; + case ActivityStatus.Cancelled: + case ActivityStatus.Purged: + counts.Cancelled++; + + break; + case ActivityStatus.AwaitingAgentResponse: + case ActivityStatus.AwaitingCustomerAnswer: + case ActivityStatus.Reserved: + case ActivityStatus.Dialing: + case ActivityStatus.InProgress: + counts.InProgress++; + + break; + default: + counts.Pending++; + + break; + } + } + + return counts; + } + + private static void Accumulate(ActivityProgressCounts totals, ActivityProgressCounts counts) + { + totals.Total += counts.Total; + totals.Completed += counts.Completed; + totals.Pending += counts.Pending; + totals.InProgress += counts.InProgress; + totals.Failed += counts.Failed; + totals.Cancelled += counts.Cancelled; + totals.TotalAttempts += counts.TotalAttempts; + } + + private static string ResolveAgentName(AgentProfile agent) + { + if (!string.IsNullOrWhiteSpace(agent.DisplayName)) + { + return agent.DisplayName; + } + + if (!string.IsNullOrWhiteSpace(agent.UserName)) + { + return agent.UserName; + } + + return agent.ItemId; + } + + private static double GetWrapUpSeconds(Interaction interaction) + { + if (!interaction.WrapUpStartedUtc.HasValue || + !interaction.WrapUpCompletedUtc.HasValue || + interaction.WrapUpCompletedUtc.Value < interaction.WrapUpStartedUtc.Value) + { + return 0d; + } + + return (interaction.WrapUpCompletedUtc.Value - interaction.WrapUpStartedUtc.Value).TotalSeconds; + } + + private sealed class QueueUsageAccumulator + { + public long Handled { get; set; } + + public long Answered { get; set; } + + public long Abandoned { get; set; } + + public double TalkTimeTotal { get; set; } + + public double AnswerSpeedTotal { get; set; } + + public long AnsweredWithHandleTime { get; set; } + + public int CurrentWaiting { get; set; } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRetentionService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRetentionService.cs new file mode 100644 index 000000000..afca62a0f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRetentionService.cs @@ -0,0 +1,196 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . It iterates the +/// registered retention policies rather than hard-coding a purge per table, drains each entity until it is +/// empty, and reports honestly when the cycle budget stopped it short instead of truncating silently. +/// +public sealed class ContactCenterRetentionService : IContactCenterRetentionService +{ + private readonly IEnumerable _policies; + private readonly ISession _session; + private readonly IClock _clock; + private readonly ContactCenterRetentionOptions _options; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The registered retention policies, one per high-volume table. + /// The tenant YesSql session, committed between batches to bound transaction size. + /// The clock used to compute cutoffs. + /// The configured retention options. + /// The logger. + public ContactCenterRetentionService( + IEnumerable policies, + ISession session, + IClock clock, + IOptions options, + ILogger logger) + { + _policies = policies; + _session = session; + _clock = clock; + _options = options.Value; + _logger = logger; + } + + /// + public async Task PurgeAsync(CancellationToken cancellationToken = default) + { + var nowUtc = _clock.UtcNow; + var batchSize = _options.PurgeBatchSize > 0 ? _options.PurgeBatchSize : ContactCenterRetentionOptions.DefaultPurgeBatchSize; + var batchBudget = _options.MaxPurgeBatchesPerCycle > 0 + ? _options.MaxPurgeBatchesPerCycle + : ContactCenterRetentionOptions.DefaultMaxPurgeBatchesPerCycle; + + var report = new ContactCenterRetentionReport(); + + foreach (var policy in _policies) + { + // The budget is per entity. Sharing one budget across every entity would let whichever policy is + // registered first consume all of it, and the tables behind it would never be purged at all. + var remainingBatches = batchBudget; + + var result = new ContactCenterEntityRetentionResult + { + EntityName = policy.EntityName, + }; + + report.Entities.Add(result); + + if (!policy.TryGetCutoff(nowUtc, _options, out var cutoffUtc)) + { + continue; + } + + result.IsEnabled = true; + result.CutoffUtc = cutoffUtc; + + while (true) + { + if (remainingBatches <= 0 || cancellationToken.IsCancellationRequested) + { + result.WorkRemains = true; + + break; + } + + int purged; + + try + { + purged = await policy.PurgeBatchAsync(cutoffUtc, batchSize, cancellationToken); + } + catch (ContactCenterRetentionBatchException ex) + { + // One entity failing must not stop the remaining entities from draining, otherwise a single + // unhealthy table would keep every other table growing forever. The failed batch staged a mix of + // completed records and the failing record's partial side effects into the shared session, and the + // session cannot commit only the completed records. Committing here would flush the failing + // record's partial state too — for example a recording-erased event whose outbox message was never + // staged, which the next cycle's idempotency key would then suppress, orphaning the media. Discard + // the whole batch instead so nothing partial is ever committed: every record it touched rolls back + // and is retried cleanly on the next cycle, and repeated deletes are idempotent. + _logger.LogError(ex, "Contact Center retention failed while purging entity {EntityName}; the batch was discarded and will be retried on the next cycle.", policy.EntityName); + result.WorkRemains = true; + + try + { + await _session.ResetAsync(); + } + catch (Exception resetException) + { + // A session that cannot even be reset is unusable, so stop the cycle rather than let the next + // entity run against a poisoned session and appear to purge while its deletes are discarded. + _logger.LogError(resetException, "Contact Center retention could not reset the session after a failed batch for entity {EntityName}. The cycle was stopped because the session cannot be reused.", policy.EntityName); + MarkUnvisitedEntitiesAsUnfinished(report, nowUtc); + + return report; + } + + break; + } + catch (Exception ex) + { + // Reaching here means the batch failed before it staged any work — the only uncaught path left in + // a batch is the query that reads the expired records, and the per-record loop converts every + // later failure (a delete or a prepare side effect) into a ContactCenterRetentionBatchException. + // The session is therefore still usable, so the remaining entities can drain normally. + _logger.LogError(ex, "Contact Center retention failed while reading expired records for entity {EntityName}.", policy.EntityName); + result.WorkRemains = true; + + break; + } + + remainingBatches--; + + if (purged == 0) + { + break; + } + + try + { + await _session.SaveChangesAsync(cancellationToken); + } + catch (Exception ex) + { + // The deletes only reach the database here, so this is where a deadlock or a lost connection + // surfaces. A failed flush cancels the session permanently, which means every later entity + // would appear to purge while its deletes were silently discarded. Stop the cycle instead. + _logger.LogError(ex, "Contact Center retention failed while committing purged records for entity {EntityName}. The cycle was stopped because the session cannot be reused.", policy.EntityName); + result.WorkRemains = true; + + // Entities the cycle never reached still have work. Leaving them out of the report would make + // an untouched backlog indistinguishable from a drained one in the operator warning. + MarkUnvisitedEntitiesAsUnfinished(report, nowUtc); + + return report; + } + + // Counted only once the deletes have actually committed, so a failed flush cannot over-report. + result.PurgedCount += purged; + + if (purged < batchSize) + { + break; + } + } + } + + return report; + } + + private void MarkUnvisitedEntitiesAsUnfinished(ContactCenterRetentionReport report, DateTime nowUtc) + { + var visited = report.Entities.Select(entity => entity.EntityName).ToHashSet(StringComparer.Ordinal); + + foreach (var policy in _policies) + { + if (visited.Contains(policy.EntityName)) + { + continue; + } + + // An entity whose window is disabled purges nothing, so reporting it as unfinished would name a table + // in the operator warning that no amount of budget or window tuning could ever drain. + var isEnabled = policy.TryGetCutoff(nowUtc, _options, out var cutoffUtc); + + report.Entities.Add(new ContactCenterEntityRetentionResult + { + EntityName = policy.EntityName, + IsEnabled = isEnabled, + CutoffUtc = isEnabled ? cutoffUtc : null, + WorkRemains = isEnabled, + }); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterScopeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterScopeExecutor.cs new file mode 100644 index 000000000..c00d09139 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterScopeExecutor.cs @@ -0,0 +1,71 @@ +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Environment.Shell.Scope; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Executes Contact Center operations through Orchard shell scopes. +/// +public sealed class ContactCenterScopeExecutor : IContactCenterScopeExecutor +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The current shell service provider. + public ContactCenterScopeExecutor(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + /// + public async Task ExecuteAsync(Func operation) + where TContext : notnull + { + ArgumentNullException.ThrowIfNull(operation); + + if (ShellScope.Current is null) + { + await using var scope = _serviceProvider.CreateAsyncScope(); + await operation(scope.ServiceProvider.GetRequiredService()); + + return; + } + + await ShellScope.UsingChildScopeAsync(scope => + operation(scope.ServiceProvider.GetRequiredService())); + } + + /// + public bool ScheduleAfterCommit(Func operation) + where TContext : notnull + { + ArgumentNullException.ThrowIfNull(operation); + + if (ShellScope.Current is null) + { + return false; + } + + ShellScope.AddDeferredTask(scope => + operation(scope.ServiceProvider.GetRequiredService())); + + return true; + } + + /// + public bool ScheduleAfterCommit(Func operation) + { + ArgumentNullException.ThrowIfNull(operation); + + if (ShellScope.Current is null) + { + return false; + } + + ShellScope.AddDeferredTask(_ => operation()); + + return true; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillManager.cs new file mode 100644 index 000000000..5e13cb6d4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillManager.cs @@ -0,0 +1,54 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterSkillManager : CatalogManager, IContactCenterSkillManager +{ + private readonly IContactCenterSkillStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying skill store. + /// The catalog entry handlers for skills. + /// The logger instance. + public ContactCenterSkillManager( + IContactCenterSkillStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + var skill = await _store.FindByNameAsync(name, cancellationToken); + + if (skill is not null) + { + await LoadAsync(skill, cancellationToken); + } + + return skill; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var skills = await _store.ListEnabledAsync(cancellationToken); + + foreach (var skill in skills) + { + await LoadAsync(skill, cancellationToken); + } + + return skills; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillStore.cs new file mode 100644 index 000000000..fab1568ef --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillStore.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterSkillStore : DocumentCatalog, IContactCenterSkillStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterSkillStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByNameAsync(string name, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(name); + + return await Session.Query( + index => index.Name == name, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var skills = await Session.Query( + index => index.Enabled, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return skills.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTopologyEvaluator.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTopologyEvaluator.cs new file mode 100644 index 000000000..9938d3417 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTopologyEvaluator.cs @@ -0,0 +1,151 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Decides whether a deployment satisfies the topology its operator declared. +/// +/// +/// Pure by design. Gathering the observations needs the shell, the feature manager, and the container; deciding +/// what they mean does not. Keeping the decision separate is what lets every branch be tested directly instead +/// of through a booted tenant, which is the difference between a rule that is verified and one that is merely +/// present. +/// +public static class ContactCenterTopologyEvaluator +{ + /// + /// The Orchard database provider value required by every production topology. + /// + /// + /// PostgreSQL 16 is the only database the support matrix declares production-capable. This is a literal + /// rather than a reference to Orchard's constant so the pure decision layer stays free of a data-layer + /// dependency; a contract test pins it to the support matrix. + /// + public const string RequiredProductionDatabaseProvider = "Postgres"; + + /// + /// The feature that supplies the Redis connection every other Redis-backed feature depends on. + /// + public const string RedisFeatureId = "OrchardCore.Redis"; + + /// + /// The feature that replaces the process-local lock with a Redis-backed distributed lock. + /// + public const string RedisLockFeatureId = "OrchardCore.Redis.Lock"; + + /// + /// The feature that replaces the in-memory SignalR backplane with a Redis-backed one. + /// + public const string SignalRRedisBackplaneFeatureId = "CrestApps.OrchardCore.SignalR.Redis"; + + /// + /// Evaluates a deployment against the topology it declared. + /// + /// What the running deployment was observed to be. + /// The verdict, listing every unmet requirement. + public static ContactCenterTopologyValidationResult Evaluate(ContactCenterTopologyObservations observations) + { + ArgumentNullException.ThrowIfNull(observations); + + var declaredProfileId = observations.DeclaredProfileId?.Trim(); + + if (string.IsNullOrEmpty(declaredProfileId)) + { + // An undeclared topology is normal for development, tests, and demos, and is the default. It is not + // acceptable in a production host, where it would let a deployment escape every requirement below + // simply by setting nothing. + return observations.IsProductionHostEnvironment + ? new ContactCenterTopologyValidationResult + { + DeclaredProfileId = null, + IsProductionTopology = false, + Failures = + [ + "The host is running in a production environment but no Contact Center topology profile is declared. " + + $"Set 'CrestApps_ContactCenter:Topology:ProfileId' to '{ContactCenterTopologyProfiles.SingleNodeDistributedId}'.", + ], + } + : new ContactCenterTopologyValidationResult + { + DeclaredProfileId = null, + IsProductionTopology = false, + }; + } + + var profile = ContactCenterTopologyProfiles.Find(declaredProfileId); + + if (profile is null) + { + // Falling back to the development profile here would turn a typo into a silent downgrade, which is + // the exact failure this validator exists to prevent. + return new ContactCenterTopologyValidationResult + { + DeclaredProfileId = declaredProfileId, + IsProductionTopology = false, + Failures = + [ + $"The declared Contact Center topology profile '{declaredProfileId}' is not recognized. " + + $"Recognized profiles are: {string.Join(", ", ContactCenterTopologyProfiles.All.Select(candidate => candidate.Id).Order(StringComparer.Ordinal))}.", + ], + }; + } + + if (!profile.IsProduction) + { + // A non-production topology imposes no infrastructure requirements; it is a statement that the + // deployment is not claiming support, which is always internally consistent. + return new ContactCenterTopologyValidationResult + { + DeclaredProfileId = profile.Id, + IsProductionTopology = false, + }; + } + + var failures = new List(); + + if (profile.RequiresSharedRelationalDatabase + && !string.Equals(observations.DatabaseProvider, RequiredProductionDatabaseProvider, StringComparison.OrdinalIgnoreCase)) + { + failures.Add( + $"Topology '{profile.Id}' requires the '{RequiredProductionDatabaseProvider}' database provider, " + + $"but this tenant is configured with '{observations.DatabaseProvider ?? "none"}'."); + } + + if (profile.RequiresRedisDistributedLock || profile.RequiresRedisBackplane) + { + // Both Redis-backed features resolve their connection through this one, so a missing base feature is + // reported once rather than as two derived failures that hide the actual cause. + if (!observations.RedisFeatureEnabled) + { + failures.Add($"Topology '{profile.Id}' requires the '{RedisFeatureId}' feature, which is not enabled."); + } + } + + if (profile.RequiresRedisDistributedLock) + { + if (!observations.RedisLockFeatureEnabled) + { + failures.Add($"Topology '{profile.Id}' requires the '{RedisLockFeatureId}' feature, which is not enabled."); + } + + if (observations.DistributedLockIsProcessLocal) + { + failures.Add( + $"Topology '{profile.Id}' requires a distributed lock, but the resolved lock is process-local. " + + "A process-local lock cannot serialize two overlapping processes during a rolling restart or a shell reload."); + } + } + + if (profile.RequiresRedisBackplane && !observations.SignalRRedisBackplaneFeatureEnabled) + { + failures.Add($"Topology '{profile.Id}' requires the '{SignalRRedisBackplaneFeatureId}' feature, which is not enabled."); + } + + return new ContactCenterTopologyValidationResult + { + DeclaredProfileId = profile.Id, + IsProductionTopology = true, + Failures = failures, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTopologyState.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTopologyState.cs new file mode 100644 index 000000000..611941db3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTopologyState.cs @@ -0,0 +1,51 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Holds the topology verdict for this tenant so the readiness probe and the work admission gate answer from +/// one decision rather than re-deriving it. +/// +/// +/// Registered as a singleton per tenant shell. The verdict is established during activation and is immutable +/// for the life of the shell, because every input to it — declared profile, database provider, enabled +/// features, resolved lock — can only change by rebuilding the shell. +/// +/// Until the verdict is recorded the tenant is not admissible. Starting admissible and tightening +/// later would open a window in which work is accepted by a deployment that is about to be found unsupported, +/// and that window is exactly when a shell reload is in progress. +/// +/// +public sealed class ContactCenterTopologyState +{ + private ContactCenterTopologyValidationResult _result; + + /// + /// Gets the recorded verdict, or when validation has not run yet. + /// + public ContactCenterTopologyValidationResult Result => Volatile.Read(ref _result); + + /// + /// Gets a value indicating whether this tenant may admit Contact Center work. + /// + public bool IsAdmissible + { + get + { + var result = Volatile.Read(ref _result); + + return result is not null && result.IsSatisfied; + } + } + + /// + /// Records the verdict for this tenant shell. + /// + /// The verdict produced by . + public void Record(ContactCenterTopologyValidationResult result) + { + ArgumentNullException.ThrowIfNull(result); + + Volatile.Write(ref _result, result); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTransferService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTransferService.cs new file mode 100644 index 000000000..37a0be2a3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTransferService.cs @@ -0,0 +1,297 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterTransferService : IContactCenterTransferService +{ + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IActivityQueueService _queueService; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly ICallControlAuthorizationService _callControlAuthorizationService; + private readonly ITransferDestinationResolver _transferDestinationResolver; + private readonly IContactCenterEventPublisher _publisher; + private readonly ITelephonyCommandExecutor _commandExecutor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The call session manager that owns live call topology. + /// The queue service used to re-enqueue queue transfers. + /// The voice provider resolver. + /// The Contact Center event publisher. + /// The executor that provides a bounded server-owned provider-operation token. + /// The clock used to stamp transfer times. + /// The shared call-control authorization boundary. + /// The typed transfer destination resolver. + public ContactCenterTransferService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IActivityQueueService queueService, + IContactCenterVoiceProviderResolver voiceProviderResolver, + IContactCenterEventPublisher publisher, + ITelephonyCommandExecutor commandExecutor, + IClock clock, + ICallControlAuthorizationService callControlAuthorizationService, + ITransferDestinationResolver transferDestinationResolver) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _queueService = queueService; + _voiceProviderResolver = voiceProviderResolver; + _callControlAuthorizationService = callControlAuthorizationService; + _transferDestinationResolver = transferDestinationResolver; + _publisher = publisher; + _commandExecutor = commandExecutor; + _clock = clock; + } + + /// + public async Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrEmpty(request.InteractionId)) + { + return TransferResult.Failure("A transfer requires an interaction."); + } + + if (string.IsNullOrEmpty(request.TargetId)) + { + return TransferResult.Failure("A transfer requires a destination."); + } + + if (string.IsNullOrEmpty(request.InitiatedByUserId)) + { + return TransferResult.Failure("The requested call is not available."); + } + + var interaction = await _interactionManager.FindByIdAsync(request.InteractionId, cancellationToken); + + if (interaction is null) + { + return TransferResult.Failure("The interaction could not be found."); + } + + var authorization = await _callControlAuthorizationService.AuthorizeAsync(new CallControlAuthorizationContext + { + Principal = request.Principal, + UserId = request.InitiatedByUserId, + Verb = CallControlVerb.Transfer, + InteractionId = interaction.ItemId, + ProviderName = interaction.ProviderName, + }, cancellationToken); + + if (!authorization.Succeeded) + { + return TransferResult.Failure(authorization.FailureReason); + } + + var providerCallId = authorization.ProviderCallId; + + var destination = await _transferDestinationResolver.ResolveAsync(request, request.Principal, cancellationToken); + + if (!destination.Succeeded) + { + await PublishTransferDeniedAsync(request, interaction, destination.FailureReason, cancellationToken); + + return TransferResult.Failure(destination.FailureReason); + } + + var provider = _voiceProviderResolver.Get(interaction.ProviderName); + + if (provider is not IContactCenterVoiceTransferProvider transferProvider || + !provider.Capabilities.HasFlag(ContactCenterVoiceProviderCapabilities.CallTransfer) || + string.IsNullOrEmpty(providerCallId)) + { + return TransferResult.Failure("The voice provider does not support call transfer."); + } + + try + { + var transferRequest = new ContactCenterVoiceTransferRequest + { + InteractionId = interaction.ItemId, + ProviderCallId = providerCallId, + TransferType = request.Type, + TargetType = destination.TargetType, + Target = destination.ResolvedTarget, + }; + + if (!string.IsNullOrEmpty(destination.ProviderEndpointUserId)) + { + transferRequest.Metadata[ContactCenterConstants.TransferMetadata.AgentUserId] = + destination.ProviderEndpointUserId; + } + + var providerResult = await _commandExecutor.ExecuteAsync(commandCancellationToken => + transferProvider.TransferAsync(transferRequest, commandCancellationToken)); + + if (providerResult?.Succeeded != true || providerResult.OutcomeUnknown) + { + return TransferResult.Failure( + providerResult?.ErrorMessage ?? "The voice provider did not confirm the call transfer."); + } + + var now = _clock.UtcNow; + + await RecordTransferTopologyAsync( + interaction, + request, + destination, + providerResult, + now, + CancellationToken.None); + + var entry = new InteractionTransferHistoryEntry + { + FromParticipantId = request.InitiatedByAgentId ?? interaction.AgentId, + ToParticipantId = destination.ResolvedTarget, + TargetType = destination.TargetType.ToString(), + RequestedUtc = now, + }; + + var reason = await ApplyTargetAsync(request, interaction, destination, CancellationToken.None); + + entry.CompletedUtc = now; + entry.Result = reason; + interaction.TransferHistory.Add(entry); + interaction.TransitionTo(InteractionStatus.Transferring); + + await _interactionManager.UpdateAsync(interaction, cancellationToken: CancellationToken.None); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionTransferred, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = request.InitiatedByAgentId ?? interaction.AgentId, + SourceComponent = ContactCenterConstants.Components.Interactions, + }; + + await _publisher.PublishAsync(interactionEvent, CancellationToken.None); + + return TransferResult.Success(reason); + } + catch (TimeoutException) + { + return TransferResult.Unknown( + "The voice provider did not confirm the call transfer before the server timeout; the provider outcome is unknown."); + } + catch (OperationCanceledException) + { + return TransferResult.Unknown( + "The call transfer was interrupted before the provider outcome could be confirmed."); + } + } + + private async Task RecordTransferTopologyAsync( + Interaction interaction, + TransferRequest request, + TransferDestinationResolutionResult destination, + ContactCenterVoiceProviderResult providerResult, + DateTime now, + CancellationToken cancellationToken) + { + var callSession = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (callSession is null) + { + return; + } + + // A consultative transfer opens a private consult leg that the customer cannot hear. Recording it on + // the topology is what lets a supervisor see the customer is held while the agent talks to someone + // else, and lets reporting tell a completed warm transfer apart from an abandoned consult. + if (request.Type == InteractionTransferType.Consultative) + { + var consultId = IdGenerator.GenerateId(); + + CallTopologyProjector.StartConsult( + callSession, + consultId, + request.InitiatedByAgentId ?? interaction.AgentId, + destination.TargetType, + request.TargetId, + destination.ResolvedTarget, + now, + providerResult?.ProviderLegId); + + CallTopologyProjector.AdvanceConsult(callSession, consultId, ConsultCallStatus.Ringing, now); + } + + // The destination has not answered yet, so the only identifier that can link the two sides is the + // provider call the provider created for the destination. When the provider does not report one, no + // relationship is recorded rather than a fabricated one. + CallTopologyProjector.Relate( + callSession, + CallRelationshipKind.TransferredTo, + now, + relatedProviderCallId: providerResult?.ProviderCallId); + + await _callSessionManager.UpdateAsync(callSession, cancellationToken: cancellationToken); + } + + private async Task ApplyTargetAsync( + TransferRequest request, + Interaction interaction, + TransferDestinationResolutionResult destination, + CancellationToken cancellationToken) + { + switch (destination.TargetType) + { + case InteractionTransferTargetType.Queue: + if (!string.IsNullOrEmpty(interaction.ActivityItemId)) + { + await _queueService.EnqueueAsync(interaction.ActivityItemId, destination.ResolvedTarget, priority: null, cancellationToken); + + return "Re-queued to the target queue."; + } + + return "Queued transfer requested without an activity."; + case InteractionTransferTargetType.Agent: + return "Transfer to agent requested."; + case InteractionTransferTargetType.External: + return "Transfer to external destination requested."; + case InteractionTransferTargetType.EntryPoint: + return "Transfer to entry point requested."; + default: + return "Transfer requested."; + } + } + + private Task PublishTransferDeniedAsync( + TransferRequest request, + Interaction interaction, + string reason, + CancellationToken cancellationToken) + { + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionTransferDenied, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = request.InitiatedByAgentId ?? interaction.AgentId, + SourceComponent = ContactCenterConstants.Components.Interactions, + }; + + interactionEvent.SetData(new Dictionary + { + ["targetType"] = request.TargetType.ToString(), + ["reason"] = reason ?? string.Empty, + }); + + return _publisher.PublishAsync(interactionEvent, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceMediaProviderResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceMediaProviderResolver.cs new file mode 100644 index 000000000..16c25dcea --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceMediaProviderResolver.cs @@ -0,0 +1,47 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default resolver for Contact Center live-media providers. +/// +public sealed class ContactCenterVoiceMediaProviderResolver : IContactCenterVoiceMediaProviderResolver +{ + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly IEnumerable _mediaProviders; + + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center voice provider resolver. + /// The registered live-media providers. + public ContactCenterVoiceMediaProviderResolver( + IContactCenterVoiceProviderResolver voiceProviderResolver, + IEnumerable mediaProviders) + { + _voiceProviderResolver = voiceProviderResolver; + _mediaProviders = mediaProviders; + } + + /// + public IContactCenterVoiceMediaProvider Get(string technicalName = null) + { + var voiceProvider = _voiceProviderResolver.Get(technicalName); + + if (voiceProvider is null) + { + return null; + } + + return _mediaProviders.FirstOrDefault(provider => + string.Equals(provider.TechnicalName, voiceProvider.TechnicalName, StringComparison.OrdinalIgnoreCase)); + } + + /// + public IEnumerable GetAll() + { + var supportedProviderNames = _voiceProviderResolver.GetAll() + .Select(provider => provider.TechnicalName) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + return _mediaProviders.Where(provider => supportedProviderNames.Contains(provider.TechnicalName)); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProjection.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProjection.cs new file mode 100644 index 000000000..4e92e2994 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProjection.cs @@ -0,0 +1,56 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.Telephony.Core.Services; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Projects the normalized provider voice event stream onto Contact Center interactions and call sessions. +/// +/// The projection consumes the shared normalized stream instead of owning ingress. The ingestion lease is +/// already held by the provider-neutral ingestor when this runs, so the durable de-duplication record this +/// projection writes is the only one taken for the delivery. +/// +/// +public sealed class ContactCenterVoiceProjection : INormalizedVoiceEventHandler +{ + private readonly IProviderVoiceEventSink _providerVoiceEventSink; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center provider voice event sink. + /// The logger instance. + public ContactCenterVoiceProjection( + IProviderVoiceEventSink providerVoiceEventSink, + ILogger logger) + { + _providerVoiceEventSink = providerVoiceEventSink; + _logger = logger; + } + + /// + public int Order => 100; + + /// + public async Task HandleAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(providerEvent); + + var handled = await _providerVoiceEventSink.IngestAsync(providerEvent, cancellationToken); + + if (handled && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Normalized voice event for provider {ProviderName} call {CallId} flowed into Contact Center.", + providerEvent.ProviderName, + providerEvent.ProviderCallId.SanitizeLogValue()); + } + + return handled; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProviderResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProviderResolver.cs new file mode 100644 index 000000000..0d06982e4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProviderResolver.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default resolver for Contact Center voice providers. +/// +public sealed class ContactCenterVoiceProviderResolver : IContactCenterVoiceProviderResolver +{ + private readonly IEnumerable _providers; + + /// + /// Initializes a new instance of the class. + /// + /// The registered voice providers. + public ContactCenterVoiceProviderResolver(IEnumerable providers) + { + _providers = providers; + } + + /// + public IContactCenterVoiceProvider Get(string technicalName = null) + { + if (string.IsNullOrEmpty(technicalName)) + { + return _providers.FirstOrDefault(); + } + + return _providers.FirstOrDefault(provider => string.Equals(provider.TechnicalName, technicalName, StringComparison.OrdinalIgnoreCase)); + } + + /// + public IEnumerable GetAll() + { + return _providers; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateActivityProjection.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateActivityProjection.cs new file mode 100644 index 000000000..c54e82fe0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateActivityProjection.cs @@ -0,0 +1,133 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.Extensions.Logging; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterWorkStateActivityProjection : IContactCenterWorkStateActivityProjection +{ + private const int MaxProjectionAttempts = 3; + + private readonly IContactCenterWorkStateManager _workStateManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The work state manager. + /// The CRM activity manager. + /// The executor used to retry the projection in a fresh scope. + /// The logger. + public ContactCenterWorkStateActivityProjection( + IContactCenterWorkStateManager workStateManager, + IOmnichannelActivityManager activityManager, + IContactCenterScopeExecutor scopeExecutor, + ILogger logger) + { + _workStateManager = workStateManager; + _activityManager = activityManager; + _scopeExecutor = scopeExecutor; + _logger = logger; + } + + /// + public async Task ProjectAsync(string activityItemId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(activityItemId)) + { + return; + } + + try + { + await ProjectCoreAsync(activityItemId, cancellationToken); + } + catch (ConcurrencyException) + { + await RetryInFreshScopeAsync(activityItemId, cancellationToken); + } + } + + /// + public async Task TrySeedAsync(ContactCenterWorkState workState, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(workState); + + if (string.IsNullOrEmpty(workState.ActivityItemId)) + { + return false; + } + + var activity = await _activityManager.FindByIdAsync(workState.ActivityItemId, cancellationToken); + + if (activity is null) + { + return false; + } + + ContactCenterWorkStateProjector.SeedFromActivity(workState, activity); + + return true; + } + + private async Task ProjectCoreAsync(string activityItemId, CancellationToken cancellationToken) + { + var workState = await _workStateManager.FindByActivityIdAsync(activityItemId, cancellationToken); + + if (workState is null) + { + return; + } + + var activity = await _activityManager.FindByIdAsync(activityItemId, cancellationToken); + + if (activity is null) + { + return; + } + + if (!ContactCenterWorkStateProjector.HasDivergence(activity, workState)) + { + return; + } + + ContactCenterWorkStateProjector.Apply(activity, workState); + + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + } + + private async Task RetryInFreshScopeAsync(string activityItemId, CancellationToken cancellationToken) + { + Exception lastException = null; + + for (var attempt = 2; attempt <= MaxProjectionAttempts; attempt++) + { + try + { + await _scopeExecutor.ExecuteAsync(projection => + projection.ProjectAsync(activityItemId, cancellationToken)); + + return; + } + catch (ConcurrencyException exception) + { + lastException = exception; + } + } + + // The CRM activity is a read model for this data; work state remains authoritative and the next + // routing transition re-schedules the projection, so a losing race is logged rather than thrown. + _logger.LogWarning( + lastException, + "Unable to project Contact Center work state onto the CRM activity '{ActivityItemId}' after {Attempts} attempts.", + activityItemId.SanitizeLogValue(), + MaxProjectionAttempts); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateManager.cs new file mode 100644 index 000000000..c07f6467d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateManager.cs @@ -0,0 +1,56 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterWorkStateManager : CatalogManager, IContactCenterWorkStateManager +{ + private readonly IContactCenterWorkStateStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying work state store. + /// The catalog entry handlers for work state. + /// The logger instance. + public ContactCenterWorkStateManager( + IContactCenterWorkStateStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + var workState = await _store.FindByActivityIdAsync(activityItemId, cancellationToken); + + if (workState is not null) + { + await LoadAsync(workState, cancellationToken); + } + + return workState; + } + + /// + public async Task> ListByActivityIdsAsync( + IEnumerable activityItemIds, + CancellationToken cancellationToken = default) + { + var states = await _store.ListByActivityIdsAsync(activityItemIds, cancellationToken); + + foreach (var state in states) + { + await LoadAsync(state, cancellationToken); + } + + return states; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateProjector.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateProjector.cs new file mode 100644 index 000000000..9a4eaf458 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateProjector.cs @@ -0,0 +1,81 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Copies routing-owned work state onto the CRM activity that projects it. The copy lives here rather than +/// inside the projection service so that every caller that has to reconcile the read model — the projection, +/// the backfill, and tests — uses one definition of what the read model contains. +/// +public static class ContactCenterWorkStateProjector +{ + /// + /// Copies every projected field from the work state onto the activity. + /// + /// The CRM activity that carries the read model. + /// The routing-owned work state that is authoritative. + public static void Apply(OmnichannelActivity activity, ContactCenterWorkState workState) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(workState); + + activity.AssignmentStatus = workState.AssignmentStatus; + activity.ReservationId = workState.ReservationId; + activity.ReservedById = workState.ReservedById; + activity.ReservedByUsername = workState.ReservedByUsername; + activity.ReservedUtc = workState.ReservedUtc; + activity.ReservationExpiresUtc = workState.ReservationExpiresUtc; + activity.AssignedToId = workState.AssignedToId; + activity.AssignedToUsername = workState.AssignedToUsername; + activity.AssignedToUtc = workState.AssignedToUtc; + activity.Attempts = workState.Attempts; + } + + /// + /// Copies the projected fields an activity already carries back onto a work state. This is how routing + /// adopts work that existed before the work state document did, so an upgraded installation does not need + /// a separate backfill pass to keep in-flight work routable. + /// + /// The work state to seed. + /// The CRM activity whose projected fields describe the pre-existing routing state. + public static void SeedFromActivity(ContactCenterWorkState workState, OmnichannelActivity activity) + { + ArgumentNullException.ThrowIfNull(workState); + ArgumentNullException.ThrowIfNull(activity); + + workState.AdoptActivityAssignmentStatus(activity.AssignmentStatus); + workState.ReservationId = activity.ReservationId; + workState.ReservedById = activity.ReservedById; + workState.ReservedByUsername = activity.ReservedByUsername; + workState.ReservedUtc = activity.ReservedUtc; + workState.ReservationExpiresUtc = activity.ReservationExpiresUtc; + workState.AssignedToId = activity.AssignedToId; + workState.AssignedToUsername = activity.AssignedToUsername; + workState.AssignedToUtc = activity.AssignedToUtc; + workState.Attempts = activity.Attempts; + } + + /// + /// Determines whether the activity's read model differs from the authoritative work state. + /// + /// The CRM activity that carries the read model. + /// The routing-owned work state that is authoritative. + /// when at least one projected field differs; otherwise, . + public static bool HasDivergence(OmnichannelActivity activity, ContactCenterWorkState workState) + { + ArgumentNullException.ThrowIfNull(activity); + ArgumentNullException.ThrowIfNull(workState); + + return activity.AssignmentStatus != workState.AssignmentStatus || + !string.Equals(activity.ReservationId, workState.ReservationId, StringComparison.Ordinal) || + !string.Equals(activity.ReservedById, workState.ReservedById, StringComparison.Ordinal) || + !string.Equals(activity.ReservedByUsername, workState.ReservedByUsername, StringComparison.Ordinal) || + activity.ReservedUtc != workState.ReservedUtc || + activity.ReservationExpiresUtc != workState.ReservationExpiresUtc || + !string.Equals(activity.AssignedToId, workState.AssignedToId, StringComparison.Ordinal) || + !string.Equals(activity.AssignedToUsername, workState.AssignedToUsername, StringComparison.Ordinal) || + activity.AssignedToUtc != workState.AssignedToUtc || + activity.Attempts != workState.Attempts; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateService.cs new file mode 100644 index 000000000..ab172cf47 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateService.cs @@ -0,0 +1,120 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterWorkStateService : IContactCenterWorkStateService +{ + private readonly IContactCenterWorkStateManager _workStateManager; + private readonly IContactCenterWorkStateActivityProjection _activityProjection; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The work state manager. + /// The optional CRM projection, which is absent when CRM activity management is not enabled. + /// The executor used to project work state to the CRM after commit. + /// The clock used to stamp work state times. + public ContactCenterWorkStateService( + IContactCenterWorkStateManager workStateManager, + IEnumerable activityProjections, + IContactCenterScopeExecutor scopeExecutor, + IClock clock) + { + _workStateManager = workStateManager; + _activityProjection = activityProjections.FirstOrDefault(); + _scopeExecutor = scopeExecutor; + _clock = clock; + } + + /// + public async Task GetAsync(string activityItemId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(activityItemId)) + { + return null; + } + + var workState = await _workStateManager.FindByActivityIdAsync(activityItemId, cancellationToken); + + if (workState is not null) + { + return workState; + } + + // Work that was already in flight when this feature was upgraded has no work state document yet. Rather + // than reporting it as unassigned with zero attempts — which would re-offer live work and defeat the + // dialer's attempt cap — the pre-existing projection on the activity is adopted as the answer. + if (_activityProjection is null) + { + return null; + } + + var adopted = new ContactCenterWorkState + { + ActivityItemId = activityItemId, + }; + + if (!await _activityProjection.TrySeedAsync(adopted, cancellationToken)) + { + return null; + } + + return adopted; + } + + /// + public async Task MutateAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(mutate); + + if (string.IsNullOrEmpty(activityItemId)) + { + return null; + } + + var workState = await _workStateManager.FindByActivityIdAsync(activityItemId, cancellationToken); + var isNew = workState is null; + + if (isNew) + { + workState = await _workStateManager.NewAsync(cancellationToken: cancellationToken); + workState.ActivityItemId = activityItemId; + workState.CreatedUtc = _clock.UtcNow; + + if (_activityProjection is not null) + { + await _activityProjection.TrySeedAsync(workState, cancellationToken); + } + } + + mutate(workState); + workState.ModifiedUtc = _clock.UtcNow; + + if (isNew) + { + await _workStateManager.CreateAsync(workState, cancellationToken: cancellationToken); + } + else + { + await _workStateManager.UpdateAsync(workState, cancellationToken: cancellationToken); + } + + if (_activityProjection is not null && + !_scopeExecutor.ScheduleAfterCommit( + projection => projection.ProjectAsync(activityItemId, CancellationToken.None))) + { + await _activityProjection.ProjectAsync(activityItemId, cancellationToken); + } + + return workState; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateStore.cs new file mode 100644 index 000000000..8c884359e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterWorkStateStore.cs @@ -0,0 +1,62 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; +using YesSql.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class ContactCenterWorkStateStore : DocumentCatalog, IContactCenterWorkStateStore +{ + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public ContactCenterWorkStateStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(activityItemId); + + return Session.Query( + index => index.ActivityItemId == activityItemId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListByActivityIdsAsync( + IEnumerable activityItemIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(activityItemIds); + + var ids = activityItemIds + .Where(id => !string.IsNullOrEmpty(id)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + if (ids.Length == 0) + { + return []; + } + + var states = await Session.Query( + index => index.ActivityItemId.IsIn(ids), + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return states.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs new file mode 100644 index 000000000..50601320d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs @@ -0,0 +1,174 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of backed by the calendar catalog. +/// +public sealed class DefaultBusinessHoursService : IBusinessHoursService +{ + private readonly IBusinessHoursCalendarManager _calendarManager; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The business-hours calendar manager. + /// The clock used to resolve the current instant. + public DefaultBusinessHoursService( + IBusinessHoursCalendarManager calendarManager, + IClock clock) + { + _calendarManager = calendarManager; + _clock = clock; + } + + /// + public Task IsOpenAsync(string calendarId, CancellationToken cancellationToken = default) + { + return IsOpenAsync(calendarId, _clock.UtcNow, cancellationToken); + } + + /// + public async Task IsOpenAsync(string calendarId, DateTime utcInstant, CancellationToken cancellationToken = default) + { + return await IsOpenAsync(calendarId, utcInstant, null, cancellationToken); + } + + /// + public async Task IsOpenAsync( + string calendarId, + DateTime utcInstant, + string timeZoneId, + CancellationToken cancellationToken = default) + { + return await EvaluateAsync(calendarId, utcInstant, timeZoneId, cancellationToken) ?? true; + } + + /// + public async Task EvaluateAsync( + string calendarId, + DateTime utcInstant, + string timeZoneId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(calendarId)) + { + return null; + } + + var calendar = await _calendarManager.FindByIdAsync(calendarId, cancellationToken); + + if (calendar is null || !calendar.Enabled) + { + return null; + } + + return IsOpen(calendar, utcInstant, timeZoneId); + } + + /// + /// Evaluates whether the supplied calendar is open at the given UTC instant. + /// + /// The calendar to evaluate. + /// The UTC instant to evaluate. + /// when the calendar is open; otherwise, . + public static bool IsOpen(BusinessHoursCalendar calendar, DateTime utcInstant) + { + return IsOpen(calendar, utcInstant, null); + } + + /// + /// Evaluates whether the supplied calendar is open at the given UTC instant using an optional time-zone override. + /// + /// The calendar to evaluate. + /// The UTC instant to evaluate. + /// The time zone used instead of the calendar time zone when supplied. + /// when the calendar is open; otherwise, . + public static bool IsOpen(BusinessHoursCalendar calendar, DateTime utcInstant, string timeZoneId) + { + ArgumentNullException.ThrowIfNull(calendar); + + var timeZone = ResolveTimeZone(string.IsNullOrWhiteSpace(timeZoneId) ? calendar.TimeZoneId : timeZoneId); + var local = TimeZoneInfo.ConvertTimeFromUtc(DateTime.SpecifyKind(utcInstant, DateTimeKind.Utc), timeZone); + var localDate = DateOnly.FromDateTime(local); + + if (calendar.Holidays is not null && calendar.Holidays.Contains(localDate)) + { + return false; + } + + var schedule = calendar.WeeklySchedule; + var window = schedule?.FirstOrDefault(day => day.Day == local.DayOfWeek); + var minuteOfDay = (local.Hour * 60) + local.Minute; + + if (IsWithinSameDayWindow(window, minuteOfDay)) + { + return true; + } + + var previousDay = local.DayOfWeek == DayOfWeek.Sunday + ? DayOfWeek.Saturday + : (DayOfWeek)((int)local.DayOfWeek - 1); + var previousWindow = schedule?.FirstOrDefault(day => day.Day == previousDay); + + return IsWithinPreviousOvernightWindow(previousWindow, minuteOfDay); + } + + private static bool IsWithinSameDayWindow(BusinessHoursDay window, int minuteOfDay) + { + if (!IsValidWindow(window)) + { + return false; + } + + if (window.OpenMinute == window.CloseMinute) + { + return true; + } + + if (window.OpenMinute < window.CloseMinute) + { + return minuteOfDay >= window.OpenMinute && minuteOfDay < window.CloseMinute; + } + + return minuteOfDay >= window.OpenMinute; + } + + private static bool IsWithinPreviousOvernightWindow(BusinessHoursDay window, int minuteOfDay) + { + return IsValidWindow(window) && + window.OpenMinute > window.CloseMinute && + minuteOfDay < window.CloseMinute; + } + + private static bool IsValidWindow(BusinessHoursDay window) + { + return window is not null && + window.IsOpen && + window.OpenMinute is >= 0 and <= 1440 && + window.CloseMinute is >= 0 and <= 1440; + } + + private static TimeZoneInfo ResolveTimeZone(string timeZoneId) + { + if (string.IsNullOrWhiteSpace(timeZoneId)) + { + return TimeZoneInfo.Utc; + } + + try + { + return TimeZoneInfo.FindSystemTimeZoneById(timeZoneId); + } + catch (TimeZoneNotFoundException) + { + return TimeZoneInfo.Utc; + } + catch (InvalidTimeZoneException) + { + return TimeZoneInfo.Utc; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs new file mode 100644 index 000000000..000259a0f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs @@ -0,0 +1,89 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . Events are +/// recorded in the durable interaction event history and enqueued through +/// before handler dispatch so application restarts cannot create an event-delivery gap. +/// +public sealed class DefaultContactCenterEventPublisher : IContactCenterEventPublisher +{ + private readonly IInteractionEventStore _eventStore; + private readonly IContactCenterOutbox _outbox; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The durable interaction event store. + /// The outbox that dispatches events to handlers with durable retry. + /// The executor used to schedule post-commit dispatch. + /// The clock used to stamp events. + /// The logger instance. + public DefaultContactCenterEventPublisher( + IInteractionEventStore eventStore, + IContactCenterOutbox outbox, + IContactCenterScopeExecutor scopeExecutor, + IClock clock, + ILogger logger) + { + _eventStore = eventStore; + _outbox = outbox; + _scopeExecutor = scopeExecutor; + _clock = clock; + _logger = logger; + } + + /// + public async Task PublishAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + if (interactionEvent.OccurredUtc == default) + { + interactionEvent.OccurredUtc = _clock.UtcNow; + } + + if (string.IsNullOrEmpty(interactionEvent.ItemId)) + { + interactionEvent.ItemId = IdGenerator.GenerateId(); + } + + if (interactionEvent.SchemaVersion <= 0) + { + interactionEvent.SchemaVersion = ContactCenterConstants.CurrentEventSchemaVersion; + } + + if (string.IsNullOrEmpty(interactionEvent.ActorId)) + { + interactionEvent.ActorId = ContactCenterConstants.SystemActor; + } + + if (!string.IsNullOrEmpty(interactionEvent.IdempotencyKey) && + await _eventStore.ExistsByIdempotencyKeyAsync(interactionEvent.IdempotencyKey, cancellationToken)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipping duplicate Contact Center event '{EventType}' with idempotency key '{IdempotencyKey}'.", + interactionEvent.EventType, + interactionEvent.IdempotencyKey.SanitizeLogValue()); + } + + return; + } + + await _eventStore.CreateAsync(interactionEvent, cancellationToken); + await _outbox.EnqueueAsync(interactionEvent, cancellationToken); + + _scopeExecutor.ScheduleAfterCommit( + context => context.DispatchAsync(interactionEvent.ItemId)); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultDialerAbandonmentPolicyService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultDialerAbandonmentPolicyService.cs new file mode 100644 index 000000000..2a05ff014 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultDialerAbandonmentPolicyService.cs @@ -0,0 +1,107 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Options; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation. It enforces the +/// rolling abandonment-rate cap only for automated pacing modes, honors a sample floor to avoid volatile +/// suppression, and fails closed when a cap is enforced but the statistics cannot be proven. +/// +public sealed class DefaultDialerAbandonmentPolicyService : IDialerAbandonmentPolicyService +{ + private readonly IEnumerable _statisticsProviders; + private readonly ContactCenterComplianceOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The registered rolling abandonment statistics providers, if any. + /// The tenant-level compliance options. + public DefaultDialerAbandonmentPolicyService( + IEnumerable statisticsProviders, + IOptions options) + { + _statisticsProviders = statisticsProviders; + _options = options.Value; + } + + /// + public async Task EvaluateAsync(DialerProfile profile, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + + if (!profile.EnforceAbandonmentCap) + { + return DialerAbandonmentEvaluation.Permitted(true, 0, 0, "The abandonment cap is not enforced for this profile."); + } + + if (!profile.Mode.IsAutomated()) + { + return DialerAbandonmentEvaluation.Permitted( + true, + 0, + 0, + "Manual and preview dialing bind an agent to every call and cannot abandon a connected party."); + } + + var window = TimeSpan.FromMinutes(_options.AbandonmentRollingWindowMinutes); + var statistics = await ResolveStatisticsAsync(profile.ItemId, window, cancellationToken); + + if (statistics is null) + { + return DialerAbandonmentEvaluation.Suppressed( + false, + 0, + 0, + "The rolling abandonment statistics required to prove compliance were unavailable."); + } + + if (statistics.LiveAnswers < profile.AbandonmentSampleFloor) + { + return DialerAbandonmentEvaluation.Permitted( + true, + 0, + statistics.LiveAnswers, + $"The rolling sample of {statistics.LiveAnswers} live answers is below the {profile.AbandonmentSampleFloor}-call floor."); + } + + var ratePercent = statistics.LiveAnswers == 0 + ? 0 + : (double)statistics.AbandonedCalls / statistics.LiveAnswers * 100; + + if (ratePercent > profile.MaxAbandonmentRatePercent) + { + return DialerAbandonmentEvaluation.Suppressed( + true, + ratePercent, + statistics.LiveAnswers, + $"The rolling abandonment rate of {ratePercent:0.##}% exceeds the {profile.MaxAbandonmentRatePercent:0.##}% cap."); + } + + return DialerAbandonmentEvaluation.Permitted( + true, + ratePercent, + statistics.LiveAnswers, + $"The rolling abandonment rate of {ratePercent:0.##}% is within the {profile.MaxAbandonmentRatePercent:0.##}% cap."); + } + + private async Task ResolveStatisticsAsync( + string dialerProfileId, + TimeSpan window, + CancellationToken cancellationToken) + { + foreach (var provider in _statisticsProviders) + { + var statistics = await provider.GetStatisticsAsync(dialerProfileId, window, cancellationToken); + + if (statistics is not null) + { + return statistics; + } + } + + return null; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultInteractionEventUpcastService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultInteractionEventUpcastService.cs new file mode 100644 index 000000000..7c883c160 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultInteractionEventUpcastService.cs @@ -0,0 +1,135 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . Registered upcasters +/// are applied one version step at a time until the event reaches the current schema version. +/// +public sealed class DefaultInteractionEventUpcastService : IInteractionEventUpcastService +{ + private readonly Dictionary<(string EventType, int FromVersion), IInteractionEventUpcaster> _upcasters = []; + private readonly int _currentVersion; + + /// + /// Initializes a new instance of the class. + /// + /// The registered upcasters. + /// + /// Thrown when two upcasters claim the same event type and version step. Picking either one arbitrarily + /// would make the converted payload depend on registration order. + /// + public DefaultInteractionEventUpcastService(IEnumerable upcasters) + : this(upcasters, ContactCenterConstants.CurrentEventSchemaVersion) + { + } + + /// + /// Initializes a new instance of the class targeting an + /// explicit schema version. The version a release understands is a parameter of the conversion, not a + /// property of the algorithm, so tests drive a chain of several steps without the shipped constant having + /// to move first. + /// + /// The registered upcasters. + /// The schema version events are converted up to. + internal DefaultInteractionEventUpcastService(IEnumerable upcasters, int currentVersion) + { + ArgumentNullException.ThrowIfNull(upcasters); + + _currentVersion = currentVersion; + + foreach (var upcaster in upcasters) + { + var key = (upcaster.EventType ?? string.Empty, upcaster.FromVersion); + + if (_upcasters.TryGetValue(key, out var existing)) + { + throw new InteractionEventUpcastException( + $"Two Contact Center event upcasters convert '{key.Item1}' from schema version {upcaster.FromVersion}: '{existing.GetType().FullName}' and '{upcaster.GetType().FullName}'. Exactly one upcaster may own a version step."); + } + + _upcasters[key] = upcaster; + } + } + + /// + public void Upcast(InteractionEvent interactionEvent) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + var current = _currentVersion; + + // A row written before the version was recorded reads as zero or less. It is the first version by + // definition, so it enters the chain at the bottom rather than being treated as already current. + var version = interactionEvent.SchemaVersion <= 0 + ? 1 + : interactionEvent.SchemaVersion; + + if (version > current) + { + throw new InteractionEventUpcastException( + $"Contact Center event '{interactionEvent.ItemId}' of type '{interactionEvent.EventType}' was written at schema version {version}, but this release understands version {current}. It was written by a newer release; reading it here would deserialize a payload shape this code does not know into the shape it does, silently substituting defaults for whatever moved."); + } + + if (version == current) + { + interactionEvent.SchemaVersion = current; + + return; + } + + var payload = Parse(interactionEvent); + + while (version < current) + { + var upcaster = Resolve(interactionEvent.EventType, version) + ?? throw new InteractionEventUpcastException( + $"No Contact Center event upcaster converts '{interactionEvent.EventType}' from schema version {version} to {version + 1}, so event '{interactionEvent.ItemId}' cannot be read by this release. Every version step between 1 and {current} needs an upcaster for each event type whose payload changed at that step."); + + payload = upcaster.Upcast(payload); + version++; + } + + interactionEvent.Data = payload is null + ? null + : payload.ToJsonString(); + + interactionEvent.SchemaVersion = current; + } + + private IInteractionEventUpcaster Resolve(string eventType, int fromVersion) + { + // An upcaster declared for the event type owns the step. A type-agnostic upcaster covers the steps no + // event type claimed, which is what a change to a field every event carries looks like. + if (!string.IsNullOrEmpty(eventType) && + _upcasters.TryGetValue((eventType, fromVersion), out var specific)) + { + return specific; + } + + return _upcasters.TryGetValue((string.Empty, fromVersion), out var universal) + ? universal + : null; + } + + private static JsonNode Parse(InteractionEvent interactionEvent) + { + if (string.IsNullOrEmpty(interactionEvent.Data)) + { + return null; + } + + try + { + return JsonNode.Parse(interactionEvent.Data); + } + catch (JsonException exception) + { + throw new InteractionEventUpcastException( + $"The payload of Contact Center event '{interactionEvent.ItemId}' of type '{interactionEvent.EventType}' is not valid JSON and cannot be converted from schema version {interactionEvent.SchemaVersion}.", + exception); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialProviderCommandTypeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialProviderCommandTypeExecutor.cs new file mode 100644 index 000000000..8338013be --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialProviderCommandTypeExecutor.cs @@ -0,0 +1,326 @@ +using System.Globalization; +using System.Text.Json; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Handles provider command execution for commands. +/// Deserializes the dial request, stamps idempotency metadata, routes the outbound call, and projects +/// outcomes onto the linked interaction and CRM activity. +/// +public sealed class DialProviderCommandTypeExecutor : IProviderCommandTypeExecutor +{ + private static readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IEnumerable _dispatchValidators; + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IAgentProfileManager _agentManager; + private readonly IContactCenterActivityWriter _activityWriter; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The policy validators applied before recovering a pending dispatch. + /// The router used to execute outbound voice commands. + /// The manager used to project interaction outcomes. + /// The writer used to apply CRM activity changes outside the routing transaction. + /// The clock used to stamp UTC timestamps on projections. + /// The call session manager used to persist first-command ownership. + /// The agent profile manager used to resolve the dialing user. + public DialProviderCommandTypeExecutor( + IEnumerable dispatchValidators, + IVoiceContactCenterCallRouter voiceCallRouter, + IInteractionManager interactionManager, + IContactCenterActivityWriter activityWriter, + IClock clock, + ICallSessionManager callSessionManager, + IAgentProfileManager agentManager) + { + _dispatchValidators = dispatchValidators; + _voiceCallRouter = voiceCallRouter; + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _agentManager = agentManager; + _activityWriter = activityWriter; + _clock = clock; + } + + /// + public ProviderCommandType CommandType => ProviderCommandType.Dial; + + /// + public async Task CanDispatchAsync(ProviderCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + if (string.IsNullOrWhiteSpace(command.RequestPayload)) + { + return false; + } + + var validated = false; + + foreach (var validator in _dispatchValidators) + { + validated = true; + + if (!await validator.CanDispatchAsync(command, cancellationToken)) + { + return false; + } + } + + if (!validated) + { + return false; + } + + var request = DeserializeDialRequest(command); + + if (!await IsAuthorizedFirstDialAsync(request, cancellationToken)) + { + return false; + } + + await EnsureOwnedDialSessionAsync(request, command, cancellationToken); + + return true; + } + + /// + public async Task ExecuteAsync( + ProviderCommand command, + ProviderCommandClaim claim, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(claim); + + var request = DeserializeDialRequest(command); + + StampRequest(request, command, claim); + + if (!await IsAuthorizedFirstDialAsync(request, cancellationToken)) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + OutcomeUnknown = true, + ProviderName = command.ProviderName, + ErrorCode = "dial_denied", + ErrorMessage = "The requested dial is not available.", + }; + } + + await EnsureOwnedDialSessionAsync(request, command, cancellationToken); + + return await _voiceCallRouter.RouteOutboundAsync(request, command.ProviderName, cancellationToken); + } + + /// + public async Task ProjectSuccessAsync( + ProviderCommand command, + ContactCenterVoiceProviderResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(result); + + // Resolve the owned session first so the interaction projection never diverges + // from the session binding. A conflict (session already bound to a DIFFERENT call) + // means the new dial outcome must be treated as unknown: do not repoint the + // interaction's ProviderInteractionId at the new call id. + var sessionConflict = false; + CallSession ownedSession = null; + + if (!string.IsNullOrWhiteSpace(command.InteractionId) && + !string.IsNullOrWhiteSpace(result.ProviderCallId)) + { + ownedSession = await _callSessionManager.FindByInteractionIdAsync(command.InteractionId, cancellationToken); + + if (ownedSession is not null && + !string.IsNullOrWhiteSpace(ownedSession.ProviderCallId) && + !string.Equals(ownedSession.ProviderCallId, result.ProviderCallId, StringComparison.Ordinal)) + { + sessionConflict = true; + } + } + + if (!sessionConflict && !string.IsNullOrWhiteSpace(command.InteractionId)) + { + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + if (interaction is not null) + { + interaction.TransitionTo(InteractionStatus.Ringing); + interaction.ProviderName = string.IsNullOrWhiteSpace(result.ProviderName) + ? command.ProviderName + : result.ProviderName; + interaction.ProviderInteractionId = result.ProviderCallId; + interaction.StartedUtc = _clock.UtcNow; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + } + + if (!string.IsNullOrWhiteSpace(command.ActivityItemId)) + { + await _activityWriter.ScheduleUpdateAsync( + command.ActivityItemId, + activity => activity.Status = ActivityStatus.Dialing, + cancellationToken); + } + + if (!sessionConflict && ownedSession is not null && string.IsNullOrWhiteSpace(ownedSession.ProviderCallId)) + { + ownedSession.ProviderCallId = result.ProviderCallId; + ownedSession.ProviderName = string.IsNullOrWhiteSpace(result.ProviderName) + ? command.ProviderName + : result.ProviderName; + ownedSession.TransitionTo(VoiceCallState.Ringing); + + await _callSessionManager.UpdateAsync(ownedSession, cancellationToken: cancellationToken); + } + } + + /// + public async Task ProjectFailureAsync(ProviderCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + if (!string.IsNullOrWhiteSpace(command.InteractionId)) + { + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + // A dial can fail because the interaction already ended: an outbound attempt is cancelled while the + // provider request is in flight, or the compensation for an unproven dial arrives after the record + // was closed. A settled interaction already carries its real outcome, and this projection is what + // lets a compensating command finish, so refusing to overwrite that outcome matters more than + // recording a second failure. + if (interaction is not null && !interaction.IsSettled) + { + interaction.TransitionTo(InteractionStatus.Failed); + interaction.EndedUtc = _clock.UtcNow; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + } + + if (!string.IsNullOrWhiteSpace(command.ActivityItemId)) + { + await _activityWriter.ScheduleUpdateAsync( + command.ActivityItemId, + activity => activity.Status = ActivityStatus.Failed, + cancellationToken); + } + } + + /// + public async Task ProjectOutcomeUnknownAsync( + ProviderCommand command, + string errorCode, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + if (!string.IsNullOrWhiteSpace(command.InteractionId)) + { + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + if (interaction is not null) + { + interaction.TechnicalMetadata["providerErrorCode"] = errorCode; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + } + + if (!string.IsNullOrWhiteSpace(command.ActivityItemId)) + { + await _activityWriter.ScheduleUpdateAsync( + command.ActivityItemId, + activity => activity.Status = ActivityStatus.Dialing, + cancellationToken); + } + } + + private static ContactCenterDialRequest DeserializeDialRequest(ProviderCommand command) + { + var request = JsonSerializer.Deserialize( + command.RequestPayload, + _serializerOptions); + + return request ?? throw new JsonException("The provider command request payload deserialized to null."); + } + + private static void StampRequest( + ContactCenterDialRequest request, + ProviderCommand command, + ProviderCommandClaim claim) + { + request.CommandId = command.CommandId; + request.Metadata ??= new Dictionary(); + request.Metadata[ContactCenterConstants.CommandMetadata.CommandId] = command.CommandId; + request.Metadata[TelephonyConstants.RequestMetadata.IdempotencyKey] = command.CommandId; + request.Metadata[ContactCenterConstants.CommandMetadata.FenceToken] = claim.FenceToken.ToString(CultureInfo.InvariantCulture); + request.Metadata[TelephonyConstants.RequestMetadata.FenceToken] = claim.FenceToken.ToString(CultureInfo.InvariantCulture); + } + + private async Task IsAuthorizedFirstDialAsync( + ContactCenterDialRequest request, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.InteractionId) || + string.IsNullOrWhiteSpace(request.AgentId) || + string.IsNullOrWhiteSpace(request.AgentUserId) || + !ExternalDestinationPolicy.IsAllowed(request.Destination) || + (!string.IsNullOrWhiteSpace(request.CallerId) && !ExternalDestinationPolicy.IsAllowed(request.CallerId))) + { + return false; + } + + var agent = await _agentManager.FindByUserIdAsync(request.AgentUserId, cancellationToken); + + return agent is not null && + string.Equals(agent.ItemId, request.AgentId, StringComparison.Ordinal); + } + + private async Task EnsureOwnedDialSessionAsync( + ContactCenterDialRequest request, + ProviderCommand command, + CancellationToken cancellationToken) + { + var session = await _callSessionManager.FindByInteractionIdAsync(request.InteractionId, cancellationToken); + + if (session is not null) + { + return; + } + + var now = _clock.UtcNow; + session = await _callSessionManager.NewAsync(cancellationToken: cancellationToken); + session.InteractionId = request.InteractionId; + session.ActivityItemId = request.ActivityId; + session.ProviderName = command.ProviderName; + session.Direction = InteractionDirection.Outbound; + session.DeliveryModel = VoiceProviderDeliveryModel.ServerSideAcd; + session.AgentId = request.AgentId; + session.QueueId = request.QueueId; + session.FromAddress = request.CallerId; + session.ToAddress = request.Destination; + session.TransitionTo(VoiceCallState.Planned); + session.DurableCommandId = command.CommandId; + session.CreatedUtc = now; + + await _callSessionManager.CreateAsync(session, cancellationToken: cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptCompensationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptCompensationService.cs new file mode 100644 index 000000000..247edf679 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptCompensationService.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides reservation and queue compensation for outbound dial attempts. +/// +public sealed class DialerAttemptCompensationService : IDialerAttemptCompensationService +{ + private readonly IActivityReservationService _reservationService; + + /// + /// Initializes a new instance of the class. + /// + /// The reservation service. + public DialerAttemptCompensationService(IActivityReservationService reservationService) + { + _reservationService = reservationService; + } + + /// + public async Task CompensateAsync( + ActivityReservation reservation, + bool removeFromQueue, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(reservation); + + await _reservationService.CompensateAsync( + reservation.ItemId, + removeFromQueue, + cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs new file mode 100644 index 000000000..25759c086 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs @@ -0,0 +1,257 @@ +using System.Text.Json; +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Telephony; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . Every attempt runs the +/// outbound compliance gate first; eligible attempts are routed through the Voice Contact Center Call +/// Router, while suppressed attempts release the reservation and record an auditable suppression event. +/// +public sealed class DialerAttemptService : IDialerAttemptService +{ + private readonly IDialerEligibilityService _eligibilityService; + private readonly IActivityReservationService _reservationService; + private readonly IDialerAttemptCompensationService _compensationService; + private readonly IInteractionManager _interactionManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IContactCenterActivityWriter _activityWriter; + private readonly IAgentProfileManager _agentManager; + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; + private readonly IContactCenterEventPublisher _publisher; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IProviderCommandStateService _providerCommandStateService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The compliance gate evaluated before every attempt. + /// The reservation service used to release failed or suppressed attempts. + /// The service used to release failed or suppressed attempts. + /// The interaction manager used to record attempts. + /// The CRM activity manager. + /// The routing-owned work state service. + /// The writer used to apply CRM activity changes outside the routing transaction. + /// The agent profile manager used to resolve the reserved agent. + /// The voice call router. + /// The Contact Center event publisher. + /// The executor used for compensation and post-commit command wake-up. + /// The service used to persist provider command intent. + /// The logger instance. + public DialerAttemptService( + IDialerEligibilityService eligibilityService, + IActivityReservationService reservationService, + IDialerAttemptCompensationService compensationService, + IInteractionManager interactionManager, + IOmnichannelActivityManager activityManager, + IContactCenterWorkStateService workStateService, + IContactCenterActivityWriter activityWriter, + IAgentProfileManager agentManager, + IVoiceContactCenterCallRouter voiceCallRouter, + IContactCenterEventPublisher publisher, + IContactCenterScopeExecutor scopeExecutor, + IProviderCommandStateService providerCommandStateService, + ILogger logger) + { + _eligibilityService = eligibilityService; + _reservationService = reservationService; + _compensationService = compensationService; + _interactionManager = interactionManager; + _activityManager = activityManager; + _workStateService = workStateService; + _activityWriter = activityWriter; + _agentManager = agentManager; + _voiceCallRouter = voiceCallRouter; + _publisher = publisher; + _scopeExecutor = scopeExecutor; + _providerCommandStateService = providerCommandStateService; + _logger = logger; + } + + /// + public async Task TryDialAsync(DialerProfile profile, ActivityReservation reservation, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + ArgumentNullException.ThrowIfNull(reservation); + + var activity = await _activityManager.FindByIdAsync(reservation.ActivityItemId, cancellationToken); + + if (activity is null) + { + await _compensationService.CompensateAsync(reservation, removeFromQueue: true, cancellationToken); + + return false; + } + + var eligibility = await _eligibilityService.EvaluateAsync(new DialerEligibilityContext + { + Profile = profile, + Activity = activity, + }, cancellationToken); + + if (!eligibility.IsEligible) + { + await SuppressAsync(profile, reservation, activity, eligibility, cancellationToken); + + return false; + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + if (agent is null || string.IsNullOrWhiteSpace(agent.UserId)) + { + _logger.LogWarning( + "The dialer attempt for activity '{ActivityItemId}' failed closed because reserved agent '{AgentId}' could not be resolved to an Orchard user.", + reservation.ActivityItemId.SanitizeLogValue(), + reservation.AgentId.SanitizeLogValue()); + + return false; + } + + var acceptedReservation = await _reservationService.AcceptAsync(reservation.ItemId, cancellationToken); + + if (acceptedReservation is null) + { + return false; + } + + var interaction = await _interactionManager.NewAsync(cancellationToken: cancellationToken); + interaction.Channel = InteractionChannel.Voice; + interaction.Direction = InteractionDirection.Outbound; + interaction.TransitionTo(InteractionStatus.Created); + interaction.ActivityItemId = activity.ItemId; + interaction.QueueId = profile.QueueId; + interaction.AgentId = reservation.AgentId; + interaction.ProviderName = _voiceCallRouter.GetOutboundProviderName(profile.ProviderName); + interaction.CustomerAddress = activity.PreferredDestination; + interaction.TechnicalMetadata[ContactCenterConstants.CommandMetadata.CommandId] = interaction.ItemId; + var request = new ContactCenterDialRequest + { + ActivityId = activity.ItemId, + InteractionId = interaction.ItemId, + CommandId = interaction.ItemId, + AgentId = reservation.AgentId, + AgentUserId = agent.UserId, + QueueId = profile.QueueId, + CampaignId = profile.CampaignId, + Destination = activity.PreferredDestination, + CallerId = profile.CallerId, + Metadata = new Dictionary + { + [ContactCenterConstants.CommandMetadata.CommandId] = interaction.ItemId, + [TelephonyConstants.RequestMetadata.IdempotencyKey] = interaction.ItemId, + }, + }; + + try + { + await _workStateService.MutateAsync( + activity.ItemId, + workState => workState.Attempts++, + cancellationToken); + await _interactionManager.CreateAsync(interaction, cancellationToken: cancellationToken); + await _publisher.PublishAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.DialerAttemptStarted, + InteractionId = interaction.ItemId, + AggregateType = nameof(DialerProfile), + AggregateId = profile.ItemId, + SourceComponent = ContactCenterConstants.Components.Dialer, + IdempotencyKey = $"dialer-attempt:{interaction.ItemId}", + }, cancellationToken); + await _providerCommandStateService.RegisterAsync(new ProviderCommandRegistration + { + CommandId = interaction.ItemId, + ProviderName = interaction.ProviderName, + CommandType = ProviderCommandType.Dial, + ActivityItemId = activity.ItemId, + InteractionId = interaction.ItemId, + ReservationId = acceptedReservation.ItemId, + DialerProfileId = profile.ItemId, + RequestPayload = JsonSerializer.Serialize(request), + }, cancellationToken); + } + catch + { + await _scopeExecutor.ExecuteAsync(service => + service.CompensateAsync(acceptedReservation, removeFromQueue: true, CancellationToken.None)); + + throw; + } + + _scopeExecutor.ScheduleAfterCommit(processor => + processor.DispatchAsync(interaction.ItemId, CancellationToken.None)); + + return true; + } + + private async Task SuppressAsync( + DialerProfile profile, + ActivityReservation reservation, + OmnichannelActivity activity, + DialerEligibilityResult eligibility, + CancellationToken cancellationToken) + { + var status = ResolveSuppressedStatus(eligibility.Reason); + + if (status.HasValue) + { + await _activityWriter.ScheduleUpdateAsync( + activity.ItemId, + suppressed => suppressed.Status = status.Value, + cancellationToken); + } + + await _compensationService.CompensateAsync(reservation, removeFromQueue: status.HasValue, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Suppressed outbound attempt for activity '{ActivityItemId}' on profile '{Profile}': {Reason}.", + activity.ItemId.SanitizeLogValue(), + profile.Name, + eligibility.Reason); + } + + var data = new DialerSuppressionEventData + { + ProfileItemId = profile.ItemId, + ActivityItemId = activity.ItemId, + Reason = eligibility.Reason, + Description = eligibility.Description, + Destination = activity.PreferredDestination, + }; + + var suppressionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.DialSuppressed, + AggregateType = nameof(OmnichannelActivity), + AggregateId = activity.ItemId, + SourceComponent = ContactCenterConstants.Components.Dialer, + }; + + suppressionEvent.SetData(data); + + await _publisher.PublishAsync(suppressionEvent, cancellationToken); + } + + private static ActivityStatus? ResolveSuppressedStatus(DialerSuppressionReason reason) + { + return reason switch + { + DialerSuppressionReason.NoDestination => ActivityStatus.Failed, + DialerSuppressionReason.MaxAttemptsReached => ActivityStatus.Failed, + DialerSuppressionReason.DoNotCall => ActivityStatus.Cancelled, + DialerSuppressionReason.NationalDoNotCallRegistry => ActivityStatus.Cancelled, + _ => null, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerEligibilityContext.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerEligibilityContext.cs new file mode 100644 index 000000000..eb4c1138e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerEligibilityContext.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Carries the data the outbound compliance gate needs to evaluate whether an activity may be dialed. +/// +public sealed class DialerEligibilityContext +{ + /// + /// Gets or sets the dialer profile that governs the attempt. + /// + public DialerProfile Profile { get; set; } + + /// + /// Gets or sets the CRM activity being considered for dialing. + /// + public OmnichannelActivity Activity { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerEligibilityResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerEligibilityResult.cs new file mode 100644 index 000000000..4c13dfb7d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerEligibilityResult.cs @@ -0,0 +1,53 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Represents the outcome of an outbound dialer compliance evaluation. +/// +public sealed class DialerEligibilityResult +{ + /// + /// Gets a value indicating whether the activity may be dialed. + /// + public bool IsEligible { get; private init; } + + /// + /// Gets the reason the attempt was suppressed, or when eligible. + /// + public DialerSuppressionReason Reason { get; private init; } + + /// + /// Gets a human-readable explanation of the suppression decision. + /// + public string Description { get; private init; } + + /// + /// Creates an eligible result. + /// + /// An eligible . + public static DialerEligibilityResult Eligible() + { + return new DialerEligibilityResult + { + IsEligible = true, + Reason = DialerSuppressionReason.None, + }; + } + + /// + /// Creates a suppressed result. + /// + /// The reason the attempt was suppressed. + /// A human-readable explanation of the suppression decision. + /// A suppressed . + public static DialerEligibilityResult Suppressed(DialerSuppressionReason reason, string description) + { + return new DialerEligibilityResult + { + IsEligible = false, + Reason = reason, + Description = description, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileManager.cs new file mode 100644 index 000000000..1f3f0cf7b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileManager.cs @@ -0,0 +1,54 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class DialerProfileManager : CatalogManager, IDialerProfileManager +{ + private readonly IDialerProfileStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying dialer profile store. + /// The catalog entry handlers for dialer profiles. + /// The logger instance. + public DialerProfileManager( + IDialerProfileStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var profiles = await _store.ListEnabledAsync(cancellationToken); + + foreach (var profile in profiles) + { + await LoadAsync(profile, cancellationToken); + } + + return profiles; + } + + /// + public async Task FindByCampaignAsync(string campaignId, CancellationToken cancellationToken = default) + { + var profile = await _store.FindByCampaignAsync(campaignId, cancellationToken); + + if (profile is not null) + { + await LoadAsync(profile, cancellationToken); + } + + return profile; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileStore.cs new file mode 100644 index 000000000..e0135612c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileStore.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class DialerProfileStore : DocumentCatalog, IDialerProfileStore +{ + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public DialerProfileStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task> ListEnabledAsync(CancellationToken cancellationToken = default) + { + var profiles = await Session.Query( + index => index.Enabled, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return profiles.ToArray(); + } + + /// + public async Task FindByCampaignAsync(string campaignId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(campaignId); + + return await Session.Query( + index => index.CampaignId == campaignId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs new file mode 100644 index 000000000..22fc30076 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs @@ -0,0 +1,82 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . The service validates the +/// profile, ensures a voice provider can place outbound calls, and delegates pacing to the registered +/// for the profile's mode. Agent-driven modes (Manual and Preview) and +/// unsupported modes (such as the blocked Predictive mode) do not run an automated cycle. +/// +public sealed class DialerService : IDialerService +{ + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; + private readonly IDialerStrategyResolver _strategyResolver; + private readonly IContactCenterFeatureWorkManager _workManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The voice call router used to confirm outbound routing is available. + /// The resolver that maps a dialing mode to its strategy. + /// The feature work manager used to fence automated pacing during feature quiescence. + /// The logger instance. + public DialerService( + IVoiceContactCenterCallRouter voiceCallRouter, + IDialerStrategyResolver strategyResolver, + IContactCenterFeatureWorkManager workManager, + ILogger logger) + { + _voiceCallRouter = voiceCallRouter; + _strategyResolver = strategyResolver; + _workManager = workManager; + _logger = logger; + } + + /// + public async Task RunCycleAsync(DialerProfile profile, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + + if (!profile.Enabled || string.IsNullOrEmpty(profile.QueueId)) + { + return 0; + } + + if (profile.Mode is DialerMode.Manual or DialerMode.Preview) + { + return 0; + } + + using var workLease = _workManager.TryEnter(ContactCenterConstants.Feature.DialerAutomated); + + if (workLease is null) + { + return 0; + } + + if (!_voiceCallRouter.CanRouteOutbound(profile.ProviderName)) + { + _logger.LogWarning("No Contact Center voice provider can route outbound calls for dialer profile '{Profile}'.", profile.Name); + + return 0; + } + + var strategy = _strategyResolver.Resolve(profile.Mode); + + if (strategy is null) + { + _logger.LogWarning( + "The '{Mode}' dialing mode is not enabled for dialer profile '{Profile}'. Automated dialing was skipped.", + profile.Mode, + profile.Name); + + return 0; + } + + return await strategy.RunCycleAsync(profile, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerStrategyBase.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerStrategyBase.cs new file mode 100644 index 000000000..834b50d0f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerStrategyBase.cs @@ -0,0 +1,67 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the shared reserve-then-dial pacing loop used by automated dialing strategies. Each cycle +/// reserves an agent through routing and asks the attempt service to place a compliant call, stopping +/// once the mode's per-cycle limit is reached or no further agent can be reserved. +/// +public abstract class DialerStrategyBase : IDialerStrategy +{ + private readonly IActivityAssignmentService _assignmentService; + private readonly IDialerAttemptService _attemptService; + + /// + /// Initializes a new instance of the class. + /// + /// The assignment service used to reserve agents and activities. + /// The attempt service that applies compliance and places each call. + protected DialerStrategyBase( + IActivityAssignmentService assignmentService, + IDialerAttemptService attemptService) + { + _assignmentService = assignmentService; + _attemptService = attemptService; + } + + /// + public abstract DialerMode Mode { get; } + + /// + /// Gets the maximum number of attempts the strategy may start in a single pacing cycle. + /// + /// The dialer profile being run. + /// The per-cycle attempt limit. + protected abstract int GetMaxAttemptsPerCycle(DialerProfile profile); + + /// + public async Task RunCycleAsync(DialerProfile profile, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(profile); + + var maxAttempts = Math.Max(GetMaxAttemptsPerCycle(profile), 1); + var attempted = 0; + var started = 0; + + var reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); + + while (reservation is not null && attempted < maxAttempts) + { + attempted++; + + if (await _attemptService.TryDialAsync(profile, reservation, cancellationToken)) + { + started++; + } + + if (attempted < maxAttempts) + { + reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); + } + } + + return started; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerStrategyResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerStrategyResolver.cs new file mode 100644 index 000000000..5b9b3e4dc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerStrategyResolver.cs @@ -0,0 +1,28 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Resolves a registered by dialing mode. Modes without a registered +/// strategy (such as the blocked Predictive mode) resolve to so the caller can +/// safely refuse to dial. +/// +public sealed class DialerStrategyResolver : IDialerStrategyResolver +{ + private readonly IEnumerable _strategies; + + /// + /// Initializes a new instance of the class. + /// + /// The registered dialing strategies. + public DialerStrategyResolver(IEnumerable strategies) + { + _strategies = strategies; + } + + /// + public IDialerStrategy Resolve(DialerMode mode) + { + return _strategies.FirstOrDefault(strategy => strategy.Mode == mode); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointResolver.cs new file mode 100644 index 000000000..540dcbfd9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointResolver.cs @@ -0,0 +1,54 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class EntryPointResolver : IEntryPointResolver +{ + private readonly IContactCenterEntryPointManager _entryPointManager; + private readonly IBusinessHoursService _businessHours; + + /// + /// Initializes a new instance of the class. + /// + /// The entry point manager. + /// The business-hours service used to evaluate open/closed state. + public EntryPointResolver( + IContactCenterEntryPointManager entryPointManager, + IBusinessHoursService businessHours) + { + _entryPointManager = entryPointManager; + _businessHours = businessHours; + } + + /// + public async Task FindByDialedNumberAsync(string dialedNumber, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(dialedNumber)) + { + return null; + } + + var entryPoints = await _entryPointManager.ListEnabledAsync(cancellationToken); + + return entryPoints.FirstOrDefault(entryPoint => entryPoint.DialedNumbers is not null && + entryPoint.DialedNumbers.Any(number => string.Equals(number?.Trim(), dialedNumber, StringComparison.OrdinalIgnoreCase))); + } + + /// + public async Task ResolveAsync(string dialedNumber, CancellationToken cancellationToken = default) + { + var entryPoint = await FindByDialedNumberAsync(dialedNumber, cancellationToken); + + if (entryPoint is null) + { + return null; + } + + var isOpen = await _businessHours.IsOpenAsync(entryPoint.BusinessHoursCalendarId, cancellationToken); + + return EntryPointRoutingPlanner.CreatePlan(entryPoint, isOpen); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointRoutingPlanner.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointRoutingPlanner.cs new file mode 100644 index 000000000..b9210f8bf --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointRoutingPlanner.cs @@ -0,0 +1,58 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Builds an from an entry point and its open/closed state. +/// +public static class EntryPointRoutingPlanner +{ + /// + /// Creates the routing plan for the supplied entry point. + /// + /// The matched entry point. + /// Whether the entry point is currently open. + /// The routing plan. + public static EntryPointRoutingPlan CreatePlan(ContactCenterEntryPoint entryPoint, bool isOpen) + { + ArgumentNullException.ThrowIfNull(entryPoint); + + var plan = new EntryPointRoutingPlan + { + EntryPoint = entryPoint, + IsOpen = isOpen, + Priority = entryPoint.Priority, + ClosedAction = entryPoint.ClosedAction, + }; + + if (isOpen) + { + plan.ShouldQueue = true; + plan.TargetQueueId = entryPoint.TargetQueueId; + + return plan; + } + + switch (entryPoint.ClosedAction) + { + case EntryPointClosedAction.HoldInQueue: + plan.ShouldQueue = true; + plan.TargetQueueId = entryPoint.TargetQueueId; + break; + case EntryPointClosedAction.Overflow: + plan.ShouldQueue = true; + plan.TargetQueueId = string.IsNullOrEmpty(entryPoint.OverflowQueueId) + ? entryPoint.TargetQueueId + : entryPoint.OverflowQueueId; + break; + case EntryPointClosedAction.Voicemail: + case EntryPointClosedAction.Reject: + plan.ShouldQueue = false; + plan.TargetQueueId = null; + break; + } + + return plan; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityAssignmentService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityAssignmentService.cs new file mode 100644 index 000000000..0f873d4fb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityAssignmentService.cs @@ -0,0 +1,25 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Assigns queued activities to available agents based on queue membership, priority, and idle time. +/// +public interface IActivityAssignmentService +{ + /// + /// Reserves the next eligible activity in the queue for the longest-idle available agent. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The created reservation, or when no work or agent is available. + Task AssignNextAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Assigns as many waiting activities as there are available agents in the queue. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The number of reservations created. + Task AssignQueueAsync(string queueId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueGroupManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueGroupManager.cs new file mode 100644 index 000000000..2d483dd96 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueGroupManager.cs @@ -0,0 +1,18 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for queue groups. +/// +public interface IActivityQueueGroupManager : ICatalogManager +{ + /// + /// Finds the queue group with the specified unique name. + /// + /// The queue-group name. + /// The token to monitor for cancellation requests. + /// The matching queue group, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueGroupStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueGroupStore.cs new file mode 100644 index 000000000..0aa94d613 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueGroupStore.cs @@ -0,0 +1,18 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for queue groups. +/// +public interface IActivityQueueGroupStore : ICatalog +{ + /// + /// Finds the queue group with the specified unique name. + /// + /// The queue-group name. + /// The token to monitor for cancellation requests. + /// The matching queue group, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueManager.cs new file mode 100644 index 000000000..084826349 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueManager.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for queues. +/// +public interface IActivityQueueManager : ICatalogManager +{ + /// + /// Finds the queue with the specified unique name. + /// + /// The queue name. + /// The token to monitor for cancellation requests. + /// The matching queue, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled queue. + /// + /// The token to monitor for cancellation requests. + /// The enabled queues. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueService.cs new file mode 100644 index 000000000..4e13772c6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueService.cs @@ -0,0 +1,37 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Manages the lifecycle of queue items as activities enter and leave Contact Center queues. +/// +public interface IActivityQueueService +{ + /// + /// Adds a CRM activity to a queue so it can be routed to an agent. + /// + /// The CRM activity identifier. + /// The queue identifier. + /// The optional priority override; the queue default is used when null. + /// The token to monitor for cancellation requests. + /// The created queue item. + Task EnqueueAsync(string activityItemId, string queueId, InteractionPriority? priority, CancellationToken cancellationToken = default); + + /// + /// Removes a queue item from its queue with the supplied final status. + /// + /// The queue item to dequeue. + /// The final status to record. + /// The token to monitor for cancellation requests. + Task DequeueAsync(QueueItem queueItem, QueueItemStatus status, CancellationToken cancellationToken = default); + + /// + /// Moves waiting items from the queue to its configured overflow queue when they have waited past the + /// overflow threshold, or when the queue is closed and configured to overflow after hours. + /// + /// The queue whose waiting items are evaluated for overflow. + /// The token to monitor for cancellation requests. + /// The number of items moved to the overflow queue. + Task OverflowDueAsync(ActivityQueue queue, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueStore.cs new file mode 100644 index 000000000..7f6bfb62c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueStore.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for queues. +/// +public interface IActivityQueueStore : ICatalog +{ + /// + /// Finds the queue with the specified unique name. + /// + /// The queue name. + /// The token to monitor for cancellation requests. + /// The matching queue, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled queue. + /// + /// The token to monitor for cancellation requests. + /// The enabled queues. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs new file mode 100644 index 000000000..716e14174 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs @@ -0,0 +1,44 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for activity reservations. +/// +public interface IActivityReservationManager : ICatalogManager +{ + /// + /// Lists the pending reservations that have passed their expiration time. + /// + /// The current UTC time. + /// The token to monitor for cancellation requests. + /// The pending reservations that have expired. + Task> ListExpiredAsync(DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// Finds the pending reservation currently held by the specified agent. + /// + /// The agent identifier. + /// The token to monitor for cancellation requests. + /// The pending reservation, or when none exists. + Task FindPendingByAgentAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Lists the non-terminal reservations currently bound to the specified agent. + /// + /// The agent identifier. + /// The token to monitor for cancellation requests. + /// The pending and accepted reservations for the agent. + Task> ListActiveByAgentAsync( + string agentId, + CancellationToken cancellationToken = default); + + /// + /// Lists the non-terminal (pending or accepted) reservations bound to the specified activity. + /// + /// The activity identifier. + /// The token to monitor for cancellation requests. + /// The pending and accepted reservations for the activity. + Task> ListActiveByActivityAsync(string activityItemId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs new file mode 100644 index 000000000..c5f74d5c2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs @@ -0,0 +1,62 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Coordinates the activity reservation lifecycle that locks a queue item for an agent before assignment. +/// +public interface IActivityReservationService +{ + /// + /// Reserves a queue item for an agent and creates a short-lived reservation lock. + /// + /// The queue item to reserve. + /// The agent to reserve the item for. + /// The number of seconds before the reservation expires. + /// The token to monitor for cancellation requests. + /// The created reservation. + Task ReserveAsync(QueueItem queueItem, AgentProfile agent, int timeoutSeconds, CancellationToken cancellationToken = default); + + /// + /// Accepts a pending reservation and converts it into an assignment. + /// + /// The reservation identifier. + /// The token to monitor for cancellation requests. + /// The accepted reservation, or when not found or no longer pending. + Task AcceptAsync(string reservationId, CancellationToken cancellationToken = default); + + /// + /// Rejects a pending reservation and returns the item to its queue. + /// + /// The reservation identifier. + /// The token to monitor for cancellation requests. + /// The rejected reservation, or when not found or no longer pending. + Task RejectAsync(string reservationId, CancellationToken cancellationToken = default); + + /// + /// Cancels a pending or accepted reservation and returns the item to its queue. + /// + /// The reservation identifier. + /// The token to monitor for cancellation requests. + /// The canceled reservation, or when not found or no longer active. + Task CancelAsync(string reservationId, CancellationToken cancellationToken = default); + + /// + /// Compensates a pending or accepted reservation after its provider command cannot complete. + /// + /// The reservation identifier. + /// Whether the owned queue item should be terminally removed. + /// The token to monitor for cancellation requests. + /// The compensated reservation, or when not found or no longer active. + Task CompensateAsync( + string reservationId, + bool removeFromQueue, + CancellationToken cancellationToken = default); + + /// + /// Expires every pending reservation that has passed its timeout and returns items to their queues. + /// + /// The token to monitor for cancellation requests. + /// The number of reservations that expired. + Task ExpireDueAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs new file mode 100644 index 000000000..d590c00d7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs @@ -0,0 +1,44 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for activity reservations. +/// +public interface IActivityReservationStore : ICatalog +{ + /// + /// Lists the pending reservations that have passed their expiration time. + /// + /// The current UTC time. + /// The token to monitor for cancellation requests. + /// The pending reservations that have expired. + Task> ListExpiredAsync(DateTime utcNow, CancellationToken cancellationToken = default); + + /// + /// Finds the pending reservation currently held by the specified agent. + /// + /// The agent identifier. + /// The token to monitor for cancellation requests. + /// The pending reservation, or when none exists. + Task FindPendingByAgentAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Lists the non-terminal reservations currently bound to the specified agent. + /// + /// The agent identifier. + /// The token to monitor for cancellation requests. + /// The pending and accepted reservations for the agent. + Task> ListActiveByAgentAsync( + string agentId, + CancellationToken cancellationToken = default); + + /// + /// Lists the non-terminal (pending or accepted) reservations bound to the specified activity. + /// + /// The activity identifier. + /// The token to monitor for cancellation requests. + /// The pending and accepted reservations for the activity. + Task> ListActiveByActivityAsync(string activityItemId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingService.cs new file mode 100644 index 000000000..25f97b184 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingService.cs @@ -0,0 +1,23 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Selects an eligible agent for a queued activity by applying routing strategies. +/// +public interface IActivityRoutingService +{ + /// + /// Selects the agent that should receive the queued item. + /// + /// The queue being routed. + /// The queued activity. + /// The available agents signed in to the queue. + /// The token to monitor for cancellation requests. + /// The explainable routing decision. + Task SelectAgentAsync( + ActivityQueue queue, + QueueItem queueItem, + IEnumerable agents, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingStrategy.cs new file mode 100644 index 000000000..831ac727b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingStrategy.cs @@ -0,0 +1,21 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Scores or filters routing candidates for a queued activity. +/// +public interface IActivityRoutingStrategy +{ + /// + /// Gets the strategy order. Lower values run first. + /// + int Order { get; } + + /// + /// Applies the routing strategy to the current candidate set. + /// + /// The routing context. + /// The token to monitor for cancellation requests. + ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentAvailabilityRecoveryService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentAvailabilityRecoveryService.cs new file mode 100644 index 000000000..ea804372f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentAvailabilityRecoveryService.cs @@ -0,0 +1,14 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Recovers agent capacity from orphaned or expired after-call work. +/// +public interface IAgentAvailabilityRecoveryService +{ + /// + /// Completes orphaned or expired after-call work so agents do not remain unavailable indefinitely. + /// + /// The token to monitor for cancellation requests. + /// The number of agent wrap-up states recovered. + Task RecoverAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentAvailabilityService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentAvailabilityService.cs new file mode 100644 index 000000000..ce38ddf7b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentAvailabilityService.cs @@ -0,0 +1,31 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Computes the canonical routing availability of Contact Center agents. +/// +public interface IAgentAvailabilityService +{ + /// + /// Gets the canonical availability of the specified agent for a queue. + /// + /// The agent profile identifier. + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The availability projection, or when the agent is not eligible. + Task GetAsync( + string agentId, + string queueId, + CancellationToken cancellationToken = default); + + /// + /// Lists agents that are entitled, opted in, live, available, and within capacity for the specified queue. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The canonical availability projections for eligible agents. + Task> ListForQueueAsync( + string queueId, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs new file mode 100644 index 000000000..3d65f9f72 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs @@ -0,0 +1,77 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Coordinates agent sign-in, sign-out, presence, and queue/campaign membership for the Contact Center. +/// +public interface IAgentPresenceManager +{ + /// + /// Signs the agent in, makes them available, and joins the supplied queues and campaigns. + /// + /// The Orchard user identifier. + /// The queues to sign in to. + /// The dialer campaigns to sign in to. + /// The token to monitor for cancellation requests. + /// The agent profile after sign-in. + Task SignInAsync(string userId, IEnumerable queueIds, IEnumerable campaignIds, CancellationToken cancellationToken = default); + + /// + /// Updates the queues and campaigns for an already-signed-in agent without changing their presence or active work. + /// + /// The Orchard user identifier. + /// The queues to remain signed in to. + /// The dialer campaigns to remain signed in to. + /// The token to monitor for cancellation requests. + /// The updated agent profile, or when no profile exists. + Task UpdateMembershipsAsync(string userId, IEnumerable queueIds, IEnumerable campaignIds, CancellationToken cancellationToken = default); + + /// + /// Signs the agent out and takes them offline. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The agent profile after sign-out, or when none exists. + Task SignOutAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Sets the agent presence state and optional reason code. + /// + /// The Orchard user identifier. + /// The presence state to apply. + /// The optional reason code. + /// The token to monitor for cancellation requests. + /// The agent profile after the change, or when none exists. + Task SetPresenceAsync(string userId, AgentPresenceStatus status, string reason, CancellationToken cancellationToken = default); + + /// + /// Moves the agent into wrap-up after a handled communication session ends, while preserving any + /// requested follow-up presence state such as break. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The agent profile after the change, or when none exists. + Task StartWrapUpAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Releases the agent after wrap-up completion, applying any pending requested presence state. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The agent profile after the change, or when none exists. + Task CompleteWorkAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Applies manager-owned queue and campaign entitlements to the agent profile, pruning any live queue + /// or campaign membership that the new entitlements no longer authorize while preserving the agent's + /// current presence status and active reservation. + /// + /// The agent profile identifier. + /// The queues the agent is allowed to sign in to. + /// The dialer campaigns the agent is allowed to sign in to. + /// The token to monitor for cancellation requests. + /// The updated agent profile, or when no profile exists. + Task UpdateEntitlementsAsync(string agentId, IEnumerable allowedQueueIds, IEnumerable allowedCampaignIds, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileManager.cs new file mode 100644 index 000000000..8328c7712 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileManager.cs @@ -0,0 +1,37 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for agent profiles. +/// +public interface IAgentProfileManager : ICatalogManager +{ + /// + /// Finds the agent profile for the specified user. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The matching agent profile, or when none exists. + Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Lists every agent profile that is available and signed in to the specified queue. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The available agents for the queue. + Task> ListAvailableForQueueAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Lists agent profiles in the specified presence state. + /// + /// The presence state to match. + /// The token to monitor for cancellation requests. + /// The matching agent profiles. + Task> ListByPresenceAsync( + AgentPresenceStatus presenceStatus, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileStore.cs new file mode 100644 index 000000000..ca1233b9d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileStore.cs @@ -0,0 +1,37 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for agent profiles. +/// +public interface IAgentProfileStore : ICatalog +{ + /// + /// Finds the agent profile for the specified user. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The matching agent profile, or when none exists. + Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Lists every agent profile that is available and signed in to the specified queue. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The available agents for the queue. + Task> ListAvailableForQueueAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Lists agent profiles in the specified presence state. + /// + /// The presence state to match. + /// The token to monitor for cancellation requests. + /// The matching agent profiles. + Task> ListByPresenceAsync( + AgentPresenceStatus presenceStatus, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionManager.cs new file mode 100644 index 000000000..4eb10d941 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionManager.cs @@ -0,0 +1,37 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for live agent sessions. +/// +public interface IAgentSessionManager : ICatalogManager +{ + /// + /// Finds the live session for the specified user. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The matching session, or when none exists. + Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Lists online sessions whose heartbeat is older than the supplied cut-off time, oldest heartbeat first. + /// The result is bounded, so a large backlog is drained across several calls rather than in one pass. + /// + /// The UTC time before which a heartbeat is considered stale. + /// The token to monitor for cancellation requests. + /// A bounded, oldest-heartbeat-first page of the stale online sessions. + Task> ListStaleAsync(DateTime heartbeatCutoffUtc, CancellationToken cancellationToken = default); + + /// + /// Lists sessions for the specified users. + /// + /// The Orchard user identifiers. + /// The token to monitor for cancellation requests. + /// The matching agent sessions. + Task> ListByUserIdsAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionService.cs new file mode 100644 index 000000000..e4554e72d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionService.cs @@ -0,0 +1,63 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Coordinates the live connection lifecycle of agent sessions: registering and removing SignalR +/// connections, recording heartbeats, building reconnect snapshots, and expiring sessions whose client +/// has gone away so routing stops targeting a dead connection. +/// +public interface IAgentSessionService +{ + /// + /// Registers a new live connection for the agent, creating the session when one does not exist and + /// refreshing the queue and campaign membership snapshot from the agent profile. + /// + /// The Orchard user identifier. + /// The SignalR connection identifier. + /// The user name of the agent. + /// The display name of the agent. + /// The token to monitor for cancellation requests. + /// The agent session after the connection is registered. + Task ConnectAsync(string userId, string connectionId, string userName, string displayName, CancellationToken cancellationToken = default); + + /// + /// Removes a live connection from the agent session and marks the session offline when no + /// connections remain. + /// + /// The Orchard user identifier. + /// The SignalR connection identifier that dropped. + /// The token to monitor for cancellation requests. + /// The agent session after the connection is removed, or when none exists. + Task DisconnectAsync(string userId, string connectionId, CancellationToken cancellationToken = default); + + /// + /// Records a heartbeat for the agent session so the cleanup task does not consider it stale. The stamp is + /// committed in its own unit of work, so it is durable when this method returns rather than when the caller + /// commits. A heartbeat that loses its version check to a concurrent writer is not recorded and does not + /// throw; it is not retried, because a retry could write an older timestamp over a newer one. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The agent session as the caller's unit of work sees it, whether or not this heartbeat was the write that stamped it, or when none exists. + Task HeartbeatAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Builds the reconnect snapshot the agent desktop needs to restore its state. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The agent desktop snapshot. + Task BuildSnapshotAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Marks sessions whose heartbeat has gone stale as offline and signs the agents out so routing no longer + /// targets clients that are no longer connected. A single pass expires a bounded number of sessions, oldest + /// heartbeat first, so a backlog left by an event that made every session stale at once — a deployment that + /// drops every connection — is drained over consecutive passes rather than in one. A caller that needs the + /// backlog cleared cannot assume one call is enough. + /// + /// The token to monitor for cancellation requests. + /// The number of sessions that were expired by this pass. + Task ExpireStaleAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionStore.cs new file mode 100644 index 000000000..21d972150 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentSessionStore.cs @@ -0,0 +1,38 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for live agent sessions. +/// +public interface IAgentSessionStore : ICatalog +{ + /// + /// Finds the live session for the specified user. + /// + /// The Orchard user identifier. + /// The token to monitor for cancellation requests. + /// The matching session, or when none exists. + Task FindByUserIdAsync(string userId, CancellationToken cancellationToken = default); + + /// + /// Lists online sessions whose heartbeat is older than the supplied cut-off time. The result is bounded, + /// because the caller locks, re-reads and deletes each session it is handed; whatever does not fit is + /// expired by the next pass. + /// + /// The UTC time before which a heartbeat is considered stale. + /// The token to monitor for cancellation requests. + /// The stale online sessions. + Task> ListStaleAsync(DateTime heartbeatCutoffUtc, CancellationToken cancellationToken = default); + + /// + /// Lists sessions for the specified users. + /// + /// The Orchard user identifiers. + /// The token to monitor for cancellation requests. + /// The matching agent sessions. + Task> ListByUserIdsAsync( + IReadOnlyCollection userIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentStateReasonCodeManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentStateReasonCodeManager.cs new file mode 100644 index 000000000..3bac4e573 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentStateReasonCodeManager.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for agent state reason codes. +/// +public interface IAgentStateReasonCodeManager : ICatalogManager +{ + /// + /// Finds the reason code with the specified unique name. + /// + /// The reason code name. + /// The token to monitor for cancellation requests. + /// The matching reason code, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled reason code ordered for display. + /// + /// The token to monitor for cancellation requests. + /// The enabled reason codes. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentStateReasonCodeStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentStateReasonCodeStore.cs new file mode 100644 index 000000000..2de17860a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentStateReasonCodeStore.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for agent state reason codes. +/// +public interface IAgentStateReasonCodeStore : ICatalog +{ + /// + /// Finds the reason code with the specified unique name. + /// + /// The reason code name. + /// The token to monitor for cancellation requests. + /// The matching reason code, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled reason code ordered for display. + /// + /// The token to monitor for cancellation requests. + /// The enabled reason codes. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentWorkStateHealingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentWorkStateHealingService.cs new file mode 100644 index 000000000..76a79c891 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentWorkStateHealingService.cs @@ -0,0 +1,24 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Repairs orphaned queue, reservation, and interaction state for an agent so stale records do not keep +/// blocking future routing. +/// +public interface IAgentWorkStateHealingService +{ + /// + /// Repairs stale state before an explicitly requested sign-in or sign-out resets the agent. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The number of stale state fragments that were healed. + Task HealForResetAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Repairs stale state before queued inbound work is re-offered to an available agent. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The number of stale state fragments that were healed. + Task HealForAvailabilityAsync(string agentId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursCalendarManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursCalendarManager.cs new file mode 100644 index 000000000..4b9ce9ef8 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursCalendarManager.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for business-hours calendars. +/// +public interface IBusinessHoursCalendarManager : ICatalogManager +{ + /// + /// Finds the calendar with the specified unique name. + /// + /// The calendar name. + /// The token to monitor for cancellation requests. + /// The matching calendar, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled calendar. + /// + /// The token to monitor for cancellation requests. + /// The enabled calendars. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursCalendarStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursCalendarStore.cs new file mode 100644 index 000000000..a24301ef0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursCalendarStore.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for business-hours calendars. +/// +public interface IBusinessHoursCalendarStore : ICatalog +{ + /// + /// Finds the calendar with the specified unique name. + /// + /// The calendar name. + /// The token to monitor for cancellation requests. + /// The matching calendar, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled calendar. + /// + /// The token to monitor for cancellation requests. + /// The enabled calendars. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursService.cs new file mode 100644 index 000000000..e7af376a0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IBusinessHoursService.cs @@ -0,0 +1,52 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Evaluates whether a business-hours calendar reports a queue as open at a given moment. +/// +public interface IBusinessHoursService +{ + /// + /// Determines whether the specified calendar is currently open. + /// + /// The calendar identifier; an empty value is always open. + /// The token to monitor for cancellation requests. + /// when the calendar is open or unrestricted; otherwise, . + Task IsOpenAsync(string calendarId, CancellationToken cancellationToken = default); + + /// + /// Determines whether the specified calendar is open at the supplied UTC instant. + /// + /// The calendar identifier; an empty value is always open. + /// The UTC instant to evaluate. + /// The token to monitor for cancellation requests. + /// when the calendar is open or unrestricted; otherwise, . + Task IsOpenAsync(string calendarId, DateTime utcInstant, CancellationToken cancellationToken = default); + + /// + /// Determines whether the specified calendar is open at the supplied UTC instant using an optional time-zone override. + /// + /// The calendar identifier; an empty value is always open. + /// The UTC instant to evaluate. + /// The time zone used instead of the calendar time zone when supplied. + /// The token to monitor for cancellation requests. + /// when the calendar is open or unrestricted; otherwise, . + Task IsOpenAsync( + string calendarId, + DateTime utcInstant, + string timeZoneId, + CancellationToken cancellationToken = default); + + /// + /// Evaluates a required calendar and distinguishes an unavailable or disabled calendar from an open calendar. + /// + /// The required calendar identifier. + /// The UTC instant to evaluate. + /// The time zone used instead of the calendar time zone when supplied. + /// The token to monitor for cancellation requests. + /// when the calendar is unavailable or disabled; otherwise, whether it is open. + Task EvaluateAsync( + string calendarId, + DateTime utcInstant, + string timeZoneId, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallControlAuthorizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallControlAuthorizationService.cs new file mode 100644 index 000000000..5320bb979 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallControlAuthorizationService.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the shared fail-closed authorization boundary for call-control operations. +/// +public interface ICallControlAuthorizationService +{ + /// + /// Authorizes a call-control operation using server-resolved call-session ownership. + /// + /// The authorization context. + /// The token to monitor for cancellation requests. + /// The authorization result with the server-resolved provider call identifier. + Task AuthorizeAsync( + CallControlAuthorizationContext context, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs new file mode 100644 index 000000000..228a296a1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs @@ -0,0 +1,35 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for call sessions. +/// +public interface ICallSessionManager : ICatalogManager +{ + /// + /// Finds the call session with the specified provider call identifier. + /// + /// The provider call identifier. + /// The token to monitor for cancellation requests. + /// The matching call session, or when none is found. + Task FindByProviderCallIdAsync(string providerCallId, CancellationToken cancellationToken = default); + + /// + /// Finds the call session with the specified provider and provider call identifier. + /// + /// The provider technical name. + /// The provider call identifier. + /// The token to monitor for cancellation requests. + /// The matching call session, or when none is found. + Task FindByProviderCallIdAsync(string providerName, string providerCallId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent call session linked to the specified interaction. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The matching call session, or when none is found. + Task FindByInteractionIdAsync(string interactionId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs new file mode 100644 index 000000000..a41bff9da --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs @@ -0,0 +1,44 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for call sessions. +/// +public interface ICallSessionStore : ICatalog +{ + /// + /// Finds the call session with the specified provider call identifier. + /// + /// The provider call identifier. + /// The token to monitor for cancellation requests. + /// The matching call session, or when none is found. + Task FindByProviderCallIdAsync(string providerCallId, CancellationToken cancellationToken = default); + + /// + /// Finds the call session with the specified provider and provider call identifier. + /// + /// The provider technical name. + /// The provider call identifier. + /// The token to monitor for cancellation requests. + /// The matching call session, or when none is found. + Task FindByProviderCallIdAsync(string providerName, string providerCallId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent call session linked to the specified interaction. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The matching call session, or when none is found. + Task FindByInteractionIdAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Counts the call sessions that have not yet ended, using an aggregate query without materializing the + /// rows. A session is active while it has no recorded end time, so this is the number of live calls the + /// tenant is currently handling. + /// + /// The token to monitor for cancellation requests. + /// The number of active call sessions. + Task CountActiveAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestManager.cs new file mode 100644 index 000000000..9c12c152b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestManager.cs @@ -0,0 +1,19 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for callback requests. +/// +public interface ICallbackRequestManager : ICatalogManager +{ + /// + /// Lists pending callbacks that are due at or before the supplied UTC instant and are not currently claimed by an unexpired lease. + /// + /// The current UTC instant. + /// The maximum number of callbacks to return. + /// The token to monitor for cancellation requests. + /// The due callbacks ordered by their scheduled time and bounded by . + Task> ListDueAsync(DateTime utcNow, int maxCount, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestStore.cs new file mode 100644 index 000000000..35e9ca113 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestStore.cs @@ -0,0 +1,19 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for callback requests. +/// +public interface ICallbackRequestStore : ICatalog +{ + /// + /// Lists pending callbacks that are due at or before the supplied UTC instant and are not currently claimed by an unexpired lease. + /// + /// The current UTC instant. + /// The maximum number of callbacks to return. + /// The token to monitor for cancellation requests. + /// The due callbacks ordered by their scheduled time and bounded by . + Task> ListDueAsync(DateTime utcNow, int maxCount, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackService.cs new file mode 100644 index 000000000..5d4e450f7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackService.cs @@ -0,0 +1,25 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Schedules callbacks and promotes due callbacks into outbound CRM activities. +/// +public interface ICallbackService +{ + /// + /// Schedules a callback for a future time and records it as pending. + /// + /// The callback to schedule. + /// The token to monitor for cancellation requests. + /// The scheduled callback. + Task ScheduleAsync(CallbackRequest callback, CancellationToken cancellationToken = default); + + /// + /// Promotes every pending callback that is due into an outbound CRM activity and, when a queue is set, + /// enqueues it for routing. + /// + /// The token to monitor for cancellation requests. + /// The number of callbacks promoted. + Task PromoteDueAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterActivityWriter.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterActivityWriter.cs new file mode 100644 index 000000000..d3a502f08 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterActivityWriter.cs @@ -0,0 +1,35 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Writes CRM activity fields on behalf of Contact Center routing without enlisting the write in the routing +/// transaction, so a losing compare-and-set race against a concurrent CRM edit can never fail a routing +/// transition. +/// +public interface IContactCenterActivityWriter +{ + /// + /// Schedules a CRM activity mutation to run after the current routing scope commits. When no shell scope + /// is available the mutation is applied immediately instead. + /// + /// The CRM activity identifier. + /// The mutation to apply. + /// The token to monitor for cancellation requests. + Task ScheduleUpdateAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken = default); + + /// + /// Applies a CRM activity mutation and commits it, retrying in a fresh scope when a concurrent CRM edit + /// wins the compare-and-set race. + /// + /// The CRM activity identifier. + /// The mutation to apply. + /// The token to monitor for cancellation requests. + Task UpdateAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistProvider.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistProvider.cs new file mode 100644 index 000000000..e3be6b571 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistProvider.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides optional AI assistance for the Contact Center: interaction summarization and disposition +/// suggestions. AI modules implement this seam so assistance can be added without coupling the Contact +/// Center to a specific AI provider. +/// +public interface IContactCenterAssistProvider +{ + /// + /// Gets the order in which this provider is consulted. Lower values run first. + /// + int Order { get; } + + /// + /// Suggests a disposition for the interaction, or when the provider has no suggestion. + /// + /// The assist context. + /// The token to monitor for cancellation requests. + /// The suggested disposition, or . + Task SuggestDispositionAsync(AssistContext context, CancellationToken cancellationToken = default); + + /// + /// Summarizes the interaction, or returns when the provider has no summary. + /// + /// The assist context. + /// The token to monitor for cancellation requests. + /// The summary text, or . + Task SummarizeAsync(AssistContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistService.cs new file mode 100644 index 000000000..82874d383 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistService.cs @@ -0,0 +1,31 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Orchestrates the registered AI assist providers to summarize interactions and suggest dispositions. +/// Returns the first provider result, so assistance is available only when a provider is installed. +/// +public interface IContactCenterAssistService +{ + /// + /// Gets a value indicating whether any AI assist provider is available. + /// + bool IsAvailable { get; } + + /// + /// Suggests a disposition using the first provider that returns one. + /// + /// The assist context. + /// The token to monitor for cancellation requests. + /// The suggested disposition, or when none is available. + Task SuggestDispositionAsync(AssistContext context, CancellationToken cancellationToken = default); + + /// + /// Summarizes an interaction using the first provider that returns a summary. + /// + /// The assist context. + /// The token to monitor for cancellation requests. + /// The summary, or when none is available. + Task SummarizeAsync(AssistContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterCallCommandService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterCallCommandService.cs new file mode 100644 index 000000000..826a4fbbe --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterCallCommandService.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Coordinates the agent-facing call commands for offered interactions as single, atomic, audited +/// operations. Accepting an offer accepts the reservation, connects the live media to the agent, and +/// advances the interaction and call session together so the orchestration state and the media state +/// can never diverge. This replaces uncoordinated, best-effort client actions. +/// +public interface IContactCenterCallCommandService +{ + /// + /// Accepts an offered inbound interaction: accepts the reservation, connects (bridges) the live + /// call to the agent for server-side ACD providers, and advances the interaction and call session + /// to the connected state. For agent-device-native providers, the returned result indicates the + /// agent's device must answer the media. + /// + /// The reservation identifier of the offered interaction. + /// The Orchard user identifier of the agent accepting the offer. + /// The token to monitor for cancellation requests. + /// The command result describing the outcome. + Task AcceptInboundOfferAsync(string reservationId, string agentUserId, CancellationToken cancellationToken = default); + + /// + /// Declines an offered inbound interaction: rejects the reservation, returns the work to its queue, + /// and re-offers it to the next available agent. + /// + /// The reservation identifier of the offered interaction. + /// The Orchard user identifier of the agent declining the offer. + /// The token to monitor for cancellation requests. + /// The command result describing the outcome. + Task DeclineInboundOfferAsync(string reservationId, string agentUserId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointManager.cs new file mode 100644 index 000000000..4df3707fa --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointManager.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for inbound entry points. +/// +public interface IContactCenterEntryPointManager : ICatalogManager +{ + /// + /// Finds the entry point with the specified unique name. + /// + /// The entry point name. + /// The token to monitor for cancellation requests. + /// The matching entry point, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled entry point. + /// + /// The token to monitor for cancellation requests. + /// The enabled entry points. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointStore.cs new file mode 100644 index 000000000..db93ff950 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointStore.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for inbound entry points. +/// +public interface IContactCenterEntryPointStore : ICatalog +{ + /// + /// Finds the entry point with the specified unique name. + /// + /// The entry point name. + /// The token to monitor for cancellation requests. + /// The matching entry point, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled entry point. + /// + /// The token to monitor for cancellation requests. + /// The enabled entry points. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventDeduplicationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventDeduplicationService.cs new file mode 100644 index 000000000..3f152f4fd --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventDeduplicationService.cs @@ -0,0 +1,21 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides durable, per-handler event idempotency so an at-least-once handler that applies a +/// non-idempotent effect (such as a metrics increment or a workflow trigger) processes each event exactly +/// once even when the event is replayed by outbox retries. +/// +public interface IContactCenterEventDeduplicationService +{ + /// + /// Attempts to reserve one durable event for one handler. When this returns the + /// caller must apply its effect in the same session so the reservation and the effect commit atomically; + /// when it returns the event was already processed by this handler and the + /// effect must be skipped. + /// + /// The stable, versioned identifier of the handler reserving the event. + /// The durable identifier of the event being processed. + /// The token to monitor for cancellation requests. + /// when the event was newly reserved; otherwise . + Task TryBeginAsync(string handlerId, string eventId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventHandler.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventHandler.cs new file mode 100644 index 000000000..80dbea1a3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventHandler.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines a handler that reacts to published Contact Center domain events. Handlers allow components +/// to react to the interaction lifecycle without being directly coupled to the component that raised the event. +/// +public interface IContactCenterEventHandler +{ + /// + /// Gets the stable, versioned technical identifier of this handler. The identifier must be unique + /// across all registered handlers and stable across deployments; it must not depend on the CLR type + /// name, assembly version, or registration order so that outbox delivery checkpoints remain valid + /// when handlers are renamed, reordered, or shipped from a new assembly version. + /// + string HandlerId { get; } + + /// + /// Gets the machine-readable replay contract for this handler. Outbox delivery is at-least-once, so + /// this value states honestly how the handler stays idempotent when the same event is dispatched again. + /// Registration validation rejects an + /// contract. + /// + ContactCenterHandlerReplaySafety ReplaySafety { get; } + + /// + /// Handles the specified Contact Center domain event. + /// + /// The event to handle. + /// The token to monitor for cancellation requests. + Task HandleAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventPublisher.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventPublisher.cs new file mode 100644 index 000000000..4660fd79a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventPublisher.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Publishes Contact Center domain events. Publishing records the event in the durable interaction +/// event history and dispatches it to the registered instances. +/// +public interface IContactCenterEventPublisher +{ + /// + /// Publishes the specified Contact Center domain event. + /// + /// The event to publish. + /// The token to monitor for cancellation requests. + Task PublishAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricDeltaStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricDeltaStore.cs new file mode 100644 index 000000000..4a348322a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricDeltaStore.cs @@ -0,0 +1,48 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for unrolled daily event count contributions. +/// +public interface IContactCenterMetricDeltaStore : ICatalog +{ + /// + /// Lists a bounded batch of contributions so the roller never has to hold the whole table in memory. No + /// order is requested: the roller sums whatever it is handed and removes exactly those rows, so the order + /// carries no meaning, and asking for one would make the engine sort the entire backlog before it could + /// return a single batch. + /// + /// The maximum number of contributions to return. + /// The token to monitor for cancellation requests. + /// The batch of contributions. + Task> ListBatchAsync(int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists the contributions whose day falls within the inclusive range. A reader has to add these to the + /// rolled-up totals, because a contribution that has not been folded yet is still a real event. + /// + /// The inclusive lower UTC date. + /// The inclusive upper UTC date. + /// The token to monitor for cancellation requests. + /// The contributions in the range. + Task> ListByDateRangeAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Lists the contributions positioned after the supplied document identifier, in document order, so a + /// caller that has to account for every one of them can walk the whole table. The walk resumes from a + /// position rather than from an offset because the roller deletes the rows it folds from anywhere in the + /// table: an offset would step over rows that are still waiting once earlier ones are gone, and those + /// counts would be missed with nothing to show for it. The walk removes that skew; it does not turn the + /// pages into one snapshot. Identifiers are allocated before the transaction that commits them, so a + /// contribution can still become visible below a position the walk has already passed, and a caller that + /// has to be exact has to account for that separately. The contributions are read from the index alone, + /// without loading the documents they belong to. + /// + /// The document identifier to resume after; zero starts from the beginning. + /// The maximum number of contributions to return. + /// The token to monitor for cancellation requests. + /// The contributions after the supplied position. + Task> ListContributionsAfterAsync(long afterDocumentId, int count, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricRollupService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricRollupService.cs new file mode 100644 index 000000000..f65eaf65c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricRollupService.cs @@ -0,0 +1,16 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Folds appended event count contributions into the daily totals they belong to. +/// +public interface IContactCenterMetricRollupService +{ + /// + /// Folds a bounded amount of appended contributions into their daily totals and removes the contributions + /// that were folded. Only the contributions this call read are removed, so a contribution appended while + /// the fold is running is left for the next one rather than being discarded uncounted. + /// + /// The token to monitor for cancellation requests. + /// The number of contributions folded. + Task RollupAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricStore.cs new file mode 100644 index 000000000..8c5de5376 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricStore.cs @@ -0,0 +1,36 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for daily event metrics. +/// +public interface IContactCenterMetricStore : ICatalog +{ + /// + /// Finds the metric for the specified day and event type. + /// + /// The day key formatted as yyyy-MM-dd. + /// The domain event type. + /// The token to monitor for cancellation requests. + /// The matching metric, or when none exists. + Task FindAsync(string dateKey, string eventType, CancellationToken cancellationToken = default); + + /// + /// Lists the metrics whose day falls within the inclusive range. + /// + /// The inclusive lower UTC date. + /// The inclusive upper UTC date. + /// The token to monitor for cancellation requests. + /// The metrics in the range. + Task> ListByDateRangeAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Lists every stored metric. The result set is bounded by the number of distinct day and event-type + /// combinations and is used when rebuilding or drift-checking the projection. + /// + /// The token to monitor for cancellation requests. + /// All stored metrics. + Task> ListAllAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsProjectionMaintenanceService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsProjectionMaintenanceService.cs new file mode 100644 index 000000000..0a1e3c399 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsProjectionMaintenanceService.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Maintains the daily event-count metrics projection by rebuilding it from the durable event log and by +/// detecting drift between the stored projection and the recomputed truth. It makes the projection +/// self-healing and auditable rather than a write-once side effect of event delivery. +/// +public interface IContactCenterMetricsProjectionMaintenanceService +{ + /// + /// Recomputes every daily metric from the source-of-truth event log and reconciles the stored + /// projection to match, then advances the replay checkpoint. Missing metrics are created, incorrect + /// counts are corrected, and metrics with no supporting events are removed. Contributions that have been + /// appended but not folded into the totals yet are excluded from the reconciled totals, because folding + /// them later adds them; a rebuild therefore does not count them twice. An event recorded, or a contribution + /// folded, while the rebuild is running can leave that total briefly short until the next rebuild; the + /// operation is idempotent and converges on a re-run. + /// + /// The token to monitor for cancellation requests. + /// The number of stored metrics that were created, updated, or deleted. + Task RebuildAsync(CancellationToken cancellationToken = default); + + /// + /// Recomputes every daily metric from the source-of-truth event log and compares it to the stored + /// projection without modifying anything, returning every detected discrepancy. Contributions that have + /// been appended but not folded into the totals yet are counted as part of the projection, so a roller + /// that is merely behind is not reported as drift. + /// + /// The token to monitor for cancellation requests. + /// The detected drifts, or an empty list when the projection is consistent. + Task> DetectDriftAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsService.cs new file mode 100644 index 000000000..605867f55 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsService.cs @@ -0,0 +1,25 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Records and reports the daily Contact Center event-count projection used for operational and +/// historical reporting. +/// +public interface IContactCenterMetricsService +{ + /// + /// Increments the daily count for the specified event type on the day the event occurred. + /// + /// The domain event type. + /// The UTC time the event occurred. + /// The token to monitor for cancellation requests. + Task RecordAsync(string eventType, DateTime occurredUtc, CancellationToken cancellationToken = default); + + /// + /// Returns the total count of each event type over the inclusive UTC date range. + /// + /// The inclusive lower UTC date. + /// The inclusive upper UTC date. + /// The token to monitor for cancellation requests. + /// A dictionary of event type to total count. + Task> GetSummaryAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMonitoringService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMonitoringService.cs new file mode 100644 index 000000000..fd773b51f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMonitoringService.cs @@ -0,0 +1,56 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Orchestrates scoped, audited supervisor engagement with live calls (monitor, whisper, barge, take +/// over). Engagement is gated by the voice provider's capabilities; provider modules execute the media. +/// +public interface IContactCenterMonitoringService +{ + /// + /// Gets the executable supervisor engagement modes available for an interaction. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The available engagement modes. + Task> GetAvailableModesAsync( + string interactionId, + CancellationToken cancellationToken = default); + + /// + /// Engages a live interaction as a supervisor using the requested mode when the provider supports it. + /// + /// The interaction identifier. + /// The supervisor performing the engagement. + /// The authenticated supervisor principal. + /// The engagement mode. + /// The token to monitor for cancellation requests. + /// The engagement result. + Task EngageAsync( + string interactionId, + string supervisorId, + ClaimsPrincipal principal, + MonitorMode mode, + CancellationToken cancellationToken = default); + + /// + /// Stops a supervisor engagement on a live interaction, releasing only the supervisor-owned media without + /// affecting the underlying customer-to-agent call. Authorized under the same boundary as + /// . + /// + /// The interaction identifier. + /// The supervisor whose engagement should be stopped. + /// The authenticated supervisor principal. + /// The engagement mode being stopped. + /// The token to monitor for cancellation requests. + /// The stop result. + Task StopEngagementAsync( + string interactionId, + string supervisorId, + ClaimsPrincipal principal, + MonitorMode mode, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs new file mode 100644 index 000000000..d1fe82734 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs @@ -0,0 +1,79 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Dispatches Contact Center domain events to their handlers and guarantees at-least-once delivery. +/// Every event is durably enqueued before dispatch and remains pending until all handlers complete or +/// the message is dead-lettered. Handlers must therefore be idempotent. +/// +public interface IContactCenterOutbox +{ + /// + /// Durably enqueues an event for handler dispatch. + /// + /// The event to enqueue. + /// The token to monitor for cancellation requests. + Task EnqueueAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default); + + /// + /// Runs every incomplete registered handler for an already-enqueued event. Successful delivery + /// removes the outbox message; failures are retried with exponential back-off. + /// + /// The event to dispatch. + /// The token to monitor for cancellation requests. + Task DispatchAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default); + + /// + /// Processes the outbox messages that are due for retry, re-running their handlers and applying + /// exponential back-off or dead-lettering based on the outcome. Each due message is processed in its + /// own fresh Orchard child scope so a poison message or a canceled YesSql session cannot block the + /// remaining messages in the batch. + /// + /// The token to monitor for cancellation requests. + /// The number of messages that were successfully redelivered and removed. + Task DispatchDueAsync(CancellationToken cancellationToken = default); + + /// + /// Processes one due outbox message by its identifier. This is the per-message unit of work that + /// runs in an isolated child scope; it reloads the message, re-runs its + /// incomplete handlers, and either removes the message or schedules a retry. + /// + /// The durable outbox message identifier. + /// The token to monitor for cancellation requests. + /// when the message completed and was removed; otherwise . + Task DispatchDueMessageAsync(string messageId, CancellationToken cancellationToken = default); + + /// + /// Executes one registered handler for an event. The outbox runs this operation in an isolated child + /// scope so the handler effect and any replay marker commit together, while an exception rolls both + /// back without canceling the message-dispatch session. + /// + /// The event to handle. + /// The stable identifier of the handler to execute. + /// The token to monitor for cancellation requests. + Task DispatchHandlerAsync( + InteractionEvent interactionEvent, + string handlerId, + CancellationToken cancellationToken = default); + + /// + /// Settles a claimed message in a fresh scope after handler execution, rejecting stale owners by fence. + /// + /// The durable outbox message identifier. + /// The owner token captured when the message was claimed. + /// The fence token captured when the message was claimed. + /// The stable identifiers of handlers that completed. + /// The first handler error, or when all handlers completed. + /// Whether settlement is deferred only because an expected feature-owned handler is unavailable. + /// The token to monitor for cancellation requests. + /// when the message completed; otherwise . + Task SettleClaimAsync( + string messageId, + string ownerToken, + long fenceToken, + IReadOnlyCollection completedHandlerIds, + string error, + bool handlerUnavailable, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs new file mode 100644 index 000000000..3692d783c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs @@ -0,0 +1,44 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for Contact Center outbox messages. +/// +public interface IContactCenterOutboxStore : ICatalog +{ + /// + /// Finds the outbox message associated with an event. + /// + /// The interaction event identifier. + /// The token to monitor for cancellation requests. + /// The matching message, or when none exists. + Task FindByEventIdAsync(string eventId, CancellationToken cancellationToken = default); + + /// + /// Lists the pending messages whose next attempt time is at or before the supplied time, oldest first. + /// + /// The current UTC time used to select due messages. + /// The maximum number of messages to return in one batch. + /// The token to monitor for cancellation requests. + /// The due outbox messages. + Task> ListDueAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default); + + /// + /// Counts the messages currently in the supplied dispatch state. + /// + /// The dispatch state to count. + /// The token to monitor for cancellation requests. + /// The number of messages in the supplied state. + Task CountByStatusAsync(OutboxMessageStatus status, CancellationToken cancellationToken = default); + + /// + /// Counts the pending or claimed messages whose next attempt is already due at or before the supplied time. + /// A sustained non-zero result indicates the dispatcher is not keeping up with the backlog. + /// + /// The current UTC time used to select overdue messages. + /// The token to monitor for cancellation requests. + /// The number of overdue messages. + Task CountOverdueAsync(DateTime nowUtc, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterProcessedEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterProcessedEventStore.cs new file mode 100644 index 000000000..fb55e8fb2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterProcessedEventStore.cs @@ -0,0 +1,11 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for event deduplication markers. +/// +public interface IContactCenterProcessedEventStore : ICatalog +{ +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterProjectionCheckpointStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterProjectionCheckpointStore.cs new file mode 100644 index 000000000..94194ae35 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterProjectionCheckpointStore.cs @@ -0,0 +1,18 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for projection replay checkpoints. +/// +public interface IContactCenterProjectionCheckpointStore : ICatalog +{ + /// + /// Finds the checkpoint recorded for the specified projection handler. + /// + /// The stable, versioned projection handler identifier. + /// The token to monitor for cancellation requests. + /// The matching checkpoint, or when none exists. + Task FindByHandlerAsync(string handlerId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRecordingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRecordingService.cs new file mode 100644 index 000000000..712240ec5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRecordingService.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Orchestrates call recording state for interactions. It owns the recording lifecycle and audit events; +/// provider modules execute the media capture. +/// +public interface IContactCenterRecordingService +{ + /// + /// Starts recording the interaction. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The outcome of the recording state change, including an explicit indeterminate result when the command was interrupted. + Task StartAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Pauses recording (for example while sensitive data is captured). + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The outcome of the recording state change, including an explicit indeterminate result when the command was interrupted. + Task PauseAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Resumes a paused recording. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The outcome of the recording state change, including an explicit indeterminate result when the command was interrupted. + Task ResumeAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Stops recording the interaction. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The outcome of the recording state change, including an explicit indeterminate result when the command was interrupted. + Task StopAsync(string interactionId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterReportingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterReportingService.cs new file mode 100644 index 000000000..89061a846 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterReportingService.cs @@ -0,0 +1,127 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Produces the historical Contact Center reports used by supervisors and administrators to understand +/// call activity, agent productivity, queue usage, and campaign/subject progress. +/// +public interface IContactCenterReportingService +{ + /// + /// Builds the call insights report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The call insights report. + Task GetCallInsightsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Builds the filtered call insights report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The report dimension filters. + /// The token to monitor for cancellation requests. + /// The call insights report. + Task GetCallInsightsAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default); + + /// + /// Builds the agent productivity report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The agent productivity report. + Task GetAgentProductivityAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Builds the filtered agent productivity report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The report dimension filters. + /// The token to monitor for cancellation requests. + /// The agent productivity report. + Task GetAgentProductivityAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default); + + /// + /// Builds the queue usage report over the inclusive UTC period, including live waiting depth. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The queue usage report. + Task GetQueueUsageAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Builds the filtered queue usage report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The report dimension filters. + /// The token to monitor for cancellation requests. + /// The queue usage report. + Task GetQueueUsageAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default); + + /// + /// Builds the campaign summary report over the inclusive UTC period, showing completed versus pending + /// activities for each campaign. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The campaign summary report. + Task GetCampaignSummaryAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Builds the filtered campaign summary report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The report dimension filters. + /// The token to monitor for cancellation requests. + /// The campaign summary report. + Task GetCampaignSummaryAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default); + + /// + /// Builds the subject inventory report over the inclusive UTC period, showing completed versus pending + /// activities for each subject type. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The subject inventory report. + Task GetSubjectInventoryAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default); + + /// + /// Builds the filtered subject inventory report over the inclusive UTC period. + /// + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The report dimension filters. + /// The token to monitor for cancellation requests. + /// The subject inventory report. + Task GetSubjectInventoryAsync( + DateTime fromUtc, + DateTime toUtc, + ContactCenterReportCriteria criteria, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRetentionService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRetentionService.cs new file mode 100644 index 000000000..aa74306e1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRetentionService.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Enforces Contact Center data-governance retention by draining every high-volume table of records that have +/// aged past their configured window. +/// +public interface IContactCenterRetentionService +{ + /// + /// Runs one retention cycle across every registered retention policy, draining each entity until it is + /// empty or the cycle budget is exhausted. + /// + /// The token to monitor for cancellation requests. + /// + /// A report describing what each entity purged and whether the cycle returned the database to steady state. + /// + Task PurgeAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterScopeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterScopeExecutor.cs new file mode 100644 index 000000000..c4555b23d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterScopeExecutor.cs @@ -0,0 +1,31 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Executes Contact Center operations in isolated Orchard shell scopes and schedules typed work after commit. +/// +public interface IContactCenterScopeExecutor +{ + /// + /// Executes an operation in a new child scope using the requested scoped context. + /// + /// The scoped context type. + /// The operation to execute. + Task ExecuteAsync(Func operation) + where TContext : notnull; + + /// + /// Schedules an operation to execute after the current Orchard shell scope commits. + /// + /// The scoped context type. + /// The operation to execute after commit. + /// when the operation was scheduled; otherwise, . + bool ScheduleAfterCommit(Func operation) + where TContext : notnull; + + /// + /// Schedules a captured operation to execute after the current Orchard shell scope commits. + /// + /// The operation to execute after commit. + /// when the operation was scheduled; otherwise, . + bool ScheduleAfterCommit(Func operation); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillManager.cs new file mode 100644 index 000000000..f043f98f7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillManager.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for Contact Center skills. +/// +public interface IContactCenterSkillManager : ICatalogManager +{ + /// + /// Finds the skill with the specified unique name. + /// + /// The skill name. + /// The token to monitor for cancellation requests. + /// The matching skill, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled skill. + /// + /// The token to monitor for cancellation requests. + /// The enabled skills. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillStore.cs new file mode 100644 index 000000000..138d7ca08 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillStore.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for Contact Center skills. +/// +public interface IContactCenterSkillStore : ICatalog +{ + /// + /// Finds the skill with the specified unique name. + /// + /// The skill name. + /// The token to monitor for cancellation requests. + /// The matching skill, or when none exists. + Task FindByNameAsync(string name, CancellationToken cancellationToken = default); + + /// + /// Lists every enabled skill. + /// + /// The token to monitor for cancellation requests. + /// The enabled skills. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterTransferService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterTransferService.cs new file mode 100644 index 000000000..ca8edbdac --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterTransferService.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Orchestrates transfers of live interactions to another agent, queue, or external destination. It owns +/// the transfer decision and the interaction history; provider modules execute the media handoff. +/// +public interface IContactCenterTransferService +{ + /// + /// Transfers a live interaction to the requested destination, records the transfer on the + /// interaction history, and — for queue transfers — re-enqueues the underlying activity for routing. + /// + /// The transfer request. + /// The token to monitor for cancellation requests. + /// The transfer result. + Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateActivityProjection.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateActivityProjection.cs new file mode 100644 index 000000000..63c61ef37 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateActivityProjection.cs @@ -0,0 +1,27 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Projects Contact Center work state onto the CRM activity so CRM listing, filtering, and reporting stay +/// readable. The projection runs after the routing scope commits and never participates in a routing +/// transaction, so a CRM edit that conflicts with it can never fail a reservation. +/// +public interface IContactCenterWorkStateActivityProjection +{ + /// + /// Reconciles the CRM activity with the current work state of that activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + Task ProjectAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Seeds a work state document that does not exist yet from the routing fields the CRM activity already + /// carries, so work that predates this feature is not reported as unassigned with no attempts. + /// + /// The work state to seed. + /// The token to monitor for cancellation requests. + /// true when a CRM activity was found and the work state was seeded from it; otherwise, false. + Task TrySeedAsync(ContactCenterWorkState workState, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateManager.cs new file mode 100644 index 000000000..343fc0e35 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateManager.cs @@ -0,0 +1,28 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for Contact Center work state. +/// +public interface IContactCenterWorkStateManager : ICatalogManager +{ + /// + /// Finds the work state that belongs to the specified CRM activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The work state, or when the activity has never been routed. + Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Lists the work state documents that belong to the specified CRM activities. + /// + /// The CRM activity identifiers. + /// The token to monitor for cancellation requests. + /// The work state documents that exist for the requested activities. + Task> ListByActivityIdsAsync( + IEnumerable activityItemIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateService.cs new file mode 100644 index 000000000..060bfc288 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateService.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the routing-facing entry point for reading and transitioning Contact Center work state. +/// +public interface IContactCenterWorkStateService +{ + /// + /// Gets the work state for an activity without creating one. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The work state, or when the activity has never been routed. + Task GetAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Applies a routing transition to the work state of an activity, creating the work state on first use, + /// and schedules the CRM projection to run after the current scope commits. + /// + /// The CRM activity identifier. + /// The transition to apply. + /// The token to monitor for cancellation requests. + /// The persisted work state, or when no activity identifier was supplied. + Task MutateAsync( + string activityItemId, + Action mutate, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateStore.cs new file mode 100644 index 000000000..044094d08 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterWorkStateStore.cs @@ -0,0 +1,28 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for Contact Center work state. +/// +public interface IContactCenterWorkStateStore : ICatalog +{ + /// + /// Finds the work state that belongs to the specified CRM activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The work state, or when the activity has never been routed. + Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Lists the work state documents that belong to the specified CRM activities. + /// + /// The CRM activity identifiers. + /// The token to monitor for cancellation requests. + /// The work state documents that exist for the requested activities. + Task> ListByActivityIdsAsync( + IEnumerable activityItemIds, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAbandonmentPolicyService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAbandonmentPolicyService.cs new file mode 100644 index 000000000..130704ee5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAbandonmentPolicyService.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Evaluates a dialer profile against its rolling abandonment-rate cap before outbound dialing so regulated +/// campaigns cannot exceed their configured tolerance. The policy fails closed: when a cap is enforced for +/// an automated pacing mode but the statistics cannot be proven, dialing is suppressed. +/// +public interface IDialerAbandonmentPolicyService +{ + /// + /// Evaluates whether outbound dialing is permitted for a dialer profile under its abandonment policy. + /// + /// The dialer profile whose abandonment policy is evaluated. + /// A token used to cancel the operation. + /// An auditable describing the decision. + Task EvaluateAsync(DialerProfile profile, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAbandonmentStatisticsProvider.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAbandonmentStatisticsProvider.cs new file mode 100644 index 000000000..d7cbd4ff8 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAbandonmentStatisticsProvider.cs @@ -0,0 +1,23 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Supplies the rolling outbound-dialing statistics an abandonment policy needs to decide whether a dialer +/// profile stays within its configured cap. Implementations are provider-neutral and read from a durable, +/// distributed-safe source so the rate is consistent across nodes. +/// +public interface IDialerAbandonmentStatisticsProvider +{ + /// + /// Gets the rolling abandonment statistics for a dialer profile over the supplied window. + /// + /// The identifier of the dialer profile to measure. + /// The rolling window over which to measure abandonment. + /// A token used to cancel the operation. + /// + /// The measured statistics, or when the statistics cannot be determined, which + /// the policy treats as a fail-closed signal. + /// + Task GetStatisticsAsync(string dialerProfileId, TimeSpan window, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAttemptCompensationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAttemptCompensationService.cs new file mode 100644 index 000000000..83fafb5dd --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAttemptCompensationService.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Compensates an outbound dial attempt by releasing its reservation and optionally removing its queue item. +/// +public interface IDialerAttemptCompensationService +{ + /// + /// Releases the reservation and optionally removes the associated queue item. + /// + /// The reservation to release. + /// Whether to remove the associated queue item. + /// The cancellation token. + Task CompensateAsync( + ActivityReservation reservation, + bool removeFromQueue, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAttemptService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAttemptService.cs new file mode 100644 index 000000000..a998384bd --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerAttemptService.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Places a single compliant outbound dialing attempt for a reserved activity. The attempt service is +/// the only path that runs the compliance gate, records communication-history interactions, routes the +/// call through the Voice Contact Center Call Router, and audits suppressed attempts. +/// +public interface IDialerAttemptService +{ + /// + /// Attempts to dial the reserved activity, applying the compliance gate before placing the call. + /// + /// The dialer profile that governs the attempt. + /// The reservation that pairs the activity with an agent. + /// The token to monitor for cancellation requests. + /// when an outbound call was started; otherwise . + Task TryDialAsync(DialerProfile profile, ActivityReservation reservation, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerEligibilityService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerEligibilityService.cs new file mode 100644 index 000000000..dd5d5e661 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerEligibilityService.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Evaluates outbound compliance before every dialing attempt. The gate enforces do-not-call and +/// communication preferences, national do-not-call registries, calling windows, retry cool-down, and +/// attempt limits, and records an auditable suppression reason when an attempt must be blocked. +/// +public interface IDialerEligibilityService +{ + /// + /// Evaluates whether the activity in the supplied context may be dialed. + /// + /// The evaluation context containing the dialer profile and activity. + /// The token to monitor for cancellation requests. + /// The eligibility result describing whether the attempt is allowed and, if not, why. + Task EvaluateAsync(DialerEligibilityContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileManager.cs new file mode 100644 index 000000000..526b95aa5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileManager.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for dialer profiles. +/// +public interface IDialerProfileManager : ICatalogManager +{ + /// + /// Lists every enabled dialer profile. + /// + /// The token to monitor for cancellation requests. + /// The enabled dialer profiles. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); + + /// + /// Finds the dialer profile that targets the specified campaign. + /// + /// The campaign identifier. + /// The token to monitor for cancellation requests. + /// The matching dialer profile, or when none exists. + Task FindByCampaignAsync(string campaignId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileStore.cs new file mode 100644 index 000000000..645172af7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileStore.cs @@ -0,0 +1,25 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for dialer profiles. +/// +public interface IDialerProfileStore : ICatalog +{ + /// + /// Lists every enabled dialer profile. + /// + /// The token to monitor for cancellation requests. + /// The enabled dialer profiles. + Task> ListEnabledAsync(CancellationToken cancellationToken = default); + + /// + /// Finds the dialer profile that targets the specified campaign. + /// + /// The campaign identifier. + /// The token to monitor for cancellation requests. + /// The matching dialer profile, or when none exists. + Task FindByCampaignAsync(string campaignId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs new file mode 100644 index 000000000..89d08879b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Orchestrates outbound dialing: reserves agents through routing, creates communication-history +/// interactions, and asks the Voice Contact Center Call Router to place each call. +/// +public interface IDialerService +{ + /// + /// Runs one pacing cycle for the dialer profile, placing calls for as many reserved activities as pacing allows. + /// + /// The dialer profile to run. + /// The token to monitor for cancellation requests. + /// The number of outbound attempts started. + Task RunCycleAsync(DialerProfile profile, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerStrategy.cs new file mode 100644 index 000000000..a64e54411 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerStrategy.cs @@ -0,0 +1,26 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Encapsulates the pacing and reservation behavior for a single outbound dialing mode. Each safe, +/// automated dialing mode is implemented as a dedicated strategy so that unsupported modes can be +/// withheld entirely rather than falling through to an unsafe default. +/// +public interface IDialerStrategy +{ + /// + /// Gets the dialing mode this strategy implements. + /// + DialerMode Mode { get; } + + /// + /// Runs one pacing cycle for the dialer profile, reserving agents and placing calls within the + /// mode's pacing limits. + /// + /// The dialer profile to run. + /// The token to monitor for cancellation requests. + /// The number of outbound attempts started in the cycle. + Task RunCycleAsync(DialerProfile profile, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerStrategyResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerStrategyResolver.cs new file mode 100644 index 000000000..2c39ce694 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerStrategyResolver.cs @@ -0,0 +1,16 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Resolves the registered for a dialing mode. +/// +public interface IDialerStrategyResolver +{ + /// + /// Resolves the strategy that implements the specified dialing mode. + /// + /// The dialing mode to resolve. + /// The matching strategy, or when no automated strategy supports the mode. + IDialerStrategy Resolve(DialerMode mode); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IEntryPointResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IEntryPointResolver.cs new file mode 100644 index 000000000..ff724eb04 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IEntryPointResolver.cs @@ -0,0 +1,25 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Resolves the inbound entry point that serves a dialed number and produces its routing plan. +/// +public interface IEntryPointResolver +{ + /// + /// Finds the enabled entry point that serves the specified dialed number. + /// + /// The dialed number (DID) the caller reached. + /// The token to monitor for cancellation requests. + /// The matching entry point, or when none matches. + Task FindByDialedNumberAsync(string dialedNumber, CancellationToken cancellationToken = default); + + /// + /// Resolves the routing plan for the specified dialed number, evaluating business hours. + /// + /// The dialed number (DID) the caller reached. + /// The token to monitor for cancellation requests. + /// The routing plan, or when no entry point matches. + Task ResolveAsync(string dialedNumber, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInboundVoiceService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInboundVoiceService.cs new file mode 100644 index 000000000..06f926b82 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInboundVoiceService.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Orchestrates inbound voice calls. It turns a normalized into a CRM +/// activity and interaction, resolves the target queue and subject, and routes the call to an +/// available agent. Telephony remains responsible for the underlying media execution. +/// +public interface IInboundVoiceService +{ + /// + /// Handles a normalized inbound voice event end to end: creates the interaction and CRM activity, + /// enqueues the work, reserves an available agent, and offers the ringing call to that agent. + /// + /// The normalized inbound voice event. + /// The token to monitor for cancellation requests. + /// The routing outcome describing the created records and the offered agent. + Task HandleInboundAsync(InboundVoiceEvent inboundEvent, CancellationToken cancellationToken = default); + + /// + /// Reserves the next available agent for the queue and offers the queued inbound call to that + /// agent. Used to route a call initially and to re-offer it after an agent declines. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The identifier of the user the call was offered to, or when no agent is available. + Task OfferNextAsync(string queueId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs new file mode 100644 index 000000000..fd14254de --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs @@ -0,0 +1,63 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for the durable interaction event history. +/// +public interface IInteractionEventStore : ICatalog +{ + /// + /// Lists the events recorded for the specified interaction, oldest first. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The ordered list of events for the interaction. + Task> ListByInteractionAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Determines whether an event with the specified idempotency key has already been recorded. + /// + /// The idempotency key to check. + /// The token to monitor for cancellation requests. + /// when a matching event exists; otherwise, . + Task ExistsByIdempotencyKeyAsync(string idempotencyKey, CancellationToken cancellationToken = default); + + /// + /// Lists a bounded batch of events that occurred strictly before the supplied cutoff, oldest first. + /// + /// The exclusive UTC cutoff; events older than this are returned. + /// The maximum number of events to return. + /// The token to monitor for cancellation requests. + /// The batch of expired events. + Task> ListOlderThanAsync(DateTime cutoffUtc, int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists the events of the supplied types recorded against the supplied aggregate type up to and including + /// the supplied instant, oldest first. Reporting reads the event log through this method rather than + /// querying it directly so that a stored payload written by an earlier release is brought to the current + /// schema version before the report deserializes it. + /// + /// The aggregate type the events were recorded against. + /// The event types to include. When empty, every event type is included. + /// The inclusive UTC upper bound on occurrence time. + /// The token to monitor for cancellation requests. + /// The matching events, oldest first. + Task> ListByAggregateTypeAsync( + string aggregateType, + IEnumerable eventTypes, + DateTime occurredThroughUtc, + CancellationToken cancellationToken = default); + + /// + /// Lists a page of events ordered deterministically by occurrence time then identifier. It is the + /// forward-only enumeration used to replay the entire event log during a projection rebuild or drift + /// check; callers page until fewer than events are returned. + /// + /// The number of events to skip. + /// The maximum number of events to return. + /// The token to monitor for cancellation requests. + /// The requested page of events, oldest first. + Task> ListOrderedPageAsync(int skip, int take, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventUpcastService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventUpcastService.cs new file mode 100644 index 000000000..896f0bbad --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventUpcastService.cs @@ -0,0 +1,22 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Brings a persisted Contact Center domain event up to the schema version the running code understands, +/// before any caller reads its payload. +/// +public interface IInteractionEventUpcastService +{ + /// + /// Converts the event's payload to in place, + /// applying one registered per version step. + /// + /// The event read from storage. + /// + /// Thrown when the event was written by a newer release than the one reading it, or when no upcaster is + /// registered for a version step the event has to cross. Both cases mean the payload cannot be interpreted, + /// and returning it unconverted would hand a caller a payload it would silently misread. + /// + void Upcast(InteractionEvent interactionEvent); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventUpcaster.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventUpcaster.cs new file mode 100644 index 000000000..60062a3d9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventUpcaster.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Nodes; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Converts the payload of a persisted Contact Center domain event from one schema version to the next. +/// A durable event log outlives the code that wrote it: an event published months ago is redelivered by the +/// outbox, replayed by a projection, and read by a report long after its payload shape has changed. Without a +/// conversion the stored JSON is deserialized straight into today's type, so a renamed, split, or re-united +/// property arrives as its default value and the handler acts on a payload that is quietly wrong rather than +/// failing. One implementation converts exactly one version step for one event type, and the steps are chained +/// until the payload reaches . +/// +public interface IInteractionEventUpcaster +{ + /// + /// Gets the canonical event type this upcaster converts, or to convert the payload of + /// every event type at . See . + /// + string EventType { get; } + + /// + /// Gets the schema version this upcaster converts from. It always produces the payload at + /// plus one, because a step that skipped a version would leave a hole no other + /// upcaster could be written to fill. + /// + int FromVersion { get; } + + /// + /// Converts a payload written at into the payload shape of the next version. + /// + /// The stored payload, or when the event carries no payload. + /// The converted payload, or when the event carries no payload. + JsonNode Upcast(JsonNode payload); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs new file mode 100644 index 000000000..8256ebbb4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs @@ -0,0 +1,119 @@ +using CrestApps.Core.Models; +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for interactions. +/// +public interface IInteractionManager : ICatalogManager +{ + /// + /// Finds the interaction linked to the specified CRM activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Finds the interaction with the specified correlation identifier. + /// + /// The correlation identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent interaction with the specified provider interaction or call identifier. + /// + /// The provider interaction or call identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByProviderInteractionIdAsync(string providerInteractionId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent interaction with the specified provider and provider interaction or call identifier. + /// + /// The provider technical name. + /// The provider interaction or call identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByProviderInteractionIdAsync(string providerName, string providerInteractionId, CancellationToken cancellationToken = default); + + /// + /// Pages interactions that are currently in the specified status. + /// + /// The page number to load. + /// The number of entries per page. + /// The status to filter by. + /// The token to monitor for cancellation requests. + /// The page of matching interactions. + Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default); + + /// + /// Counts the active (not ended and not failed) interactions currently connected to the specified agent. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The number of active interactions for the agent. + Task CountActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Counts active interactions for the specified agents. + /// + /// The agent profile identifiers. + /// The token to monitor for cancellation requests. + /// The active interaction count keyed by agent identifier. + Task> CountActiveByAgentIdsAsync( + IReadOnlyCollection agentIds, + CancellationToken cancellationToken = default); + + /// + /// Finds the most recent live interaction the specified agent is currently handling. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The active interaction, or when the agent is not on a live interaction. + Task FindActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Lists interactions whose after-call work is still incomplete for the specified agent. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The pending wrap-up interactions, newest first. + Task> ListPendingWrapUpsByAgentAsync( + string agentId, + CancellationToken cancellationToken = default); + + /// + /// Lists the most recent interactions handled by the specified agent, newest first. + /// + /// The agent profile identifier. + /// The maximum number of interactions to return. + /// The token to monitor for cancellation requests. + /// The agent's most recent interactions. + Task> ListRecentByAgentAsync(string agentId, int take, CancellationToken cancellationToken = default); + + /// + /// Lists active interactions that still carry a provider call identifier and therefore can be + /// revalidated against the telephony server, oldest first and bounded for reconciliation sweeps. + /// + /// The maximum number of interactions to return. + /// The token to monitor for cancellation requests. + /// The oldest active provider-backed interactions bounded by . + Task> ListActiveWithProviderCallIdAsync(int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists active interactions for the specified provider that still carry a provider call identifier, + /// oldest first and bounded for reconciliation sweeps. + /// + /// The provider technical name. + /// The maximum number of interactions to return. + /// The token to monitor for cancellation requests. + /// The oldest active provider-backed interactions bounded by . + Task> ListActiveWithProviderCallIdAsync(string providerName, int maxCount, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs new file mode 100644 index 000000000..a466d2e31 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs @@ -0,0 +1,119 @@ +using CrestApps.Core.Models; +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for interactions. +/// +public interface IInteractionStore : ICatalog +{ + /// + /// Finds the interaction linked to the specified CRM activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Finds the interaction with the specified correlation identifier. + /// + /// The correlation identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent interaction with the specified provider interaction or call identifier. + /// + /// The provider interaction or call identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByProviderInteractionIdAsync(string providerInteractionId, CancellationToken cancellationToken = default); + + /// + /// Finds the most recent interaction with the specified provider and provider interaction or call identifier. + /// + /// The provider technical name. + /// The provider interaction or call identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + Task FindByProviderInteractionIdAsync(string providerName, string providerInteractionId, CancellationToken cancellationToken = default); + + /// + /// Pages interactions that are currently in the specified status, oldest first. + /// + /// The page number to load. + /// The number of entries per page. + /// The status to filter by. + /// The token to monitor for cancellation requests. + /// The page of matching interactions. + Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default); + + /// + /// Counts the active (not ended and not failed) interactions currently connected to the specified agent. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The number of active interactions for the agent. + Task CountActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Counts active interactions for the specified agents. + /// + /// The agent profile identifiers. + /// The token to monitor for cancellation requests. + /// The active interaction count keyed by agent identifier. + Task> CountActiveByAgentIdsAsync( + IReadOnlyCollection agentIds, + CancellationToken cancellationToken = default); + + /// + /// Finds the most recent live interaction the specified agent is currently handling. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The active interaction, or when the agent is not on a live interaction. + Task FindActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default); + + /// + /// Lists interactions whose after-call work is still incomplete for the specified agent. + /// + /// The agent profile identifier. + /// The token to monitor for cancellation requests. + /// The pending wrap-up interactions, newest first. + Task> ListPendingWrapUpsByAgentAsync( + string agentId, + CancellationToken cancellationToken = default); + + /// + /// Lists the most recent interactions handled by the specified agent, newest first. + /// + /// The agent profile identifier. + /// The maximum number of interactions to return. + /// The token to monitor for cancellation requests. + /// The agent's most recent interactions. + Task> ListRecentByAgentAsync(string agentId, int take, CancellationToken cancellationToken = default); + + /// + /// Lists active interactions that still carry a provider call identifier and therefore can be + /// revalidated against the telephony server, oldest first and bounded for reconciliation sweeps. + /// + /// The maximum number of interactions to return. + /// The token to monitor for cancellation requests. + /// The oldest active provider-backed interactions bounded by . + Task> ListActiveWithProviderCallIdAsync(int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists active interactions for the specified provider that still carry a provider call identifier, + /// oldest first and bounded for reconciliation sweeps. + /// + /// The provider technical name. + /// The maximum number of interactions to return. + /// The token to monitor for cancellation requests. + /// The oldest active provider-backed interactions bounded by . + Task> ListActiveWithProviderCallIdAsync(string providerName, int maxCount, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs new file mode 100644 index 000000000..456a36789 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Synchronizes Contact Center interaction state with authoritative provider call state. +/// +public interface IProviderCallStateSynchronizationService +{ + /// + /// Refreshes the specified interaction from the provider's current call state when the provider + /// supports state lookups. + /// + /// The interaction to refresh. + /// The token to monitor for cancellation requests. + /// The refreshed interaction. + Task RefreshInteractionAsync(Interaction interaction, CancellationToken cancellationToken = default); + + /// + /// Reconciles all active provider-backed interactions against the current provider state. + /// + /// The token to monitor for cancellation requests. + /// The number of interactions whose provider state was refreshed. + Task ReconcileActiveInteractionsAsync(CancellationToken cancellationToken = default); + + /// + /// Reconciles active provider-backed interactions for the specified provider against its current call state. + /// + /// The provider technical name. + /// The token to monitor for cancellation requests. + /// The number of interactions whose provider state was refreshed. + Task ReconcileProviderInteractionsAsync(string providerName, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandDispatchValidator.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandDispatchValidator.cs new file mode 100644 index 000000000..085b5a1ce --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandDispatchValidator.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Revalidates policy that may have changed after a pending provider command was persisted but before crash +/// recovery attempts to dispatch it. +/// +public interface IProviderCommandDispatchValidator +{ + /// + /// Determines whether a pending command may still be dispatched. + /// + /// The pending command being recovered. + /// The token to monitor for cancellation requests. + /// when dispatch remains allowed; otherwise, . + Task CanDispatchAsync(ProviderCommand command, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandManager.cs new file mode 100644 index 000000000..a1c15cad5 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandManager.cs @@ -0,0 +1,36 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for provider commands. +/// +public interface IProviderCommandManager : ICatalogManager +{ + /// + /// Finds a command by its stable idempotency key. + /// + /// The stable idempotency key. + /// The token to monitor for cancellation requests. + /// The command, or when none exists. + Task FindByCommandIdAsync(string commandId, CancellationToken cancellationToken = default); + + /// + /// Lists the commands that are due for dispatch, reconciliation, or compensation recovery, ordered by their due time. + /// + /// The current UTC time. + /// The maximum number of commands to return. + /// The token to monitor for cancellation requests. + /// The commands whose next attempt time has elapsed. + Task> ListDueAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists the claimed or sent commands whose lease has expired and can be reclaimed. + /// + /// The current UTC time. + /// The maximum number of commands to return. + /// The token to monitor for cancellation requests. + /// The commands whose lease has expired. + Task> ListReclaimableAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandProcessor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandProcessor.cs new file mode 100644 index 000000000..d04bd933d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandProcessor.cs @@ -0,0 +1,58 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Dispatches durable provider commands and safely recovers commands that require dispatch, reconciliation, +/// or compensation. +/// +public interface IProviderCommandProcessor +{ + /// + /// Processes the command identified by its stable idempotency key. + /// + /// The stable idempotency key of the command to process. + /// The token to monitor for cancellation requests. + /// The command after its attempted processing. + Task DispatchAsync(string commandId, CancellationToken cancellationToken = default); + + /// + /// Settles a provider dispatch response in the current fresh tenant scope after validating the claim fence. + /// + /// The stable idempotency key of the command. + /// The ownership claim that sent the provider request. + /// The provider response, or when no reliable response was returned. + /// The durable reason used when the result cannot prove a terminal outcome. + /// The token to monitor for cancellation requests. + /// The command after fenced settlement. + Task SettleDispatchAsync( + string commandId, + ProviderCommandClaim claim, + ContactCenterVoiceProviderResult result, + string outcomeUnknownReason, + CancellationToken cancellationToken = default); + + /// + /// Settles a provider reconciliation response in the current fresh tenant scope after validating the claim fence. + /// + /// The stable idempotency key of the command. + /// The ownership claim that performed reconciliation. + /// The provider reconciliation response, or when no reliable response was returned. + /// The durable reason used when reconciliation cannot prove a terminal outcome. + /// The token to monitor for cancellation requests. + /// The command after fenced settlement. + Task SettleReconciliationAsync( + string commandId, + ProviderCommandClaim claim, + ContactCenterVoiceCommandReconciliationResult result, + string inconclusiveReason, + CancellationToken cancellationToken = default); + + /// + /// Escalates expired leases and processes commands due for dispatch, reconciliation, or compensation. + /// + /// The token to monitor for cancellation requests. + /// The number of due commands whose processing was attempted. + Task RecoverDueAsync(CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandStateService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandStateService.cs new file mode 100644 index 000000000..e4c62dd8a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandStateService.cs @@ -0,0 +1,198 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Coordinates the durable provider-command state machine. Every transition is validated against the legal +/// transition graph and, where required, against the command's fence token and owner token so a superseded +/// worker, a lost provider response, or a retry can never cause a duplicate customer action. A command whose +/// outcome cannot be proven is paused rather than re-sent. +/// +public interface IProviderCommandStateService +{ + /// + /// Registers a new command in the state, or returns the + /// existing command when one already exists for the same idempotency key. + /// + /// The command registration intent. + /// The token to monitor for cancellation requests. + /// The registered or pre-existing command. + Task RegisterAsync(ProviderCommandRegistration registration, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire a fenced, exclusive lease over the command so it can be sent to the provider. + /// A pending command, or a claimed command whose lease has expired, can be claimed. + /// + /// The stable idempotency key. + /// The duration the acquired lease remains valid. + /// The token to monitor for cancellation requests. + /// The acquired claim, or when the command cannot be claimed. + Task TryClaimAsync(string commandId, TimeSpan leaseDuration, CancellationToken cancellationToken = default); + + /// + /// Marks a claimed command as sent to the provider. The presented claim must match the command's current + /// fence and owner tokens. + /// + /// The stable idempotency key. + /// The claim held by the caller. + /// The provider-assigned reference, when already known. + /// The token to monitor for cancellation requests. + /// The updated command. + Task MarkSentAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default); + + /// + /// Returns a sent command to pending when the provider adapter proves no provider call was attempted because its feature is quiescing. + /// + /// The stable command identifier. + /// The current owner and fence claim. + /// The reason dispatch was deferred. + /// The token to monitor for cancellation requests. + /// The deferred pending command. + Task DeferSentAsync( + string commandId, + ProviderCommandClaim claim, + string reason, + CancellationToken cancellationToken = default); + + /// + /// Confirms that a sent command executed. The presented claim must match the command's current fence and + /// owner tokens. + /// + /// The stable idempotency key. + /// The claim held by the caller. + /// The provider-assigned reference captured on confirmation. + /// The token to monitor for cancellation requests. + /// The confirmed command. + Task ConfirmSentAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default); + + /// + /// Stages confirmation of a sent command without committing the tenant session so outcome projections can + /// be persisted in the same database transaction. + /// + /// The stable idempotency key. + /// The claim held by the caller. + /// The provider-assigned reference captured on confirmation. + /// The token to monitor for cancellation requests. + /// The staged confirmed command. + Task StageConfirmSentAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default); + + /// + /// Marks a sent command as having an unknown outcome because the provider response was lost or ambiguous. + /// The command must be reconciled before any retry and is never re-sent directly from this state. + /// + /// The stable idempotency key. + /// The claim held by the caller. + /// The reason the outcome is unknown. + /// The token to monitor for cancellation requests. + /// The updated command. + Task MarkOutcomeUnknownAsync(string commandId, ProviderCommandClaim claim, string reason = null, CancellationToken cancellationToken = default); + + /// + /// Stages an unknown provider outcome without committing the tenant session so related projections can be + /// persisted in the same database transaction. + /// + /// The stable idempotency key. + /// The claim held by the caller. + /// The reason the outcome is unknown. + /// The token to monitor for cancellation requests. + /// The staged command. + Task StageOutcomeUnknownAsync(string commandId, ProviderCommandClaim claim, string reason = null, CancellationToken cancellationToken = default); + + /// + /// Escalates a command whose lease has expired. A sent command becomes + /// so it is reconciled before retry, while a claimed + /// command that was never sent returns to . + /// + /// The stable idempotency key. + /// The token to monitor for cancellation requests. + /// The updated command. + Task EscalateExpiredLeaseAsync(string commandId, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire the exclusive lease used for provider reconciliation. + /// + /// The stable idempotency key. + /// The duration of the reconciliation lease. + /// The token to monitor for cancellation requests. + /// The acquired claim, or when another worker owns reconciliation. + Task TryClaimReconciliationAsync(string commandId, TimeSpan leaseDuration, CancellationToken cancellationToken = default); + + /// + /// Confirms a command from reconciliation while the caller owns the reconciliation fence. + /// + /// The stable idempotency key. + /// The reconciliation claim held by the caller. + /// The provider-assigned reference captured during reconciliation. + /// The token to monitor for cancellation requests. + /// The confirmed command. + Task ConfirmFromReconciliationAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default); + + /// + /// Stages reconciliation confirmation without committing the tenant session so outcome projections can be + /// persisted in the same database transaction. + /// + /// The stable idempotency key. + /// The reconciliation claim held by the caller. + /// The provider-assigned reference captured during reconciliation. + /// The token to monitor for cancellation requests. + /// The staged confirmed command. + Task StageConfirmFromReconciliationAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default); + + /// + /// Begins compensating a pending command whose request cannot be dispatched. + /// + /// The stable idempotency key. + /// The reason compensation is required. + /// The token to monitor for cancellation requests. + /// The updated command. + Task BeginPendingCompensationAsync(string commandId, string reason = null, CancellationToken cancellationToken = default); + + /// + /// Begins compensation while the caller still owns the dispatch or reconciliation fence. + /// + /// The stable idempotency key. + /// The claim held by the caller. + /// The reason compensation is required. + /// The token to monitor for cancellation requests. + /// The compensating command. + Task BeginCompensationAsync(string commandId, ProviderCommandClaim claim, string reason = null, CancellationToken cancellationToken = default); + + /// + /// Attempts to acquire a fenced, exclusive lease for a command that is awaiting compensation. + /// + /// The stable idempotency key. + /// The duration the acquired compensation lease remains valid. + /// The token to monitor for cancellation requests. + /// The acquired claim, or when compensation is already owned. + Task TryClaimCompensationAsync(string commandId, TimeSpan leaseDuration, CancellationToken cancellationToken = default); + + /// + /// Completes compensation, moving the command to the terminal + /// state. + /// + /// The stable idempotency key. + /// The compensation claim held by the caller. + /// The token to monitor for cancellation requests. + /// The compensated command. + Task CompleteCompensationAsync(string commandId, ProviderCommandClaim claim, CancellationToken cancellationToken = default); + + /// + /// Pauses reconciliation while the caller owns the reconciliation fence. + /// + /// The stable idempotency key. + /// The reconciliation claim held by the caller. + /// The reason automated processing cannot continue safely. + /// The token to monitor for cancellation requests. + /// The paused command. + Task PauseAsync(string commandId, ProviderCommandClaim claim, string reason = null, CancellationToken cancellationToken = default); + + /// + /// Fails a command definitively, moving it to the terminal + /// state. + /// + /// The stable idempotency key. + /// The reason the command failed. + /// The token to monitor for cancellation requests. + /// The failed command. + Task FailAsync(string commandId, string reason = null, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandStore.cs new file mode 100644 index 000000000..5953bd161 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandStore.cs @@ -0,0 +1,36 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the durable persistence contract for provider commands. +/// +public interface IProviderCommandStore : ICatalog +{ + /// + /// Finds a command by its stable idempotency key. + /// + /// The stable idempotency key. + /// The token to monitor for cancellation requests. + /// The command, or when none exists. + Task FindByCommandIdAsync(string commandId, CancellationToken cancellationToken = default); + + /// + /// Lists the commands that are due for dispatch, reconciliation, or compensation recovery, ordered by their due time. + /// + /// The current UTC time. + /// The maximum number of commands to return. + /// The token to monitor for cancellation requests. + /// The commands whose next attempt time has elapsed. + Task> ListDueAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default); + + /// + /// Lists the claimed or sent commands whose lease has expired and can be reclaimed. + /// + /// The current UTC time. + /// The maximum number of commands to return. + /// The token to monitor for cancellation requests. + /// The commands whose lease has expired. + Task> ListReclaimableAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandTypeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandTypeExecutor.cs new file mode 100644 index 000000000..c6c519c20 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCommandTypeExecutor.cs @@ -0,0 +1,69 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Handles provider-type-specific dispatch logic for a single . +/// The processor owns the state machine, claims, reconciliation, compensation, and session commit; +/// the executor owns request preparation, provider execution, and outcome projections. +/// +public interface IProviderCommandTypeExecutor +{ + /// + /// Gets the this executor handles. + /// + ProviderCommandType CommandType { get; } + + /// + /// Determines whether the command may still be dispatched to the provider. + /// + /// The pending command to evaluate. + /// The token to monitor for cancellation requests. + /// + /// when dispatch is still allowed; otherwise, . + /// + Task CanDispatchAsync(ProviderCommand command, CancellationToken cancellationToken = default); + + /// + /// Prepares and submits the command to the provider, returning the raw provider result. + /// The state machine claim has already been recorded as sent before this call. + /// + /// The command to execute. + /// The active claim holding the fence and owner tokens. + /// The token to monitor for cancellation requests. + /// The provider result, or when the outcome cannot be determined. + Task ExecuteAsync( + ProviderCommand command, + ProviderCommandClaim claim, + CancellationToken cancellationToken = default); + + /// + /// Projects a confirmed success outcome onto any domain entities linked to the command. + /// + /// The confirmed command. + /// The successful provider result that triggered confirmation. + /// The token to monitor for cancellation requests. + Task ProjectSuccessAsync( + ProviderCommand command, + ContactCenterVoiceProviderResult result, + CancellationToken cancellationToken = default); + + /// + /// Projects a definitive failure outcome onto any domain entities linked to the command. + /// + /// The command that is being compensated. + /// The token to monitor for cancellation requests. + Task ProjectFailureAsync(ProviderCommand command, CancellationToken cancellationToken = default); + + /// + /// Projects an unknown outcome onto any domain entities linked to the command. + /// + /// The command whose outcome is unknown. + /// The provider or internal error code describing the uncertainty. + /// The token to monitor for cancellation requests. + Task ProjectOutcomeUnknownAsync( + ProviderCommand command, + string errorCode, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceEventService.cs new file mode 100644 index 000000000..611316580 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceEventService.cs @@ -0,0 +1,23 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Ingests normalized instances from telephony providers and PBX +/// webhooks. It is the single seam through which provider call-state changes update the matching +/// interaction and call session, keeping orchestration and analytics in sync regardless of provider. +/// +public interface IProviderVoiceEventService +{ + /// + /// Ingests a normalized provider voice event: matches it to the interaction and call session by + /// provider call identifier, advances their normalized state and timestamps, bridges the agent for + /// answered outbound calls on server-side ACD providers, and publishes the corresponding domain + /// events. Events carrying an already-seen idempotency key are ignored. + /// + /// The normalized provider voice event. + /// The token to monitor for cancellation requests. + /// The updated or created call session, or when no interaction matched. + Task IngestAsync(ProviderVoiceEvent providerEvent, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceOfferSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceOfferSynchronizationService.cs new file mode 100644 index 000000000..99a404357 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceOfferSynchronizationService.cs @@ -0,0 +1,16 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Reconciles queue, reservation, and agent offer state when provider truth shows that a pre-connect +/// voice offer has already ended. +/// +public interface IProviderVoiceOfferSynchronizationService +{ + /// + /// Clears stale pre-connect offer state for the specified interaction when the provider has already + /// ended the call before it was authoritatively connected. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + Task ReconcileEndedOfferAsync(string interactionId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderWebhookInboxStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderWebhookInboxStore.cs new file mode 100644 index 000000000..0f4bb218a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderWebhookInboxStore.cs @@ -0,0 +1,63 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for durable provider webhook inbox messages. +/// +public interface IProviderWebhookInboxStore : ICatalog +{ + /// + /// Finds a message by its canonical provider and provider-scoped delivery identifier. + /// + /// The canonical provider technical name. + /// The provider-scoped delivery identifier. + /// The token to monitor for cancellation requests. + /// The matching message, or when none exists. + Task FindByDeliveryAsync( + string providerName, + string deliveryId, + CancellationToken cancellationToken = default); + + /// + /// Lists pending messages due for processing, oldest first. + /// + /// The current UTC time. + /// The maximum number of messages to return. + /// The token to monitor for cancellation requests. + /// The due inbox messages. + Task> ListDueAsync( + DateTime nowUtc, + int maxCount, + CancellationToken cancellationToken = default); + + /// + /// Counts the messages currently in the supplied processing state. + /// + /// The processing state to count. + /// The token to monitor for cancellation requests. + /// The number of messages in the supplied state. + Task CountByStatusAsync(ProviderWebhookInboxStatus status, CancellationToken cancellationToken = default); + + /// + /// Counts the pending or claimed messages whose next attempt is already due at or before the supplied time. + /// A sustained non-zero result indicates provider ingress is not draining fast enough. + /// + /// The current UTC time used to select overdue messages. + /// The token to monitor for cancellation requests. + /// The number of overdue messages. + Task CountOverdueAsync(DateTime nowUtc, CancellationToken cancellationToken = default); + + /// + /// Lists processed tombstones that are older than the supplied retention cutoff. + /// + /// The exclusive UTC cutoff for retained tombstones. + /// The maximum number of tombstones to return. + /// The token to monitor for cancellation requests. + /// The processed tombstones eligible for deletion. + Task> ListProcessedBeforeAsync( + DateTime processedBeforeUtc, + int maxCount, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemManager.cs new file mode 100644 index 000000000..22109a03c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemManager.cs @@ -0,0 +1,68 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the management contract for queue items. +/// +public interface IQueueItemManager : ICatalogManager +{ + /// + /// Lists the items waiting in the specified queue, highest priority and oldest first. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The waiting items ordered for routing. + Task> ListWaitingAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Finds the active queue item for the specified activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The matching queue item, or when none exists. + Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Counts the items currently waiting in the specified queue using an aggregate query, without + /// materializing the waiting rows. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The number of waiting items in the queue. + Task CountWaitingAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Counts the items currently waiting in each of the specified queues using a single aggregate query. + /// Queues with no waiting items are absent from the result rather than present with a zero. + /// + /// The queue identifiers to count. + /// The token to monitor for cancellation requests. + /// The number of waiting items keyed by queue identifier. + Task> CountWaitingByQueueIdsAsync( + IReadOnlyCollection queueIds, + CancellationToken cancellationToken = default); + + /// + /// Counts the items waiting in the specified queue that were enqueued before the threshold, using an + /// aggregate query. This supports SLA-breach counting without loading the waiting rows. + /// + /// The queue identifier. + /// The UTC instant before which a waiting item is counted. + /// The token to monitor for cancellation requests. + /// The number of waiting items enqueued before . + Task CountWaitingOlderThanAsync( + string queueId, + DateTime thresholdUtc, + CancellationToken cancellationToken = default); + + /// + /// Finds the single oldest waiting item in the specified queue using a bounded top-one query, without + /// loading the whole waiting backlog. This supports longest-wait measurement. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The oldest waiting item, or when the queue has none waiting. + Task FindLongestWaitingAsync(string queueId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemStore.cs new file mode 100644 index 000000000..ae048217b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemStore.cs @@ -0,0 +1,76 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Defines the persistence contract for queue items. +/// +public interface IQueueItemStore : ICatalog +{ + /// + /// Lists the items waiting in the specified queue, highest priority and oldest first. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The waiting items ordered for routing. + Task> ListWaitingAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Finds the active queue item for the specified activity. + /// + /// The CRM activity identifier. + /// The token to monitor for cancellation requests. + /// The matching queue item, or when none exists. + Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default); + + /// + /// Counts the items currently waiting in the specified queue using an aggregate query, without + /// materializing the waiting rows. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The number of waiting items in the queue. + Task CountWaitingAsync(string queueId, CancellationToken cancellationToken = default); + + /// + /// Counts the items currently waiting across every queue using an aggregate query, without materializing the + /// waiting rows. This is the tenant-wide queued-interaction backlog an operator weighs before draining a node. + /// + /// The token to monitor for cancellation requests. + /// The total number of waiting items across all queues. + Task CountAllWaitingAsync(CancellationToken cancellationToken = default); + + /// + /// Counts the items currently waiting in each of the specified queues using a single aggregate query. + /// Queues with no waiting items are absent from the result rather than present with a zero. + /// + /// The queue identifiers to count. + /// The token to monitor for cancellation requests. + /// The number of waiting items keyed by queue identifier. + Task> CountWaitingByQueueIdsAsync( + IReadOnlyCollection queueIds, + CancellationToken cancellationToken = default); + + /// + /// Counts the items waiting in the specified queue that were enqueued before the threshold, using an + /// aggregate query. This supports SLA-breach counting without loading the waiting rows. + /// + /// The queue identifier. + /// The UTC instant before which a waiting item is counted. + /// The token to monitor for cancellation requests. + /// The number of waiting items enqueued before . + Task CountWaitingOlderThanAsync( + string queueId, + DateTime thresholdUtc, + CancellationToken cancellationToken = default); + + /// + /// Finds the single oldest waiting item in the specified queue using a bounded top-one query, without + /// loading the whole waiting backlog. This supports longest-wait measurement. + /// + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// The oldest waiting item, or when the queue has none waiting. + Task FindLongestWaitingAsync(string queueId, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IRecordingAccessGovernanceService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IRecordingAccessGovernanceService.cs new file mode 100644 index 000000000..2d76d2eb7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IRecordingAccessGovernanceService.cs @@ -0,0 +1,41 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Governs post-capture access to recordings. It records the recording access audit trail and enforces the +/// right-to-erasure policy at the orchestration layer, delegating actual media deletion to the owning media store. +/// +public interface IRecordingAccessGovernanceService +{ + /// + /// Records that a captured recording was accessed or retrieved, appending an entry to the recording access + /// audit trail. + /// + /// The identifier of the interaction whose recording was accessed. + /// The identifier of the actor that accessed the recording. + /// The stated purpose for accessing the recording. + /// The token to monitor for cancellation requests. + /// when the access was audited; otherwise, when the interaction has no captured recording to access. + Task RecordAccessAsync( + string interactionId, + string actorId, + string purpose, + CancellationToken cancellationToken = default); + + /// + /// Erases the captured recording reference for an interaction in response to a right-to-erasure request, + /// unless the recording is under legal hold. Actual media deletion is delegated to the owning media store + /// through the published erasure event. + /// + /// The identifier of the interaction whose recording should be erased. + /// The identifier of the actor that requested erasure. + /// The stated reason for the erasure request. + /// The token to monitor for cancellation requests. + /// A decision describing whether the recording reference was erased or why erasure was denied. + Task EraseAsync( + string interactionId, + string actorId, + string reason, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IRecordingGovernancePolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IRecordingGovernancePolicy.cs new file mode 100644 index 000000000..534f32992 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IRecordingGovernancePolicy.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Evaluates the tenant recording governance policy that gates whether a voice interaction may be recorded and +/// resolves the retention and legal-hold metadata to apply when recording is captured. +/// +public interface IRecordingGovernancePolicy +{ + /// + /// Evaluates whether recording may start or resume for the specified interaction under the tenant policy. + /// + /// The interaction whose recording is being evaluated. + /// The token to monitor for cancellation requests. + /// A decision describing whether recording is permitted and, when permitted, the retention and legal-hold metadata to apply. + Task EvaluateStartAsync(Interaction interaction, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ISupervisorQueueAuthorizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ISupervisorQueueAuthorizationService.cs new file mode 100644 index 000000000..74e3358c0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ISupervisorQueueAuthorizationService.cs @@ -0,0 +1,23 @@ +using System.Security.Claims; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Authorizes supervisor access to queue-scoped Contact Center data and operations. +/// +public interface ISupervisorQueueAuthorizationService +{ + /// + /// Determines whether the supervisor can access a queue. + /// + /// The authenticated principal. + /// The Orchard user identifier. + /// The queue identifier. + /// The token to monitor for cancellation requests. + /// when access is allowed; otherwise . + Task IsAuthorizedAsync( + ClaimsPrincipal principal, + string userId, + string queueId, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ITransferDestinationResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ITransferDestinationResolver.cs new file mode 100644 index 000000000..c59cc6fca --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ITransferDestinationResolver.cs @@ -0,0 +1,22 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Resolves typed, logical transfer destinations into provider-safe endpoints. +/// +public interface ITransferDestinationResolver +{ + /// + /// Resolves and authorizes a transfer destination. + /// + /// The transfer request containing a typed logical destination. + /// The authenticated principal initiating the transfer. + /// The token to monitor for cancellation requests. + /// The resolved destination result. + Task ResolveAsync( + TransferRequest request, + ClaimsPrincipal principal, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IVoiceContactCenterCallRouter.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IVoiceContactCenterCallRouter.cs new file mode 100644 index 000000000..48085cedf --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IVoiceContactCenterCallRouter.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Routes inbound and outbound voice calls for the Contact Center while keeping provider-specific media +/// execution in Telephony or PBX provider modules. +/// +public interface IVoiceContactCenterCallRouter +{ + /// + /// Determines whether an outbound voice call can be routed through the configured provider. + /// + /// The optional provider technical name. + /// when an outbound voice provider is available; otherwise . + bool CanRouteOutbound(string providerName = null); + + /// + /// Resolves the outbound provider technical name that would be used for a route. + /// + /// The optional provider technical name. + /// The resolved provider technical name, or when no provider is available. + string GetOutboundProviderName(string providerName = null); + + /// + /// Routes a normalized inbound voice event into Contact Center work. + /// + /// The normalized inbound voice event. + /// The token to monitor for cancellation requests. + /// The inbound routing result. + Task RouteInboundAsync(InboundVoiceEvent inboundEvent, CancellationToken cancellationToken = default); + + /// + /// Routes an outbound voice dial request to a Contact Center voice provider. + /// + /// The outbound voice dial request. + /// The optional provider technical name. + /// The token to monitor for cancellation requests. + /// The voice provider operation result. + Task RouteOutboundAsync(ContactCenterDialRequest request, string providerName = null, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InboundVoiceEventSink.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InboundVoiceEventSink.cs new file mode 100644 index 000000000..4a23e3a68 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InboundVoiceEventSink.cs @@ -0,0 +1,35 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Adapts provider-facing inbound voice events to the Contact Center voice router. +/// +public sealed class InboundVoiceEventSink : IInboundVoiceEventSink +{ + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; + + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center voice call router. + public InboundVoiceEventSink(IVoiceContactCenterCallRouter voiceCallRouter) + { + _voiceCallRouter = voiceCallRouter; + } + + /// + public async Task RouteAsync( + InboundVoiceEvent inboundEvent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(inboundEvent); + + var result = await _voiceCallRouter.RouteInboundAsync(inboundEvent, cancellationToken); + + return new InboundVoiceRouteOutcome + { + InteractionId = result?.InteractionId, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InboundVoiceInteractionProbe.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InboundVoiceInteractionProbe.cs new file mode 100644 index 000000000..81e0d2649 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InboundVoiceInteractionProbe.cs @@ -0,0 +1,39 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Determines whether a durable, still-active Contact Center interaction exists for a provider call. +/// +public sealed class InboundVoiceInteractionProbe : IInboundVoiceInteractionProbe +{ + private readonly IInteractionManager _interactionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager used to resolve provider calls. + public InboundVoiceInteractionProbe(IInteractionManager interactionManager) + { + _interactionManager = interactionManager; + } + + /// + public async Task HasActiveInteractionAsync( + string providerName, + string providerCallId, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(providerCallId)) + { + return false; + } + + var interaction = string.IsNullOrWhiteSpace(providerName) + ? await _interactionManager.FindByProviderInteractionIdAsync(providerCallId, cancellationToken) + : await _interactionManager.FindByProviderInteractionIdAsync(providerName, providerCallId, cancellationToken); + + return interaction is not null && + interaction.Status is not (InteractionStatus.Ended or InteractionStatus.Failed); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs new file mode 100644 index 000000000..b959f1999 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs @@ -0,0 +1,136 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; +using YesSql.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class InteractionEventStore : DocumentCatalog, IInteractionEventStore +{ + private readonly IInteractionEventUpcastService _upcastService; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The service that brings a stored event to the current schema version. + public InteractionEventStore( + ISession session, + IInteractionEventUpcastService upcastService) + : base(session) + { + _upcastService = upcastService; + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + /// Brings every event read through this store to the current schema version. The conversion belongs here + /// rather than at each caller because the durable event log is read from several places — post-commit + /// dispatch, outbox redelivery, projection replay and reporting — and a caller that forgot to convert would + /// not fail, it would read a stale payload as though it were current. + /// + /// The event read from storage. + /// A completed task. + protected override ValueTask LoadingAsync(InteractionEvent record) + { + _upcastService.Upcast(record); + + return ValueTask.CompletedTask; + } + + /// + public async Task> ListByInteractionAsync(string interactionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(interactionId); + + var events = await Session.Query( + index => index.InteractionId == interactionId, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.OccurredUtc) + .ListAsync(cancellationToken); + + return await LoadedAsync(events); + } + + /// + public async Task ExistsByIdempotencyKeyAsync(string idempotencyKey, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(idempotencyKey); + + var match = await Session.Query( + index => index.IdempotencyKey == idempotencyKey, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + + return match is not null; + } + + /// + public async Task> ListOlderThanAsync(DateTime cutoffUtc, int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? 100 : maxCount; + + var events = await Session.Query( + index => index.OccurredUtc < cutoffUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.OccurredUtc) + .Take(take) + .ListAsync(cancellationToken); + + return await LoadedAsync(events); + } + + /// + public async Task> ListByAggregateTypeAsync( + string aggregateType, + IEnumerable eventTypes, + DateTime occurredThroughUtc, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(aggregateType); + + var types = eventTypes is null + ? [] + : eventTypes.Where(eventType => !string.IsNullOrEmpty(eventType)).ToArray(); + + var query = Session.Query( + index => index.AggregateType == aggregateType && index.OccurredUtc <= occurredThroughUtc, + collection: ContactCenterConstants.CollectionName); + + if (types.Length > 0) + { + query = query.Where(index => index.EventType.IsIn(types)); + } + + var events = await query + .OrderBy(index => index.OccurredUtc) + .ThenBy(index => index.ItemId) + .ListAsync(cancellationToken); + + return await LoadedAsync(events); + } + + /// + public async Task> ListOrderedPageAsync( + int skip, + int take, + CancellationToken cancellationToken = default) + { + var boundedSkip = skip < 0 ? 0 : skip; + var boundedTake = take <= 0 ? 100 : take; + + var events = await Session.Query( + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.OccurredUtc) + .ThenBy(index => index.ItemId) + .Skip(boundedSkip) + .Take(boundedTake) + .ListAsync(cancellationToken); + + return await LoadedAsync(events); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventUpcastException.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventUpcastException.cs new file mode 100644 index 000000000..5ecc8965f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventUpcastException.cs @@ -0,0 +1,28 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Thrown when a persisted Contact Center domain event cannot be brought to the schema version the running +/// code understands. The exception is deliberate: the alternative is to hand a caller a payload written to a +/// shape it does not know, which deserializes without error and produces defaults where the missing data was. +/// +public sealed class InteractionEventUpcastException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + public InteractionEventUpcastException(string message) + : base(message) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The message that describes the error. + /// The exception that caused this exception. + public InteractionEventUpcastException(string message, Exception innerException) + : base(message, innerException) + { + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs new file mode 100644 index 000000000..2d9e8f11c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs @@ -0,0 +1,183 @@ +using CrestApps.Core.Models; +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of that delegates storage +/// to and loads entries through catalog handlers. +/// +public sealed class InteractionManager : CatalogManager, IInteractionManager +{ + private readonly IInteractionStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying interaction store. + /// The catalog entry handlers for interactions. + /// The logger instance. + public InteractionManager( + IInteractionStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + var interaction = await _store.FindByActivityIdAsync(activityItemId, cancellationToken); + + if (interaction is not null) + { + await LoadAsync(interaction, cancellationToken); + } + + return interaction; + } + + /// + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + var interaction = await _store.FindByCorrelationIdAsync(correlationId, cancellationToken); + + if (interaction is not null) + { + await LoadAsync(interaction, cancellationToken); + } + + return interaction; + } + + /// + public async Task FindByProviderInteractionIdAsync(string providerInteractionId, CancellationToken cancellationToken = default) + { + var interaction = await _store.FindByProviderInteractionIdAsync(providerInteractionId, cancellationToken); + + if (interaction is not null) + { + await LoadAsync(interaction, cancellationToken); + } + + return interaction; + } + + /// + public async Task FindByProviderInteractionIdAsync( + string providerName, + string providerInteractionId, + CancellationToken cancellationToken = default) + { + var interaction = await _store.FindByProviderInteractionIdAsync(providerName, providerInteractionId, cancellationToken); + + if (interaction is not null) + { + await LoadAsync(interaction, cancellationToken); + } + + return interaction; + } + + /// + public async Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default) + { + var result = await _store.PageByStatusAsync(page, pageSize, status, cancellationToken); + + foreach (var entry in result.Entries) + { + await LoadAsync(entry, cancellationToken); + } + + return result; + } + + /// + public Task CountActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + return _store.CountActiveByAgentAsync(agentId, cancellationToken); + } + + /// + public Task> CountActiveByAgentIdsAsync( + IReadOnlyCollection agentIds, + CancellationToken cancellationToken = default) + { + return _store.CountActiveByAgentIdsAsync(agentIds, cancellationToken); + } + + /// + public async Task FindActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + var interaction = await _store.FindActiveByAgentAsync(agentId, cancellationToken); + + if (interaction is not null) + { + await LoadAsync(interaction, cancellationToken); + } + + return interaction; + } + + /// + public async Task> ListPendingWrapUpsByAgentAsync( + string agentId, + CancellationToken cancellationToken = default) + { + var interactions = await _store.ListPendingWrapUpsByAgentAsync(agentId, cancellationToken); + + foreach (var interaction in interactions) + { + await LoadAsync(interaction, cancellationToken); + } + + return interactions; + } + + /// + public async Task> ListRecentByAgentAsync(string agentId, int take, CancellationToken cancellationToken = default) + { + var interactions = await _store.ListRecentByAgentAsync(agentId, take, cancellationToken); + + foreach (var interaction in interactions) + { + await LoadAsync(interaction, cancellationToken); + } + + return interactions; + } + + /// + public async Task> ListActiveWithProviderCallIdAsync(int maxCount, CancellationToken cancellationToken = default) + { + var interactions = await _store.ListActiveWithProviderCallIdAsync(maxCount, cancellationToken); + + foreach (var interaction in interactions) + { + await LoadAsync(interaction, cancellationToken); + } + + return interactions; + } + + /// + public async Task> ListActiveWithProviderCallIdAsync( + string providerName, + int maxCount, + CancellationToken cancellationToken = default) + { + var interactions = await _store.ListActiveWithProviderCallIdAsync(providerName, maxCount, cancellationToken); + + foreach (var interaction in interactions) + { + await LoadAsync(interaction, cancellationToken); + } + + return interactions; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionQueries.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionQueries.cs new file mode 100644 index 000000000..bac75e327 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionQueries.cs @@ -0,0 +1,60 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; +using YesSql; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Builds the hand-written statements the interaction hot paths execute. The text lives here rather than inside +/// the store so the query-plan gate can execute the statement production executes: a gate that rebuilt an +/// equivalent statement of its own would prove a plan for a query nothing runs. +/// +public static class InteractionQueries +{ + /// + /// Returns the parameter name the agent identifier at the supplied position is bound to. + /// + /// The position of the agent identifier within the batch. + public static string AgentIdParameterName(int index) => $"AgentId{index}"; + + /// + /// Builds the statement that counts, per agent, how much live work that agent is already holding. Routing + /// runs it before every offer, so it is the statement the query-plan budget is asserted against. + /// + /// The YesSql configuration that names the table, schema, prefix and dialect. + /// The number of agent identifiers the statement will be executed with. + public static string BuildActiveCountByAgentSql(IConfiguration configuration, int agentIdCount) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentOutOfRangeException.ThrowIfLessThan(agentIdCount, 1); + + var dialect = configuration.SqlDialect; + var tableName = configuration.TableNameConvention.GetIndexTable(typeof(InteractionIndex), ContactCenterConstants.CollectionName); + var agentColumn = dialect.QuoteForColumnName(nameof(InteractionIndex.AgentId)); + var statusColumn = dialect.QuoteForColumnName(nameof(InteractionIndex.Status)); + + // One placeholder per agent rather than one bound collection. A data access layer that expands a bound + // collection into placeholders does so only on providers that cannot bind an array; on the providers + // that can, the collection is sent as a single value, and "IN" against a single value is not valid + // syntax at all. Writing the placeholders here gives the statement one shape on every supported engine, + // and it is that shape the query-plan gates measure. + var agentPlaceholders = string.Join( + ", ", + Enumerable.Range(0, agentIdCount).Select(index => $"@{AgentIdParameterName(index)}")); + + // An inclusive IN can be answered from an index that leads with the status column. The chain of + // inequalities this replaced could not be, so the planner had to read every interaction for the agent. + var occupyingStatuses = string.Join(", ", InteractionStatuses.OccupyingAgent.Select(status => (int)status)); + + var builder = new SqlBuilder(configuration.TablePrefix, dialect); + builder.Select(); + builder.Selector($"{agentColumn}, COUNT(*) AS {dialect.QuoteForColumnName("ActiveCount")}"); + builder.Table(tableName, alias: null, configuration.Schema); + builder.WhereAnd($"{agentColumn} IN ({agentPlaceholders})"); + builder.WhereAnd($"{statusColumn} IN ({occupyingStatuses})"); + builder.GroupBy(agentColumn); + + return builder.ToSqlString(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs new file mode 100644 index 000000000..ba8f3d4b3 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs @@ -0,0 +1,251 @@ +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using Dapper; +using YesSql; +using YesSql.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class InteractionStore : DocumentCatalog, IInteractionStore +{ + private const int QueryBatchSize = 500; + private const int DefaultReconciliationBatchSize = 200; + + /// + /// Gets a value indicating that interaction updates use YesSql document-version concurrency checks so + /// concurrent provider-event ingestion cannot lose or reverse a communication state update. A losing + /// writer observes a instead of silently overwriting a newer commit. + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public InteractionStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(activityItemId); + + return await Session.Query( + index => index.ActivityItemId == activityItemId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(correlationId); + + return await Session.Query( + index => index.CorrelationId == correlationId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task FindByProviderInteractionIdAsync(string providerInteractionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerInteractionId); + + return await Session.Query( + index => index.ProviderInteractionId == providerInteractionId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task FindByProviderInteractionIdAsync( + string providerName, + string providerInteractionId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + ArgumentException.ThrowIfNullOrEmpty(providerInteractionId); + + return await Session.Query( + index => index.ProviderName == providerName && + index.ProviderInteractionId == providerInteractionId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default) + { + var query = Session.Query( + index => index.Status == status, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.CreatedUtc); + + var skip = (Math.Max(page, 1) - 1) * pageSize; + + return new PageResult + { + Count = await query.CountAsync(cancellationToken), + Entries = (await query.Skip(skip).Take(pageSize).ListAsync(cancellationToken)).ToArray(), + }; + } + + /// + public async Task CountActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + return await Session.Query( + index => index.AgentId == agentId && + index.Status.IsIn(InteractionStatuses.OccupyingAgent), + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task> CountActiveByAgentIdsAsync( + IReadOnlyCollection agentIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(agentIds); + + if (agentIds.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var counts = new Dictionary(StringComparer.Ordinal); + + var configuration = Session.Store.Configuration; + + // Counting in the database rather than materializing one row per interaction: an agent's live work is + // read on every routing decision, so loading the rows only to group them in memory scales the cost of a + // decision with how busy the contact center is, which is exactly when the decision must be cheapest. + // The flush is what makes the raw statement safe: it runs on the session's own transaction, so it must + // first see the writes the caller has made in this unit of work but not yet committed. + await Session.FlushAsync(cancellationToken); + var transaction = await Session.BeginTransactionAsync(cancellationToken); + + foreach (var agentIdBatch in agentIds.Chunk(QueryBatchSize)) + { + var sql = InteractionQueries.BuildActiveCountByAgentSql(configuration, agentIdBatch.Length); + var parameters = new DynamicParameters(); + + for (var index = 0; index < agentIdBatch.Length; index++) + { + parameters.Add(InteractionQueries.AgentIdParameterName(index), agentIdBatch[index]); + } + + var rows = await transaction.Connection.QueryAsync( + new CommandDefinition( + sql, + parameters, + transaction, + cancellationToken: cancellationToken)); + + foreach (var row in rows) + { + counts[row.AgentId] = row.ActiveCount; + } + } + + return counts; + } + + /// + public async Task FindActiveByAgentAsync(string agentId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + return await Session.Query( + index => index.AgentId == agentId && + index.Status.IsIn(InteractionStatuses.OccupyingAgent), + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListPendingWrapUpsByAgentAsync( + string agentId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + return (await Session.Query( + index => index.AgentId == agentId && + index.WrapUpStartedUtc != null && + index.WrapUpCompletedUtc == null, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.WrapUpStartedUtc) + .ListAsync(cancellationToken)).ToArray(); + } + + /// + public async Task> ListRecentByAgentAsync(string agentId, int take, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(agentId); + + return (await Session.Query( + index => index.AgentId == agentId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.CreatedUtc) + .Take(take) + .ListAsync(cancellationToken)).ToArray(); + } + + /// + public async Task> ListActiveWithProviderCallIdAsync(int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? DefaultReconciliationBatchSize : maxCount; + + return (await Session.Query( + index => index.Status.IsIn(InteractionStatuses.Unsettled), + collection: ContactCenterConstants.CollectionName) + .Where(index => index.ProviderInteractionId != null && index.ProviderInteractionId != string.Empty) + .OrderBy(index => index.CreatedUtc) + .Take(take) + .ListAsync(cancellationToken)).ToArray(); + } + + /// + public async Task> ListActiveWithProviderCallIdAsync( + string providerName, + int maxCount, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + + var take = maxCount <= 0 ? DefaultReconciliationBatchSize : maxCount; + + return (await Session.Query( + index => index.ProviderName == providerName && + index.Status.IsIn(InteractionStatuses.Unsettled), + collection: ContactCenterConstants.CollectionName) + .Where(index => index.ProviderInteractionId != null && index.ProviderInteractionId != string.Empty) + .OrderBy(index => index.CreatedUtc) + .Take(take) + .ListAsync(cancellationToken)).ToArray(); + } + + private sealed class AgentInteractionCount + { + public string AgentId { get; set; } + + public int ActiveCount { get; set; } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LeastBusyRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LeastBusyRoutingStrategy.cs new file mode 100644 index 000000000..b073489a2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LeastBusyRoutingStrategy.cs @@ -0,0 +1,60 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Ranks eligible agents from the one handling the fewest active interactions to the most. Runs only when +/// the queue uses the strategy. +/// +public sealed class LeastBusyRoutingStrategy : IActivityRoutingStrategy +{ + private readonly IInteractionManager _interactionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager used to count active agent interactions. + public LeastBusyRoutingStrategy(IInteractionManager interactionManager) + { + _interactionManager = interactionManager; + } + + /// + public int Order => 100; + + /// + public async ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Queue.RoutingStrategy != QueueRoutingStrategy.LeastBusy) + { + return; + } + + var eligibleCandidates = context.Candidates + .Where(candidate => candidate.IsEligible) + .ToArray(); + + var loads = new List<(ActivityRoutingCandidate Candidate, int ActiveCount)>(eligibleCandidates.Length); + + foreach (var candidate in eligibleCandidates) + { + var activeCount = await _interactionManager.CountActiveByAgentAsync(candidate.Agent.ItemId, cancellationToken); + loads.Add((candidate, activeCount)); + } + + var ranked = loads + .OrderBy(load => load.ActiveCount) + .ThenBy(load => load.Candidate.Agent.PresenceChangedUtc ?? DateTime.MaxValue) + .ToArray(); + + for (var index = 0; index < ranked.Length; index++) + { + var (candidate, activeCount) = ranked[index]; + candidate.Score += ranked.Length - index; + candidate.AddReason($"Least-busy rank {index + 1} ({activeCount} active interactions)."); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LongestIdleRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LongestIdleRoutingStrategy.cs new file mode 100644 index 000000000..074b656af --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LongestIdleRoutingStrategy.cs @@ -0,0 +1,39 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Scores eligible agents by how long they have been available. Runs only when the queue uses the +/// strategy. +/// +public sealed class LongestIdleRoutingStrategy : IActivityRoutingStrategy +{ + /// + public int Order => 100; + + /// + public ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Queue.RoutingStrategy != QueueRoutingStrategy.LongestIdle) + { + return ValueTask.CompletedTask; + } + + var eligibleCandidates = context.Candidates + .Where(candidate => candidate.IsEligible) + .OrderBy(candidate => candidate.Agent.PresenceChangedUtc ?? DateTime.MaxValue) + .ToArray(); + + for (var index = 0; index < eligibleCandidates.Length; index++) + { + var candidate = eligibleCandidates[index]; + candidate.Score += eligibleCandidates.Length - index; + candidate.AddReason($"Longest-idle rank {index + 1}."); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/PowerDialerStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/PowerDialerStrategy.cs new file mode 100644 index 000000000..6dfd387cb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/PowerDialerStrategy.cs @@ -0,0 +1,39 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Implements Power dialing: the system reserves agents and places a controlled, capped number of +/// calls per pacing cycle. The per-agent call count is hard-capped at +/// because reliable answer-rate forecasting and abandonment controls are required before higher +/// over-dialing ratios can be used safely. +/// +public sealed class PowerDialerStrategy : DialerStrategyBase +{ + /// + /// The maximum number of calls per agent allowed for Power dialing until predictive pacing exists. + /// + public const int MaxCallsPerAgent = 3; + + /// + /// Initializes a new instance of the class. + /// + /// The assignment service used to reserve agents and activities. + /// The attempt service that applies compliance and places each call. + public PowerDialerStrategy( + IActivityAssignmentService assignmentService, + IDialerAttemptService attemptService) + : base(assignmentService, attemptService) + { + } + + /// + public override DialerMode Mode => DialerMode.Power; + + /// + protected override int GetMaxAttemptsPerCycle(DialerProfile profile) + { + return Math.Clamp(profile.CallsPerAgent, 1, MaxCallsPerAgent); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProgressiveDialerStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProgressiveDialerStrategy.cs new file mode 100644 index 000000000..cb6a9ec1b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProgressiveDialerStrategy.cs @@ -0,0 +1,38 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Implements Progressive dialing: the system places one call per agent as agents become available, +/// draining the available agents reserved during the cycle. A safety bound caps the number of calls +/// started in a single cycle to protect against runaway pacing. +/// +public sealed class ProgressiveDialerStrategy : DialerStrategyBase +{ + /// + /// The maximum number of calls a single Progressive cycle may start as a safety bound. + /// + public const int MaxCallsPerCycle = 100; + + /// + /// Initializes a new instance of the class. + /// + /// The assignment service used to reserve agents and activities. + /// The attempt service that applies compliance and places each call. + public ProgressiveDialerStrategy( + IActivityAssignmentService assignmentService, + IDialerAttemptService attemptService) + : base(assignmentService, attemptService) + { + } + + /// + public override DialerMode Mode => DialerMode.Progressive; + + /// + protected override int GetMaxAttemptsPerCycle(DialerProfile profile) + { + return MaxCallsPerCycle; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallActionCommandTypeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallActionCommandTypeExecutor.cs new file mode 100644 index 000000000..d0d7c5def --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallActionCommandTypeExecutor.cs @@ -0,0 +1,550 @@ +using System.Globalization; +using System.Text.Json; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the shared implementation for durable provider commands that act on an existing call. +/// +public abstract class ProviderCallActionCommandTypeExecutor : IProviderCommandTypeExecutor +{ + private const string OwnerMetadataKey = "providerCommandOwner"; + + private static readonly JsonSerializerOptions _serializerOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly ITelephonyService _telephonyService; + private readonly IInteractionManager _interactionManager; + private readonly ICallControlAuthorizationService _callControlAuthorizationService; + private readonly IActivityQueueService _queueService; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IContactCenterActivityWriter _activityWriter; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The optional telephony services used to execute the provider action. + /// The interaction manager used to validate and project linked interactions. + /// The queue service used to restore live work after a definitive action failure. + /// The routing-owned work state service. + /// The writer used to apply CRM activity changes outside the routing transaction. + /// The Contact Center event publisher. + /// The clock used to stamp projections. + /// The shared call-control authorization boundary. + protected ProviderCallActionCommandTypeExecutor( + IEnumerable telephonyServices, + IInteractionManager interactionManager, + IActivityQueueService queueService, + IContactCenterWorkStateService workStateService, + IContactCenterActivityWriter activityWriter, + IContactCenterEventPublisher publisher, + IClock clock, + ICallControlAuthorizationService callControlAuthorizationService) + { + _telephonyService = telephonyServices.FirstOrDefault(); + _interactionManager = interactionManager; + _callControlAuthorizationService = callControlAuthorizationService; + _queueService = queueService; + _workStateService = workStateService; + _activityWriter = activityWriter; + _publisher = publisher; + _clock = clock; + } + + /// + public abstract ProviderCommandType CommandType { get; } + + /// + public async Task CanDispatchAsync(ProviderCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + if (command.CommandType != CommandType || + _telephonyService is null) + { + return false; + } + + var request = DeserializeRequest(command.RequestPayload); + + if (request is null || + string.IsNullOrWhiteSpace(request.ProviderCallId) || + string.IsNullOrWhiteSpace(command.InteractionId)) + { + return false; + } + + var interaction = await _interactionManager.FindByIdAsync(command.InteractionId, cancellationToken); + + if (interaction is null || IsTerminal(interaction.Status)) + { + return false; + } + + if (!string.IsNullOrWhiteSpace(interaction.ProviderInteractionId) && + !string.Equals(interaction.ProviderInteractionId, request.ProviderCallId, StringComparison.Ordinal)) + { + return false; + } + + if (request.Initiator == CallControlInitiator.Agent && + string.IsNullOrWhiteSpace(request.AgentUserId)) + { + return false; + } + + var authorization = await AuthorizeAsync(command, request, cancellationToken); + + return authorization.Succeeded; + } + + /// + public async Task ExecuteAsync( + ProviderCommand command, + ProviderCommandClaim claim, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(claim); + + var request = DeserializeRequest(command.RequestPayload); + + if (request is null || + string.IsNullOrWhiteSpace(request.ProviderCallId)) + { + return CreateUnknownResult(command, request?.ProviderCallId, "invalid_request", "The provider call action request could not be deserialized."); + } + + if (_telephonyService is null) + { + return CreateUnknownResult(command, request.ProviderCallId, GetErrorCodePrefix("unavailable"), "No telephony service is registered."); + } + + if (request.Initiator == CallControlInitiator.Agent && + string.IsNullOrWhiteSpace(request.AgentUserId)) + { + return CreateUnknownResult(command, request.ProviderCallId, GetErrorCodePrefix("denied"), "The requested call is not available."); + } + + var authorization = await AuthorizeAsync(command, request, cancellationToken); + + if (!authorization.Succeeded) + { + return CreateUnknownResult(command, request.ProviderCallId, GetErrorCodePrefix("denied"), authorization.FailureReason); + } + + var resolvedProviderCallId = authorization.ProviderCallId; + + var call = new CallReference + { + CallId = resolvedProviderCallId, + Metadata = NormalizeMetadata(request.Metadata), + }; + + call.Metadata[ContactCenterConstants.CommandMetadata.CommandId] = claim.CommandId; + call.Metadata[ContactCenterConstants.CommandMetadata.FenceToken] = claim.FenceToken.ToString(CultureInfo.InvariantCulture); + call.Metadata[OwnerMetadataKey] = claim.OwnerToken; + var result = await ExecuteTelephonyAsync(_telephonyService, call, cancellationToken); + + return ToVoiceProviderResult(command, request, result); + } + + /// + public async Task ProjectSuccessAsync( + ProviderCommand command, + ContactCenterVoiceProviderResult result, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(result); + + var request = DeserializeRequest(command.RequestPayload); + + if (request is null) + { + return; + } + + var interaction = await GetInteractionAsync(command.InteractionId, cancellationToken); + + if (interaction is null) + { + return; + } + + var now = _clock.UtcNow; + + // A hangup that succeeds at the provider can land after the call had already been recorded as failed, + // and a settled interaction keeps whichever ending it recorded first. + if (!interaction.IsSettled) + { + interaction.TransitionTo(InteractionStatus.Ended); + } + + interaction.EndedUtc ??= now; + ApplyProjectionMetadata(interaction, command, request, "Succeeded", null, null, now); + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + await _publisher.PublishAsync(CreateCallEndedEvent(command, interaction, request, now), cancellationToken); + } + + /// + public async Task ProjectFailureAsync(ProviderCommand command, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + var request = DeserializeRequest(command.RequestPayload); + var interaction = await GetInteractionAsync(command.InteractionId, cancellationToken); + + if (request?.ReofferOnFailure == true && + interaction is not null && + !IsTerminal(interaction.Status) && + !string.IsNullOrWhiteSpace(request.ActivityItemId) && + !string.IsNullOrWhiteSpace(request.QueueId)) + { + await _queueService.EnqueueAsync(request.ActivityItemId, request.QueueId, null, cancellationToken); + await _workStateService.MutateAsync( + request.ActivityItemId, + workState => workState.TransitionTo(ActivityAssignmentStatus.Available), + cancellationToken); + + await _activityWriter.ScheduleUpdateAsync(request.ActivityItemId, activity => + { + activity.Status = ActivityStatus.AwaitingAgentResponse; + activity.CompletedUtc = null; + }, cancellationToken); + + interaction.Requeue(); + interaction.EndedUtc = null; + ApplyProjectionMetadata( + interaction, + command, + request, + "FailedRequeued", + GetErrorCodePrefix("failed"), + "The provider action failed and the live call was returned to routing.", + _clock.UtcNow); + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + await _publisher.PublishAsync(CreateOfferRequeuedEvent(command, request), cancellationToken); + + return; + } + + await ProjectDiagnosticAsync( + command, + "Failed", + GetErrorCodePrefix("failed"), + "The provider action failed.", + cancellationToken); + } + + /// + public async Task ProjectOutcomeUnknownAsync( + ProviderCommand command, + string errorCode, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(command); + + await ProjectDiagnosticAsync( + command, + "Unknown", + errorCode, + "The provider could not prove the provider action outcome.", + cancellationToken); + } + + /// + /// Executes the provider-specific telephony action. + /// + /// The telephony service used to execute the action. + /// The call reference and metadata for the action. + /// The token to monitor for cancellation requests. + /// The telephony result. + protected abstract Task ExecuteTelephonyAsync( + ITelephonyService telephonyService, + CallReference call, + CancellationToken cancellationToken); + + /// + /// Gets the display name used in diagnostic metadata. + /// + protected abstract string ActionName { get; } + + /// + /// Gets the prefix used when constructing provider-facing error codes. + /// + protected abstract string ErrorCodePrefix { get; } + + private async Task ProjectDiagnosticAsync( + ProviderCommand command, + string outcome, + string errorCode, + string errorMessage, + CancellationToken cancellationToken) + { + var request = DeserializeRequest(command.RequestPayload); + + if (request is null) + { + return; + } + + var interaction = await GetInteractionAsync(command.InteractionId, cancellationToken); + + if (interaction is null) + { + return; + } + + ApplyProjectionMetadata(interaction, command, request, outcome, errorCode, errorMessage, _clock.UtcNow); + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + } + + private static ProviderCallActionCommandRequest DeserializeRequest(string payload) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(payload, _serializerOptions); + } + catch (JsonException) + { + return null; + } + catch (NotSupportedException) + { + return null; + } + } + + private Task AuthorizeAsync( + ProviderCommand command, + ProviderCallActionCommandRequest request, + CancellationToken cancellationToken) + { + return _callControlAuthorizationService.AuthorizeAsync(new CallControlAuthorizationContext + { + Initiator = request.Initiator, + UserId = request.AgentUserId, + Verb = CommandType == ProviderCommandType.SendToVoicemail + ? CallControlVerb.Voicemail + : CallControlVerb.Decline, + InteractionId = command.InteractionId, + ProviderName = command.ProviderName, + ProviderCallId = request.ProviderCallId, + }, cancellationToken); + } + + private static bool IsTerminal(InteractionStatus status) + { + return status is InteractionStatus.Ended or InteractionStatus.Failed; + } + + private static ContactCenterVoiceProviderResult CreateUnknownResult( + ProviderCommand command, + string providerCallId, + string errorCode, + string errorMessage) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + OutcomeUnknown = true, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + ProviderCallId = providerCallId ?? string.Empty, + ProviderName = command.ProviderName ?? string.Empty, + }; + } + + private ContactCenterVoiceProviderResult ToVoiceProviderResult( + ProviderCommand command, + ProviderCallActionCommandRequest request, + TelephonyResult result) + { + if (result is null) + { + return CreateUnknownResult( + command, + request.ProviderCallId, + GetErrorCodePrefix("outcome_unknown"), + "The provider did not return a result."); + } + + return new ContactCenterVoiceProviderResult + { + Succeeded = result.Succeeded, + OutcomeUnknown = result.OutcomeUnknown, + ErrorCode = result.OutcomeUnknown + ? GetErrorCodePrefix("outcome_unknown") + : result.Succeeded + ? null + : GetErrorCodePrefix("failed"), + ErrorMessage = result.Error, + ProviderCallId = request.ProviderCallId, + ProviderName = command.ProviderName ?? string.Empty, + }; + } + + private async Task GetInteractionAsync(string interactionId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(interactionId)) + { + return null; + } + + return await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + } + + private static Dictionary NormalizeMetadata(IDictionary metadata) + { + if (metadata is null || metadata.Count == 0) + { + return []; + } + + var normalized = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in metadata) + { + normalized[entry.Key] = NormalizeValue(entry.Value); + } + + return normalized; + } + + private static object NormalizeValue(object value) + { + if (value is not JsonElement jsonElement) + { + return value; + } + + return NormalizeJsonElement(jsonElement); + } + + private static object NormalizeJsonElement(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number when element.TryGetInt64(out var longValue) => longValue, + JsonValueKind.Number when element.TryGetDecimal(out var decimalValue) => decimalValue, + JsonValueKind.Number when element.TryGetDouble(out var doubleValue) => doubleValue, + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Array => element.EnumerateArray().Select(NormalizeJsonElement).ToList(), + JsonValueKind.Object => element.EnumerateObject().ToDictionary( + property => property.Name, + property => NormalizeJsonElement(property.Value), + StringComparer.OrdinalIgnoreCase), + _ => element.ToString(), + }; + } + + private InteractionEvent CreateCallEndedEvent( + ProviderCommand command, + Interaction interaction, + ProviderCallActionCommandRequest request, + DateTime occurredUtc) + { + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.CallEnded, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + CorrelationId = interaction.CorrelationId, + CausationId = command.CommandId, + ActorId = string.IsNullOrWhiteSpace(command.ProviderName) + ? ContactCenterConstants.SystemActor + : command.ProviderName, + SourceComponent = ContactCenterConstants.Components.CallSessions, + OccurredUtc = occurredUtc, + IdempotencyKey = command.CommandId, + }; + + interactionEvent.SetData(new Dictionary + { + ["action"] = ActionName, + ["providerCallId"] = request.ProviderCallId ?? string.Empty, + ["providerName"] = command.ProviderName ?? string.Empty, + ["metadata"] = NormalizeMetadata(request.Metadata), + }); + + return interactionEvent; + } + + private static InteractionEvent CreateOfferRequeuedEvent( + ProviderCommand command, + ProviderCallActionCommandRequest request) + { + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.OfferRequeued, + InteractionId = command.InteractionId, + AggregateType = nameof(ActivityReservation), + AggregateId = command.ActivityItemId, + ActorId = ContactCenterConstants.SystemActor, + SourceComponent = ContactCenterConstants.Components.Voice, + IdempotencyKey = ContactCenterClaimKeys.BuildProviderDomainEventIdempotencyKey( + command.CommandId, + ContactCenterConstants.Events.OfferRequeued), + }; + + interactionEvent.SetData(new OfferDeclinedEventData + { + QueueId = request.QueueId, + }); + + return interactionEvent; + } + + private static void ApplyProjectionMetadata( + Interaction interaction, + ProviderCommand command, + ProviderCallActionCommandRequest request, + string outcome, + string errorCode, + string errorMessage, + DateTime projectedUtc) + { + interaction.TechnicalMetadata["providerCallActionCommandId"] = command.CommandId; + interaction.TechnicalMetadata["providerCallActionType"] = command.CommandType.ToString(); + interaction.TechnicalMetadata["providerCallActionOutcome"] = outcome; + interaction.TechnicalMetadata["providerCallActionProviderCallId"] = request.ProviderCallId ?? string.Empty; + interaction.TechnicalMetadata["providerCallActionProviderName"] = command.ProviderName ?? string.Empty; + interaction.TechnicalMetadata["providerCallActionProjectedUtc"] = projectedUtc; + interaction.TechnicalMetadata["providerCallActionMetadata"] = NormalizeMetadata(request.Metadata); + + if (!string.IsNullOrWhiteSpace(errorCode)) + { + interaction.TechnicalMetadata["providerCallActionErrorCode"] = errorCode; + } + + if (!string.IsNullOrWhiteSpace(errorMessage)) + { + interaction.TechnicalMetadata["providerCallActionErrorMessage"] = errorMessage; + } + } + + private string GetErrorCodePrefix(string suffix) + { + return $"{ErrorCodePrefix}_{suffix}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateReconciler.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateReconciler.cs new file mode 100644 index 000000000..f43688922 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateReconciler.cs @@ -0,0 +1,26 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Adapts provider-facing reconciliation to the Contact Center synchronization service. +/// +public sealed class ProviderCallStateReconciler : IProviderCallStateReconciler +{ + private readonly IProviderCallStateSynchronizationService _synchronizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center provider-state synchronization service. + public ProviderCallStateReconciler(IProviderCallStateSynchronizationService synchronizationService) + { + _synchronizationService = synchronizationService; + } + + /// + public Task ReconcileAsync( + string providerName, + CancellationToken cancellationToken = default) + { + return _synchronizationService.ReconcileProviderInteractionsAsync(providerName, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs new file mode 100644 index 000000000..707f023b2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs @@ -0,0 +1,281 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Logging; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Reconciles active Contact Center interactions with the current provider call state so stale offers and +/// restart windows do not drift away from the telephony server truth. +/// +public sealed class ProviderCallStateSynchronizationService : IProviderCallStateSynchronizationService +{ + private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan _lockExpiration = TimeSpan.FromMinutes(2); + private const string ReconciliationLockKey = "ContactCenterProviderCallStateReconciliation"; + private const int MaxReconciliationBatchSize = 200; + + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IProviderVoiceEventService _providerVoiceEventService; + private readonly IProviderVoiceOfferSynchronizationService _offerSynchronizationService; + private readonly ITelephonyProviderResolver _telephonyProviderResolver; + private readonly IDistributedLock _distributedLock; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The call session manager. + /// The provider voice-event ingestion service. + /// The provider-ended offer synchronization service. + /// The telephony provider resolver. + /// The distributed lock used to prevent overlapping reconciliation sweeps. + /// The clock used to stamp reconciliation events. + /// The logger. + public ProviderCallStateSynchronizationService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IProviderVoiceEventService providerVoiceEventService, + IProviderVoiceOfferSynchronizationService offerSynchronizationService, + ITelephonyProviderResolver telephonyProviderResolver, + IDistributedLock distributedLock, + IClock clock, + ILogger logger) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _providerVoiceEventService = providerVoiceEventService; + _offerSynchronizationService = offerSynchronizationService; + _telephonyProviderResolver = telephonyProviderResolver; + _distributedLock = distributedLock; + _clock = clock; + _logger = logger; + } + + /// + public async Task RefreshInteractionAsync(Interaction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + if (string.IsNullOrWhiteSpace(interaction.ProviderName) || string.IsNullOrWhiteSpace(interaction.ProviderInteractionId)) + { + return interaction; + } + + var currentSession = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (currentSession is not null && + TryMapTerminalInteractionStatus(currentSession.State, out var terminalStatus)) + { + if (interaction.Status != terminalStatus) + { + var previousStatus = interaction.Status; + interaction.TransitionTo(terminalStatus); + interaction.StartedUtc ??= currentSession.StartedUtc; + interaction.AnsweredUtc ??= currentSession.AnsweredUtc; + interaction.EndedUtc ??= currentSession.EndedUtc ?? _clock.UtcNow; + + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + _logger.LogWarning( + "Repaired interaction '{InteractionId}' from '{PreviousStatus}' to '{CurrentStatus}' because call session '{CallSessionId}' is terminal in provider state '{ProviderState}'.", + interaction.ItemId.SanitizeLogValue(), + previousStatus, + interaction.Status, + currentSession.ItemId.SanitizeLogValue(), + currentSession.State); + } + + await _offerSynchronizationService.ReconcileEndedOfferAsync(interaction.ItemId, cancellationToken); + + return interaction; + } + + var providerName = interaction.ProviderName; + var provider = await _telephonyProviderResolver.GetAsync(providerName); + + if (provider is null) + { + provider = await _telephonyProviderResolver.GetAsync(); + + if (provider is not null) + { + providerName = provider.Name.Name; + + _logger.LogWarning( + "Reconciling interaction '{InteractionId}' through the current default provider '{Provider}' because its stored provider '{StoredProvider}' is no longer registered.", + interaction.ItemId.SanitizeLogValue(), + providerName, + interaction.ProviderName); + } + } + + if (provider is not ITelephonyCallStateProvider stateProvider) + { + return interaction; + } + + var lookup = await stateProvider.GetCallStateAsync(interaction.ProviderInteractionId, cancellationToken); + + if (!lookup.Succeeded) + { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Skipping provider-state reconciliation for interaction '{InteractionId}' because provider '{Provider}' could not resolve call '{ProviderCallId}': {ErrorMessage}", + interaction.ItemId.SanitizeLogValue(), + interaction.ProviderName, + interaction.ProviderInteractionId.SanitizeLogValue(), + lookup.Error.SanitizeLogValue()); + } + + return interaction; + } + + if (lookup.Found && IsEquivalent(currentSession, lookup.Call)) + { + return interaction; + } + + var providerEvent = BuildProviderEvent(interaction, providerName, lookup); + await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); + + return await _interactionManager.FindByProviderInteractionIdAsync( + providerName, + interaction.ProviderInteractionId, + cancellationToken) + ?? interaction; + } + + /// + public async Task ReconcileActiveInteractionsAsync(CancellationToken cancellationToken = default) + { + return await ReconcileAsync(providerName: null, cancellationToken); + } + + /// + public async Task ReconcileProviderInteractionsAsync(string providerName, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + + return await ReconcileAsync(providerName, cancellationToken); + } + + private async Task ReconcileAsync(string providerName, CancellationToken cancellationToken) + { + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + ReconciliationLockKey, + _lockTimeout, + _lockExpiration); + + if (!locked) + { + return 0; + } + + await using var acquiredLock = locker; + + var interactions = string.IsNullOrEmpty(providerName) + ? await _interactionManager.ListActiveWithProviderCallIdAsync(MaxReconciliationBatchSize, cancellationToken) + : await _interactionManager.ListActiveWithProviderCallIdAsync(providerName, MaxReconciliationBatchSize, cancellationToken); + var refreshed = 0; + + foreach (var interaction in interactions) + { + var currentStatus = interaction.Status; + var updated = await RefreshInteractionAsync(interaction, cancellationToken); + + if (updated.Status != currentStatus) + { + refreshed++; + } + } + + return refreshed; + } + + private ProviderVoiceEvent BuildProviderEvent( + Interaction interaction, + string providerName, + TelephonyCallLookupResult lookup) + { + if (!lookup.Found) + { + return new ProviderVoiceEvent + { + ProviderName = providerName, + ProviderCallId = interaction.ProviderInteractionId, + State = VoiceCallState.Ended, + OccurredUtc = _clock.UtcNow, + IdempotencyKey = $"reconcile-missing:{providerName}:{interaction.ProviderInteractionId}:ended", + }; + } + + var call = lookup.Call; + var state = VoiceCallStateProjection.ToVoiceCallState(call.State, call.IsOnHold); + + return new ProviderVoiceEvent + { + ProviderName = providerName, + ProviderCallId = interaction.ProviderInteractionId, + State = state, + FromAddress = call.From, + ToAddress = call.To, + OccurredUtc = _clock.UtcNow, + IdempotencyKey = $"reconcile:{providerName}:{interaction.ProviderInteractionId}:{state}:{call.IsMuted}:{call.IsOnHold}", + IsMuted = call.IsMuted, + Metadata = call.Metadata? + .Where(entry => !string.IsNullOrWhiteSpace(entry.Key)) + .ToDictionary( + entry => entry.Key, + entry => entry.Value?.ToString() ?? string.Empty, + StringComparer.OrdinalIgnoreCase) ?? [], + }; + } + + private static bool IsEquivalent(CallSession session, TelephonyCall call) + { + if (session is null || call is null) + { + return false; + } + + var mappedState = VoiceCallStateProjection.ToVoiceCallState(call.State, call.IsOnHold); + + return session.State == mappedState && + session.IsMuted == call.IsMuted && + session.IsOnHold == (mappedState == VoiceCallState.OnHold); + } + + private static bool TryMapTerminalInteractionStatus( + VoiceCallState? callState, + out InteractionStatus interactionStatus) + { + switch (callState) + { + case VoiceCallState.Ended: + interactionStatus = InteractionStatus.Ended; + + return true; + case VoiceCallState.Failed: + case VoiceCallState.NoAnswer: + case VoiceCallState.Rejected: + case VoiceCallState.Canceled: + interactionStatus = InteractionStatus.Failed; + + return true; + default: + interactionStatus = default; + + return false; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandFenceException.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandFenceException.cs new file mode 100644 index 000000000..b6e6492b7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandFenceException.cs @@ -0,0 +1,41 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// The exception thrown when an ownership-guarded provider command transition presents a stale or unknown +/// fence token or owner token. The command is never mutated when this exception is raised, so a superseded +/// worker cannot corrupt the durable state. +/// +public sealed class ProviderCommandFenceException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The stable identifier of the command that rejected the transition. + /// The fence token currently held by the command. + /// The stale fence token that was presented. + public ProviderCommandFenceException( + string commandId, + long expectedFenceToken, + long providedFenceToken) + : base($"The provider command '{commandId}' rejected a fenced transition. Expected fence token '{expectedFenceToken}' but received '{providedFenceToken}'.") + { + CommandId = commandId; + ExpectedFenceToken = expectedFenceToken; + ProvidedFenceToken = providedFenceToken; + } + + /// + /// Gets the stable identifier of the command that rejected the transition. + /// + public string CommandId { get; } + + /// + /// Gets the fence token currently held by the command. + /// + public long ExpectedFenceToken { get; } + + /// + /// Gets the stale fence token that was presented. + /// + public long ProvidedFenceToken { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandManager.cs new file mode 100644 index 000000000..aeeb42abc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandManager.cs @@ -0,0 +1,67 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ProviderCommandManager : CatalogManager, IProviderCommandManager +{ + private readonly IProviderCommandStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying provider command store. + /// The catalog entry handlers for provider commands. + /// The logger instance. + public ProviderCommandManager( + IProviderCommandStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task FindByCommandIdAsync(string commandId, CancellationToken cancellationToken = default) + { + var command = await _store.FindByCommandIdAsync(commandId, cancellationToken); + + if (command is not null) + { + await LoadAsync(command, cancellationToken); + } + + return command; + } + + /// + public async Task> ListDueAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default) + { + var commands = await _store.ListDueAsync(nowUtc, maxCount, cancellationToken); + + foreach (var command in commands) + { + await LoadAsync(command, cancellationToken); + } + + return commands; + } + + /// + public async Task> ListReclaimableAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default) + { + var commands = await _store.ListReclaimableAsync(nowUtc, maxCount, cancellationToken); + + foreach (var command in commands) + { + await LoadAsync(command, cancellationToken); + } + + return commands; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandProcessor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandProcessor.cs new file mode 100644 index 000000000..3db8f715f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandProcessor.cs @@ -0,0 +1,677 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . It durably records a +/// command as sent before provider execution and reconciles uncertain outcomes instead of reissuing them. +/// Each is handled by a uniquely registered +/// ; a missing or duplicate registration causes safe compensation +/// without any provider contact. +/// +public sealed class ProviderCommandProcessor : IProviderCommandProcessor +{ + private const int MaxRecoveryBatchSize = 25; + private static readonly TimeSpan _leaseDuration = TimeSpan.FromMinutes(5); + + private readonly IProviderCommandManager _commandManager; + private readonly IProviderCommandStateService _stateService; + private readonly IActivityReservationService _reservationService; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly IEnumerable _executors; + private readonly ITelephonyCommandExecutor _commandExecutor; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IContactCenterFeatureWorkManager _workManager; + private readonly ISession _session; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The manager used to load due commands. + /// The durable command state machine. + /// The reservation service used for definitive-failure compensation. + /// The resolver used to find optional provider reconciliation support. + /// The typed executors that handle per-command-type provider dispatch and projections. + /// The executor that provides a bounded server-owned provider-operation token. + /// The executor used to isolate each recovery transition in a fresh shell scope. + /// The feature work manager used to fence dispatch during Voice quiescence. + /// The tenant YesSql session used to commit outcome projections. + /// The clock used to determine recovery windows. + /// The logger instance. + public ProviderCommandProcessor( + IProviderCommandManager commandManager, + IProviderCommandStateService stateService, + IActivityReservationService reservationService, + IContactCenterVoiceProviderResolver voiceProviderResolver, + IEnumerable executors, + ITelephonyCommandExecutor commandExecutor, + IContactCenterScopeExecutor scopeExecutor, + IContactCenterFeatureWorkManager workManager, + ISession session, + IClock clock, + ILogger logger) + { + _commandManager = commandManager; + _stateService = stateService; + _reservationService = reservationService; + _voiceProviderResolver = voiceProviderResolver; + _executors = executors; + _commandExecutor = commandExecutor; + _scopeExecutor = scopeExecutor; + _workManager = workManager; + _session = session; + _clock = clock; + _logger = logger; + } + + /// + public async Task DispatchAsync(string commandId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + + var command = await _commandManager.FindByCommandIdAsync(commandId, cancellationToken); + + if (command is null) + { + throw new InvalidOperationException($"The provider command '{commandId}' does not exist."); + } + + using var workLease = _workManager.TryEnter(ContactCenterConstants.Feature.Voice); + + if (workLease is null) + { + return command; + } + + return command.Status switch + { + ProviderCommandStatus.Pending => await DispatchPendingAsync(command, cancellationToken), + ProviderCommandStatus.OutcomeUnknown => await ReconcileAsync(command, cancellationToken), + ProviderCommandStatus.Compensating => await CompensateAsync(command, cancellationToken), + _ => command, + }; + } + + /// + public async Task RecoverDueAsync(CancellationToken cancellationToken = default) + { + using var workLease = _workManager.TryEnter(ContactCenterConstants.Feature.Voice); + + if (workLease is null) + { + return 0; + } + + var now = _clock.UtcNow; + var candidateIds = new HashSet(StringComparer.Ordinal); + var reclaimable = await _commandManager.ListReclaimableAsync( + now, + MaxRecoveryBatchSize, + cancellationToken); + + foreach (var command in reclaimable) + { + cancellationToken.ThrowIfCancellationRequested(); + var commandId = command.CommandId; + + await _scopeExecutor.ExecuteAsync(async stateService => + { + try + { + var escalated = await stateService.EscalateExpiredLeaseAsync(commandId, cancellationToken); + + if (IsRecoverable(escalated)) + { + candidateIds.Add(commandId); + } + } + catch (ConcurrencyException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipped expired provider command '{ProviderCommandId}' because another worker won recovery.", + commandId); + } + } + catch (ProviderCommandTransitionException) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipped expired provider command '{ProviderCommandId}' because its state changed during recovery.", + commandId); + } + } + }); + } + + var dueCommands = await _commandManager.ListDueAsync( + now, + MaxRecoveryBatchSize, + cancellationToken); + + foreach (var command in dueCommands) + { + candidateIds.Add(command.CommandId); + } + + var attempted = 0; + + foreach (var commandId in candidateIds) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await _scopeExecutor.ExecuteAsync(processor => + processor.DispatchAsync(commandId, cancellationToken)); + attempted++; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (ConcurrencyException) + { + continue; + } + catch (ProviderCommandTransitionException) + { + continue; + } + catch (Exception ex) + { + _logger.LogWarning( + "Unable to recover provider command '{ProviderCommandId}' because processing failed with {ExceptionType}.", + commandId, + ex.GetType().Name); + } + } + + return attempted; + } + + /// + public async Task SettleDispatchAsync( + string commandId, + ProviderCommandClaim claim, + ContactCenterVoiceProviderResult result, + string outcomeUnknownReason, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + ArgumentNullException.ThrowIfNull(claim); + ArgumentException.ThrowIfNullOrEmpty(outcomeUnknownReason); + + var command = await _commandManager.FindByCommandIdAsync(commandId, cancellationToken); + + if (command is null) + { + throw new InvalidOperationException($"The provider command '{commandId}' does not exist."); + } + + if (result?.Succeeded == true && !string.IsNullOrWhiteSpace(result.ProviderCallId)) + { + var confirmed = await _stateService.StageConfirmSentAsync( + commandId, + claim, + result.ProviderCallId, + cancellationToken); + var executor = ResolveExecutor(command); + + if (executor is not null) + { + await executor.ProjectSuccessAsync(confirmed, result, cancellationToken); + } + + await _session.SaveChangesAsync(cancellationToken); + + return confirmed; + } + + if (string.Equals(result?.ErrorCode, "feature_quiescing", StringComparison.Ordinal)) + { + return await _stateService.DeferSentAsync( + commandId, + claim, + "Provider dispatch was deferred because the provider feature is quiescing.", + cancellationToken); + } + + if (result is null || result.OutcomeUnknown || result.Succeeded) + { + var outcomeUnknown = await _stateService.StageOutcomeUnknownAsync( + commandId, + claim, + outcomeUnknownReason, + cancellationToken); + var executor = ResolveExecutor(command); + + if (executor is not null) + { + await executor.ProjectOutcomeUnknownAsync( + outcomeUnknown, + result?.ErrorCode ?? "provider_outcome_unknown", + cancellationToken); + } + + await _session.SaveChangesAsync(cancellationToken); + + return outcomeUnknown; + } + + var compensating = await _stateService.BeginCompensationAsync( + commandId, + claim, + result.ErrorCode ?? "The provider rejected the command.", + cancellationToken); + + return await CompensateAsync(compensating, cancellationToken); + } + + /// + public async Task SettleReconciliationAsync( + string commandId, + ProviderCommandClaim claim, + ContactCenterVoiceCommandReconciliationResult result, + string inconclusiveReason, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + ArgumentNullException.ThrowIfNull(claim); + ArgumentException.ThrowIfNullOrEmpty(inconclusiveReason); + + var command = await _commandManager.FindByCommandIdAsync(commandId, cancellationToken); + + if (command is null) + { + throw new InvalidOperationException($"The provider command '{commandId}' does not exist."); + } + + if (result?.Outcome == ContactCenterVoiceCommandReconciliationOutcome.Confirmed) + { + var confirmed = await _stateService.StageConfirmFromReconciliationAsync( + commandId, + claim, + result.ProviderCallId, + cancellationToken); + var executor = ResolveExecutor(command); + + if (executor is not null) + { + var syntheticResult = new ContactCenterVoiceProviderResult + { + Succeeded = true, + ProviderCallId = result.ProviderCallId ?? confirmed.ProviderReference, + ProviderName = command.ProviderName, + }; + + await executor.ProjectSuccessAsync(confirmed, syntheticResult, cancellationToken); + } + + await _session.SaveChangesAsync(cancellationToken); + + return confirmed; + } + + if (result?.Outcome == ContactCenterVoiceCommandReconciliationOutcome.NotExecuted) + { + var compensating = await _stateService.BeginCompensationAsync( + commandId, + claim, + result.Message ?? "The provider confirmed that the command did not execute.", + cancellationToken); + + return await CompensateAsync(compensating, cancellationToken); + } + + return await _stateService.PauseAsync( + commandId, + claim, + result?.Message ?? inconclusiveReason, + cancellationToken); + } + + private async Task DispatchPendingAsync( + ProviderCommand command, + CancellationToken cancellationToken) + { + var executor = ResolveExecutor(command); + + if (executor is null) + { + _logger.LogError( + "No unique executor is registered for command type '{CommandType}'. Command '{CommandId}' will be compensated without provider contact.", + command.CommandType, + command.CommandId); + + var unsupportedCompensation = await _stateService.BeginPendingCompensationAsync( + command.CommandId, + $"No executor is registered for command type '{command.CommandType}'.", + cancellationToken); + + return await CompensateAsync(unsupportedCompensation, cancellationToken); + } + + if (!await executor.CanDispatchAsync(command, cancellationToken)) + { + var ineligibleCompensation = await _stateService.BeginPendingCompensationAsync( + command.CommandId, + "The command is no longer eligible for outbound dispatch.", + cancellationToken); + + return await CompensateAsync(ineligibleCompensation, cancellationToken); + } + + var claim = await _stateService.TryClaimAsync(command.CommandId, _leaseDuration, cancellationToken); + + if (claim is null) + { + return command; + } + + try + { + await _stateService.MarkSentAsync(command.CommandId, claim, cancellationToken: cancellationToken); + } + catch (ConcurrencyException) + { + return command; + } + catch (ProviderCommandTransitionException) + { + return await _commandManager.FindByCommandIdAsync(command.CommandId, cancellationToken) ?? command; + } + + ContactCenterVoiceProviderResult result; + + try + { + result = await _commandExecutor.ExecuteAsync( + commandCancellationToken => executor.ExecuteAsync(command, claim, commandCancellationToken)); + } + catch (TimeoutException) + { + return await SettleDispatchInFreshScopeAsync( + command.CommandId, + claim, + new ContactCenterVoiceProviderResult + { + OutcomeUnknown = true, + ErrorCode = "provider_dispatch_timeout", + }, + "Provider dispatch exceeded the server-owned command timeout.", + CancellationToken.None); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + await SettleDispatchInFreshScopeAsync( + command.CommandId, + claim, + new ContactCenterVoiceProviderResult + { + OutcomeUnknown = true, + ErrorCode = "provider_dispatch_cancelled", + }, + "Provider dispatch was cancelled after the command was sent.", + CancellationToken.None); + + throw; + } + catch (OperationCanceledException) + { + return await SettleDispatchInFreshScopeAsync( + command.CommandId, + claim, + new ContactCenterVoiceProviderResult + { + OutcomeUnknown = true, + ErrorCode = "provider_dispatch_cancelled", + }, + "Provider dispatch was interrupted after the command was sent.", + CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning( + "Provider command '{ProviderCommandId}' returned no reliable result because dispatch failed with {ExceptionType}.", + command.CommandId, + ex.GetType().Name); + + return await SettleDispatchInFreshScopeAsync( + command.CommandId, + claim, + new ContactCenterVoiceProviderResult + { + OutcomeUnknown = true, + ErrorCode = "provider_dispatch_failed", + }, + "Provider dispatch did not return a reliable result.", + CancellationToken.None); + } + + return await SettleDispatchInFreshScopeAsync( + command.CommandId, + claim, + result, + result?.Succeeded == true + ? "The provider did not return a call identifier." + : "The provider could not prove the command outcome.", + CancellationToken.None); + } + + private async Task ReconcileAsync(ProviderCommand command, CancellationToken cancellationToken) + { + var claim = await _stateService.TryClaimReconciliationAsync( + command.CommandId, + _leaseDuration, + cancellationToken); + + if (claim is null) + { + return command; + } + + var provider = _voiceProviderResolver.Get(command.ProviderName); + + if (provider is not IContactCenterVoiceCommandReconciler reconciler) + { + return await SettleReconciliationInFreshScopeAsync( + command.CommandId, + claim, + null, + "The provider does not support command reconciliation.", + cancellationToken); + } + + ContactCenterVoiceCommandReconciliationResult result; + + try + { + result = await reconciler.ReconcileCommandAsync(command.CommandId, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + "Provider command '{ProviderCommandId}' reconciliation failed with {ExceptionType}.", + command.CommandId, + ex.GetType().Name); + + return await SettleReconciliationInFreshScopeAsync( + command.CommandId, + claim, + null, + "The provider could not reconcile the command outcome.", + cancellationToken); + } + + return await SettleReconciliationInFreshScopeAsync( + command.CommandId, + claim, + result, + "The provider could not prove the command outcome.", + cancellationToken); + } + + private async Task CompensateAsync(ProviderCommand command, CancellationToken cancellationToken) + { + var claim = await _stateService.TryClaimCompensationAsync( + command.CommandId, + _leaseDuration, + cancellationToken); + + if (claim is null) + { + return command; + } + + await CompensateReservationAsync(command, cancellationToken); + + var executor = ResolveExecutor(command); + + if (executor is not null) + { + await executor.ProjectFailureAsync(command, cancellationToken); + } + + return await _stateService.CompleteCompensationAsync(command.CommandId, claim, cancellationToken); + } + + private async Task CompensateReservationAsync(ProviderCommand command, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(command.ReservationId)) + { + await _reservationService.CompensateAsync( + command.ReservationId, + command.RemoveReservationFromQueueOnFailure, + cancellationToken); + } + } + + private async Task SettleDispatchInFreshScopeAsync( + string commandId, + ProviderCommandClaim claim, + ContactCenterVoiceProviderResult result, + string outcomeUnknownReason, + CancellationToken cancellationToken) + { + ProviderCommand settled = null; + + try + { + await _scopeExecutor.ExecuteAsync(async processor => + { + settled = await processor.SettleDispatchAsync( + commandId, + claim, + result, + outcomeUnknownReason, + cancellationToken); + }); + + return settled; + } + catch (ProviderCommandFenceException ex) + { + _logger.LogWarning( + "Ignored a stale provider response for command '{ProviderCommandId}' with fence {FenceToken}; a newer owner controls settlement.", + commandId, + ex.ProvidedFenceToken); + + return await _commandManager.FindByCommandIdAsync(commandId, CancellationToken.None); + } + catch (ConcurrencyException) + { + return await _commandManager.FindByCommandIdAsync(commandId, CancellationToken.None); + } + catch (ProviderCommandTransitionException) + { + return await _commandManager.FindByCommandIdAsync(commandId, CancellationToken.None); + } + } + + private async Task SettleReconciliationInFreshScopeAsync( + string commandId, + ProviderCommandClaim claim, + ContactCenterVoiceCommandReconciliationResult result, + string inconclusiveReason, + CancellationToken cancellationToken) + { + ProviderCommand settled = null; + + try + { + await _scopeExecutor.ExecuteAsync(async processor => + { + settled = await processor.SettleReconciliationAsync( + commandId, + claim, + result, + inconclusiveReason, + cancellationToken); + }); + + return settled; + } + catch (ProviderCommandFenceException ex) + { + _logger.LogWarning( + "Ignored a stale provider reconciliation response for command '{ProviderCommandId}' with fence {FenceToken}; a newer owner controls settlement.", + commandId, + ex.ProvidedFenceToken); + + return await _commandManager.FindByCommandIdAsync(commandId, CancellationToken.None); + } + catch (ConcurrencyException) + { + return await _commandManager.FindByCommandIdAsync(commandId, CancellationToken.None); + } + catch (ProviderCommandTransitionException) + { + return await _commandManager.FindByCommandIdAsync(commandId, CancellationToken.None); + } + } + + private static bool IsRecoverable(ProviderCommand command) + { + return command?.Status is ProviderCommandStatus.Pending + or ProviderCommandStatus.OutcomeUnknown + or ProviderCommandStatus.Compensating; + } + + private IProviderCommandTypeExecutor ResolveExecutor(ProviderCommand command) + { + IProviderCommandTypeExecutor found = null; + var hasDuplicate = false; + + foreach (var executor in _executors) + { + if (executor.CommandType != command.CommandType) + { + continue; + } + + if (found is not null) + { + hasDuplicate = true; + break; + } + + found = executor; + } + + return hasDuplicate ? null : found; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandStateService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandStateService.cs new file mode 100644 index 000000000..bdcc8279c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandStateService.cs @@ -0,0 +1,573 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . Each transition loads +/// the current command, validates it against the legal transition graph and any required fence and owner +/// tokens, then persists the change through the provider command manager and commits it in the tenant +/// session so the durable state and the fence advance atomically. +/// +public sealed class ProviderCommandStateService : IProviderCommandStateService +{ + private static readonly TimeSpan _registrationLockTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan _registrationLockExpiration = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _initialRecoveryDelay = TimeSpan.FromMinutes(5); + private static readonly Dictionary _allowedTransitions = + new Dictionary + { + [ProviderCommandStatus.Pending] = [ProviderCommandStatus.Claimed, ProviderCommandStatus.Compensating, ProviderCommandStatus.Failed], + [ProviderCommandStatus.Claimed] = [ProviderCommandStatus.Sent, ProviderCommandStatus.Pending, ProviderCommandStatus.Failed], + [ProviderCommandStatus.Sent] = [ProviderCommandStatus.Pending, ProviderCommandStatus.Confirmed, ProviderCommandStatus.OutcomeUnknown, ProviderCommandStatus.Compensating, ProviderCommandStatus.Failed], + [ProviderCommandStatus.OutcomeUnknown] = [ProviderCommandStatus.Confirmed, ProviderCommandStatus.Compensating, ProviderCommandStatus.Paused, ProviderCommandStatus.Failed], + [ProviderCommandStatus.Paused] = [ProviderCommandStatus.Confirmed, ProviderCommandStatus.Compensating, ProviderCommandStatus.OutcomeUnknown, ProviderCommandStatus.Failed], + [ProviderCommandStatus.Compensating] = [ProviderCommandStatus.Compensated, ProviderCommandStatus.Failed], + [ProviderCommandStatus.Confirmed] = [], + [ProviderCommandStatus.Compensated] = [], + [ProviderCommandStatus.Failed] = [], + }; + + private readonly IProviderCommandManager _manager; + private readonly ISession _session; + private readonly IDistributedLock _distributedLock; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The provider command manager used to read and persist commands. + /// The tenant YesSql session used to commit each transition. + /// The distributed lock used to serialize command registration by idempotency key. + /// The clock used to stamp transition times. + public ProviderCommandStateService( + IProviderCommandManager manager, + ISession session, + IDistributedLock distributedLock, + IClock clock) + { + _manager = manager; + _session = session; + _distributedLock = distributedLock; + _clock = clock; + } + + /// + public async Task RegisterAsync(ProviderCommandRegistration registration, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(registration); + ArgumentException.ThrowIfNullOrEmpty(registration.CommandId); + ArgumentException.ThrowIfNullOrEmpty(registration.ProviderName); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + $"ContactCenterProviderCommand:Register:{registration.CommandId}", + _registrationLockTimeout, + _registrationLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"The provider command '{registration.CommandId}' is currently being registered."); + } + + await using var acquiredLock = locker; + + var existing = await _manager.FindByCommandIdAsync(registration.CommandId, cancellationToken); + + if (existing is not null) + { + return existing; + } + + var now = _clock.UtcNow; + var command = await _manager.NewAsync(cancellationToken: cancellationToken); + command.CommandId = registration.CommandId; + command.ProviderName = registration.ProviderName; + command.CommandType = registration.CommandType; + command.ActivityItemId = registration.ActivityItemId; + command.InteractionId = registration.InteractionId; + command.ReservationId = registration.ReservationId; + command.RemoveReservationFromQueueOnFailure = registration.RemoveReservationFromQueueOnFailure; + command.DialerProfileId = registration.DialerProfileId; + command.RequestPayload = registration.RequestPayload; + command.Status = ProviderCommandStatus.Pending; + command.FenceToken = 0; + command.OwnerToken = null; + command.LeaseExpiresUtc = now; + command.NextAttemptUtc = now.Add(_initialRecoveryDelay); + command.CreatedUtc = now; + command.ModifiedUtc = now; + + await _manager.CreateAsync(command, cancellationToken: cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return command; + } + + /// + public async Task TryClaimAsync(string commandId, TimeSpan leaseDuration, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + + if (leaseDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(leaseDuration), "The lease duration must be positive."); + } + + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + var claimable = command.Status == ProviderCommandStatus.Pending || + (command.Status == ProviderCommandStatus.Claimed && command.LeaseExpiresUtc <= now); + + if (!claimable) + { + return null; + } + + command.FenceToken += 1; + command.OwnerToken = IdGenerator.GenerateId(); + command.LeaseExpiresUtc = now.Add(leaseDuration); + command.Status = ProviderCommandStatus.Claimed; + command.AttemptCount += 1; + command.ModifiedUtc = now; + + try + { + await PersistAsync(command, cancellationToken); + } + catch (ConcurrencyException) + { + return null; + } + + return CreateClaim(command); + } + + /// + public async Task MarkSentAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Sent); + EnsureClaim(command, claim, now); + + command.Status = ProviderCommandStatus.Sent; + command.SentUtc = now; + command.ProviderReference = providerReference ?? command.ProviderReference; + command.ModifiedUtc = now; + + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task DeferSentAsync( + string commandId, + ProviderCommandClaim claim, + string reason, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reason); + + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Pending); + EnsureClaim(command, claim, now); + + command.Status = ProviderCommandStatus.Pending; + command.OwnerToken = null; + command.LeaseExpiresUtc = default; + command.SentUtc = null; + command.NextAttemptUtc = now.Add(_initialRecoveryDelay); + command.LastError = reason; + command.ModifiedUtc = now; + + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task ConfirmSentAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default) + { + var command = await StageConfirmSentAsync(commandId, claim, providerReference, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return command; + } + + /// + public async Task StageConfirmSentAsync(string commandId, ProviderCommandClaim claim, string providerReference = null, CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Confirmed); + EnsureClaim(command, claim, now); + + ApplyConfirmed(command, providerReference, now); + + await _manager.UpdateAsync(command, cancellationToken: cancellationToken); + + return command; + } + + /// + public async Task MarkOutcomeUnknownAsync(string commandId, ProviderCommandClaim claim, string reason = null, CancellationToken cancellationToken = default) + { + var command = await StageOutcomeUnknownAsync(commandId, claim, reason, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return command; + } + + /// + public async Task StageOutcomeUnknownAsync(string commandId, ProviderCommandClaim claim, string reason = null, CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.OutcomeUnknown); + EnsureClaim(command, claim, now); + + command.Status = ProviderCommandStatus.OutcomeUnknown; + command.LastError = reason; + command.NextAttemptUtc = now; + command.ModifiedUtc = now; + + await _manager.UpdateAsync(command, cancellationToken: cancellationToken); + + return command; + } + + /// + public async Task EscalateExpiredLeaseAsync(string commandId, CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + if (command.LeaseExpiresUtc > now) + { + return command; + } + + var target = command.Status switch + { + ProviderCommandStatus.Sent => ProviderCommandStatus.OutcomeUnknown, + ProviderCommandStatus.Claimed => ProviderCommandStatus.Pending, + _ => (ProviderCommandStatus?)null, + }; + + if (target is null) + { + return command; + } + + EnsureTransitionAllowed(command, target.Value); + + command.Status = target.Value; + command.OwnerToken = null; + command.NextAttemptUtc = now; + command.ModifiedUtc = now; + + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task TryClaimReconciliationAsync( + string commandId, + TimeSpan leaseDuration, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + + if (leaseDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(leaseDuration), "The lease duration must be positive."); + } + + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + if (command.Status != ProviderCommandStatus.OutcomeUnknown || command.LeaseExpiresUtc > now) + { + return null; + } + + command.FenceToken += 1; + command.OwnerToken = IdGenerator.GenerateId(); + command.LeaseExpiresUtc = now.Add(leaseDuration); + command.ReconcileCount += 1; + command.ModifiedUtc = now; + + try + { + await PersistAsync(command, cancellationToken); + } + catch (ConcurrencyException) + { + return null; + } + + return CreateClaim(command); + } + + /// + public async Task ConfirmFromReconciliationAsync( + string commandId, + ProviderCommandClaim claim, + string providerReference = null, + CancellationToken cancellationToken = default) + { + var command = await StageConfirmFromReconciliationAsync(commandId, claim, providerReference, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return command; + } + + /// + public async Task StageConfirmFromReconciliationAsync( + string commandId, + ProviderCommandClaim claim, + string providerReference = null, + CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Confirmed); + EnsureClaim(command, claim, now); + + ApplyConfirmed(command, providerReference, now); + + await _manager.UpdateAsync(command, cancellationToken: cancellationToken); + + return command; + } + + /// + public async Task BeginPendingCompensationAsync(string commandId, string reason = null, CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + + if (command.Status != ProviderCommandStatus.Pending) + { + throw new ProviderCommandTransitionException( + command.CommandId, + command.Status, + ProviderCommandStatus.Compensating); + } + + EnsureTransitionAllowed(command, ProviderCommandStatus.Compensating); + + var now = _clock.UtcNow; + ApplyCompensating(command, reason, now); + + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task BeginCompensationAsync( + string commandId, + ProviderCommandClaim claim, + string reason = null, + CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Compensating); + EnsureClaim(command, claim, now); + + ApplyCompensating(command, reason, now); + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task TryClaimCompensationAsync( + string commandId, + TimeSpan leaseDuration, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + + if (leaseDuration <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(leaseDuration), "The lease duration must be positive."); + } + + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + if (command.Status != ProviderCommandStatus.Compensating || command.LeaseExpiresUtc > now) + { + return null; + } + + command.FenceToken += 1; + command.OwnerToken = IdGenerator.GenerateId(); + command.LeaseExpiresUtc = now.Add(leaseDuration); + command.AttemptCount += 1; + command.ModifiedUtc = now; + + try + { + await PersistAsync(command, cancellationToken); + } + catch (ConcurrencyException) + { + return null; + } + + return CreateClaim(command); + } + + /// + public async Task CompleteCompensationAsync( + string commandId, + ProviderCommandClaim claim, + CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Compensated); + EnsureClaim(command, claim, now); + + command.Status = ProviderCommandStatus.Compensated; + command.OwnerToken = null; + command.LeaseExpiresUtc = now; + command.CompletedUtc = now; + command.ModifiedUtc = now; + + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task PauseAsync( + string commandId, + ProviderCommandClaim claim, + string reason = null, + CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + var now = _clock.UtcNow; + + EnsureTransitionAllowed(command, ProviderCommandStatus.Paused); + EnsureClaim(command, claim, now); + + ApplyPaused(command, reason, now); + await PersistAsync(command, cancellationToken); + + return command; + } + + /// + public async Task FailAsync(string commandId, string reason = null, CancellationToken cancellationToken = default) + { + var command = await LoadRequiredAsync(commandId, cancellationToken); + + EnsureTransitionAllowed(command, ProviderCommandStatus.Failed); + + var now = _clock.UtcNow; + command.Status = ProviderCommandStatus.Failed; + command.LastError = reason; + command.CompletedUtc = now; + command.ModifiedUtc = now; + + await PersistAsync(command, cancellationToken); + + return command; + } + + private static void ApplyConfirmed(ProviderCommand command, string providerReference, DateTime now) + { + command.Status = ProviderCommandStatus.Confirmed; + command.ProviderReference = providerReference ?? command.ProviderReference; + command.CompletedUtc = now; + command.ModifiedUtc = now; + } + + private static void ApplyCompensating(ProviderCommand command, string reason, DateTime now) + { + command.Status = ProviderCommandStatus.Compensating; + command.LastError = reason; + command.OwnerToken = null; + command.LeaseExpiresUtc = now; + command.NextAttemptUtc = now; + command.ModifiedUtc = now; + } + + private static void ApplyPaused(ProviderCommand command, string reason, DateTime now) + { + command.Status = ProviderCommandStatus.Paused; + command.LastError = reason; + command.OwnerToken = null; + command.LeaseExpiresUtc = now; + command.ModifiedUtc = now; + } + + private static ProviderCommandClaim CreateClaim(ProviderCommand command) + { + return new ProviderCommandClaim + { + CommandId = command.CommandId, + FenceToken = command.FenceToken, + OwnerToken = command.OwnerToken, + LeaseExpiresUtc = command.LeaseExpiresUtc, + }; + } + + private static void EnsureTransitionAllowed(ProviderCommand command, ProviderCommandStatus target) + { + if (!_allowedTransitions.TryGetValue(command.Status, out var allowed) || !allowed.Contains(target)) + { + throw new ProviderCommandTransitionException(command.CommandId, command.Status, target); + } + } + + private static void EnsureClaim(ProviderCommand command, ProviderCommandClaim claim, DateTime now) + { + ArgumentNullException.ThrowIfNull(claim); + + if (command.FenceToken != claim.FenceToken || + !string.Equals(command.OwnerToken, claim.OwnerToken, StringComparison.Ordinal) || + command.LeaseExpiresUtc <= now) + { + throw new ProviderCommandFenceException(command.CommandId, command.FenceToken, claim.FenceToken); + } + } + + private async Task LoadRequiredAsync(string commandId, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + + var command = await _manager.FindByCommandIdAsync(commandId, cancellationToken); + + if (command is null) + { + throw new InvalidOperationException($"The provider command '{commandId}' does not exist."); + } + + return command; + } + + private async Task PersistAsync(ProviderCommand command, CancellationToken cancellationToken) + { + await _manager.UpdateAsync(command, cancellationToken: cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandStore.cs new file mode 100644 index 000000000..97dbb977f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandStore.cs @@ -0,0 +1,74 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-backed provider command store. Updates use document-version optimistic concurrency so +/// two workers racing on the same command cannot both win a transition. +/// +public sealed class ProviderCommandStore : DocumentCatalog, IProviderCommandStore +{ + /// + /// The default maximum number of commands returned by a batch query. + /// + public const int DefaultBatchSize = 100; + + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session. + public ProviderCommandStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByCommandIdAsync(string commandId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(commandId); + + return await Session.Query( + index => index.CommandId == commandId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListDueAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? DefaultBatchSize : maxCount; + var commands = await Session.Query( + index => (index.Status == ProviderCommandStatus.Pending || + index.Status == ProviderCommandStatus.OutcomeUnknown || + index.Status == ProviderCommandStatus.Compensating) && + index.NextAttemptUtc <= nowUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.NextAttemptUtc) + .Take(take) + .ListAsync(cancellationToken); + + return commands.ToArray(); + } + + /// + public async Task> ListReclaimableAsync(DateTime nowUtc, int maxCount, CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? DefaultBatchSize : maxCount; + var commands = await Session.Query( + index => (index.Status == ProviderCommandStatus.Claimed || index.Status == ProviderCommandStatus.Sent) && + index.LeaseExpiresUtc <= nowUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.LeaseExpiresUtc) + .Take(take) + .ListAsync(cancellationToken); + + return commands.ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandTransitionException.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandTransitionException.cs new file mode 100644 index 000000000..b79828edb --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCommandTransitionException.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// The exception thrown when a provider command is asked to move between two states that the durable state +/// machine does not permit. The command is never mutated when this exception is raised. +/// +public sealed class ProviderCommandTransitionException : InvalidOperationException +{ + /// + /// Initializes a new instance of the class. + /// + /// The stable identifier of the command that could not transition. + /// The current status of the command. + /// The requested target status. + public ProviderCommandTransitionException( + string commandId, + ProviderCommandStatus from, + ProviderCommandStatus to) + : base($"The provider command '{commandId}' cannot transition from '{from}' to '{to}'.") + { + CommandId = commandId; + From = from; + To = to; + } + + /// + /// Gets the stable identifier of the command that could not transition. + /// + public string CommandId { get; } + + /// + /// Gets the current status of the command. + /// + public ProviderCommandStatus From { get; } + + /// + /// Gets the requested target status. + /// + public ProviderCommandStatus To { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventInboxHandler.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventInboxHandler.cs new file mode 100644 index 000000000..9ed0a9e5e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventInboxHandler.cs @@ -0,0 +1,43 @@ +using System.Text.Json; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Processes normalized provider voice events from the durable webhook inbox. +/// +public sealed class ProviderVoiceEventInboxHandler : IProviderWebhookInboxHandler +{ + /// + /// The stable handler technical name persisted with normalized provider voice events. + /// + public const string HandlerTechnicalName = "provider-voice-event"; + + private readonly IProviderVoiceEventService _providerVoiceEventService; + + /// + /// Initializes a new instance of the class. + /// + /// The provider voice event service. + public ProviderVoiceEventInboxHandler(IProviderVoiceEventService providerVoiceEventService) + { + _providerVoiceEventService = providerVoiceEventService; + } + + /// + public string TechnicalName => HandlerTechnicalName; + + /// + public ContactCenterHandlerReplaySafety ReplaySafety => ContactCenterHandlerReplaySafety.GuardedByDurableStore; + + /// + public async Task HandleAsync(string payload, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(payload); + + var providerEvent = JsonSerializer.Deserialize(payload) + ?? throw new InvalidDataException("The provider voice event payload could not be deserialized."); + + await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs new file mode 100644 index 000000000..d7bf5df58 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs @@ -0,0 +1,904 @@ +using System.Text.Json; +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Core.Models; +using CrestApps.OrchardCore.Telephony.Core.Services; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Logging; +using OrchardCore; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ProviderVoiceEventService : IProviderVoiceEventService +{ + private const int MaxIngestionAttempts = 3; + + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly ITelephonyProviderResolver _telephonyProviderResolver; + private readonly IInteractionEventStore _eventStore; + private readonly IContactCenterEventPublisher _publisher; + private readonly IAgentPresenceManager _presenceManager; + private readonly IProviderIdentityResolver _providerIdentityResolver; + private readonly IProviderCommandStateService _providerCommandStateService; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly ISession _session; + private readonly IVoiceIngressGate _ingressGate; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The call session manager. + /// The voice provider resolver used to bridge answered outbound calls. + /// The telephony provider resolver used to protect provider-scoped call identities. + /// The interaction event store used to de-duplicate provider events. + /// The Contact Center event publisher. + /// The presence manager used to move agents into wrap-up after handled calls end. + /// The resolver used to canonicalize provider aliases before keying. + /// The service used to persist outbound bridge intent. + /// The executor used to wake provider-command processing after commit. + /// The YesSql session used to commit provider truth before releasing the ingestion lock. + /// The provider-neutral gate that serializes each provider call stream. + /// The clock used to stamp times. + /// The logger instance. + public ProviderVoiceEventService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IContactCenterVoiceProviderResolver voiceProviderResolver, + ITelephonyProviderResolver telephonyProviderResolver, + IInteractionEventStore eventStore, + IContactCenterEventPublisher publisher, + IAgentPresenceManager presenceManager, + IProviderIdentityResolver providerIdentityResolver, + IProviderCommandStateService providerCommandStateService, + IContactCenterScopeExecutor scopeExecutor, + ISession session, + IVoiceIngressGate ingressGate, + IClock clock, + ILogger logger) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _voiceProviderResolver = voiceProviderResolver; + _telephonyProviderResolver = telephonyProviderResolver; + _eventStore = eventStore; + _publisher = publisher; + _presenceManager = presenceManager; + _providerIdentityResolver = providerIdentityResolver; + _providerCommandStateService = providerCommandStateService; + _scopeExecutor = scopeExecutor; + _session = session; + _ingressGate = ingressGate; + _clock = clock; + _logger = logger; + } + + /// + public async Task IngestAsync(ProviderVoiceEvent providerEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(providerEvent); + + if (string.IsNullOrEmpty(providerEvent.ProviderCallId)) + { + return null; + } + + try + { + return await IngestCoreAsync(providerEvent, cancellationToken); + } + catch (ConcurrencyException) + { + // The retry starts from the event as it was handed in, because the adjustments the attempt made + // are derived from it and re-deriving them is what makes the retry equivalent to the first try. + return await RetryInFreshScopeAsync(providerEvent, cancellationToken); + } + } + + private async Task IngestCoreAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken) + { + // Canonicalize the provider identity before any interaction, call, or event key is built so that + // provider-contributed aliases (for example "Default Asterisk") collapse to a single stable + // identity ("Asterisk") instead of mutating the stored provider name. + var canonicalProviderName = _providerIdentityResolver.Canonicalize(providerEvent.ProviderName); + + // Scope the provider-supplied idempotency key by the canonical provider so identical raw delivery + // identifiers emitted by different providers (for example the same numeric id from Asterisk and + // DialPad) cannot collide in the shared interaction-event idempotency space. Non-provider domain + // events are unaffected because this path only runs for normalized provider voice events. + var legacyIdempotencyKey = providerEvent.IdempotencyKey; + + providerEvent = providerEvent with + { + ProviderName = canonicalProviderName, + IdempotencyKey = ContactCenterClaimKeys.BuildProviderEventIdempotencyKey( + canonicalProviderName, + legacyIdempotencyKey), + }; + + // The gate is the single lock authority for the stream. When ingestion was reached through the + // provider-neutral fan-out the lease is already held, and the gate satisfies this request + // re-entrantly instead of taking a second lock on the same call. + await using var acquiredLock = await _ingressGate.AcquireAsync( + providerEvent.ProviderName, + providerEvent.ProviderCallId, + cancellationToken); + + Interaction interaction = null; + var matchedByCallIdOnly = false; + + if (!string.IsNullOrWhiteSpace(providerEvent.ProviderName)) + { + interaction = await _interactionManager.FindByProviderInteractionIdAsync( + providerEvent.ProviderName, + providerEvent.ProviderCallId, + cancellationToken); + } + + if (interaction is null) + { + interaction = await _interactionManager.FindByProviderInteractionIdAsync(providerEvent.ProviderCallId, cancellationToken); + matchedByCallIdOnly = interaction is not null; + } + + if (interaction is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Received a provider voice event for call '{ProviderCallId}' that does not match any interaction.", + providerEvent.ProviderCallId.SanitizeLogValue()); + } + + return null; + } + + if (matchedByCallIdOnly && + !string.IsNullOrWhiteSpace(providerEvent.ProviderName) && + !string.IsNullOrWhiteSpace(interaction.ProviderName) && + !string.Equals(interaction.ProviderName, providerEvent.ProviderName, StringComparison.Ordinal) && + (_voiceProviderResolver.Get(interaction.ProviderName) is not null || + await _telephonyProviderResolver.GetAsync(interaction.ProviderName) is not null)) + { + _logger.LogWarning( + "Ignored provider voice event for call '{ProviderCallId}' from provider '{ProviderName}' because the call id matched an interaction owned by active provider '{StoredProviderName}'.", + providerEvent.ProviderCallId.SanitizeLogValue(), + providerEvent.ProviderName, + interaction.ProviderName); + + return null; + } + + var providerNameCanonicalized = false; + + if (!string.IsNullOrWhiteSpace(providerEvent.ProviderName) && + !string.Equals(interaction.ProviderName, providerEvent.ProviderName, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Provider voice event for call '{ProviderCallId}' used provider '{ProviderName}', but the matching interaction was stored as '{StoredProviderName}'. Canonicalizing the interaction to the event provider.", + providerEvent.ProviderCallId.SanitizeLogValue(), + providerEvent.ProviderName, + interaction.ProviderName); + + interaction.ProviderName = providerEvent.ProviderName; + providerNameCanonicalized = true; + } + + var duplicateEvent = !string.IsNullOrEmpty(providerEvent.IdempotencyKey) && + await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, cancellationToken); + + if (!duplicateEvent && + !string.IsNullOrEmpty(legacyIdempotencyKey) && + !string.Equals(legacyIdempotencyKey, providerEvent.IdempotencyKey, StringComparison.Ordinal)) + { + var interactionEvents = await _eventStore.ListByInteractionAsync(interaction.ItemId, cancellationToken); + duplicateEvent = interactionEvents?.Any(interactionEvent => + string.Equals(interactionEvent.IdempotencyKey, legacyIdempotencyKey, StringComparison.Ordinal)) == true; + } + + if (duplicateEvent) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipping duplicate provider voice event with idempotency key '{IdempotencyKey}'.", + providerEvent.IdempotencyKey.SanitizeLogValue()); + } + + if (providerNameCanonicalized) + { + await _session.SaveChangesAsync(cancellationToken); + } + + return (!string.IsNullOrWhiteSpace(providerEvent.ProviderName) + ? await _callSessionManager.FindByProviderCallIdAsync( + providerEvent.ProviderName, + providerEvent.ProviderCallId, + cancellationToken) + : await _callSessionManager.FindByProviderCallIdAsync(providerEvent.ProviderCallId, cancellationToken)) + ?? await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + } + + var now = providerEvent.OccurredUtc ?? _clock.UtcNow; + var session = await EnsureSessionAsync(interaction, providerEvent, now, cancellationToken); + + if (session is null) + { + _logger.LogWarning( + "Refused provider voice event for call '{ProviderCallId}' because the interaction-matched session is already bound to a different call.", + providerEvent.ProviderCallId.SanitizeLogValue()); + + await _session.SaveChangesAsync(cancellationToken); + + return null; + } + + if (ShouldIgnoreEvent(session, providerEvent, now)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Ignored stale provider voice event '{IdempotencyKey}' for call '{ProviderCallId}'. Current state: {CurrentState}; incoming state: {IncomingState}; last provider event: {LastProviderEventUtc}; incoming event: {OccurredUtc}.", + providerEvent.IdempotencyKey.SanitizeLogValue(), + providerEvent.ProviderCallId.SanitizeLogValue(), + session.State, + providerEvent.State, + session.LastProviderEventUtc, + now); + } + + await _session.SaveChangesAsync(cancellationToken); + + return session; + } + + var previousState = session.State; + var previousIsMuted = session.IsMuted; + var previousRecordingState = session.RecordingState; + var previousIsConference = session.IsConference; + var previousParticipantCount = session.ParticipantCount; + + ApplyState(session, interaction, providerEvent.State, now); + ApplyProviderDetails(session, interaction, providerEvent, now); + ApplyHangupCause(session, providerEvent); + + // The watermark is monotonic. Accepting a late terminal delivery must not rewind it, because a rewound + // watermark would re-admit deliveries the machine has already decided are stale. + session.LastProviderEventUtc = session.LastProviderEventUtc.HasValue && session.LastProviderEventUtc.Value > now + ? session.LastProviderEventUtc.Value + : now; + + if (providerEvent.SequenceNumber.HasValue) + { + session.HighWaterSequence = session.HighWaterSequence.HasValue + ? Math.Max(session.HighWaterSequence.Value, providerEvent.SequenceNumber.Value) + : providerEvent.SequenceNumber.Value; + } + + var startsWrapUp = IsTerminalState(providerEvent.State) && + !string.IsNullOrEmpty(session.AgentId) && + (session.AnsweredUtc.HasValue || interaction.AnsweredUtc.HasValue); + + if (startsWrapUp) + { + interaction.WrapUpStartedUtc ??= now; + } + + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + if (startsWrapUp) + { + await _presenceManager.StartWrapUpAsync(session.AgentId, cancellationToken); + } + + foreach (var eventType in ResolveEventTypes( + previousState, + session.State, + previousIsMuted, + session.IsMuted, + previousRecordingState, + session.RecordingState, + previousIsConference, + session.IsConference, + previousParticipantCount, + session.ParticipantCount)) + { + var idempotencyKey = ResolveEventIdempotencyKey(providerEvent.IdempotencyKey, eventType); + + await PublishAsync(eventType, interaction.ItemId, session.AgentId, idempotencyKey, cancellationToken); + } + + if (providerEvent.State == VoiceCallState.Connected) + { + await StageAnsweredOutboundBridgeAsync(session, interaction, cancellationToken); + } + + await _session.SaveChangesAsync(cancellationToken); + + return session; + } + + private async Task RetryInFreshScopeAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken) + { + Exception lastException = null; + + for (var attempt = 2; attempt <= MaxIngestionAttempts; attempt++) + { + CallSession session = null; + + try + { + await _scopeExecutor.ExecuteAsync(async service => + { + session = await service.IngestAsync(providerEvent, cancellationToken); + }); + + return session; + } + catch (ConcurrencyException exception) + { + lastException = exception; + } + } + + throw lastException ?? new ConcurrencyException(new Document()); + } + + private async Task EnsureSessionAsync( + Interaction interaction, + ProviderVoiceEvent providerEvent, + DateTime now, + CancellationToken cancellationToken) + { + var session = (!string.IsNullOrWhiteSpace(providerEvent.ProviderName) + ? await _callSessionManager.FindByProviderCallIdAsync( + providerEvent.ProviderName, + providerEvent.ProviderCallId, + cancellationToken) + : await _callSessionManager.FindByProviderCallIdAsync(providerEvent.ProviderCallId, cancellationToken)) + ?? await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (session is not null) + { + if (string.IsNullOrWhiteSpace(session.ProviderCallId) && + !string.IsNullOrWhiteSpace(providerEvent.ProviderCallId)) + { + session.ProviderCallId = providerEvent.ProviderCallId; + + if (string.IsNullOrWhiteSpace(session.ProviderName)) + { + session.ProviderName = providerEvent.ProviderName; + } + + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + } + else if (!string.IsNullOrWhiteSpace(session.ProviderCallId) && + !string.IsNullOrWhiteSpace(providerEvent.ProviderCallId) && + !string.Equals(session.ProviderCallId, providerEvent.ProviderCallId, StringComparison.Ordinal)) + { + // The interaction-matched session is bound to a DIFFERENT call id. + // Returning null causes IngestCoreAsync to refuse this event rather + // than mis-applying it to the wrong session. + return null; + } + + return session; + } + + session = await _callSessionManager.NewAsync(cancellationToken: cancellationToken); + session.InteractionId = interaction.ItemId; + session.ActivityItemId = interaction.ActivityItemId; + session.ProviderName = interaction.ProviderName ?? providerEvent.ProviderName; + session.ProviderCallId = providerEvent.ProviderCallId; + session.Direction = interaction.Direction; + session.AgentId = interaction.AgentId; + session.QueueId = interaction.QueueId; + session.FromAddress = providerEvent.FromAddress ?? interaction.CustomerAddress; + session.ToAddress = providerEvent.ToAddress; + + // Seed the freshly created session with the interaction's pre-event state instead of the incoming + // provider state. When the very first observed provider state is terminal (for example, a + // reconciliation sweep that discovers the call no longer exists on the provider), this preserves a + // real non-terminal -> terminal transition so the CallEnded event is still published and queue, + // reservation, and agent cleanup runs. Without this seed the session would be created already + // terminal, ResolveEventTypes would see no transition, and the offer would never be released. + session.MirrorProviderState(ResolveInitialSessionState(interaction)); + session.RecordingState = interaction.RecordingState; + session.RecordingReference = interaction.RecordingReference; + session.CreatedUtc = now; + session.LastProviderEventUtc = now; + await _callSessionManager.CreateAsync(session, cancellationToken: cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.CallSessionCreated, interaction.ItemId, session.AgentId, idempotencyKey: null, cancellationToken); + + return session; + } + + private static bool ShouldIgnoreEvent(CallSession session, ProviderVoiceEvent providerEvent, DateTime occurredUtc) + { + return VoiceStreamOrdering.ShouldDiscard( + new VoiceStreamWatermark + { + Phase = GetLifecyclePhase(session.State), + HighWaterSequence = session.HighWaterSequence, + LastEventUtc = session.LastProviderEventUtc, + }, + new VoiceStreamDelivery + { + Phase = GetLifecyclePhase(providerEvent.State), + SequenceNumber = providerEvent.SequenceNumber, + OccurredUtc = occurredUtc, + }); + } + + private static VoiceCallLifecyclePhase GetLifecyclePhase(VoiceCallState state) + { + return state switch + { + VoiceCallState.Planned => VoiceCallLifecyclePhase.Planned, + VoiceCallState.Dialing => VoiceCallLifecyclePhase.Alerting, + VoiceCallState.Ringing => VoiceCallLifecyclePhase.Alerting, + VoiceCallState.Connected => VoiceCallLifecyclePhase.Established, + VoiceCallState.OnHold => VoiceCallLifecyclePhase.Established, + VoiceCallState.Ending => VoiceCallLifecyclePhase.Ending, + _ => VoiceCallLifecyclePhase.Terminal, + }; + } + + private static void ApplyState(CallSession session, Interaction interaction, VoiceCallState state, DateTime now) + { + session.MirrorProviderState(state); + session.IsMuted = state is VoiceCallState.Ended or + VoiceCallState.Failed or + VoiceCallState.NoAnswer or + VoiceCallState.Rejected or + VoiceCallState.Canceled or + VoiceCallState.Transferred + ? false + : session.IsMuted; + + switch (state) + { + case VoiceCallState.Dialing: + case VoiceCallState.Ringing: + session.StartedUtc ??= now; + break; + case VoiceCallState.Connected: + session.StartedUtc ??= now; + session.AnsweredUtc ??= now; + session.IsOnHold = false; + break; + case VoiceCallState.OnHold: + session.IsOnHold = true; + break; + case VoiceCallState.Ending: + break; + case VoiceCallState.Ended: + case VoiceCallState.Failed: + case VoiceCallState.NoAnswer: + case VoiceCallState.Rejected: + case VoiceCallState.Canceled: + case VoiceCallState.Transferred: + session.EndedUtc ??= now; + session.IsOnHold = false; + + if (session.AnsweredUtc.HasValue) + { + session.TalkSeconds = Math.Max(0, (now - session.AnsweredUtc.Value).TotalSeconds - session.HoldSeconds); + } + + break; + } + + // The call session is the authority for a provider-backed call, and this projection keeps the interaction + // reporting the same thing the session does. Ordering was already decided upstream, so re-deciding it + // here with the interaction lifecycle table would let the two records disagree instead of agreeing. + interaction.MirrorSessionStatus(MapInteractionStatus(state)); + + switch (state) + { + case VoiceCallState.Connected: + interaction.StartedUtc ??= now; + interaction.AnsweredUtc ??= now; + break; + case VoiceCallState.Ended: + case VoiceCallState.Failed: + case VoiceCallState.NoAnswer: + case VoiceCallState.Rejected: + case VoiceCallState.Canceled: + case VoiceCallState.Transferred: + interaction.EndedUtc ??= now; + break; + } + } + + private static void ApplyProviderDetails(CallSession session, Interaction interaction, ProviderVoiceEvent providerEvent, DateTime now) + { + if (!string.IsNullOrWhiteSpace(providerEvent.ProviderName)) + { + session.ProviderName = providerEvent.ProviderName; + interaction.ProviderName = providerEvent.ProviderName; + } + + if (!string.IsNullOrWhiteSpace(providerEvent.FromAddress)) + { + session.FromAddress = providerEvent.FromAddress; + } + + if (!string.IsNullOrWhiteSpace(providerEvent.ToAddress)) + { + session.ToAddress = providerEvent.ToAddress; + } + + // A terminal call is normalized as not muted by ApplyState, which runs first. Re-applying a provider + // mute flag here would contradict that decision and leave an ended call flagged as muted, so the flag + // is honored only while the call is still live. + if (providerEvent.IsMuted.HasValue && !IsTerminalState(session.State)) + { + session.IsMuted = providerEvent.IsMuted.Value; + } + + if (providerEvent.RecordingState.HasValue) + { + session.RecordingState = providerEvent.RecordingState.Value; + interaction.RecordingState = providerEvent.RecordingState.Value; + } + + if (!string.IsNullOrWhiteSpace(providerEvent.RecordingReference)) + { + session.RecordingReference = providerEvent.RecordingReference; + interaction.RecordingReference = providerEvent.RecordingReference; + } + + ApplyTopology(session, providerEvent, now); + + if (providerEvent.Metadata.Count > 0) + { + foreach (var entry in providerEvent.Metadata) + { + session.Metadata[entry.Key] = entry.Value; + } + } + + if (providerEvent.AnswerClassification.HasValue) + { + var classificationValue = providerEvent.AnswerClassification.Value.ToString(); + + session.Metadata[ContactCenterConstants.TelephonyMetadata.AnswerClassification] = classificationValue; + interaction.TechnicalMetadata[ContactCenterConstants.TelephonyMetadata.AnswerClassification] = classificationValue; + } + } + + private static void ApplyTopology(CallSession session, ProviderVoiceEvent providerEvent, DateTime now) + { + CallTopologyProjector.ApplyReportedParticipation( + session, + providerEvent.IsConference, + providerEvent.ParticipantCount, + now); + + // Providers that publish per-leg events name the leg; providers that publish per-call events do not, + // and for those the call itself is the only leg the platform can honestly claim to have observed. + var providerLegId = string.IsNullOrEmpty(providerEvent.ProviderLegId) + ? providerEvent.ProviderCallId + : providerEvent.ProviderLegId; + + if (string.IsNullOrEmpty(providerLegId)) + { + return; + } + + // The leg carried on the session's own call identifier is the party the contact center is serving. + // Any other leg on the same session belongs to a party the platform did not originate, so its role is + // left undetermined rather than guessed. + var role = string.Equals(providerLegId, session.ProviderCallId, StringComparison.Ordinal) + ? CallPartyRole.Customer + : CallPartyRole.Unknown; + + var address = session.Direction == InteractionDirection.Inbound + ? session.FromAddress + : session.ToAddress; + + if (IsTerminalState(providerEvent.State)) + { + // The session's own hangup cause is assigned after this projection runs, so the event's cause is + // read directly rather than a field that is still null on the delivery that ends the call. + CallTopologyProjector.EndLeg(session, providerLegId, now, providerEvent.HangupCause); + + // Every remaining leg ends with the call. A terminal session accepts no further deliveries, so a + // leg left open here would stay open, and the bridge would keep claiming a party that has gone. + CallTopologyProjector.EndRemainingLegs(session, now); + + // Stopping an engagement is refused once the call is terminal, so an engagement left live here + // could never be closed by the supervisor who opened it and would report someone as listening to a + // call that has ended. + CallTopologyProjector.EndRemainingMonitorSessions(session, now); + CallTopologyProjector.DestroyBridge(session, now); + + return; + } + + var status = MapCallLegStatus(providerEvent.State); + + CallTopologyProjector.UpsertLeg(session, providerLegId, role, status, now, address); + + if (status is CallLegStatus.Answered or CallLegStatus.OnHold) + { + CallTopologyProjector.EnsureBridge(session, session.Bridge?.ProviderBridgeId, now); + CallTopologyProjector.Join(session, providerLegId, role, now, address: address); + } + } + + private static CallLegStatus MapCallLegStatus(VoiceCallState state) + { + return state switch + { + VoiceCallState.Planned => CallLegStatus.Unknown, + VoiceCallState.Dialing => CallLegStatus.Dialing, + VoiceCallState.Ringing => CallLegStatus.Ringing, + VoiceCallState.Connected => CallLegStatus.Answered, + VoiceCallState.OnHold => CallLegStatus.OnHold, + VoiceCallState.Ending => CallLegStatus.Answered, + _ => CallLegStatus.Unknown, + }; + } + + private static InteractionStatus MapInteractionStatus(VoiceCallState state) + { + return state switch + { + VoiceCallState.Planned => InteractionStatus.Created, + VoiceCallState.Dialing => InteractionStatus.Ringing, + VoiceCallState.Ringing => InteractionStatus.Ringing, + VoiceCallState.Connected => InteractionStatus.Connected, + VoiceCallState.OnHold => InteractionStatus.Held, + VoiceCallState.Ending => InteractionStatus.Connected, + VoiceCallState.Transferred => InteractionStatus.Transferring, + VoiceCallState.Ended => InteractionStatus.Ended, + VoiceCallState.Failed => InteractionStatus.Failed, + VoiceCallState.NoAnswer => InteractionStatus.Failed, + VoiceCallState.Rejected => InteractionStatus.Failed, + VoiceCallState.Canceled => InteractionStatus.Failed, + _ => InteractionStatus.Created, + }; + } + + private static VoiceCallState ResolveInitialSessionState(Interaction interaction) + { + return interaction.Status switch + { + InteractionStatus.Connected => VoiceCallState.Connected, + InteractionStatus.Held => VoiceCallState.OnHold, + InteractionStatus.Transferring => VoiceCallState.Connected, + InteractionStatus.Conferenced => VoiceCallState.Connected, + _ => VoiceCallState.Ringing, + }; + } + + private static List ResolveEventTypes( + VoiceCallState previousState, + VoiceCallState currentState, + bool previousIsMuted, + bool currentIsMuted, + RecordingState previousRecordingState, + RecordingState currentRecordingState, + bool previousIsConference, + bool currentIsConference, + int previousParticipantCount, + int currentParticipantCount) + { + var eventTypes = new List + { + ContactCenterConstants.Events.CallSessionUpdated, + }; + + if (currentState == VoiceCallState.Connected && previousState != VoiceCallState.Connected) + { + eventTypes.Add(ContactCenterConstants.Events.CallConnected); + } + + if (currentState == VoiceCallState.OnHold && previousState != VoiceCallState.OnHold) + { + eventTypes.Add(ContactCenterConstants.Events.CallHeld); + } + + if (previousState == VoiceCallState.OnHold && currentState == VoiceCallState.Connected) + { + eventTypes.Add(ContactCenterConstants.Events.CallResumed); + } + + if (currentIsMuted && !previousIsMuted) + { + eventTypes.Add(ContactCenterConstants.Events.CallMuted); + } + + if (!currentIsMuted && previousIsMuted) + { + eventTypes.Add(ContactCenterConstants.Events.CallUnmuted); + } + + if (currentRecordingState != previousRecordingState) + { + eventTypes.AddRange(ResolveRecordingEvents(previousRecordingState, currentRecordingState)); + } + + // Participation now changes as legs join and leave the bridge, not only when a provider publishes a + // conference count. An ordinary two-party call gaining its customer and agent legs is not a conference + // change, so the event stays scoped to calls that are, or have just stopped being, a conference. + if (currentIsConference != previousIsConference || + ((currentIsConference || previousIsConference) && currentParticipantCount != previousParticipantCount)) + { + eventTypes.Add(ContactCenterConstants.Events.CallConferenceChanged); + } + + if (IsTerminalState(currentState) && !IsTerminalState(previousState)) + { + eventTypes.Add(ContactCenterConstants.Events.CallEnded); + } + + return eventTypes; + } + + private static string[] ResolveRecordingEvents( + RecordingState previousState, + RecordingState currentState) + { + if (currentState == previousState) + { + return []; + } + + return currentState switch + { + RecordingState.Recording when previousState == RecordingState.Paused + => [ContactCenterConstants.Events.RecordingResumed], + RecordingState.Recording => [ContactCenterConstants.Events.RecordingStarted], + RecordingState.Paused => [ContactCenterConstants.Events.RecordingPaused], + RecordingState.Stopped => [ContactCenterConstants.Events.RecordingStopped], + _ => [], + }; + } + + private static void ApplyHangupCause(CallSession session, ProviderVoiceEvent providerEvent) + { + if (!IsTerminalState(session.State) || + session.HangupCause.HasValue) + { + return; + } + + var hangupCause = providerEvent.HangupCause ?? InferHangupCause(session.State); + + // The provider owns the release cause, but only the session knows whether the call was ever + // answered, and that is what separates a completed conversation from an abandoned one. A + // provider reports the same normal release for both, so this one refinement belongs here. + if (hangupCause == HangupCause.NormalClearing && !session.AnsweredUtc.HasValue) + { + hangupCause = HangupCause.Canceled; + } + else if (hangupCause == HangupCause.Canceled && session.AnsweredUtc.HasValue) + { + hangupCause = HangupCause.NormalClearing; + } + + session.HangupCause = hangupCause; + } + + // A provider that reports a terminal state without a release cause has still reported the outcome + // through the state itself, so the cause is derived from it rather than left unset. No call may end + // without a recorded cause, because an unrecorded one cannot be counted in compliance or abandon + // reporting later. + private static HangupCause InferHangupCause(VoiceCallState state) + { + return state switch + { + VoiceCallState.Ended => HangupCause.NormalClearing, + VoiceCallState.Transferred => HangupCause.NormalClearing, + VoiceCallState.NoAnswer => HangupCause.NoAnswer, + VoiceCallState.Rejected => HangupCause.Rejected, + VoiceCallState.Canceled => HangupCause.Canceled, + _ => HangupCause.Failed, + }; + } + + private static bool IsTerminalState(VoiceCallState state) + { + return state is VoiceCallState.Ended or + VoiceCallState.Failed or + VoiceCallState.NoAnswer or + VoiceCallState.Rejected or + VoiceCallState.Canceled or + VoiceCallState.Transferred; + } + + private static string ResolveEventIdempotencyKey(string providerEventKey, string eventType) + { + if (string.IsNullOrEmpty(providerEventKey) || + eventType == ContactCenterConstants.Events.CallSessionUpdated) + { + return providerEventKey; + } + + return ContactCenterClaimKeys.BuildProviderDomainEventIdempotencyKey(providerEventKey, eventType); + } + + private async Task StageAnsweredOutboundBridgeAsync( + CallSession session, + Interaction interaction, + CancellationToken cancellationToken) + { + if (session.Direction != InteractionDirection.Outbound || string.IsNullOrEmpty(session.AgentId)) + { + return; + } + + var provider = _voiceProviderResolver.Get(session.ProviderName); + + if (provider is null || + provider.DeliveryModel != VoiceProviderDeliveryModel.ServerSideAcd || + !provider.Capabilities.HasFlag(ContactCenterVoiceProviderCapabilities.AgentConnect) || + provider is not IContactCenterVoiceCallControlProvider) + { + return; + } + + if (!session.Metadata.TryGetValue(ContactCenterConstants.CommandMetadata.CommandId, out var commandId) || + string.IsNullOrEmpty(commandId)) + { + commandId = IdGenerator.GenerateId(); + session.Metadata[ContactCenterConstants.CommandMetadata.CommandId] = commandId; + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); + } + + await _providerCommandStateService.RegisterAsync(new ProviderCommandRegistration + { + CommandId = commandId, + ProviderName = session.ProviderName, + CommandType = ProviderCommandType.Answer, + ActivityItemId = interaction.ActivityItemId, + InteractionId = interaction.ItemId, + RemoveReservationFromQueueOnFailure = false, + RequestPayload = JsonSerializer.Serialize(new ProviderAnswerCommandRequest + { + ActivityId = interaction.ActivityItemId, + InteractionId = interaction.ItemId, + ProviderCallId = session.ProviderCallId, + AgentId = session.AgentId, + QueueId = session.QueueId, + }), + }, cancellationToken); + + _scopeExecutor.ScheduleAfterCommit(processor => + processor.DispatchAsync(commandId, CancellationToken.None)); + } + + private Task PublishAsync(string eventType, string interactionId, string actorId, string idempotencyKey, CancellationToken cancellationToken) + { + return _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + InteractionId = interactionId, + AggregateType = nameof(CallSession), + AggregateId = interactionId, + ActorId = actorId, + SourceComponent = ContactCenterConstants.Components.CallSessions, + IdempotencyKey = idempotencyKey, + }, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventSink.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventSink.cs new file mode 100644 index 000000000..f00138aae --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventSink.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Adapts provider-facing voice-event ingestion to the Contact Center call-session service. +/// +public sealed class ProviderVoiceEventSink : IProviderVoiceEventSink +{ + private readonly IProviderVoiceEventService _providerVoiceEventService; + + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center provider voice-event service. + public ProviderVoiceEventSink(IProviderVoiceEventService providerVoiceEventService) + { + _providerVoiceEventService = providerVoiceEventService; + } + + /// + public async Task IngestAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(providerEvent); + + return await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken) is not null; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs new file mode 100644 index 000000000..ecddbbb8a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs @@ -0,0 +1,215 @@ +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Reconciles routing state when provider truth reports that a queued, offered, or assigned call ended. +/// +public sealed class ProviderVoiceOfferSynchronizationService : IProviderVoiceOfferSynchronizationService +{ + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IQueueItemManager _queueItemManager; + private readonly IActivityReservationManager _reservationManager; + private readonly IAgentProfileManager _agentManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterWorkStateService _workStateService; + private readonly IServiceProvider _serviceProvider; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The call session manager. + /// The queue item manager. + /// The reservation manager. + /// The agent manager. + /// The activity manager. + /// The routing-owned work state service. + /// The service provider used to lazily resolve presence management without an event-publisher cycle. + /// The clock. + /// The logger. + public ProviderVoiceOfferSynchronizationService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IQueueItemManager queueItemManager, + IActivityReservationManager reservationManager, + IAgentProfileManager agentManager, + IOmnichannelActivityManager activityManager, + IContactCenterWorkStateService workStateService, + IServiceProvider serviceProvider, + IClock clock, + ILogger logger) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _queueItemManager = queueItemManager; + _reservationManager = reservationManager; + _agentManager = agentManager; + _activityManager = activityManager; + _workStateService = workStateService; + _serviceProvider = serviceProvider; + _clock = clock; + _logger = logger; + } + + /// + public async Task ReconcileEndedOfferAsync(string interactionId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(interactionId); + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null || string.IsNullOrWhiteSpace(interaction.ActivityItemId)) + { + return; + } + + var session = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (interaction.Status is not InteractionStatus.Ended and not InteractionStatus.Failed && + !IsTerminalState(session?.State)) + { + return; + } + + var queueItem = await _queueItemManager.FindByActivityIdAsync(interaction.ActivityItemId, cancellationToken); + var activity = await _activityManager.FindByIdAsync(interaction.ActivityItemId, cancellationToken); + + // Cancel every lingering reservation bound to this activity. Reject/re-offer cycles can accumulate + // multiple accepted reservations for the same activity, and leaving them behind keeps an agent's + // ActiveReservationId pointing at dead work, which blocks all future offers. + var reservations = await _reservationManager.ListActiveByActivityAsync(interaction.ActivityItemId, cancellationToken); + var providerReportedAnswered = interaction.AnsweredUtc.HasValue || session?.AnsweredUtc.HasValue == true; + var wasAnsweredByAgent = providerReportedAnswered && + (queueItem?.Status == QueueItemStatus.Assigned || + reservations.Any(reservation => reservation.Status == ReservationStatus.Accepted)); + var canceledReservationIds = new HashSet(StringComparer.Ordinal); + string reservationAgentId = null; + + foreach (var reservation in reservations) + { + reservationAgentId ??= reservation.AgentId; + reservation.TransitionTo(ReservationStatus.Canceled); + + // This is the age settled reservations are purged by. + reservation.ModifiedUtc = _clock.UtcNow; + + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + canceledReservationIds.Add(reservation.ItemId); + } + + if (wasAnsweredByAgent) + { + if (queueItem?.Status == QueueItemStatus.Assigned) + { + queueItem.TransitionTo(QueueItemStatus.Completed); + queueItem.DequeuedUtc = _clock.UtcNow; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + + var answeredAgentId = session?.AgentId ?? interaction.AgentId ?? reservationAgentId; + + if (!string.IsNullOrWhiteSpace(answeredAgentId)) + { + var presenceManager = _serviceProvider.GetRequiredService(); + var agent = await _agentManager.FindByIdAsync(answeredAgentId, cancellationToken); + var activityWasCompleted = activity?.Status is ActivityStatus.Completed or ActivityStatus.Cancelled or ActivityStatus.Purged; + + if (activityWasCompleted && + agent is not null && + string.IsNullOrWhiteSpace(agent.ActiveReservationId) && + agent.PresenceStatus is AgentPresenceStatus.Busy or AgentPresenceStatus.WrapUp) + { + await presenceManager.CompleteWorkAsync(answeredAgentId, cancellationToken); + } + else if (!activityWasCompleted) + { + await presenceManager.StartWrapUpAsync(answeredAgentId, cancellationToken); + } + } + + return; + } + + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Provider truth ended pre-connect interaction '{InteractionId}'. Clearing stale queue and offer state for activity '{ActivityItemId}'.", + interaction.ItemId.SanitizeLogValue(), + interaction.ActivityItemId.SanitizeLogValue()); + } + + if (queueItem is not null && + queueItem.Status is QueueItemStatus.Waiting or QueueItemStatus.Reserved or QueueItemStatus.Assigned) + { + queueItem.TransitionTo(QueueItemStatus.Removed); + queueItem.DequeuedUtc = _clock.UtcNow; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + + var agentId = reservationAgentId ?? session?.AgentId ?? interaction.AgentId; + + if (!string.IsNullOrWhiteSpace(agentId)) + { + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is not null) + { + if (!string.IsNullOrWhiteSpace(agent.ActiveReservationId) && + canceledReservationIds.Contains(agent.ActiveReservationId)) + { + agent.ActiveReservationId = null; + } + + if (agent.PresenceStatus is AgentPresenceStatus.Reserved or + AgentPresenceStatus.Busy or + AgentPresenceStatus.WrapUp) + { + agent.PresenceStatus = AgentPresenceUtilities.ResolveDefaultReadyState(agent); + } + + agent.RequestedPresenceStatus = null; + agent.PresenceChangedUtc = _clock.UtcNow; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + } + } + + if (activity is null) + { + return; + } + + await _workStateService.MutateAsync(activity.ItemId, workState => + { + workState.TransitionTo(ActivityAssignmentStatus.Released); + workState.AssignedToId = null; + workState.AssignedToUsername = null; + workState.AssignedToUtc = null; + workState.ReservationId = null; + workState.ReservedById = null; + workState.ReservedByUsername = null; + workState.ReservedUtc = null; + workState.ReservationExpiresUtc = null; + }, cancellationToken); + } + + private static bool IsTerminalState(VoiceCallState? state) + { + return state is VoiceCallState.Ended or + VoiceCallState.Failed or + VoiceCallState.NoAnswer or + VoiceCallState.Rejected or + VoiceCallState.Canceled or + VoiceCallState.Transferred; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookInbox.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookInbox.cs new file mode 100644 index 000000000..88a9078ce --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookInbox.cs @@ -0,0 +1,527 @@ +using System.Security.Cryptography; +using System.Text; +using CrestApps.Core.Support; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony.Core.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides durable, idempotent provider webhook acceptance and retryable payload dispatch. +/// +public sealed class ProviderWebhookInbox : IProviderWebhookInbox +{ + /// + /// The maximum number of failed processing attempts before a message is dead-lettered. + /// + public const int MaxAttempts = 10; + + /// + /// The maximum number of due messages processed in one background pass. + /// + public const int MaxBatchSize = 100; + + /// + /// The maximum number of processed tombstones purged in one cleanup pass. + /// + public const int MaxTombstoneCleanupBatchSize = 100; + + /// + /// The maximum provider-scoped delivery identifier length supported by the durable index. + /// + public const int MaxDeliveryIdLength = 256; + + private static readonly TimeSpan _acceptanceLockTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan _acceptanceLockExpiration = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _dispatchLockTimeout = TimeSpan.FromSeconds(1); + private static readonly TimeSpan _dispatchLockExpiration = TimeSpan.FromMinutes(5); + private static readonly TimeSpan _claimLease = TimeSpan.FromMinutes(5); + private static readonly TimeSpan _missingHandlerDelay = TimeSpan.FromMinutes(5); + + /// + /// The number of days a settled delivery is kept as an idempotency tombstone. A provider redelivery arriving + /// within this window is recognised as a duplicate purely because the row still exists, so retention must + /// never delete one sooner. + /// + public const double TombstoneRetentionDays = 7; + + private const int BaseBackoffSeconds = 15; + private const int MaxBackoffSeconds = 1800; + + private readonly IReadOnlyList _handlers; + private readonly ContactCenterRetentionOptions _retentionOptions; + private readonly IProviderWebhookInboxStore _store; + private readonly ISession _session; + private readonly IDistributedLock _distributedLock; + private readonly IProviderIdentityResolver _providerIdentityResolver; + private readonly IContactCenterScopeExecutor _scopeExecutor; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The feature-scoped normalized payload handlers. + /// The durable inbox message store. + /// The tenant YesSql session used to commit acceptance and processing state. + /// The distributed lock used for idempotent acceptance and single-message dispatch. + /// The resolver used to canonicalize provider aliases before keying deliveries. + /// The executor used to isolate each due message in a fresh child scope. + /// The clock used to stamp acceptance and retry times. + /// The logger instance. + public ProviderWebhookInbox( + IEnumerable handlers, + IProviderWebhookInboxStore store, + ISession session, + IDistributedLock distributedLock, + IProviderIdentityResolver providerIdentityResolver, + IContactCenterScopeExecutor scopeExecutor, + IClock clock, + IOptions retentionOptions, + ILogger logger) + { + _handlers = ValidateHandlers(handlers); + _store = store; + _session = session; + _distributedLock = distributedLock; + _providerIdentityResolver = providerIdentityResolver; + _scopeExecutor = scopeExecutor; + _clock = clock; + _retentionOptions = retentionOptions.Value; + _logger = logger; + } + + private static IReadOnlyList ValidateHandlers(IEnumerable handlers) + { + ArgumentNullException.ThrowIfNull(handlers); + + var materialized = handlers as IReadOnlyList ?? handlers.ToArray(); + var seenTechnicalNames = new HashSet(StringComparer.Ordinal); + + foreach (var handler in materialized) + { + var technicalName = handler.TechnicalName; + + if (string.IsNullOrWhiteSpace(technicalName)) + { + throw new InvalidOperationException( + $"The provider webhook inbox handler '{handler.GetType().FullName}' must expose a non-empty stable TechnicalName."); + } + + if (!Enum.IsDefined(handler.ReplaySafety) || handler.ReplaySafety == ContactCenterHandlerReplaySafety.Unspecified) + { + throw new InvalidOperationException( + $"The provider webhook inbox handler '{technicalName}' must declare an explicit ReplaySafety contract because provider delivery is at-least-once."); + } + + if (!seenTechnicalNames.Add(technicalName)) + { + throw new InvalidOperationException( + $"The provider webhook inbox handler technical name '{technicalName}' is registered by more than one handler. Technical names must be unique so a persisted payload routes to exactly one handler."); + } + } + + return materialized; + } + + /// + public async Task AcceptAsync( + ProviderWebhookInboxDelivery delivery, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(delivery); + ArgumentException.ThrowIfNullOrEmpty(delivery.ProviderName); + ArgumentException.ThrowIfNullOrEmpty(delivery.DeliveryId); + ArgumentException.ThrowIfNullOrEmpty(delivery.HandlerName); + ArgumentException.ThrowIfNullOrEmpty(delivery.Payload); + + if (delivery.DeliveryId.Length > MaxDeliveryIdLength) + { + throw new ArgumentOutOfRangeException( + nameof(delivery), + $"The provider delivery identifier cannot exceed {MaxDeliveryIdLength} characters."); + } + + // Canonicalize the provider identity before building the delivery lock and idempotency key so that + // provider aliases resolve to a single stable identity and share one durable uniqueness constraint. + var providerName = _providerIdentityResolver.Canonicalize(delivery.ProviderName); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetDeliveryLockKey(providerName, delivery.DeliveryId), + _acceptanceLockTimeout, + _acceptanceLockExpiration); + + if (!locked) + { + return new ProviderWebhookInboxAcceptanceResult + { + Status = ProviderWebhookInboxAcceptanceStatus.Busy, + }; + } + + await using var acquiredLock = locker; + var existing = await _store.FindByDeliveryAsync( + providerName, + delivery.DeliveryId, + cancellationToken); + + if (existing is not null) + { + return new ProviderWebhookInboxAcceptanceResult + { + Status = ProviderWebhookInboxAcceptanceStatus.Duplicate, + MessageId = existing.ItemId, + }; + } + + var now = _clock.UtcNow; + var message = new ProviderWebhookInboxMessage + { + ItemId = IdGenerator.GenerateId(), + ProviderName = providerName, + DeliveryId = delivery.DeliveryId, + HandlerName = delivery.HandlerName, + Payload = delivery.Payload, + Status = ProviderWebhookInboxStatus.Pending, + NextAttemptUtc = now, + CreatedUtc = now, + ModifiedUtc = now, + }; + + await _store.CreateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return new ProviderWebhookInboxAcceptanceResult + { + Status = ProviderWebhookInboxAcceptanceStatus.Accepted, + MessageId = message.ItemId, + }; + } + + /// + public async Task DispatchAsync(string messageId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(messageId); + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetDispatchLockKey(messageId), + _dispatchLockTimeout, + _dispatchLockExpiration); + + if (!locked) + { + return false; + } + + await using var acquiredLock = locker; + var message = await _store.FindByIdAsync(messageId, cancellationToken); + + if (message is null) + { + return false; + } + + var now = _clock.UtcNow; + + if ((message.Status != ProviderWebhookInboxStatus.Pending && + message.Status != ProviderWebhookInboxStatus.Claimed) || + message.NextAttemptUtc > now) + { + return false; + } + + message.Status = ProviderWebhookInboxStatus.Claimed; + message.OwnerToken = Guid.NewGuid().ToString("N"); + message.FenceToken++; + message.NextAttemptUtc = now.Add(_claimLease); + message.ModifiedUtc = now; + await _store.UpdateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + var ownerToken = message.OwnerToken; + var fenceToken = message.FenceToken; + + var handler = _handlers.FirstOrDefault(candidate => + string.Equals(candidate.TechnicalName, message.HandlerName, StringComparison.Ordinal)); + + if (handler is null) + { + message.Status = ProviderWebhookInboxStatus.Pending; + message.OwnerToken = null; + message.LastError = "HandlerUnavailable"; + message.NextAttemptUtc = _clock.UtcNow.Add(_missingHandlerDelay); + message.ModifiedUtc = _clock.UtcNow; + await _store.UpdateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + _logger.LogError( + "No provider webhook inbox handler named '{HandlerName}' is registered for message '{MessageId}'.", + message.HandlerName, + message.ItemId.SanitizeLogValue()); + + return false; + } + + try + { + await _scopeExecutor.ExecuteAsync(inbox => + inbox.DispatchHandlerAsync(message.HandlerName, message.Payload, cancellationToken)); + + return await SettleInFreshScopeAsync( + message.ItemId, + ownerToken, + fenceToken, + true, + null, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (ConcurrencyException) + { + // Another worker committed a conflicting change to an aggregate this delivery touched (for + // example a concurrent provider event for the same call). The YesSql session is now canceled and + // must never be reused, so do not schedule a retry here. The durable claim expires and the message + // is reclaimed in a fresh scope by the next eligible dispatch pass. + throw; + } + catch (Exception exception) + { + await SettleInFreshScopeAsync( + message.ItemId, + ownerToken, + fenceToken, + false, + exception.GetType().FullName, + cancellationToken); + + _logger.LogError( + exception, + "Provider webhook inbox handler '{HandlerName}' failed for message '{MessageId}'.", + message.HandlerName, + message.ItemId.SanitizeLogValue()); + + return false; + } + } + + /// + public async Task DispatchHandlerAsync( + string handlerName, + string payload, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(handlerName); + ArgumentException.ThrowIfNullOrEmpty(payload); + + var handler = _handlers.FirstOrDefault(candidate => + string.Equals(candidate.TechnicalName, handlerName, StringComparison.Ordinal)); + + if (handler is null) + { + throw new InvalidOperationException( + $"The provider webhook inbox handler '{handlerName}' is not registered in the isolated dispatch scope."); + } + + await handler.HandleAsync(payload, cancellationToken); + } + + /// + public async Task SettleClaimAsync( + string messageId, + string ownerToken, + long fenceToken, + bool succeeded, + string errorType, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(messageId); + ArgumentException.ThrowIfNullOrEmpty(ownerToken); + + var message = await _store.FindByIdAsync(messageId, cancellationToken); + + if (message is null || + message.Status != ProviderWebhookInboxStatus.Claimed || + !string.Equals(message.OwnerToken, ownerToken, StringComparison.Ordinal) || + message.FenceToken != fenceToken) + { + throw new ConcurrencyException(new Document()); + } + + if (!succeeded) + { + await ScheduleRetryAsync(message, errorType, cancellationToken); + + return false; + } + + message.Status = ProviderWebhookInboxStatus.Completed; + message.OwnerToken = null; + message.Payload = null; + message.LastError = null; + message.ProcessedUtc = _clock.UtcNow; + message.NextAttemptUtc = message.ProcessedUtc.Value; + message.ModifiedUtc = message.ProcessedUtc; + await _store.UpdateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + + return true; + } + + /// + public async Task DispatchDueAsync(CancellationToken cancellationToken = default) + { + await PurgeExpiredTombstonesAsync(cancellationToken); + + var due = await _store.ListDueAsync(_clock.UtcNow, MaxBatchSize, cancellationToken); + var completed = 0; + + foreach (var message in due) + { + cancellationToken.ThrowIfCancellationRequested(); + var messageId = message.ItemId; + + try + { + var processed = false; + + // Isolate every due message in its own fresh Orchard child scope and YesSql session so a + // concurrency loss or a poison delivery can never poison the remaining batch. + await _scopeExecutor.ExecuteAsync(async inbox => + { + if (await inbox.DispatchAsync(messageId, cancellationToken)) + { + processed = true; + } + }); + + if (processed) + { + completed++; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + // The message's isolated scope failed (for example a concurrent worker won ownership of an + // aggregate this delivery touched). The message stays pending and is reprocessed by the next + // pass in a fresh scope, so continue draining the rest of the batch. + _logger.LogWarning( + "Isolated dispatch of provider webhook inbox message '{MessageId}' failed with {ExceptionType}; the message stays pending for the next pass.", + messageId.SanitizeLogValue(), + exception.GetType().Name); + } + } + + return completed; + } + + private async Task PurgeExpiredTombstonesAsync(CancellationToken cancellationToken) + { + // This sweep and the retention policy both delete settled inbox rows, so the shorter of the two would + // decide the table's real window. Taking the longer keeps the configured retention window meaningful + // while never letting the duplicate-detection horizon be shortened below what the inbox guarantees. + var retentionDays = Math.Max(TombstoneRetentionDays, _retentionOptions.WebhookInboxMessageRetentionDays); + var cutoff = _clock.UtcNow.Subtract(TimeSpan.FromDays(retentionDays)); + var tombstones = await _store.ListProcessedBeforeAsync( + cutoff, + MaxTombstoneCleanupBatchSize, + cancellationToken); + var count = 0; + + foreach (var tombstone in tombstones) + { + await _store.DeleteAsync(tombstone, cancellationToken); + count++; + } + + if (count > 0) + { + await _session.SaveChangesAsync(cancellationToken); + } + + return count; + } + + private async Task SettleInFreshScopeAsync( + string messageId, + string ownerToken, + long fenceToken, + bool succeeded, + string errorType, + CancellationToken cancellationToken) + { + var completed = false; + + await _scopeExecutor.ExecuteAsync(async inbox => + { + completed = await inbox.SettleClaimAsync( + messageId, + ownerToken, + fenceToken, + succeeded, + errorType, + cancellationToken); + }); + + return completed; + } + + private async Task ScheduleRetryAsync( + ProviderWebhookInboxMessage message, + string errorType, + CancellationToken cancellationToken) + { + message.AttemptCount++; + message.LastError = errorType; + message.ModifiedUtc = _clock.UtcNow; + message.OwnerToken = null; + + if (message.AttemptCount >= MaxAttempts) + { + message.Status = ProviderWebhookInboxStatus.DeadLettered; + + // This is the age settled deliveries are purged by. Without it a dead letter is never selected. + message.ProcessedUtc = _clock.UtcNow; + } + else + { + message.Status = ProviderWebhookInboxStatus.Pending; + message.NextAttemptUtc = _clock.UtcNow.Add(GetBackoff(message.AttemptCount)); + } + + await _store.UpdateAsync(message, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + } + + private static string GetDeliveryLockKey(string providerName, string deliveryId) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{providerName}\n{deliveryId}")); + + return $"ContactCenterProviderWebhookInbox:Accept:{Convert.ToHexString(bytes)}"; + } + + private static string GetDispatchLockKey(string messageId) + { + return $"ContactCenterProviderWebhookInbox:Dispatch:{messageId}"; + } + + private static TimeSpan GetBackoff(int attempt) + { + var exponent = Math.Min(attempt - 1, 30); + var seconds = Math.Min(BaseBackoffSeconds * Math.Pow(2, exponent), MaxBackoffSeconds); + + return TimeSpan.FromSeconds(seconds); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookInboxStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookInboxStore.cs new file mode 100644 index 000000000..72e08f661 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookInboxStore.cs @@ -0,0 +1,94 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-backed provider webhook inbox store. +/// +public sealed class ProviderWebhookInboxStore : DocumentCatalog, IProviderWebhookInboxStore +{ + /// + protected override bool CheckConcurrency => true; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session. + public ProviderWebhookInboxStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task FindByDeliveryAsync( + string providerName, + string deliveryId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + ArgumentException.ThrowIfNullOrEmpty(deliveryId); + + return await Session.Query( + index => index.ProviderName == providerName && index.DeliveryId == deliveryId, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListDueAsync( + DateTime nowUtc, + int maxCount, + CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? ProviderWebhookInbox.MaxBatchSize : maxCount; + var messages = await Session.Query( + index => (index.Status == ProviderWebhookInboxStatus.Pending || index.Status == ProviderWebhookInboxStatus.Claimed) && + index.NextAttemptUtc <= nowUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.NextAttemptUtc) + .Take(take) + .ListAsync(cancellationToken); + + return messages.ToArray(); + } + + /// + public async Task CountByStatusAsync(ProviderWebhookInboxStatus status, CancellationToken cancellationToken = default) + { + return await Session.Query( + index => index.Status == status, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task CountOverdueAsync(DateTime nowUtc, CancellationToken cancellationToken = default) + { + return await Session.Query( + index => (index.Status == ProviderWebhookInboxStatus.Pending || index.Status == ProviderWebhookInboxStatus.Claimed) && + index.NextAttemptUtc <= nowUtc, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task> ListProcessedBeforeAsync( + DateTime processedBeforeUtc, + int maxCount, + CancellationToken cancellationToken = default) + { + var take = maxCount <= 0 ? ProviderWebhookInbox.MaxTombstoneCleanupBatchSize : maxCount; + + return (await Session.Query( + index => index.Status == ProviderWebhookInboxStatus.Completed && + index.NextAttemptUtc < processedBeforeUtc, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.NextAttemptUtc) + .Take(take) + .ListAsync(cancellationToken)).ToArray(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookIngressLimiter.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookIngressLimiter.cs new file mode 100644 index 000000000..24e413012 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderWebhookIngressLimiter.cs @@ -0,0 +1,117 @@ +using System.Collections.Concurrent; +using System.Threading.RateLimiting; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides tenant-local provider webhook concurrency and authenticated rate limiting. +/// +public sealed class ProviderWebhookIngressLimiter : IProviderWebhookIngressLimiter, IDisposable +{ + private readonly ConcurrentDictionary _providerRateLimiters = new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrencyLimiter _concurrencyLimiter; + private readonly ProviderWebhookIngressOptions _options; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The webhook ingress limit options. + /// The clock used to evaluate signed delivery timestamps. + public ProviderWebhookIngressLimiter( + IOptions options, + IClock clock) + { + _options = options.Value; + _clock = clock; + + if (!AreOptionsValid(_options)) + { + throw new OptionsValidationException( + nameof(ProviderWebhookIngressOptions), + typeof(ProviderWebhookIngressOptions), + ["Webhook ingress rate, concurrency, period, delivery-age, or future-skew values are outside their supported ranges."]); + } + + _concurrencyLimiter = new ConcurrencyLimiter(new ConcurrencyLimiterOptions + { + PermitLimit = _options.ConcurrencyPermitLimit, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }); + } + + /// + public async ValueTask AcquireConcurrencyAsync(CancellationToken cancellationToken = default) + { + var lease = await _concurrencyLimiter.AcquireAsync(1, cancellationToken); + + return new ProviderWebhookIngressLease(lease); + } + + /// + public async ValueTask AcquireRateAsync(string provider, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(provider); + + var limiter = _providerRateLimiters.GetOrAdd(provider, _ => CreateProviderRateLimiter()); + var lease = await limiter.AcquireAsync(1, cancellationToken); + + return new ProviderWebhookIngressLease(lease); + } + + /// + public bool IsFresh(DateTime? occurredUtc) + { + if (!occurredUtc.HasValue || + occurredUtc.Value == default || + occurredUtc.Value.Kind != DateTimeKind.Utc) + { + return false; + } + + var now = _clock.UtcNow; + + return occurredUtc.Value >= now.AddSeconds(-_options.MaximumDeliveryAgeSeconds) && + occurredUtc.Value <= now.AddSeconds(_options.MaximumFutureSkewSeconds); + } + + /// + public void Dispose() + { + _concurrencyLimiter.Dispose(); + + foreach (var limiter in _providerRateLimiters.Values) + { + limiter.Dispose(); + } + + _providerRateLimiters.Clear(); + } + + private TokenBucketRateLimiter CreateProviderRateLimiter() + { + return new TokenBucketRateLimiter(new TokenBucketRateLimiterOptions + { + TokenLimit = _options.RatePermitLimit, + TokensPerPeriod = _options.RatePermitLimit, + ReplenishmentPeriod = TimeSpan.FromSeconds(_options.RatePeriodSeconds), + AutoReplenishment = true, + QueueLimit = 0, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + }); + } + + private static bool AreOptionsValid(ProviderWebhookIngressOptions options) + { + return options.ConcurrencyPermitLimit is > 0 and <= 1024 && + options.RatePermitLimit is > 0 and <= 100_000 && + options.RatePeriodSeconds is > 0 and <= 3600 && + options.MaximumDeliveryAgeSeconds is > 0 and <= 86_400 && + options.MaximumFutureSkewSeconds is >= 0 and <= 3600; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemManager.cs new file mode 100644 index 000000000..e36511e6c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemManager.cs @@ -0,0 +1,90 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class QueueItemManager : CatalogManager, IQueueItemManager +{ + private readonly IQueueItemStore _store; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying queue item store. + /// The catalog entry handlers for queue items. + /// The logger instance. + public QueueItemManager( + IQueueItemStore store, + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) + { + _store = store; + } + + /// + public async Task> ListWaitingAsync(string queueId, CancellationToken cancellationToken = default) + { + var items = await _store.ListWaitingAsync(queueId, cancellationToken); + + foreach (var item in items) + { + await LoadAsync(item, cancellationToken); + } + + return items; + } + + /// + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + var item = await _store.FindByActivityIdAsync(activityItemId, cancellationToken); + + if (item is not null) + { + await LoadAsync(item, cancellationToken); + } + + return item; + } + + /// + public Task CountWaitingAsync(string queueId, CancellationToken cancellationToken = default) + { + return _store.CountWaitingAsync(queueId, cancellationToken); + } + + /// + public Task> CountWaitingByQueueIdsAsync( + IReadOnlyCollection queueIds, + CancellationToken cancellationToken = default) + { + return _store.CountWaitingByQueueIdsAsync(queueIds, cancellationToken); + } + + /// + public Task CountWaitingOlderThanAsync( + string queueId, + DateTime thresholdUtc, + CancellationToken cancellationToken = default) + { + return _store.CountWaitingOlderThanAsync(queueId, thresholdUtc, cancellationToken); + } + + /// + public async Task FindLongestWaitingAsync(string queueId, CancellationToken cancellationToken = default) + { + var item = await _store.FindLongestWaitingAsync(queueId, cancellationToken); + + if (item is not null) + { + await LoadAsync(item, cancellationToken); + } + + return item; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemPrioritizer.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemPrioritizer.cs new file mode 100644 index 000000000..6d1d1e134 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemPrioritizer.cs @@ -0,0 +1,64 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Selects the next queue item to route, optionally aging waiting items past the SLA threshold so that older +/// work is routed ahead of newer higher-priority work. +/// +public static class QueueItemPrioritizer +{ + /// + /// Selects the highest-priority waiting item, applying SLA aging when the queue enables it. + /// + /// The waiting items to choose from. + /// The queue whose policy controls aging. + /// The current UTC instant used to measure wait time. + /// The next item to route, or when there are none. + public static QueueItem SelectNext(IEnumerable waiting, ActivityQueue queue, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(queue); + + if (waiting is null) + { + return null; + } + + return waiting + .OrderByDescending(item => GetEffectivePriority(item, queue, utcNow)) + .ThenBy(item => item.EnqueuedUtc) + .FirstOrDefault(); + } + + /// + /// Calculates the effective routing priority of an item, including any SLA-aging bonus. + /// + /// The queue item to score. + /// The queue whose policy controls aging. + /// The current UTC instant used to measure wait time. + /// The effective priority where higher values are routed first. + public static int GetEffectivePriority(QueueItem item, ActivityQueue queue, DateTime utcNow) + { + ArgumentNullException.ThrowIfNull(item); + ArgumentNullException.ThrowIfNull(queue); + + var basePriority = (int)item.Priority; + + if (!queue.EnableSlaAging || queue.SlaThresholdSeconds <= 0) + { + return basePriority; + } + + var waitSeconds = (utcNow - item.EnqueuedUtc).TotalSeconds; + var overdueSeconds = waitSeconds - queue.SlaThresholdSeconds; + + if (overdueSeconds <= 0) + { + return basePriority; + } + + var agingSteps = (int)Math.Floor(overdueSeconds / queue.SlaThresholdSeconds); + + return basePriority + agingSteps; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemQueries.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemQueries.cs new file mode 100644 index 000000000..5b9123259 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemQueries.cs @@ -0,0 +1,57 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Models; +using YesSql; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Builds the hand-written statements the queue hot paths execute. The text lives here rather than inside the +/// store so the query-plan gate can execute the statement production executes: a gate that rebuilt an +/// equivalent statement of its own would prove a plan for a query nothing runs. +/// +public static class QueueItemQueries +{ + /// + /// Returns the parameter name the queue identifier at the supplied position is bound to. + /// + /// The position of the queue identifier within the batch. + public static string QueueIdParameterName(int index) => $"QueueId{index}"; + + /// + /// Builds the statement that counts, per queue, how many items are still waiting. The agent workspace runs + /// it on every poll for every queue the agent belongs to, so it is the statement the query-plan budget is + /// asserted against. + /// + /// The YesSql configuration that names the table, schema, prefix and dialect. + /// The number of queue identifiers the statement will be executed with. + public static string BuildWaitingCountByQueueSql(IConfiguration configuration, int queueIdCount) + { + ArgumentNullException.ThrowIfNull(configuration); + ArgumentOutOfRangeException.ThrowIfLessThan(queueIdCount, 1); + + var dialect = configuration.SqlDialect; + var tableName = configuration.TableNameConvention.GetIndexTable(typeof(QueueItemIndex), ContactCenterConstants.CollectionName); + var queueColumn = dialect.QuoteForColumnName(nameof(QueueItemIndex.QueueId)); + var statusColumn = dialect.QuoteForColumnName(nameof(QueueItemIndex.Status)); + + // One placeholder per queue rather than one bound collection. A data access layer that expands a bound + // collection into placeholders does so only on providers that cannot bind an array; on the providers + // that can, the collection is sent as a single value, and "IN" against a single value is not valid + // syntax at all. Writing the placeholders here gives the statement one shape on every supported engine, + // and it is that shape the query-plan gates measure. + var queuePlaceholders = string.Join( + ", ", + Enumerable.Range(0, queueIdCount).Select(index => $"@{QueueIdParameterName(index)}")); + + var builder = new SqlBuilder(configuration.TablePrefix, dialect); + builder.Select(); + builder.Selector($"{queueColumn}, COUNT(*) AS {dialect.QuoteForColumnName("WaitingCount")}"); + builder.Table(tableName, alias: null, configuration.Schema); + builder.WhereAnd($"{queueColumn} IN ({queuePlaceholders})"); + builder.WhereAnd($"{statusColumn} = {(int)QueueItemStatus.Waiting}"); + builder.GroupBy(queueColumn); + + return builder.ToSqlString(); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemStore.cs new file mode 100644 index 000000000..33952ef1b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemStore.cs @@ -0,0 +1,162 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.YesSql.Core.Services; +using Dapper; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class QueueItemStore : DocumentCatalog, IQueueItemStore +{ + /// + protected override bool CheckConcurrency => true; + + private const int QueryBatchSize = 500; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public QueueItemStore(ISession session) + : base(session) + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public async Task> ListWaitingAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + var items = await Session.Query( + index => index.QueueId == queueId && index.Status == QueueItemStatus.Waiting, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.Priority) + .ThenBy(index => index.EnqueuedUtc) + .ListAsync(cancellationToken); + + return items.ToArray(); + } + + /// + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(activityItemId); + + return await Session.Query( + index => index.ActivityItemId == activityItemId, + collection: ContactCenterConstants.CollectionName) + .OrderByDescending(index => index.EnqueuedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task CountWaitingAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + return await Session.Query( + index => index.QueueId == queueId && index.Status == QueueItemStatus.Waiting, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task CountAllWaitingAsync(CancellationToken cancellationToken = default) + { + return await Session.Query( + index => index.Status == QueueItemStatus.Waiting, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task> CountWaitingByQueueIdsAsync( + IReadOnlyCollection queueIds, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queueIds); + + if (queueIds.Count == 0) + { + return new Dictionary(StringComparer.Ordinal); + } + + var counts = new Dictionary(StringComparer.Ordinal); + + var configuration = Session.Store.Configuration; + + // Counting every queue in one statement rather than one statement per queue: the agent workspace polls + // this for every queue an agent belongs to, so a query per queue makes the cost of a single poll grow + // with how many queues the agent covers, and it is the agents covering the most queues whose polls + // must stay cheapest. The flush is what makes the raw statement safe: it runs on the session's own + // transaction, so it must first see the writes the caller has made in this unit of work but not yet + // committed. + await Session.FlushAsync(cancellationToken); + var transaction = await Session.BeginTransactionAsync(cancellationToken); + + foreach (var queueIdBatch in queueIds.Chunk(QueryBatchSize)) + { + var sql = QueueItemQueries.BuildWaitingCountByQueueSql(configuration, queueIdBatch.Length); + var parameters = new DynamicParameters(); + + for (var index = 0; index < queueIdBatch.Length; index++) + { + parameters.Add(QueueItemQueries.QueueIdParameterName(index), queueIdBatch[index]); + } + + var rows = await transaction.Connection.QueryAsync( + new CommandDefinition( + sql, + parameters, + transaction, + cancellationToken: cancellationToken)); + + foreach (var row in rows) + { + counts[row.QueueId] = row.WaitingCount; + } + } + + return counts; + } + + /// + public async Task CountWaitingOlderThanAsync( + string queueId, + DateTime thresholdUtc, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + return await Session.Query( + index => index.QueueId == queueId + && index.Status == QueueItemStatus.Waiting + && index.EnqueuedUtc < thresholdUtc, + collection: ContactCenterConstants.CollectionName) + .CountAsync(cancellationToken); + } + + /// + public async Task FindLongestWaitingAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + return await Session.Query( + index => index.QueueId == queueId && index.Status == QueueItemStatus.Waiting, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.EnqueuedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + private sealed class QueueWaitingCount + { + public string QueueId { get; set; } + + public int WaitingCount { get; set; } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RecordingAccessGovernanceService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RecordingAccessGovernanceService.cs new file mode 100644 index 000000000..8879253c7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RecordingAccessGovernanceService.cs @@ -0,0 +1,194 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class RecordingAccessGovernanceService : IRecordingAccessGovernanceService +{ + private static readonly string[] _recordingMetadataKeys = + [ + ContactCenterConstants.RecordingMetadata.ProviderRecordingId, + ContactCenterConstants.RecordingMetadata.StorageReference, + ContactCenterConstants.RecordingMetadata.Format, + ContactCenterConstants.RecordingMetadata.DurationSeconds, + ContactCenterConstants.RecordingMetadata.RetrievalPath, + ]; + + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The call session manager used to clear the mirrored recording reference. + /// The Contact Center event publisher. + /// The clock used to stamp erasure instants. + public RecordingAccessGovernanceService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IContactCenterEventPublisher publisher, + IClock clock) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _publisher = publisher; + _clock = clock; + } + + /// + public async Task RecordAccessAsync( + string interactionId, + string actorId, + string purpose, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(interactionId)) + { + return false; + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null || string.IsNullOrEmpty(interaction.RecordingReference)) + { + return false; + } + + var accessedEvent = BuildEvent(interaction, ContactCenterConstants.Events.RecordingAccessed, actorId); + + accessedEvent.SetData(new RecordingAccessedEventData + { + ActorId = actorId, + Purpose = purpose, + RecordingReference = interaction.RecordingReference, + }); + + await _publisher.PublishAsync(accessedEvent, CancellationToken.None); + + return true; + } + + /// + public async Task EraseAsync( + string interactionId, + string actorId, + string reason, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(interactionId)) + { + return RecordingErasureDecision.Deny(ContactCenterConstants.RecordingErasureDenyReason.NoRecording); + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + // A missing interaction cannot be audited because there is no aggregate to attach the event to; the request is + // denied without an event. + if (interaction is null) + { + return RecordingErasureDecision.Deny(ContactCenterConstants.RecordingErasureDenyReason.NoRecording); + } + + // An existing interaction with no recording reference is a denied erasure request that must still be audited + // for completeness (including repeat requests after a prior successful erasure). + if (string.IsNullOrEmpty(interaction.RecordingReference)) + { + // Defensive hygiene: the reference and its call-session mirror are cleared in the same unit of work + // during erasure, so an interaction with no reference should never leave a mirrored handle behind. If a + // prior partial failure did, clear it now so it cannot resurrect access to media that no longer exists. + await ClearMirroredReferenceAsync(interaction.ItemId, cancellationToken); + + await PublishErasureDeniedAsync(interaction, actorId, ContactCenterConstants.RecordingErasureDenyReason.NoRecording); + + return RecordingErasureDecision.Deny(ContactCenterConstants.RecordingErasureDenyReason.NoRecording); + } + + // Legal hold overrides a subject erasure request: a recording under hold must be preserved until the hold is + // released, so the request is denied and audited rather than silently ignored. + if (interaction.RecordingLegalHold) + { + await PublishErasureDeniedAsync(interaction, actorId, ContactCenterConstants.RecordingErasureDenyReason.LegalHold); + + return RecordingErasureDecision.Deny(ContactCenterConstants.RecordingErasureDenyReason.LegalHold); + } + + var erasedReference = interaction.RecordingReference; + + // The orchestration layer never stores recording media, so erasure clears the opaque retrieval handle and its + // retrieval metadata and stamps the erasure instant; the published event carries the reference so the owning + // media store can delete the underlying media. The pointer clears, the erasure tombstone (the stamped + // RecordingErasedUtc), and the outbox media-deletion enqueue all share the ambient unit of work, so they + // commit together or not at all. + interaction.RecordingReference = null; + interaction.RecordingErasedUtc = _clock.UtcNow; + + if (interaction.TechnicalMetadata is not null) + { + foreach (var key in _recordingMetadataKeys) + { + interaction.TechnicalMetadata.Remove(key); + } + } + + await _interactionManager.UpdateAsync(interaction, cancellationToken: CancellationToken.None); + + // The recording reference is mirrored onto the call session, so it must be cleared in the same unit of work; + // otherwise the mirrored handle would survive erasure and could resurrect access to the deleted media. + await ClearMirroredReferenceAsync(interaction.ItemId, cancellationToken); + + var erasedEvent = BuildEvent(interaction, ContactCenterConstants.Events.RecordingErased, actorId); + + erasedEvent.SetData(new RecordingErasedEventData + { + ActorId = actorId, + Reason = reason, + RecordingReference = erasedReference, + }); + + await _publisher.PublishAsync(erasedEvent, CancellationToken.None); + + return RecordingErasureDecision.Erase(); + } + + private async Task ClearMirroredReferenceAsync(string interactionId, CancellationToken cancellationToken) + { + var callSession = await _callSessionManager.FindByInteractionIdAsync(interactionId, cancellationToken); + + if (callSession is not null && !string.IsNullOrEmpty(callSession.RecordingReference)) + { + callSession.RecordingReference = null; + + await _callSessionManager.UpdateAsync(callSession, cancellationToken: CancellationToken.None); + } + } + + private async Task PublishErasureDeniedAsync(Interaction interaction, string actorId, string denyReasonCode) + { + var deniedEvent = BuildEvent(interaction, ContactCenterConstants.Events.RecordingErasureDenied, actorId); + + deniedEvent.SetData(new RecordingErasureDeniedEventData + { + ActorId = actorId, + DenyReasonCode = denyReasonCode, + }); + + await _publisher.PublishAsync(deniedEvent, CancellationToken.None); + } + + private static InteractionEvent BuildEvent(Interaction interaction, string eventType, string actorId) + => new() + { + EventType = eventType, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = actorId, + SourceComponent = ContactCenterConstants.Components.Interactions, + }; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RecordingGovernancePolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RecordingGovernancePolicy.cs new file mode 100644 index 000000000..7bfcd1f2a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RecordingGovernancePolicy.cs @@ -0,0 +1,59 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore.Modules; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default, fail-closed implementation of that evaluates the +/// tenant recording governance settings. +/// +public sealed class RecordingGovernancePolicy : IRecordingGovernancePolicy +{ + private readonly ISiteService _siteService; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The site service used to read the tenant recording governance settings. + /// The clock used to resolve the recording retention window. + public RecordingGovernancePolicy( + ISiteService siteService, + IClock clock) + { + _siteService = siteService; + _clock = clock; + } + + /// + public async Task EvaluateStartAsync(Interaction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + var site = await _siteService.GetSiteSettingsAsync(); + var settings = site.GetOrCreate(); + + if (!settings.RecordingEnabled) + { + return RecordingGovernanceDecision.Deny(ContactCenterConstants.RecordingGovernanceDenyReason.RecordingDisabled); + } + + // Fail closed for any consent model that is not explicitly single-party (including an undefined persisted + // value): when explicit consent is required and none has been captured, recording is denied. + if (settings.RequireExplicitConsent && + settings.ConsentModel != RecordingConsentModel.SingleParty && + interaction.RecordingConsentCapturedUtc is null) + { + return RecordingGovernanceDecision.Deny(ContactCenterConstants.RecordingGovernanceDenyReason.ConsentRequired); + } + + var retentionDays = Math.Clamp(settings.RetentionDays, 0, ContactCenterRecordingSettings.MaxRetentionDays); + + var retainUntilUtc = retentionDays > 0 + ? _clock.UtcNow.AddDays(retentionDays) + : (DateTime?)null; + + return RecordingGovernanceDecision.Allow(retainUntilUtc, settings.LegalHoldByDefault); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RejectProviderCommandTypeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RejectProviderCommandTypeExecutor.cs new file mode 100644 index 000000000..9e5cfa2ec --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RejectProviderCommandTypeExecutor.cs @@ -0,0 +1,62 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Handles durable provider commands that reject a ringing call. +/// +public sealed class RejectProviderCommandTypeExecutor : ProviderCallActionCommandTypeExecutor +{ + /// + /// Initializes a new instance of the class. + /// + /// The optional telephony services used to execute the provider action. + /// The interaction manager used to validate and project linked interactions. + /// The queue service used to restore live work after a definitive action failure. + /// The routing-owned work state service. + /// The writer used to apply CRM activity changes outside the routing transaction. + /// The Contact Center event publisher. + /// The clock used to stamp projections. + /// The shared call-control authorization boundary. + public RejectProviderCommandTypeExecutor( + IEnumerable telephonyServices, + IInteractionManager interactionManager, + IActivityQueueService queueService, + IContactCenterWorkStateService workStateService, + IContactCenterActivityWriter activityWriter, + IContactCenterEventPublisher publisher, + IClock clock, + ICallControlAuthorizationService callControlAuthorizationService) + : base( + telephonyServices, + interactionManager, + queueService, + workStateService, + activityWriter, + publisher, + clock, + callControlAuthorizationService) + { + } + + /// + public override ProviderCommandType CommandType => ProviderCommandType.Reject; + + /// + protected override string ActionName => "Reject"; + + /// + protected override string ErrorCodePrefix => "reject"; + + /// + protected override Task ExecuteTelephonyAsync( + ITelephonyService telephonyService, + CallReference call, + CancellationToken cancellationToken) + { + return telephonyService.RejectAsync(call, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RequiredSkillsRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RequiredSkillsRoutingStrategy.cs new file mode 100644 index 000000000..fabf2d52f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RequiredSkillsRoutingStrategy.cs @@ -0,0 +1,54 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Rejects agents that do not have every skill required by the queue. +/// +public sealed class RequiredSkillsRoutingStrategy : IActivityRoutingStrategy +{ + /// + public int Order => 10; + + /// + public ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + // Both sides are read through the same type, so the queue and the agent cannot disagree about what + // counts as the same skill. Comparing the stored strings directly is what let an agent whose skill + // was written with surrounding whitespace go unmatched without any sign that something was wrong. + var requiredSkills = SkillTag.CreateAll(context.Queue.RequiredSkills); + + if (requiredSkills.Count == 0) + { + foreach (var candidate in context.Candidates) + { + candidate.AddReason("No queue skills are required."); + } + + return ValueTask.CompletedTask; + } + + foreach (var candidate in context.Candidates) + { + var agentSkills = new HashSet(SkillTag.CreateAll(candidate.Agent.Skills)); + + var missingSkills = requiredSkills + .Where(skill => !agentSkills.Contains(skill)) + .ToArray(); + + if (missingSkills.Length > 0) + { + candidate.IsEligible = false; + candidate.AddReason($"Missing required skills: {string.Join(", ", missingSkills.Select(skill => skill.Value))}."); + } + else + { + candidate.AddReason("Matched every required queue skill."); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ActivityReservationRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ActivityReservationRetentionPolicy.cs new file mode 100644 index 000000000..b89d14d90 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ActivityReservationRetentionPolicy.cs @@ -0,0 +1,41 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges reservations that have reached a terminal state, measured from creation. The expiry cannot serve +/// as the age because a reservation accepted well before it would have expired keeps an expiry in the +/// future, which would make it look permanently young. +/// +public sealed class ActivityReservationRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The activity reservation store. + public ActivityReservationRetentionPolicy( + ISession session, + IActivityReservationStore reservationStore) + : base(session, reservationStore) + { + } + + /// + public override string EntityName => "ActivityReservation"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.ActivityReservationRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.ModifiedUtc != null + && index.ModifiedUtc < cutoffUtc + && (index.Status == ReservationStatus.Rejected + || index.Status == ReservationStatus.Expired + || index.Status == ReservationStatus.Canceled); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/AgentSessionRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/AgentSessionRetentionPolicy.cs new file mode 100644 index 000000000..082895cfe --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/AgentSessionRetentionPolicy.cs @@ -0,0 +1,35 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges agent sessions by their last heartbeat regardless of whether they still claim to be online, so +/// a session abandoned by a crashed node is collected instead of remaining online forever. +/// +public sealed class AgentSessionRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The agent session store. + public AgentSessionRetentionPolicy( + ISession session, + IAgentSessionStore agentSessionStore) + : base(session, agentSessionStore) + { + } + + /// + public override string EntityName => "AgentSession"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.AgentSessionRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.LastHeartbeatUtc != null && index.LastHeartbeatUtc < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/CallSessionRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/CallSessionRetentionPolicy.cs new file mode 100644 index 000000000..ae5405013 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/CallSessionRetentionPolicy.cs @@ -0,0 +1,38 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges call sessions that have ended, measured from the time the call ended rather than the time it +/// was created so a long call is not purged the moment it finishes. +/// +public sealed class CallSessionRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The call session store. + public CallSessionRetentionPolicy( + ISession session, + ICallSessionStore callSessionStore) + : base(session, callSessionStore) + { + } + + /// + public override string EntityName => "CallSession"; + + /// + protected override bool IsSubjectToLegalHold => true; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.CallSessionRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.EndedUtc != null && index.EndedUtc < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/CallbackRequestRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/CallbackRequestRetentionPolicy.cs new file mode 100644 index 000000000..f96e79052 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/CallbackRequestRetentionPolicy.cs @@ -0,0 +1,51 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges callback requests that are no longer waiting, measured from the last modification. The scheduled time +/// cannot serve as the age because a callback booked weeks ahead and then canceled keeps a future scheduled +/// time, so it would never look old enough to purge. Promotion is treated as settled: nothing reads a callback +/// once it leaves the pending state, and promotion copies the destination, campaign, contact and notes onto the +/// activity, which is the durable record of the work from that point on. What does not survive the purge is the +/// agent the callback was personally reserved for and the times it was requested and booked, none of which any +/// code path reads today; the promotion event in the interaction log records that the transition happened. The +/// outcome statuses are included for completeness, but the callback aggregate has no dialing-outcome lifecycle +/// yet, so in practice a callback settles when it is promoted. +/// +public sealed class CallbackRequestRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The callback request store. + public CallbackRequestRetentionPolicy( + ISession session, + ICallbackRequestStore callbackRequestStore) + : base(session, callbackRequestStore) + { + } + + /// + public override string EntityName => "CallbackRequest"; + + /// + protected override bool IsSubjectToLegalHold => true; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.CallbackRequestRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.ModifiedUtc != null + && index.ModifiedUtc < cutoffUtc + && (index.Status == CallbackRequestStatus.Scheduled + || index.Status == CallbackRequestStatus.Completed + || index.Status == CallbackRequestStatus.Canceled + || index.Status == CallbackRequestStatus.Failed); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterEventMetricDeltaRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterEventMetricDeltaRetentionPolicy.cs new file mode 100644 index 000000000..89a434827 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterEventMetricDeltaRetentionPolicy.cs @@ -0,0 +1,39 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges appended event count contributions. The roller normally removes each contribution as it folds it, +/// so this policy only ever finds one the roller could not fold — which is why it is aged from when the +/// contribution was appended rather than from the day it counts toward, and why it shares the daily totals' +/// window: a contribution that outlives the total it belongs to would be added to a total that is no longer +/// there. It is an aggregate rather than a record of any one interaction, so the legal-hold floor does not +/// apply. +/// +public sealed class ContactCenterEventMetricDeltaRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The event metric contribution store. + public ContactCenterEventMetricDeltaRetentionPolicy( + ISession session, + IContactCenterMetricDeltaStore deltaStore) + : base(session, deltaStore) + { + } + + /// + public override string EntityName => "ContactCenterEventMetricDelta"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.EventMetricRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.CreatedUtc < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterEventMetricRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterEventMetricRetentionPolicy.cs new file mode 100644 index 000000000..c883f775d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterEventMetricRetentionPolicy.cs @@ -0,0 +1,35 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges daily event metric rollups. They are aggregates rather than records of any one interaction, so +/// they are not held by the legal-hold floor. +/// +public sealed class ContactCenterEventMetricRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The event metric store. + public ContactCenterEventMetricRetentionPolicy( + ISession session, + IContactCenterMetricStore metricStore) + : base(session, metricStore) + { + } + + /// + public override string EntityName => "ContactCenterEventMetric"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.EventMetricRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.Date < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterOutboxMessageRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterOutboxMessageRetentionPolicy.cs new file mode 100644 index 000000000..d12b09593 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterOutboxMessageRetentionPolicy.cs @@ -0,0 +1,36 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges outbox messages that have been delivered or dead-lettered, measured from creation. The retry +/// time cannot serve as the age because a settled message keeps whatever retry time it last held. +/// +public sealed class ContactCenterOutboxMessageRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The outbox store. + public ContactCenterOutboxMessageRetentionPolicy( + ISession session, + IContactCenterOutboxStore outboxStore) + : base(session, outboxStore) + { + } + + /// + public override string EntityName => "ContactCenterOutboxMessage"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.OutboxMessageRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.CreatedUtc < cutoffUtc + && (index.Status == OutboxMessageStatus.Completed || index.Status == OutboxMessageStatus.DeadLettered); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterProcessedEventRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterProcessedEventRetentionPolicy.cs new file mode 100644 index 000000000..432998fe2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterProcessedEventRetentionPolicy.cs @@ -0,0 +1,40 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges event deduplication markers. A marker may only be purged once no redelivery of the same event can +/// still arrive, so the delivery envelope acts as a floor beneath the configured window. Purging a marker +/// early makes an already-processed event look new and lets its side effect run a second time. +/// +public sealed class ContactCenterProcessedEventRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The processed event store. + public ContactCenterProcessedEventRetentionPolicy( + ISession session, + IContactCenterProcessedEventStore processedEventStore) + : base(session, processedEventStore) + { + } + + /// + public override string EntityName => "ContactCenterProcessedEvent"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.ProcessedEventRetentionDays; + + /// + protected override double GetEntityFloorDays(ContactCenterRetentionOptions options) + => options.ProcessedEventDeliveryEnvelopeDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.ProcessedUtc < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterRetentionBatchException.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterRetentionBatchException.cs new file mode 100644 index 000000000..f15185793 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterRetentionBatchException.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Raised when a purge batch fails partway through staging its deletes. The deletes staged before the failure are +/// still in the session's unit of work, so the count is carried out with the exception: without it the caller +/// cannot attribute or commit that work, and an unrelated entity's later flush would commit it anonymously. +/// +public sealed class ContactCenterRetentionBatchException : Exception +{ + /// + /// Initializes a new instance of the class. + /// + /// The entity whose batch failed. + /// The number of deletes already staged when the failure occurred. + /// The failure that interrupted the batch. + public ContactCenterRetentionBatchException( + string entityName, + int stagedBeforeFailure, + Exception innerException) + : base($"Purging entity '{entityName}' failed after staging {stagedBeforeFailure} deletes.", innerException) + { + EntityName = entityName; + StagedBeforeFailure = stagedBeforeFailure; + } + + /// + /// Gets the entity whose batch failed. + /// + public string EntityName { get; } + + /// + /// Gets the number of deletes already staged in the session when the failure occurred. + /// + public int StagedBeforeFailure { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterRetentionPolicyBase.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterRetentionPolicyBase.cs new file mode 100644 index 000000000..4a0dc1950 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterRetentionPolicyBase.cs @@ -0,0 +1,161 @@ +using System.Linq.Expressions; +using CrestApps.Core.Data.YesSql.Indexes; +using CrestApps.Core.Models; +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Provides the shared batching, cutoff, and deletion behavior for a document-backed retention policy so each +/// entity only has to declare its window, its floors, and what makes one of its records expired. +/// +/// The catalog item type being purged. +/// The index type whose table carries the age column. +public abstract class ContactCenterRetentionPolicyBase : IContactCenterRetentionPolicy + where TModel : CatalogItem + where TIndex : CatalogItemIndex +{ + private readonly ISession _session; + private readonly ICatalog _catalog; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// + /// The catalog that owns the records, used to delete them so any catalog-level delete behavior still runs + /// instead of being bypassed by a raw session delete. + /// + protected ContactCenterRetentionPolicyBase( + ISession session, + ICatalog catalog) + { + _session = session; + _catalog = catalog; + } + + /// + public abstract string EntityName { get; } + + /// + public Type IndexType => typeof(TIndex); + + /// + public Type ModelType => typeof(TModel); + + /// + /// Gets a value indicating whether this entity is held by the legal-hold floor. It is only meaningful for + /// the records that carry customer interaction history; the infrastructure tables that carry delivery + /// bookkeeping are not evidence of anything and holding them serves no governance purpose. + /// + protected virtual bool IsSubjectToLegalHold => false; + + /// + /// Gets a value indicating whether this entity is held by the projection replay horizon. Only the durable + /// event log is, because it is the only table projections can be rebuilt from. + /// + protected virtual bool IsSubjectToReplayHorizon => false; + + /// + /// Gets the configured retention window, in days, for this entity. + /// + /// The configured retention options. + /// The window in days, where zero or less means this entity is never purged. + protected abstract double GetRetentionDays(ContactCenterRetentionOptions options); + + /// + /// Gets an additional floor, in days, that is specific to this entity and applies on top of the governance + /// floors. The default is no additional floor. + /// + /// The configured retention options. + /// The entity-specific floor in days. + protected virtual double GetEntityFloorDays(ContactCenterRetentionOptions options) => 0; + + /// + /// Builds the predicate that selects records eligible for purging. It must exclude records that are still + /// live, because age alone never makes an in-flight record safe to delete. + /// + /// The exclusive UTC cutoff. + /// The expired-record predicate. + protected abstract Expression> BuildExpiredPredicate(DateTime cutoffUtc); + + /// + public bool TryGetCutoff(DateTime nowUtc, ContactCenterRetentionOptions options, out DateTime cutoffUtc) + { + ArgumentNullException.ThrowIfNull(options); + + var floorDays = GetEntityFloorDays(options); + + if (IsSubjectToLegalHold) + { + floorDays = Math.Max(floorDays, options.LegalHoldMinimumDays); + } + + if (IsSubjectToReplayHorizon) + { + floorDays = Math.Max(floorDays, options.ProjectionReplayHorizonDays); + } + + return RetentionCutoffCalculator.TryComputeCutoff(nowUtc, GetRetentionDays(options), floorDays, out cutoffUtc); + } + + /// + public LambdaExpression GetExpiredPredicate(DateTime cutoffUtc) => BuildExpiredPredicate(cutoffUtc); + + /// + /// Prepares an expired record for deletion, giving the entity a chance to run deletion side effects (such as + /// enqueuing dependent cleanup for data the row points at) and to veto deletion for records that must be + /// preserved. The default proceeds with deletion and runs no side effects. + /// + /// The expired record that is about to be deleted. + /// The token to monitor for cancellation requests. + /// to proceed with deleting the record; otherwise, to skip it. + protected virtual Task TryPrepareForDeletionAsync(TModel record, CancellationToken cancellationToken) + => Task.FromResult(true); + + /// + public async Task PurgeBatchAsync(DateTime cutoffUtc, int batchSize, CancellationToken cancellationToken = default) + { + var take = batchSize <= 0 ? 100 : batchSize; + + var expired = await _session + .Query(BuildExpiredPredicate(cutoffUtc), collection: ContactCenterConstants.CollectionName) + .Take(take) + .ListAsync(cancellationToken); + + var purged = 0; + + foreach (var record in expired) + { + try + { + // A record can veto its own deletion (for example a recording under legal hold) or run deletion side + // effects (for example enqueuing media deletion) before the row is removed. A vetoed record is skipped + // rather than deleted, and the side effects share this batch's unit of work so they commit atomically + // with the delete once the whole batch succeeds. Both the prepare step and the delete are guarded so + // that a failure anywhere in the batch is reported to the caller, which discards the entire batch + // instead of letting a half-staged record leak into an unrelated entity's later flush. + if (!await TryPrepareForDeletionAsync(record, cancellationToken)) + { + continue; + } + + await _catalog.DeleteAsync(record, cancellationToken); + } + catch (Exception ex) + { + // A record failed after staging some of its side effects, and earlier records in this batch are staged + // too. The shared session cannot selectively withdraw the staged work, so the batch is reported as + // failed and the caller discards the whole batch rather than committing any of it; the count is carried + // only for diagnostics. Every record this batch touched is retried cleanly on the next cycle. + throw new ContactCenterRetentionBatchException(EntityName, purged, ex); + } + + purged++; + } + + return purged; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterWorkStateRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterWorkStateRetentionPolicy.cs new file mode 100644 index 000000000..5aab55d7d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ContactCenterWorkStateRetentionPolicy.cs @@ -0,0 +1,39 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges routing work state that has not been touched for the configured window. Nothing in the product ever +/// deletes a work state, so without this the table grows by one row for every activity that is ever routed. +/// There is no terminal assignment status to key on, because whether the work is finished is owned by the CRM +/// activity rather than by the routing document. Purging by age alone is safe here only because the work state +/// is reconstructible: a missing one is recreated on next access and seeded from the activity projection, which +/// is the same adoption path a tenant that predates the document takes. +/// +public sealed class ContactCenterWorkStateRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The work state store. + public ContactCenterWorkStateRetentionPolicy( + ISession session, + IContactCenterWorkStateStore workStateStore) + : base(session, workStateStore) + { + } + + /// + public override string EntityName => "ContactCenterWorkState"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.WorkStateRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.ModifiedUtc != null && index.ModifiedUtc < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/IContactCenterRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/IContactCenterRetentionPolicy.cs new file mode 100644 index 000000000..69939131b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/IContactCenterRetentionPolicy.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Describes how one Contact Center table is aged out. Every high-volume table contributes a policy so the +/// retention service iterates policies instead of hard-coding a purge per table, and so a table added later +/// without a policy can be detected rather than silently growing forever. +/// +public interface IContactCenterRetentionPolicy +{ + /// + /// Gets the stable technical name of the entity this policy purges. It is used for logging and for the + /// coverage check that asserts no high-volume table lacks a policy. + /// + string EntityName { get; } + + /// + /// Gets the index type whose table this policy purges. The coverage check matches registered policies to + /// declared indexes through this property. + /// + Type IndexType { get; } + + /// + /// Gets the model type this policy purges. + /// + Type ModelType { get; } + + /// + /// Computes the cutoff before which this entity's records are eligible for purging. + /// + /// The current UTC time. + /// The configured retention options. + /// + /// When the method returns , the UTC time before which records may be purged. + /// + /// + /// when purging is enabled for this entity; otherwise + /// because its records are kept indefinitely. + /// + bool TryGetCutoff(DateTime nowUtc, ContactCenterRetentionOptions options, out DateTime cutoffUtc); + + /// + /// Gets the predicate this policy uses to select expired records. It is exposed so the retention coverage + /// checks can verify what a policy considers expired without a database round trip, which is how a policy + /// that purges purely by age and would therefore delete live records is caught. + /// + /// The exclusive UTC cutoff. + /// The expired-record predicate, typed against this policy's index. + LambdaExpression GetExpiredPredicate(DateTime cutoffUtc); + + /// + /// Deletes at most one batch of records older than the supplied cutoff. + /// + /// The exclusive UTC cutoff; only records older than this are eligible. + /// The maximum number of records to delete in this batch. + /// The token to monitor for cancellation requests. + /// The number of records deleted, which is zero once the entity has drained. + Task PurgeBatchAsync(DateTime cutoffUtc, int batchSize, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/InteractionEventRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/InteractionEventRetentionPolicy.cs new file mode 100644 index 000000000..1cd477c10 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/InteractionEventRetentionPolicy.cs @@ -0,0 +1,41 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges durable interaction events by the time they occurred. The event log is the only table +/// projections can be rebuilt from, so it is the only one held by the projection replay horizon. +/// +public sealed class InteractionEventRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The interaction event store. + public InteractionEventRetentionPolicy( + ISession session, + IInteractionEventStore eventStore) + : base(session, eventStore) + { + } + + /// + public override string EntityName => "InteractionEvent"; + + /// + protected override bool IsSubjectToLegalHold => true; + + /// + protected override bool IsSubjectToReplayHorizon => true; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.InteractionEventRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.OccurredUtc < cutoffUtc; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/InteractionRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/InteractionRetentionPolicy.cs new file mode 100644 index 000000000..4a5bab423 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/InteractionRetentionPolicy.cs @@ -0,0 +1,111 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges interactions that have ended. A live interaction is never purged no matter how old it is, +/// because age alone does not make an in-flight conversation safe to delete. +/// +public sealed class InteractionRetentionPolicy : ContactCenterRetentionPolicyBase +{ + private readonly ICallSessionManager _callSessionManager; + private readonly IContactCenterEventPublisher _publisher; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The interaction store. + /// The call session manager used to clear mirrored recording references. + /// The Contact Center event publisher used to enqueue media deletion before a row is removed. + public InteractionRetentionPolicy( + ISession session, + IInteractionStore interactionStore, + ICallSessionManager callSessionManager, + IContactCenterEventPublisher publisher) + : base(session, interactionStore) + { + _callSessionManager = callSessionManager; + _publisher = publisher; + } + + /// + public override string EntityName => "Interaction"; + + /// + protected override bool IsSubjectToLegalHold => true; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.InteractionRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.EndedUtc != null && index.EndedUtc < cutoffUtc && !index.RecordingLegalHold; + + /// + protected override async Task TryPrepareForDeletionAsync(Interaction record, CancellationToken cancellationToken) + { + // A recording under legal hold must survive retention until the hold is released; deleting the interaction + // row would orphan the held media and destroy the evidence the hold exists to preserve. Held records are + // already excluded by the expired predicate, so this per-record check is the safety net that spares a record + // whose hold was set after it was fetched. + if (record.RecordingLegalHold) + { + return false; + } + + // The interaction row carries the only reference to the recording media, so media deletion must be enqueued + // before the row is deleted or the media would be orphaned. Publishing the erasure event enqueues durable + // media deletion on the outbox in this batch's unit of work; the event payload carries the reference, so + // deletion survives the row removal. A stable idempotency key keeps a retry (for example after a failed + // batch commit re-fetches the same record) from enqueuing the deletion twice. + if (!string.IsNullOrEmpty(record.RecordingReference)) + { + await ClearMirroredReferenceAsync(record.ItemId, cancellationToken); + + var erasedEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.RecordingErased, + InteractionId = record.ItemId, + AggregateType = nameof(Interaction), + AggregateId = record.ItemId, + ActorId = ContactCenterConstants.SystemActor, + SourceComponent = ContactCenterConstants.Components.Interactions, + IdempotencyKey = $"retention-recording-erased:{record.ItemId}", + }; + + erasedEvent.SetData(new RecordingErasedEventData + { + ActorId = ContactCenterConstants.SystemActor, + Reason = ContactCenterConstants.RecordingErasureReason.Retention, + RecordingReference = record.RecordingReference, + }); + + await _publisher.PublishAsync(erasedEvent, cancellationToken); + } + else + { + // Defensive hygiene: an interaction with no reference of its own should never leave a mirrored handle + // behind, but if a prior partial failure did, clear it so the deleted row cannot leave a dangling + // pointer to media that no longer exists. + await ClearMirroredReferenceAsync(record.ItemId, cancellationToken); + } + + return true; + } + + private async Task ClearMirroredReferenceAsync(string interactionId, CancellationToken cancellationToken) + { + var callSession = await _callSessionManager.FindByInteractionIdAsync(interactionId, cancellationToken); + + if (callSession is not null && !string.IsNullOrEmpty(callSession.RecordingReference)) + { + callSession.RecordingReference = null; + + await _callSessionManager.UpdateAsync(callSession, cancellationToken: cancellationToken); + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ProviderCommandRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ProviderCommandRetentionPolicy.cs new file mode 100644 index 000000000..00e5f1836 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ProviderCommandRetentionPolicy.cs @@ -0,0 +1,39 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges provider commands that have reached a terminal state, measured from completion. Neither the +/// retry time nor the lease time can serve as the age because neither advances once a command finishes. +/// +public sealed class ProviderCommandRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The provider command store. + public ProviderCommandRetentionPolicy( + ISession session, + IProviderCommandStore commandStore) + : base(session, commandStore) + { + } + + /// + public override string EntityName => "ProviderCommand"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.ProviderCommandRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.CompletedUtc != null + && index.CompletedUtc < cutoffUtc + && (index.Status == ProviderCommandStatus.Confirmed + || index.Status == ProviderCommandStatus.Compensated + || index.Status == ProviderCommandStatus.Failed); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ProviderWebhookInboxMessageRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ProviderWebhookInboxMessageRetentionPolicy.cs new file mode 100644 index 000000000..bd58e3b4d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/ProviderWebhookInboxMessageRetentionPolicy.cs @@ -0,0 +1,44 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges provider webhook deliveries that have been processed or dead-lettered, measured from settlement. +/// A delivery that is still pending is retained regardless of age so an outage backlog is not discarded. +/// A completed row is the idempotency tombstone that makes a provider redelivery a duplicate, so it is held for +/// at least the redelivery envelope: deleting it early makes a redelivered webhook look new and runs its side +/// effect a second time. +/// +public sealed class ProviderWebhookInboxMessageRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The provider webhook inbox store. + public ProviderWebhookInboxMessageRetentionPolicy( + ISession session, + IProviderWebhookInboxStore inboxStore) + : base(session, inboxStore) + { + } + + /// + public override string EntityName => "ProviderWebhookInboxMessage"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.WebhookInboxMessageRetentionDays; + + /// + protected override double GetEntityFloorDays(ContactCenterRetentionOptions options) + => Math.Max(options.ProcessedEventDeliveryEnvelopeDays, ProviderWebhookInbox.TombstoneRetentionDays); + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.ProcessedUtc != null + && index.ProcessedUtc < cutoffUtc + && (index.Status == ProviderWebhookInboxStatus.Completed || index.Status == ProviderWebhookInboxStatus.DeadLettered); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/QueueItemRetentionPolicy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/QueueItemRetentionPolicy.cs new file mode 100644 index 000000000..84d3de174 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/Retention/QueueItemRetentionPolicy.cs @@ -0,0 +1,39 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services.Retention; + +/// +/// Purges queue items that have left the queue, measured from the time they were dequeued. Purging by +/// arrival time instead would delete an item the moment it was handled if it had waited longer than the +/// window, destroying exactly the records that describe the worst waits. +/// +public sealed class QueueItemRetentionPolicy : ContactCenterRetentionPolicyBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The tenant YesSql session used to find expired records. + /// The queue item store. + public QueueItemRetentionPolicy( + ISession session, + IQueueItemStore queueItemStore) + : base(session, queueItemStore) + { + } + + /// + public override string EntityName => "QueueItem"; + + /// + protected override double GetRetentionDays(ContactCenterRetentionOptions options) => options.QueueItemRetentionDays; + + /// + protected override Expression> BuildExpiredPredicate(DateTime cutoffUtc) + => index => index.DequeuedUtc != null + && index.DequeuedUtc < cutoffUtc + && (index.Status == QueueItemStatus.Completed || index.Status == QueueItemStatus.Removed); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RetentionCutoffCalculator.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RetentionCutoffCalculator.cs new file mode 100644 index 000000000..567e92778 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RetentionCutoffCalculator.cs @@ -0,0 +1,68 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Computes the effective interaction-event purge cutoff from the retention window and its governance floors. +/// The calculation is a pure function of the current time and options so it can be unit tested, and the floors +/// can only push the cutoff further into the past (keep data longer), never purge earlier than configured. +/// +public static class RetentionCutoffCalculator +{ + /// + /// Computes the effective interaction-event purge cutoff, honoring the projection replay horizon and + /// legal-hold floors. + /// + /// The current UTC time. + /// The configured retention options. + /// + /// When the method returns , the UTC time before which interaction events may be purged. + /// + /// + /// when purging is enabled and a cutoff was computed; otherwise + /// because purging is disabled and events are kept indefinitely. + /// + public static bool TryComputeCutoff(DateTime nowUtc, ContactCenterRetentionOptions options, out DateTime cutoffUtc) + { + ArgumentNullException.ThrowIfNull(options); + + return TryComputeCutoff( + nowUtc, + options.InteractionEventRetentionDays, + Math.Max(options.ProjectionReplayHorizonDays, options.LegalHoldMinimumDays), + out cutoffUtc); + } + + /// + /// Computes the effective purge cutoff for a single entity from its own retention window and the floor + /// that applies to it. The floor can only push the cutoff further into the past, never nearer the present, + /// so a floor can lengthen retention but can never shorten it. + /// + /// The current UTC time. + /// The configured retention window in days. Zero or less disables purging. + /// + /// The governance floor in days. Values of zero or less apply no floor. + /// + /// + /// When the method returns , the UTC time before which records may be purged. + /// + /// + /// when purging is enabled and a cutoff was computed; otherwise + /// because purging is disabled and records are kept indefinitely. + /// + public static bool TryComputeCutoff(DateTime nowUtc, double retentionDays, double minimumRetentionDays, out DateTime cutoffUtc) + { + cutoffUtc = default; + + if (retentionDays <= 0) + { + return false; + } + + var effectiveDays = Math.Max(retentionDays, Math.Max(0, minimumRetentionDays)); + + cutoffUtc = nowUtc.AddDays(-effectiveDays); + + return true; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RoundRobinRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RoundRobinRoutingStrategy.cs new file mode 100644 index 000000000..c79e94386 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RoundRobinRoutingStrategy.cs @@ -0,0 +1,41 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Distributes work fairly by ranking eligible agents from the one who least recently received an +/// assignment to the most recent. Runs only when the queue uses the +/// strategy. +/// +public sealed class RoundRobinRoutingStrategy : IActivityRoutingStrategy +{ + /// + public int Order => 100; + + /// + public ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (context.Queue.RoutingStrategy != QueueRoutingStrategy.RoundRobin) + { + return ValueTask.CompletedTask; + } + + var eligibleCandidates = context.Candidates + .Where(candidate => candidate.IsEligible) + .OrderBy(candidate => candidate.Agent.LastAssignedUtc ?? DateTime.MinValue) + .ThenBy(candidate => candidate.Agent.PresenceChangedUtc ?? DateTime.MaxValue) + .ToArray(); + + for (var index = 0; index < eligibleCandidates.Length; index++) + { + var candidate = eligibleCandidates[index]; + candidate.Score += eligibleCandidates.Length - index; + candidate.AddReason($"Round-robin rank {index + 1}."); + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/SendToVoicemailProviderCommandTypeExecutor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/SendToVoicemailProviderCommandTypeExecutor.cs new file mode 100644 index 000000000..52d07a531 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/SendToVoicemailProviderCommandTypeExecutor.cs @@ -0,0 +1,62 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Handles durable provider commands that send a ringing call to voicemail. +/// +public sealed class SendToVoicemailProviderCommandTypeExecutor : ProviderCallActionCommandTypeExecutor +{ + /// + /// Initializes a new instance of the class. + /// + /// The optional telephony services used to execute the provider action. + /// The interaction manager used to validate and project linked interactions. + /// The queue service used to restore live work after a definitive action failure. + /// The routing-owned work state service. + /// The writer used to apply CRM activity changes outside the routing transaction. + /// The Contact Center event publisher. + /// The clock used to stamp projections. + /// The shared call-control authorization boundary. + public SendToVoicemailProviderCommandTypeExecutor( + IEnumerable telephonyServices, + IInteractionManager interactionManager, + IActivityQueueService queueService, + IContactCenterWorkStateService workStateService, + IContactCenterActivityWriter activityWriter, + IContactCenterEventPublisher publisher, + IClock clock, + ICallControlAuthorizationService callControlAuthorizationService) + : base( + telephonyServices, + interactionManager, + queueService, + workStateService, + activityWriter, + publisher, + clock, + callControlAuthorizationService) + { + } + + /// + public override ProviderCommandType CommandType => ProviderCommandType.SendToVoicemail; + + /// + protected override string ActionName => "SendToVoicemail"; + + /// + protected override string ErrorCodePrefix => "voicemail"; + + /// + protected override Task ExecuteTelephonyAsync( + ITelephonyService telephonyService, + CallReference call, + CancellationToken cancellationToken) + { + return telephonyService.SendToVoicemailAsync(call, cancellationToken); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/StickyAgentRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/StickyAgentRoutingStrategy.cs new file mode 100644 index 000000000..2b6b8dc3a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/StickyAgentRoutingStrategy.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Boosts the eligible candidate who most recently owned the activity (the sticky agent) so that returning +/// work prefers the agent the customer already worked with. Runs only when the queue enables sticky routing. +/// +public sealed class StickyAgentRoutingStrategy : IActivityRoutingStrategy +{ + private const double _stickyAgentBoost = 1000d; + + /// + public int Order => 30; + + /// + public ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + if (!context.Queue.PreferStickyAgent || string.IsNullOrEmpty(context.QueueItem.StickyAgentUserId)) + { + return ValueTask.CompletedTask; + } + + foreach (var candidate in context.Candidates) + { + if (!candidate.IsEligible) + { + continue; + } + + if (string.Equals(candidate.Agent.UserId, context.QueueItem.StickyAgentUserId, StringComparison.Ordinal)) + { + candidate.Score += _stickyAgentBoost; + candidate.AddReason("Preferred sticky agent for this activity."); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/SupervisorQueueAuthorizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/SupervisorQueueAuthorizationService.cs new file mode 100644 index 000000000..5bc54aa0f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/SupervisorQueueAuthorizationService.cs @@ -0,0 +1,50 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Default supervisor queue authorization service backed by monitor permission and queue entitlements. +/// +public sealed class SupervisorQueueAuthorizationService : ISupervisorQueueAuthorizationService +{ + private readonly IAuthorizationService _authorizationService; + private readonly IAgentProfileManager _agentManager; + + /// + /// Initializes a new instance of the class. + /// + /// The authorization service. + /// The agent profile manager used to resolve supervisor entitlements. + public SupervisorQueueAuthorizationService( + IAuthorizationService authorizationService, + IAgentProfileManager agentManager) + { + _authorizationService = authorizationService; + _agentManager = agentManager; + } + + /// + public async Task IsAuthorizedAsync( + ClaimsPrincipal principal, + string userId, + string queueId, + CancellationToken cancellationToken = default) + { + if (principal is null || + string.IsNullOrWhiteSpace(userId) || + string.IsNullOrWhiteSpace(queueId)) + { + return false; + } + + if (!await _authorizationService.AuthorizeAsync(principal, ContactCenterPermissions.MonitorContactCenter)) + { + return false; + } + + var supervisor = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + + return supervisor?.AllowedQueueIds?.Contains(queueId, StringComparer.OrdinalIgnoreCase) == true; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/TransferDestinationResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/TransferDestinationResolver.cs new file mode 100644 index 000000000..833b0e3fa --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/TransferDestinationResolver.cs @@ -0,0 +1,119 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Authorization; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Default transfer destination resolver that fails closed for unsafe or unapproved external targets. +/// External transfers are resolved exclusively from the tenant's server-side approved-destination +/// catalog; callers supply only an opaque catalog entry identifier, never a raw phone number. +/// +public sealed class TransferDestinationResolver : ITransferDestinationResolver +{ + private readonly IAuthorizationService _authorizationService; + private readonly IAgentProfileManager _agentManager; + private readonly IActivityQueueManager _queueManager; + private readonly ISiteService _siteService; + + /// + /// Initializes a new instance of the class. + /// + /// The authorization service used for external transfer RBAC. + /// The agent profile manager used to resolve agent destinations. + /// The queue manager used to resolve queue destinations. + /// The site service used to read the tenant-scoped approved-destination catalog. + public TransferDestinationResolver( + IAuthorizationService authorizationService, + IAgentProfileManager agentManager, + IActivityQueueManager queueManager, + ISiteService siteService) + { + _authorizationService = authorizationService; + _agentManager = agentManager; + _queueManager = queueManager; + _siteService = siteService; + } + + /// + public async Task ResolveAsync( + TransferRequest request, + ClaimsPrincipal principal, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (string.IsNullOrWhiteSpace(request.TargetId)) + { + return TransferDestinationResolutionResult.Denied(); + } + + return request.TargetType switch + { + InteractionTransferTargetType.Agent => await ResolveAgentAsync(request.TargetId, cancellationToken), + InteractionTransferTargetType.Queue => await ResolveQueueAsync(request.TargetId, cancellationToken), + InteractionTransferTargetType.EntryPoint => ResolveEntryPoint(request.TargetId), + InteractionTransferTargetType.External => await ResolveExternalAsync(request.TargetId, principal), + _ => TransferDestinationResolutionResult.Denied(), + }; + } + + private async Task ResolveAgentAsync( + string agentId, + CancellationToken cancellationToken) + { + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + return agent is null + ? TransferDestinationResolutionResult.Denied() + : TransferDestinationResolutionResult.Success(InteractionTransferTargetType.Agent, agent.ItemId, agent.UserId); + } + + private async Task ResolveQueueAsync( + string queueId, + CancellationToken cancellationToken) + { + var queue = await _queueManager.FindByIdAsync(queueId, cancellationToken); + + return queue is null || !queue.Enabled + ? TransferDestinationResolutionResult.Denied() + : TransferDestinationResolutionResult.Success(InteractionTransferTargetType.Queue, queue.ItemId); + } + + private static TransferDestinationResolutionResult ResolveEntryPoint(string entryPointId) + { + return string.IsNullOrWhiteSpace(entryPointId) + ? TransferDestinationResolutionResult.Denied() + : TransferDestinationResolutionResult.Success(InteractionTransferTargetType.EntryPoint, entryPointId.Trim()); + } + + private async Task ResolveExternalAsync( + string targetId, + ClaimsPrincipal principal) + { + if (principal is null || + !await _authorizationService.AuthorizeAsync(principal, ContactCenterPermissions.TransferExternally)) + { + return TransferDestinationResolutionResult.Denied(); + } + + var site = await _siteService.GetSiteSettingsAsync(); + var settings = site.GetOrCreate(); + var entry = settings.Destinations + .FirstOrDefault(d => string.Equals(d.Id, targetId, StringComparison.OrdinalIgnoreCase)); + + if (entry is null || !entry.Enabled) + { + return TransferDestinationResolutionResult.Denied(); + } + + if (!ExternalDestinationPolicy.IsAllowed(entry.E164Address)) + { + return TransferDestinationResolutionResult.Denied(); + } + + return TransferDestinationResolutionResult.Success(InteractionTransferTargetType.External, entry.E164Address); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Telemetry/ContactCenterDiagnostics.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Telemetry/ContactCenterDiagnostics.cs new file mode 100644 index 000000000..b3e7869da --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Telemetry/ContactCenterDiagnostics.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; +using System.Diagnostics.Metrics; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Telemetry; + +/// +/// Defines the stable OpenTelemetry contract for the Contact Center module. The +/// and names are a public integration surface: operators subscribe to them from an +/// OpenTelemetry exporter, so they must not change without a documented migration. Instruments are process-wide +/// and thread-safe by design, following the standard pattern. +/// +public static class ContactCenterDiagnostics +{ + /// + /// The name of the that emits Contact Center distributed traces. + /// + public const string ActivitySourceName = "CrestApps.OrchardCore.ContactCenter"; + + /// + /// The name of the that emits Contact Center metrics. + /// + public const string MeterName = "CrestApps.OrchardCore.ContactCenter"; + + private static readonly Meter _meter = new(MeterName); + + private static readonly Counter _outboxRedelivered = _meter.CreateCounter( + "contactcenter.outbox.redelivered", + unit: "{message}", + description: "The number of Contact Center domain events successfully redelivered from the durable outbox."); + + private static readonly Counter _outboxDeadLettered = _meter.CreateCounter( + "contactcenter.outbox.dead_lettered", + unit: "{message}", + description: "The number of Contact Center domain events dead-lettered after exhausting their retry budget."); + + /// + /// Gets the shared used to start Contact Center trace spans. + /// + public static ActivitySource ActivitySource { get; } = new(ActivitySourceName); + + /// + /// Records that one or more Contact Center domain events were redelivered from the outbox. + /// + /// The number of redelivered events. Non-positive values are ignored. + public static void RecordOutboxRedelivered(long count) + { + if (count <= 0) + { + return; + } + + _outboxRedelivered.Add(count); + } + + /// + /// Records that a Contact Center domain event was dead-lettered. + /// + /// A low-cardinality reason category used as a metric dimension. + public static void RecordOutboxDeadLettered(string reason) + { + _outboxDeadLettered.Add(1, new KeyValuePair("reason", reason ?? "unspecified")); + } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs index 9f3d1efd7..251c3e8d8 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs @@ -127,6 +127,14 @@ public sealed class OmnichannelActivity : CatalogItem /// public int Attempts { get; set; } = 1; + /// + /// Gets or sets the number of times the automated-activities background processor has attempted to start this + /// activity and failed. This is an internal technical retry counter used only to back off and eventually fail a + /// permanently failing automated activity. It is deliberately separate from , which is a + /// routing/dialing concept projected from the contact-center work state and surfaced in reports and the UI. + /// + public int ProcessingAttempts { get; set; } + /// /// Gets or sets the assigned to id. /// diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/CrestApps.OrchardCore.Telephony.Core.csproj b/src/Core/CrestApps.OrchardCore.Telephony.Core/CrestApps.OrchardCore.Telephony.Core.csproj new file mode 100644 index 000000000..9a76274ff --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/CrestApps.OrchardCore.Telephony.Core.csproj @@ -0,0 +1,26 @@ + + + + CrestApps OrchardCore Telephony Core + + $(CrestAppsDescription) + + Provider-neutral voice ingress mechanics shared by every telephony consumer: canonical provider + identity, ingestion keying, normalized-event ordering, and the normalized-event handler contract. + This project deliberately references no Contact Center assembly so telephony can ingest provider + events while Contact Center features are disabled. + + $(PackageTags) Telephony Voice + + + + + + + + + + + + + diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceCallLifecyclePhase.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceCallLifecyclePhase.cs new file mode 100644 index 000000000..4138caa2d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceCallLifecyclePhase.cs @@ -0,0 +1,36 @@ +namespace CrestApps.OrchardCore.Telephony.Core.Models; + +/// +/// Describes how far a call has progressed through its lifecycle, independently of any consumer's own +/// call-state vocabulary. Ordering a provider stream only requires knowing whether a delivery moves the +/// call forward, so the ingress layer reasons in phases and each consumer maps its own states onto them. +/// The declared order is meaningful: a delivery whose phase is lower than the phase already recorded for +/// the stream is a regression and is never applied. +/// +public enum VoiceCallLifecyclePhase +{ + /// + /// The call exists as intent only and has not been offered to the network. + /// + Planned = 0, + + /// + /// The call is being placed or is alerting a destination, but no party has answered. + /// + Alerting = 1, + + /// + /// The call is answered and media is established, including while it is held. + /// + Established = 2, + + /// + /// The call is being torn down but has not reached a final outcome. + /// + Ending = 3, + + /// + /// The call has reached a final outcome and no further delivery can advance it. + /// + Terminal = 4, +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceStreamDelivery.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceStreamDelivery.cs new file mode 100644 index 000000000..28aebbd03 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceStreamDelivery.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.Telephony.Core.Models; + +/// +/// Describes one normalized provider delivery as the ingress ordering rules see it. It carries only what +/// ordering needs, so the same rules apply to every provider and every consumer projection. +/// +public sealed class VoiceStreamDelivery +{ + /// + /// Gets or sets the lifecycle phase the delivery reports. + /// + public VoiceCallLifecyclePhase Phase { get; set; } + + /// + /// Gets or sets the provider sequence number stamped on the delivery, when the provider stamps one. + /// + public long? SequenceNumber { get; set; } + + /// + /// Gets or sets the time the delivery occurred, in UTC. + /// + public DateTime OccurredUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceStreamWatermark.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceStreamWatermark.cs new file mode 100644 index 000000000..532fba237 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Models/VoiceStreamWatermark.cs @@ -0,0 +1,26 @@ +namespace CrestApps.OrchardCore.Telephony.Core.Models; + +/// +/// Describes what a consumer has already applied for one provider call stream. The ingress ordering rules +/// compare an incoming delivery against this watermark to decide whether the delivery moves the stream +/// forward or is a duplicate, a reordering, or a late arrival that must be discarded. +/// +public sealed class VoiceStreamWatermark +{ + /// + /// Gets or sets the lifecycle phase the stream has already reached. + /// + public VoiceCallLifecyclePhase Phase { get; set; } + + /// + /// Gets or sets the highest provider sequence number applied to the stream, when the provider stamps + /// sequence numbers. Once a value is present the provider has established a sequence domain, and an + /// unsequenced delivery can no longer be trusted to advance the stream. + /// + public long? HighWaterSequence { get; set; } + + /// + /// Gets or sets the time of the most recent delivery applied to the stream, in UTC. + /// + public DateTime? LastEventUtc { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/INormalizedVoiceEventHandler.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/INormalizedVoiceEventHandler.cs new file mode 100644 index 000000000..d0dbae37d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/INormalizedVoiceEventHandler.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Projects a normalized provider voice event onto one consumer's own state. +/// +/// Every registered handler observes every ingested event. Handlers are peers, not a responsibility chain: +/// a handler that projects the event must not prevent another handler from projecting the same event, +/// because the telephony call history and the Contact Center call session are independent views of one +/// provider stream and both must stay in sync with it. +/// +/// +public interface INormalizedVoiceEventHandler +{ + /// + /// Gets the relative order in which the handler runs. Lower values run first. + /// + int Order { get; } + + /// + /// Projects the specified normalized voice event. + /// + /// + /// The normalized voice event. Its provider name is already canonicalized, so handlers must not + /// re-canonicalize it. + /// + /// The token to monitor for cancellation requests. + /// when the handler projected the event; otherwise, . + Task HandleAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/INormalizedVoiceEventIngestor.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/INormalizedVoiceEventIngestor.cs new file mode 100644 index 000000000..be2c3209c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/INormalizedVoiceEventIngestor.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Ingests normalized provider voice events through a single hardened path and fans them out to every +/// registered . +/// +public interface INormalizedVoiceEventIngestor +{ + /// + /// Canonicalizes, gates, and fans out the specified normalized provider voice event. + /// + /// The normalized provider voice event. + /// The token to monitor for cancellation requests. + /// when at least one handler projected the event; otherwise, . + Task IngestAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/IProviderIdentityResolver.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/IProviderIdentityResolver.cs new file mode 100644 index 000000000..b2cf21c91 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/IProviderIdentityResolver.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Resolves a provider technical name or alias to its canonical technical identity. Canonicalization +/// runs before any inbox delivery, event, or call identity key is built so that provider-contributed +/// aliases (for example Default Asterisk) collapse to a single stable identity (for example +/// Asterisk). +/// +public interface IProviderIdentityResolver +{ + /// + /// Resolves the specified provider name or alias to its canonical technical name. + /// + /// The provider technical name or alias to canonicalize. + /// + /// The canonical technical name when the supplied name matches a contributed identity or alias; + /// otherwise the supplied name unchanged. A or empty input is returned as-is. + /// + string Canonicalize(string providerName); +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/IVoiceIngressGate.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/IVoiceIngressGate.cs new file mode 100644 index 000000000..391513aea --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/IVoiceIngressGate.cs @@ -0,0 +1,39 @@ +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Serializes every normalized voice event for one provider call stream behind a single distributed lock. +/// +/// The gate is the only component permitted to acquire the ingestion lock. Once a call stream is gated, any +/// nested request for the same stream — including one raised from a fresh shell scope opened while the outer +/// ingestion is still in flight — is satisfied re-entrantly instead of contending with the lease that is +/// already held. Without that guarantee, fanning one event out to several projections would either take the +/// lock once per projection or dead-lock a projection against its own caller. +/// +/// +public interface IVoiceIngressGate +{ + /// + /// Acquires the ingestion lease for the specified provider call stream. + /// + /// The canonical provider name that owns the call stream. + /// The provider-specific call identifier. + /// The token to monitor for cancellation requests. + /// + /// A lease that must be disposed once ingestion completes. Disposing a re-entrant lease releases nothing, + /// because the outermost lease still owns the underlying distributed lock. + /// + /// Thrown when the ingestion lock cannot be acquired in time. + Task AcquireAsync( + string providerName, + string providerCallId, + CancellationToken cancellationToken = default); + + /// + /// Determines whether the current asynchronous flow already holds the ingestion lease for the specified + /// provider call stream. + /// + /// The canonical provider name that owns the call stream. + /// The provider-specific call identifier. + /// when the lease is already held; otherwise, . + bool IsHeld(string providerName, string providerCallId); +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/NormalizedVoiceEventIngestor.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/NormalizedVoiceEventIngestor.cs new file mode 100644 index 000000000..c74a910a1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/NormalizedVoiceEventIngestor.cs @@ -0,0 +1,85 @@ +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Provides the default implementation of . +/// +/// This type owns the provider-neutral half of ingestion: it canonicalizes the provider identity, takes the +/// ingestion lease exactly once, and only then fans the event out. Every projection therefore observes the +/// same canonical identity and the same serialized ordering, and no projection takes a second lock on a +/// stream the ingestor already holds. +/// +/// +public sealed class NormalizedVoiceEventIngestor : INormalizedVoiceEventIngestor +{ + private readonly IEnumerable _handlers; + private readonly IProviderIdentityResolver _providerIdentityResolver; + private readonly IVoiceIngressGate _ingressGate; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The projections that consume the normalized voice event stream. + /// The resolver used to canonicalize provider aliases before keying. + /// The gate that serializes each provider call stream. + /// The logger instance. + public NormalizedVoiceEventIngestor( + IEnumerable handlers, + IProviderIdentityResolver providerIdentityResolver, + IVoiceIngressGate ingressGate, + ILogger logger) + { + _handlers = handlers; + _providerIdentityResolver = providerIdentityResolver; + _ingressGate = ingressGate; + _logger = logger; + } + + /// + public async Task IngestAsync( + ProviderVoiceEvent providerEvent, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(providerEvent); + + if (string.IsNullOrWhiteSpace(providerEvent.ProviderCallId)) + { + return false; + } + + // Canonicalize once, before the ingestion lock key is built, so that provider-contributed aliases + // collapse onto a single stable identity for every downstream projection rather than being resolved + // differently by each of them. The raw idempotency key is deliberately left untouched: scoping it is + // the concern of the projection that owns the durable de-duplication record, and rewriting it here + // would destroy the raw key that projection needs to recognize deliveries stored before scoping. + providerEvent = providerEvent with + { + ProviderName = _providerIdentityResolver.Canonicalize(providerEvent.ProviderName), + }; + + await using var lease = await _ingressGate.AcquireAsync( + providerEvent.ProviderName, + providerEvent.ProviderCallId, + cancellationToken); + + var handled = false; + + foreach (var handler in _handlers.OrderBy(handler => handler.Order)) + { + // Every handler observes every event. A handler that claims the event does not consume it, + // because the telephony call history and the Contact Center call session are independent + // projections of the same stream and suppressing either one silently desynchronizes it. + handled |= await handler.HandleAsync(providerEvent, cancellationToken); + } + + if (!handled && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug("No normalized voice event handler projected the ingested provider event."); + } + + return handled; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/ProviderIdentityResolver.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/ProviderIdentityResolver.cs new file mode 100644 index 000000000..f2f930f83 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/ProviderIdentityResolver.cs @@ -0,0 +1,66 @@ +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Provides the default implementation. Canonical identities and +/// aliases are gathered from every registered so provider modules +/// contribute their own identities without the telephony layer referencing provider implementation assemblies. +/// +public sealed class ProviderIdentityResolver : IProviderIdentityResolver +{ + private readonly Dictionary _aliasToCanonical; + + /// + /// Initializes a new instance of the class. + /// + /// The registered provider identity contributors. + public ProviderIdentityResolver(IEnumerable identityProviders) + { + _aliasToCanonical = BuildMap(identityProviders); + } + + /// + public string Canonicalize(string providerName) + { + if (string.IsNullOrWhiteSpace(providerName)) + { + return providerName; + } + + return _aliasToCanonical.TryGetValue(providerName, out var canonical) + ? canonical + : providerName; + } + + private static Dictionary BuildMap(IEnumerable identityProviders) + { + var map = new Dictionary(StringComparer.Ordinal); + + if (identityProviders is null) + { + return map; + } + + foreach (var identityProvider in identityProviders) + { + foreach (var identity in identityProvider.GetIdentities()) + { + if (identity is null || string.IsNullOrWhiteSpace(identity.CanonicalName)) + { + continue; + } + + map[identity.CanonicalName] = identity.CanonicalName; + + foreach (var alias in identity.Aliases) + { + if (!string.IsNullOrWhiteSpace(alias)) + { + map[alias] = identity.CanonicalName; + } + } + } + } + + return map; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceIngressGate.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceIngressGate.cs new file mode 100644 index 000000000..07b84ff89 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceIngressGate.cs @@ -0,0 +1,125 @@ +using OrchardCore.Locking; +using OrchardCore.Locking.Distributed; + +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Provides the default implementation on top of the tenant-scoped +/// distributed lock. +/// +public sealed class VoiceIngressGate : IVoiceIngressGate +{ + private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan _lockExpiration = TimeSpan.FromMinutes(2); + + // The held-key set flows with the asynchronous control flow rather than with the dependency-injection + // scope, because a projection is allowed to re-enter ingestion from a fresh shell scope while the outer + // lease is still held. A scope-local set would not see the outer lease and would contend with it until + // the acquisition timed out. + private static readonly AsyncLocal> _heldKeys = new(); + + private readonly IDistributedLock _distributedLock; + + /// + /// Initializes a new instance of the class. + /// + /// The tenant-scoped distributed lock used to serialize each provider call stream. + public VoiceIngressGate(IDistributedLock distributedLock) + { + _distributedLock = distributedLock; + } + + /// + public Task AcquireAsync( + string providerName, + string providerCallId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerCallId); + + cancellationToken.ThrowIfCancellationRequested(); + + var key = VoiceIngressKeys.BuildIngestionLockKey(providerName, providerCallId); + var held = _heldKeys.Value; + + if (held is null) + { + held = new HashSet(StringComparer.Ordinal); + + // The registration happens in this synchronous frame on purpose. Assigning an asynchronous-local + // value from inside an `async` method confines it to that method's execution context and reverts + // it the moment the method returns, so a nested consumer would never observe the lease this call + // is about to take and would contend with it until the acquisition timed out. + _heldKeys.Value = held; + } + + if (!held.Add(key)) + { + return Task.FromResult(ReentrantVoiceIngressLease.Instance); + } + + return AcquireCoreAsync(key, held); + } + + private async Task AcquireCoreAsync(string key, HashSet heldKeys) + { + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync(key, _lockTimeout, _lockExpiration); + + if (!locked) + { + heldKeys.Remove(key); + + throw new TimeoutException("The provider call event could not acquire its ingestion lock."); + } + + return new VoiceIngressLease(key, locker); + } + + /// + public bool IsHeld(string providerName, string providerCallId) + { + if (string.IsNullOrEmpty(providerCallId)) + { + return false; + } + + return _heldKeys.Value?.Contains(VoiceIngressKeys.BuildIngestionLockKey(providerName, providerCallId)) == true; + } + + private sealed class VoiceIngressLease : IAsyncDisposable + { + private readonly string _key; + private readonly ILocker _locker; + private bool _disposed; + + public VoiceIngressLease(string key, ILocker locker) + { + _key = key; + _locker = locker; + } + + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + _disposed = true; + _heldKeys.Value?.Remove(_key); + + if (_locker is not null) + { + await _locker.DisposeAsync(); + } + } + } + + private sealed class ReentrantVoiceIngressLease : IAsyncDisposable + { + public static readonly ReentrantVoiceIngressLease Instance = new(); + + public ValueTask DisposeAsync() + => ValueTask.CompletedTask; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceIngressKeys.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceIngressKeys.cs new file mode 100644 index 000000000..1ef71bf92 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceIngressKeys.cs @@ -0,0 +1,56 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Builds the keys that serialize and de-duplicate one provider call stream. Every consumer of the same +/// stream must derive identical keys, otherwise two consumers would serialize on different locks and +/// de-duplicate against different spaces while claiming to describe the same call. +/// +public static class VoiceIngressKeys +{ + // These prefixes are persisted in stored idempotency keys and are taken by in-flight distributed + // locks, so they are deliberately left exactly as they were before this computation moved down to + // the telephony layer. Changing either string would make an upgraded node de-duplicate against a + // different key space than a previous-version node, and make both nodes serialize the same provider + // stream on different locks during a rolling upgrade. + private const string _ingestionLockPrefix = "ContactCenterProviderVoiceEvent:"; + private const string _eventIdempotencyPrefix = "provider-event:v1:"; + + /// + /// Builds the distributed-lock key that serializes one canonical provider call stream. + /// + /// The canonical provider technical name. + /// The provider call identifier. + /// A bounded lock key derived from the provider identity and the provider call identifier. + public static string BuildIngestionLockKey(string providerName, string providerCallId) + { + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{providerName}\n{providerCallId}")); + + return $"{_ingestionLockPrefix}{Convert.ToHexString(bytes)}"; + } + + /// + /// Builds the provider-scoped idempotency key for one normalized provider delivery. + /// + /// The canonical provider technical name. + /// The provider-supplied raw delivery idempotency key. + /// + /// A bounded, collision-resistant key when both a canonical provider and a raw key are present, so + /// identical raw delivery identifiers from different providers cannot collide or exceed the database + /// idempotency column limit; otherwise the raw unchanged (including + /// or empty), preserving non-provider idempotency semantics. + /// + public static string BuildEventIdempotencyKey(string providerName, string idempotencyKey) + { + if (string.IsNullOrEmpty(idempotencyKey) || string.IsNullOrWhiteSpace(providerName)) + { + return idempotencyKey; + } + + var bytes = SHA256.HashData(Encoding.UTF8.GetBytes($"{providerName}\n{idempotencyKey}")); + + return $"{_eventIdempotencyPrefix}{Convert.ToHexString(bytes)}"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceStreamOrdering.cs b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceStreamOrdering.cs new file mode 100644 index 000000000..43afafa0a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Telephony.Core/Services/VoiceStreamOrdering.cs @@ -0,0 +1,84 @@ +using CrestApps.OrchardCore.Telephony.Core.Models; + +namespace CrestApps.OrchardCore.Telephony.Core.Services; + +/// +/// Decides whether a normalized provider delivery advances a call stream or must be discarded as stale. +/// These rules are provider-neutral and consumer-neutral on purpose: every projection built on the same +/// provider stream has to reach the same decision, because two projections that disagree about which +/// deliveries are stale will disagree about the state of the same call. +/// +public static class VoiceStreamOrdering +{ + /// + /// Determines whether the specified delivery must be discarded rather than applied to the stream. + /// + /// What the consumer has already applied for this stream. + /// The incoming normalized delivery. + /// + /// when the delivery regresses the lifecycle, repeats or precedes an established + /// sequence domain, or predates the last applied delivery; otherwise . + /// + public static bool ShouldDiscard(VoiceStreamWatermark watermark, VoiceStreamDelivery delivery) + { + ArgumentNullException.ThrowIfNull(watermark); + ArgumentNullException.ThrowIfNull(delivery); + + if (watermark.Phase == VoiceCallLifecyclePhase.Terminal || + delivery.Phase < watermark.Phase) + { + return true; + } + + // A terminal delivery is never stale. Provider nodes do not share a clock and do not all stamp + // sequence numbers, so a hangup can carry a timestamp behind the state change that preceded it or + // arrive unsequenced after a sequenced delivery. Applying the staleness guards to it would discard + // the only notification that the call is over, stranding the stream in a live phase forever. + // Ending twice is not a risk, because an already terminal stream is rejected above and an exact + // redelivery is rejected by the ingress duplicate check before ordering runs. + if (delivery.Phase == VoiceCallLifecyclePhase.Terminal) + { + return false; + } + + // Once a provider establishes a sequence domain, unsequenced deliveries cannot safely advance it. + if (watermark.HighWaterSequence.HasValue) + { + if (!delivery.SequenceNumber.HasValue || + delivery.SequenceNumber.Value <= watermark.HighWaterSequence.Value) + { + return true; + } + } + + return watermark.LastEventUtc.HasValue && + delivery.OccurredUtc < watermark.LastEventUtc.Value; + } + + /// + /// Advances the specified watermark with a delivery that has been accepted. The watermark never moves + /// backwards, because a rewound watermark would re-admit deliveries the stream has already discarded. + /// + /// The watermark to advance. + /// The accepted normalized delivery. + public static void Advance(VoiceStreamWatermark watermark, VoiceStreamDelivery delivery) + { + ArgumentNullException.ThrowIfNull(watermark); + ArgumentNullException.ThrowIfNull(delivery); + + watermark.Phase = delivery.Phase > watermark.Phase + ? delivery.Phase + : watermark.Phase; + + watermark.LastEventUtc = watermark.LastEventUtc.HasValue && watermark.LastEventUtc.Value > delivery.OccurredUtc + ? watermark.LastEventUtc.Value + : delivery.OccurredUtc; + + if (delivery.SequenceNumber.HasValue) + { + watermark.HighWaterSequence = watermark.HighWaterSequence.HasValue + ? Math.Max(watermark.HighWaterSequence.Value, delivery.SequenceNumber.Value) + : delivery.SequenceNumber.Value; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.YesSql.Core/Migrations/IndexStringColumnRebuild.cs b/src/Core/CrestApps.OrchardCore.YesSql.Core/Migrations/IndexStringColumnRebuild.cs new file mode 100644 index 000000000..7b212eade --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.YesSql.Core/Migrations/IndexStringColumnRebuild.cs @@ -0,0 +1,238 @@ +using YesSql; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.YesSql.Core.Migrations; + +/// +/// Widens the declared length of a text index column, preserving the values already stored in it. +/// +/// +/// Widening a column is a schema change, not an additive one: it alters an existing column so that a value that +/// outgrew the declared length is admitted rather than refused. An engine that enforces the length rejects an +/// over-length write outright (PostgreSQL 22001, SQL Server 8152, MySQL 1406 under the +/// default strict mode); only non-strict MySQL truncates. Widening is forward-only and repairs no row already +/// rejected or truncated under the narrow length. SQLite has no ALTER COLUMN +/// and stores every text column as unbounded TEXT, so a length is neither enforced nor changeable in +/// place there; the engines production uses (SQL Server, PostgreSQL, MySQL) do enforce it. Add, copy, drop and +/// rename is therefore used rather than an alter, because it is the one sequence available on every supported +/// engine and produces the same end state everywhere: on SQLite it re-creates the same unbounded column, and on +/// an enforcing engine it re-creates the column at the wider declared length. +/// +/// The rebuild is written to resume rather than restart, because MySQL commits each schema change on its own, so +/// an attempt that stopped part-way can leave the replacement column behind. Re-running then finds the +/// replacement already present and finishes the sequence instead of adding it a second time and failing every +/// activation from there on. The value copy is a straight assignment, so re-running it over the same rows is +/// harmless. +/// +/// +public static class IndexStringColumnRebuild +{ + private const string TemporarySuffix = "__widen"; + + // Only SQL Server materializes a column's declared default as a separately named constraint. On PostgreSQL + // and MySQL the default is part of the column definition and is removed with the column; SQLite enforces no + // length and rebuilds the whole table for any column change. So the pre-drop of the default constraint is + // needed on SQL Server alone. + private const string SqlServerDialectName = "SqlServer"; + + /// + /// Rebuilds a text column at a wider declared length, carrying its current values across unchanged. + /// + /// The index whose table carries the column. + /// The active schema builder. + /// The YesSql store. + /// The name of the column to widen. + /// The wider declared length the column must carry after the rebuild. + /// Whether the rebuilt column refuses null, matching the fresh-install declaration. + /// The default the rebuilt column declares, or when it declares none. A not-null column added to a table that already has rows needs one so the add succeeds. + /// The collection the index table belongs to. + public static async Task WidenAsync( + ISchemaBuilder schemaBuilder, + IStore store, + string columnName, + int length, + bool isNotNull, + object defaultValue, + string collection) + { + ArgumentNullException.ThrowIfNull(schemaBuilder); + ArgumentNullException.ThrowIfNull(store); + ArgumentException.ThrowIfNullOrEmpty(columnName); + + var tableName = schemaBuilder.TablePrefix + + schemaBuilder.TableNameConvention.GetIndexTable(typeof(TIndex), collection); + var quotedTableName = schemaBuilder.Dialect.QuoteForTableName(tableName, store.Configuration.Schema); + + var temporaryColumnName = columnName + TemporarySuffix; + var columnNames = await ReadColumnNamesAsync(schemaBuilder, quotedTableName); + var hasOriginal = columnNames.Contains(columnName); + var hasReplacement = columnNames.Contains(temporaryColumnName); + + if (hasOriginal) + { + // A replacement that is already present was left behind by an attempt that stopped part-way, and + // adding it again would fail every activation from here on. + if (!hasReplacement) + { + await schemaBuilder.AlterIndexTableAsync( + typeof(TIndex), + table => table.AddColumn(temporaryColumnName, column => + { + column.WithLength(length); + + if (isNotNull) + { + column.NotNull(); + } + + if (defaultValue is not null) + { + column.WithDefault(defaultValue); + } + }), + collection); + } + + // Re-read what physically exists before copying into the replacement. In normal execution the add + // above has just created it, so the copy runs. Under a pass that records schema declarations without + // executing them the replacement is not there; dropping the original now would destroy it and its + // data with no replacement to rename into its place, so the table is left exactly as it is. That a + // recording pass declares nothing further here is harmless: it captures the finished column identity + // from the create step and never observes this rebuild at all. + var physicalColumnNames = await ReadColumnNamesAsync(schemaBuilder, quotedTableName); + + if (!physicalColumnNames.Contains(temporaryColumnName)) + { + return; + } + + await CopyValuesAsync(schemaBuilder, quotedTableName, columnName, temporaryColumnName); + + // SQL Server refuses to drop a column while a default constraint still references it, and the + // original column declared a default when it was added, so the auto-named constraint SQL Server + // created for it comes down first. The replacement column re-declares the same default in the + // AddColumn above and carries it through the rename below, so the finished column keeps the default + // the fresh install declares. This is a no-op on every other engine and whenever the column never + // declared a default. The drop is issued inline here, in the same method as the replacement that puts + // the default back, so the additive-only guard reads the removal and its restoration together. + if (string.Equals(schemaBuilder.Dialect.Name, SqlServerDialectName, StringComparison.OrdinalIgnoreCase)) + { + var defaultConstraintName = await ReadSqlServerColumnDefaultNameAsync(schemaBuilder, quotedTableName, columnName); + + if (defaultConstraintName is not null) + { + var quotedConstraintName = schemaBuilder.Dialect.QuoteForColumnName(defaultConstraintName); + + await using var dropDefaultCommand = schemaBuilder.Connection.CreateCommand(); + dropDefaultCommand.Transaction = schemaBuilder.Transaction; + dropDefaultCommand.CommandText = "alter table " + quotedTableName + " drop constraint " + quotedConstraintName; + + await dropDefaultCommand.ExecuteNonQueryAsync(); + } + } + + await schemaBuilder.AlterIndexTableAsync( + typeof(TIndex), + table => table.DropColumn(columnName), + collection); + } + else if (!hasReplacement) + { + return; + } + + // Reached either straight from the copy above, or by an attempt that stopped after the original column + // was dropped and before the replacement took its name; in the second case the replacement already holds + // the copied values and repeating the earlier steps would destroy them. + await schemaBuilder.AlterIndexTableAsync( + typeof(TIndex), + table => table.RenameColumn(temporaryColumnName, columnName), + collection); + } + + /// + /// Reads the name of every column in the table. + /// + /// + /// The replacement column is looked for so an interrupted attempt resumes rather than adding it twice, and + /// the original is looked for so a run that already dropped it renames the replacement instead of failing. + /// Names are read through the data reader rather than an engine-specific catalog view, so the same probe + /// works on every supported engine, and the query is written to match no rows because only the shape is + /// wanted. + /// + private static async Task> ReadColumnNamesAsync( + ISchemaBuilder schemaBuilder, + string quotedTableName) + { + var columnNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + await using var command = schemaBuilder.Connection.CreateCommand(); + command.Transaction = schemaBuilder.Transaction; + command.CommandText = $"SELECT * FROM {quotedTableName} WHERE 1 = 0"; + + await using var reader = await command.ExecuteReaderAsync(); + + for (var ordinal = 0; ordinal < reader.FieldCount; ordinal++) + { + columnNames.Add(reader.GetName(ordinal)); + } + + return columnNames; + } + + private static async Task CopyValuesAsync( + ISchemaBuilder schemaBuilder, + string quotedTableName, + string columnName, + string temporaryColumnName) + { + var quotedColumnName = schemaBuilder.Dialect.QuoteForColumnName(columnName); + var quotedTemporaryColumnName = schemaBuilder.Dialect.QuoteForColumnName(temporaryColumnName); + + await using var command = schemaBuilder.Connection.CreateCommand(); + command.Transaction = schemaBuilder.Transaction; + command.CommandText = $"UPDATE {quotedTableName} SET {quotedTemporaryColumnName} = {quotedColumnName}"; + + await command.ExecuteNonQueryAsync(); + } + + /// + /// Reads the name SQL Server gave the default constraint bound to a column, or when + /// the column carries none. + /// + /// + /// The name is auto-generated when a defaulted column is added, so it cannot be spelled ahead of time and is + /// read from the catalog. The lookup is parameterized, and the table is resolved through OBJECT_ID so + /// a tenant whose tables live in a named schema is matched by the same quoted name the rest of the rebuild + /// uses. + /// + /// The active schema builder. + /// The quoted, schema-qualified name of the table the column belongs to. + /// The name of the column whose default constraint name is read. + private static async Task ReadSqlServerColumnDefaultNameAsync( + ISchemaBuilder schemaBuilder, + string quotedTableName, + string columnName) + { + await using var command = schemaBuilder.Connection.CreateCommand(); + command.Transaction = schemaBuilder.Transaction; + command.CommandText = + "SELECT dc.name FROM sys.default_constraints dc " + + "INNER JOIN sys.columns c ON c.object_id = dc.parent_object_id AND c.column_id = dc.parent_column_id " + + "WHERE dc.parent_object_id = OBJECT_ID(@table) AND c.name = @column"; + + var tableParameter = command.CreateParameter(); + tableParameter.ParameterName = "@table"; + tableParameter.Value = quotedTableName; + command.Parameters.Add(tableParameter); + + var columnParameter = command.CreateParameter(); + columnParameter.ParameterName = "@column"; + columnParameter.Value = columnName; + command.Parameters.Add(columnParameter); + + var result = await command.ExecuteScalarAsync(); + + return result as string; + } +} diff --git a/src/CrestApps.Docs/CrestApps.Docs.csproj b/src/CrestApps.Docs/CrestApps.Docs.csproj index f4e56132e..3077c23a9 100644 --- a/src/CrestApps.Docs/CrestApps.Docs.csproj +++ b/src/CrestApps.Docs/CrestApps.Docs.csproj @@ -3,6 +3,7 @@ $(CommonTargetFrameworks) false + false diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 8c45d626f..6fde1e38a 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -16,7 +16,7 @@ This page is organized by module and feature so related changes stay together. B - **Platform upgrade** to `.NET 10` and the Orchard Core `3.0.0` line used by this repository - **Expanded AI platform** with deployments, provider connections, orchestrators, tools, AI Documents, AI Memory, MCP, A2A, Claude, and Copilot integrations - **Broader Orchard module surface** including Content Access Control, Content Transfer, phone-number support, DNC Registry, Omnichannel Management, Recipes, SignalR, Roles, and Time Zones -- **Provider-agnostic telephony** with a soft phone widget, a SignalR hub, and a DialPad provider integration +- **Provider-agnostic telephony** with a soft phone widget, a SignalR hub, and DialPad plus Asterisk provider integrations - **Provider-agnostic retrieval architecture** built around AI Data Sources, AI Documents, and AI Memory instead of provider-bound BYOD flows - **Canonical Docusaurus documentation** under `src\CrestApps.Docs` @@ -51,6 +51,212 @@ At a high level, the platform changes are: ## Module and feature changes +### Contact Center + +- Took the screens out of the capabilities, so a deployment that serves no user interface can enable the whole contact center without activating one. Every capability feature registered its own admin menus and display drivers, which meant a headless tenant that wanted agent state, queues, the dialer, recording, or entry points got their dashboard pages whether or not it had any use for them — and, because the base feature depended on the Omnichannel management module, it got the CRM administration experience as well. Each capability now registers services only, and the screens it used to register live in a matching `.Admin` feature that depends on the capability rather than the other way round, so enabling a capability can never pull a user interface in. The split crosses module boundaries for the same reason: `CrestApps.OrchardCore.Omnichannel.Managements` is now the administration experience layered over a new headless `CrestApps.OrchardCore.Omnichannel.Activities` feature that owns the activity model itself, and the Telephony provider settings screen moved to `CrestApps.OrchardCore.Telephony.Admin` — a capability that transitively drags an administration module in is no more headless than one that registers the screens itself. `CrestApps.OrchardCore.ContactCenter.Admin` is now the root of the administration surface, carrying the settings screens and the menu the other administration features attach to, and each of `Agents`, `Queues`, `Dialer`, `Recording`, and `EntryPoints` has an administration feature of its own; the agent desktop, soft phone, supervision, analytics, and maintenance features are user experiences in their own right and were not split, because nothing would be left behind if they were. Controllers moved with the screens they serve, because a route left on a capability whose drivers have gone is worse than one that never moved: it stays registered and authorized while the display pipeline that binds and validates its form no longer resolves, so a create post persists whatever the empty editor produced. The guarantee is proved structurally rather than by a list of feature names: a tenant boots the entire headless closure and every navigation provider, display driver, and routed administration controller it can resolve is enumerated by reflection, so a screen added to a capability feature later fails the build without anyone remembering to extend the test, while each excluded feature must prove it actually serves an interface and each administration feature must register screens beyond those it inherits - measured by booting the feature's dependency closure with the feature withheld and subtracting that surface, so inheriting the administration root's screens cannot stand in for registering any - and neither list can be padded to weaken the closure. **Upgrading tenants must enable the new administration features to get their screens back**: enable `CrestApps.OrchardCore.ContactCenter.Admin`, then the `.Admin` feature of each capability administered through the dashboard, plus `CrestApps.OrchardCore.Telephony.Admin` where telephony is configured from the dashboard. See [Contact Center](../contact-center/index.md). +- Gave every table that grows with traffic a retention policy, instead of purging one and letting twelve others grow forever. Only the interaction event log was aged out; interactions, call sessions, queue items, activity reservations, outbox and webhook inbox messages, provider commands, agent sessions, daily metrics, processed-event markers and callback requests had no policy at all, which is a gap that stays invisible until a disk fills. Each entity now declares a policy that says which timestamp it is aged from, what makes one of its records finished, and which governance floors hold it beyond its configured window. Records are aged from the moment they settled — a queue item from when it was dequeued, a provider command from when it completed, a callback from when it was last modified — never from when they arrived, when they were last retried, or when they were scheduled, because ageing from arrival deletes exactly the work that waited longest and ageing from a scheduled time deletes work that has not happened yet. A record that is still live survives regardless of age. Legal hold applies to communication history, the projection replay horizon to the event log alone, and a new redelivery envelope to deduplication markers, whose premature deletion would let a redelivered event run its side effect a second time; every floor can only extend a window, never shorten one. Purging now drains each entity in batches until its table is empty rather than deleting a fixed slice, commits between batches so a large drain never accumulates one unbounded transaction, isolates a failing entity so one unhealthy table cannot keep every other table growing, and logs a warning naming the entities that still have work when its per-cycle budget runs out instead of finishing quietly. Adding a table now fails the build until it is either given a policy or exempted with a written reason. Seven indexes gained the settlement timestamp their policy needs, backfilled in a single set-based statement so an upgraded tenant does not present every existing row as infinitely old and delete its own history on the first cycle. A settlement column is only useful if something writes it, so every settlement stamp is now written on the transition that settles the record — reservations on release and cancellation, callbacks on promotion, webhook deliveries on completion and on dead-lettering — and a gate asserts each of those statements still exists. Completed webhook inbox rows are the tombstones that make a repeated provider delivery a duplicate, so they are aged from settlement rather than receipt and carry the redelivery floor, and every purge predicate is backed by an index leading with the timestamp it selects on so draining a large table does not scan it end to end. A backfill only helps if it can execute meaningfully on the database it runs against, so every migration that writes raw SQL takes its identifiers from the database dialect rather than quoting them itself: an engine that reads a hardcoded double quote as a string literal turns the statement into a comparison between a constant and a set of numbers, which matches no rows and reports success, leaving the whole pre-upgrade backlog permanently unpurged. Completed webhook inbox rows are floored by the seven-day duplicate-detection horizon the inbox already enforces, so retention can never shorten the window in which a repeated provider delivery is still recognized as a duplicate; and because that inbox already sweeps its own settled rows at that horizon, the sweep now also honors a longer configured retention window instead of silently overruling it. A purge batch that fails partway through commits and attributes the deletes it had already staged, so partial work is never committed under the next entity's transaction and left uncounted. See [Production support](../contact-center/production-support.md). +- Made recording/media erasure a first-class durable operation, because until now recorded audio was never deleted by any path: the erasure command had no caller, the media store's delete was never invoked, a second recording pointer lived on `CallSession` and was never cleared, the interaction retention path deleted the row that carried the reference on age alone without deleting the media, and the durable Asterisk ingest could write media after an erasure request, so deletion was both unimplementable and racy. An authorized, antiforgery-validated endpoint (`POST {admin}/contact-center/recordings/erase`, permission `ManageInteractions`) now takes an interaction id and a required non-empty reason and, in one YesSql unit of work, clears both the `Interaction` and mirrored `CallSession` recording references, stamps the `RecordingErasedUtc` tombstone, and enqueues durable media deletion on the existing Contact Center outbox — pointer clears, tombstone, and enqueue commit together or not at all. The outbox handler performs the media-store delete in a fresh scope; delivery is at-least-once so an already-absent recording counts as erased, an unconfirmed delete throws so the outbox retries instead of reporting false success, and a confirmed delete publishes a `RecordingMediaDeleted` receipt keyed deterministically off the erasure event. The interaction retention path now enqueues media deletion and clears the mirrored pointer before it deletes the row, and honors the per-record `RecordingLegalHold` flag — previously only the policy-level legal-hold floor widened the time window, so a held recording was both orphaned and deleted on age. The Asterisk recording ingest job consults the tombstone before download and again after storing media, deleting any media it wrote and cancelling the job if the recording was erased in the window, so a late ingest can never resurrect deleted media. Tenant removal now fails closed: a media store that does not implement tenant-wide purge, or a purge that returns unsuccessfully or throws, blocks decommissioning with an operator-visible error rather than silently orphaning media. When Orchard's Audit Trail feature is enabled, confirmed deletion — not merely request acceptance — is recorded under the **Contact Center** category as **Recording media deleted**, though the durable outbox completion plus tombstone remain authoritative if the audit category is disabled or trimmed. This delivers recording/media erasure as a component of a GDPR Art. 17 / CCPA response; it is not a general cross-entity subject-erasure feature. See [Production support](../contact-center/production-support.md). +- Widened the call-session provider-call identifier and the claim key composed from it, because a provider call identifier arrives verbatim from an external switch and can be long — a SIP Call-ID is bounded by nothing Contact Center controls — while the column declared it at a length too short for it. An engine that enforces a declared length rejects an over-length write outright (PostgreSQL `22001`, SQL Server `8152`, MySQL `1406` under the default strict mode), so the call session is never persisted and a reconciliation lookup can never find it; only non-strict MySQL truncates, and a truncated identifier — once the claim key composed from it outgrew its own column — forged a collision between two distinct calls, the opposite of the uniqueness the claim exists to guarantee. The widening is forward-only: it lets the full identifier be stored going forward and repairs no row already rejected or truncated under the narrow length. The identifier is now stored at 256 — a deliberate ceiling, comfortably longer than any real provider call identifier — and the claim key at a length derived from the parts it concatenates — the provider technical name plus a separator plus the widened identifier — so the composed key can never truncate a value its source columns can hold, and it stays within the 900-byte unique-index key limit SQL Server imposes. Because SQLite has no `ALTER COLUMN`, the widening is a rebuild rather than an alter: a replacement column is added at the wider length, the values are copied across, the original is dropped, and the replacement is renamed into its place, with the unique claim index and the covering index that name a rebuilt column dropped first and recreated afterwards, and the rebuild resumes rather than restarts so an engine that commits each schema change on its own cannot strand a tenant mid-upgrade. SQLite stores every text column as unbounded `TEXT`, so it never rejected or truncated and the rebuild is a value-preserving no-op there; the widening exists for SQL Server, PostgreSQL, and MySQL, and each destructive step of the rebuild is authorized as a machine-checked in-place rebuild in the additive-migration register. Executing the upgrade against a real PostgreSQL instance in the distributed CI gate proves both columns reach the wider length, that a seeded value survives, and that the claim-key unique index still rejects a duplicate; SQL Server and MySQL enforce the same length but are not exercised in the Postgres-only gate, so their behaviour is correct by construction rather than by test. Before it recreates the unique claim index, the widening re-checks that no two rows share a claim key and aborts with actionable repair guidance, so a duplicate written into the brief window while the index is absent — possible only on an engine that autocommits each schema change — surfaces as a clear message rather than an opaque index-creation failure. See [Production support](../contact-center/production-support.md). +- Gave the Asterisk real-time listener genuine backpressure in place of a silent drop. The bound on the in-memory buffer between the WebSocket receive loop and the dispatcher was a hard-coded `1000`, and although the buffer was declared to wait when full it was written with a non-blocking try-write, so the first time it filled the listener abandoned the connection and reconnected — the exact moment of peak load was met by dropping events. The bound is now the validated `CrestApps:Asterisk:Coordination:RealtimeEventBufferCapacity` (default 1000, capped at 100000 so a saturated buffer cannot exhaust process memory), and a full buffer applies real backpressure: the receive loop stops reading the socket and awaits a bounded window for space rather than dropping the event or the connection. That backpressure only reaches the provider as genuine flow control while the provider tolerates a stalled reader — Asterisk closes the ARI WebSocket and discards its queued events once its own `websocket_write_timeout` (100 ms by default) elapses, so the bundled `ari.conf` raises that timeout above the backpressure window; with a stock timeout the provider tears the socket down first and the listener degrades to the reconnect-and-reconcile path. The first wait of each saturation episode increments the `asterisk.realtime.ingestion.saturated` counter on the `CrestApps.OrchardCore.Asterisk` meter, tagged by provider, so sustained pressure is visible as it happens and not only through its aftermath; the episode ends only once the buffer has fully drained, so a buffer oscillating near full counts as one episode. Only if the buffer stays full for the whole validated `RealtimeEventBackpressureTimeout` (default 5 seconds) does the listener give up, reconnect, and reconcile, and repeated saturation timeouts back off exponentially instead of hot-looping. That reconciliation is pointer-driven and best-effort by design — it reconciles at most 200 known calls per invocation and no-ops behind a distributed lock when a sweep is already running — so it restores each known call's current state but does not replay every intermediate hold or resume transition, and a call whose opening event was the one dropped is not recovered; sizing the buffer higher buys time before backpressure, but no configuration makes the stream lossless. See [Production support](../contact-center/production-support.md). +- Added the minimum operational signals an operator needs to run a handover safely, and the runbook that uses them. Two gaps remained after backpressure telemetry landed: the Asterisk listener had no connectivity signal, so churn on the ARI event stream was visible only in logs, and there was no store-backed count of live calls or waiting interactions, so a node drain could not be sized before it was started. The `CrestApps.OrchardCore.Asterisk` meter now emits `asterisk.realtime.connected` (successful connects, first plus every reconnect — the connectivity signal) and `asterisk.realtime.reconnect_attempted` (reconnection attempts — a sustained rate is churn and an early warning that events may be missed between reconciliation sweeps), both tagged by provider. The live counts are surfaced as health-check gauges rather than observable-gauge metrics on purpose: an observable-gauge callback is synchronous and runs with no ambient tenant scope, so it cannot issue the per-tenant store query these counts require, and a process-global count would be wrong across tenants and nodes, whereas a health check already runs inside the tenant scope with an async body. `contactcenter-active-calls` (owned by the base feature, which owns the call session store) reports `active_calls`, and `contactcenter-queue-backlog` (owned by the Queues feature, which owns the queue item store) reports `queued_interactions`; each stays healthy at any count and carries the count in its `Data`, because the acceptable ceiling is deployment specific and a backlog verdict already belongs to the outbox check. The ownership split is deliberate — a health check registered by a feature that does not own its dependency throws on any tenant that enables the registering feature without the owning one. The [voice listener handover and rollback runbook](../contact-center/runbooks.md#voice-listener-handover-and-rollback) uses these signals: it does not claim zero downtime for a Voice tenant, because the single-active-process listener cannot be swapped side by side; it is a bounded interruption of call control sized against `active_calls` first, performed as a non-overlapping cutover, and rolled back the same way in reverse. It states plainly what happens to live calls during the gap — already-bridged media keeps flowing while control (holds, transfers, disposition) pauses; a channel parked in `Stasis()` with no dialplan continuation is stranded until a listener returns; and new inbound calls entering during the gap should rely on the upstream PBX or carrier's own retry rather than on Contact Center holding them. See [Failure runbooks](../contact-center/runbooks.md#voice-listener-handover-and-rollback). +- Gave a live call a topology instead of a handful of scalars that could not describe it. `CallSession` carried `MediaTopologyId`, `ConferenceId`, `SupervisorAgentId`, `SupervisorLegId`, a `ParticipantCount`, and an `IsConference` flag; four of those six were never written by any code path and only the store's validation read them, so supervisor monitoring had no durable state at all — an engagement fired a provider command, published an event, and left nothing behind, which meant nothing could tell that a supervisor was listening, refuse a second engagement by the same supervisor, or release the right leg when one of two engagements stopped. The session now carries `Legs` with a role and a normalized `CallLegStatus` per party, a `Bridge` whose participant entries record a join time and a leave time rather than being removed, `Consults`, `MonitorSessions`, and typed `Relationships` to other calls, with `IsConference`, `ParticipantCount`, and `ActiveMonitorSessions` computed from them. Because bridge membership is append-only, `Bridge.ParticipantsAt` reconstructs who was on a call at any past instant, which is what makes conference reporting and after-the-fact review of a disputed call possible. A provider-reported participant count is carried separately from observed membership, because a provider that publishes only a count cannot say who those parties are and fabricating members to match it would make the history a fiction. `CallTopologyProjector` is the only writer of any of it, enforced by a build gate that scans every Contact Center, Telephony, and provider source. Consultative transfers now record a `ConsultCall` and a typed relationship, supervisor monitor, whisper, and barge each have a live representation with barge alone joining the supervisor to the bridge, a duplicate engagement by the same supervisor is refused, and the Asterisk provider reports the leg it created through the neutral `ContactCenterVoiceProviderResult.ProviderLegId` so the agent becomes a real party on the bridge. `InteractionCallLeg.Status` is now the `CallLegStatus` enum rather than a free string. `CallConferenceChanged` is now raised only for calls that are, or have just stopped being, a conference, because ordinary two-party setup now changes the participant count. When a call reaches a terminal state every leg still open is ended and the bridge destroyed, not only the leg the provider named on the hangup: a terminal session accepts no further deliveries, so any other leg — the agent's, on the ordinary inbound path — would have stayed open forever and the bridge would have gone on reporting an agent who had already hung up. A leg's end time is clamped so it can never precede its start, because a hangup can carry a timestamp behind the state change before it and a leg started from the application clock can be ended from the provider's; an inverted leg was rejected by the store, which failed the whole delivery and left the call stuck mid-teardown with its agent occupied. A media move to a new provider bridge identifier now retains the previous bridge in `CallSession.PriorBridges` and creates a fresh one, and `CallSession.ParticipantsAt` searches all of them, so membership before the move is still reconstructible. A monitor session records `SupervisorUserId` and `SupervisorAgentId` separately, because a supervisor is always a user but not always an agent and the two identifiers were previously mixed in one field, which made the "a supervisor cannot monitor their own agent leg" rule compare two different identity spaces and therefore never fire. `ProviderVoiceEvent` copies also carry the provider's `HangupCause`, which the copy silently dropped, so a cause the provider actually reported was replaced by one inferred from the call state. A call that ends also closes any supervisor engagement still open on it, because stopping an engagement is refused once the call is terminal and one left open could never be closed by anyone — the call would go on reporting a supervisor as listening while their leg was already ended. Work still in flight when a call ends no longer tries to join the closed bridge: connecting an agent completes after a provider round-trip, so a caller hanging up during it would otherwise have added a live participant to a destroyed bridge and failed the whole accept. See [Live Call Topology](../contact-center/live-call-topology.md). +- Started reading the schema version stored on every contact center event, which until now was written by the publisher and read by nothing. That matters because a handler never sees the object that was published: post-commit dispatch, outbox redelivery, the provider-voice reader, and projection maintenance all reload the event from storage by identifier, so every handler deserializes JSON written by whichever release wrote it into the type the running release declares. A renamed, split, or re-united payload property does not fail that deserialization — it succeeds and substitutes a default, so an event redelivered from last month is acted on with an absent reason, a zero duration, or an empty identifier, and nothing reports a problem. Stored payloads are now converted one version step at a time up to the version the running release understands, and the conversion is applied at the event store rather than at each reader, because a reader that forgot to call it would not fail, it would return stale data as though it were current. Three situations are refused instead of absorbed: a version step with no conversion registered fails the read naming the step; an event written at a version above the one this release understands fails the read, because a node cannot convert forwards and during a rolling upgrade the newer node is already writing that version; and an event with no recorded version is treated as the first version rather than as already current. The conversion serves the reader and never rewrites the stored row, so the record of what was actually published survives a rollback. Raising the schema version without registering the conversion for the step it introduces now fails the build, which is the only moment the omission is visible, and a second gate seeds an unreadable event and requires every read path on the event store, discovered by reflection rather than listed by hand, to refuse it. Workforce reporting, which had been querying the event log straight off the session and deserializing what it got back, now reads it through the store, and a build gate refuses any code outside the store that queries the event log directly, because a reader that bypasses the store bypasses the conversion and is invisible to the coverage gate. `InteractionEventStore` takes an additional constructor argument and a new list method; code that constructs it directly must supply the upcast service. See [Production support](../contact-center/production-support.md). +- Made `ProviderVoiceEvent` immutable. It is simultaneously a public provider contract and something ingestion adjusts — the provider identity is canonicalized and the idempotency key is scoped by it — and while it was mutable those adjustments were applied to the caller's own instance, so ingestion defended itself with a hand-written copy. That copy was one more thing to keep complete, and it was not: it dropped the provider's hangup cause, and because a session infers a cause when none is supplied, every call reported the inferred cause instead of the one the provider gave, with nothing anywhere to say the real one had been lost. The type is now a record whose properties are all init-only; ingestion derives its adjusted event with a `with` expression, which copies every member by construction, and the hand-written copy is gone. `Metadata` is exposed as a read-only dictionary and snapshotted on assignment, so a caller that keeps its own reference to the dictionary cannot change an event it has already handed over. The snapshot keeps the comparer the supplied dictionary was built with, and keeps it through derivation, so a provider's case-insensitive metadata stays case-insensitive however many times the event is derived rather than quietly becoming case-sensitive and changing what consumers can find. Custom providers that assigned to a `ProviderVoiceEvent` after constructing it must use an object initializer or a `with` expression. See [Building a Custom Telephony Provider](../telephony/custom-providers.md). +- Gave contact center routing its own state document so it no longer competes with the CRM for the same activity. Assignment status, reservation identity and expiry, reserving and assigned agent, and the dialer attempt count moved to a new `ContactCenterWorkState` document keyed one-to-one to the activity by a unique index on `ActivityItemId`, mutated through `IContactCenterWorkStateService`. The reservation loop previously wrote those fields directly onto `OmnichannelActivity`, which is the same optimistic-concurrency document a person edits in the admin UI, so a reservation and a CRM edit raced: one of the two writes was lost, or the routing commit failed with a concurrency exception and stranded live work with a caller on the line. `OmnichannelActivity` keeps the same fields as an eventually-consistent read model — `OmnichannelActivityAuthorizationHandler` decides activity ownership from `AssignedToId`, and the CRM activity list, bulk-manage filters, and enterprise reports query them through `OmnichannelActivityIndex` — reconciled by `ContactCenterWorkStateActivityProjection` after the routing transaction has committed. Contact center writes to CRM-owned activity fields now go through `IContactCenterActivityWriter`, which also defers the write out of the routing transaction and retries it in a fresh scope on conflict. No backfill is required: the first read or mutation of work that predates the new document adopts the projected fields the activity already carries, so an upgrade cannot re-offer work an agent already holds or reset the dialer attempt cap. Routing decisions — dialer eligibility and disposition assignment among them — now read the authoritative work state rather than the projection. `ActivityStatus` remains CRM-owned and is not extracted. The build gate that enforces the ownership boundary now compiles the sources it scans and classifies each write's receiver by the type the compiler resolves, and reports a receiver it cannot resolve as a violation: the previous version recognized receivers by the shape of the identifier naming them, so aliasing an activity through a single local passed silently. It also covers every form that stores into a field — assignment, compound assignment, increment and decrement, and object initializers including target-typed ones — where it previously saw only plain assignments and explicitly named creations. See [Routing Work State and the CRM Activity](../contact-center/routing-work-state.md). +- Made one provider call stream feed every projection instead of the first consumer that claimed it. The Asterisk realtime dispatcher walked a first-wins bridge chain, so on a tenant with Contact Center voice enabled the telephony call-history projection never ran: `TelephonyInteraction` stayed `InProgress` forever for every call Contact Center owned and the soft phone never received the hangup, while on a tenant without Contact Center the surviving projection ran with no lock, no ordering, and no de-duplication. A new provider-neutral `CrestApps.OrchardCore.Telephony.Core` ingress now owns that seam: `INormalizedVoiceEventIngestor` canonicalizes the provider identity, takes the ingestion lease exactly once through `IVoiceIngressGate`, and fans the delivery out to every registered `INormalizedVoiceEventHandler`. `TelephonyCallHistoryVoiceEventHandler` (call history and soft-phone push) and `ContactCenterVoiceProjection` (durable `CallSession`/`Interaction`) are now peers on that stream, so telephony call history is hardened whether or not Contact Center is installed. The gate is the single lock authority: `ProviderVoiceEventService` no longer opens its own distributed lock, and a consumer that re-enters ingestion — including from the fresh scope opened by the concurrency retry — is satisfied re-entrantly rather than taking a second lock on the same call, so exactly one lease and one durable de-duplication record exist per delivery. The Asterisk bridge chain survives only for internal call-control orchestration that must absorb an event before it reaches the stream, such as parking a first-seen inbound channel or releasing a module-originated agent leg. +- Moved the shared voice vocabulary down to the telephony layer so a provider no longer has to reference Contact Center to describe a call. `ContactCenterCallState` is now `VoiceCallState`, and it moved with `ProviderVoiceEvent`, `RecordingState`, `AnswerClassification`, and the state projection (renamed `VoiceCallStateProjection`) into `CrestApps.OrchardCore.Telephony.Abstractions`. The enumeration values, their numeric order, and the serialized wire form are unchanged. +- Added the Asterisk browser-media foundation for single-node Contact Center completion. The soft phone now has a page-local browser media adapter contract with a SIP.js adapter that fetches runtime registration configuration, registers over WSS, attaches microphone media to the SIP peer connection, renders remote audio, and follows mute/hold/hangup state. The Asterisk adapter now advertises browser audio only when executable WebRTC and PJSIP Realtime settings are present, uses provider-wide server-side ACD delivery, and includes a PJSIP Realtime credential lifecycle for tenant/session/expiry-bound browser SIP credentials with SQL materialization, rotation, revocation, dialog teardown, expiration cleanup, and coturn REST TURN credentials. A scheduled per-tenant background task periodically sweeps expired credentials by consulting the durable per-tenant lease store, so expired or revoked credentials are reclaimed even when their cache record has already been evicted and stale SIP endpoints cannot accumulate in the Asterisk realtime store. +- Hardened the Asterisk browser-media credential lifecycle. Every issue, rotate, revoke, and cleanup transition runs under a tenant-qualified `IDistributedLock`. A new durable, per-tenant credential **lease** persisted in the tenant's own YesSql store is now the single source of truth for tenant/user/session ownership, expiry, the per-user cap, and revocation, replacing the previous cache-authoritative index. Because every lease query runs through the tenant's own session it is inherently tenant-isolated — the prior `LIKE 'cc-{tenant}-%'` scan over the shared Realtime tables (which could match and delete a colliding tenant's live rows, for example `acme` matching `acme-east`) has been removed, and cleanup now deletes Realtime rows only by exact authorization user for leases the current tenant owns. Issuance writes the durable lease first, then the Realtime SIP row, then the cache, so a Realtime row can never exist without a lease; a failed Realtime write marks the lease revoked so cleanup reclaims any partial row. The distributed cache is now a pure read-through performance cache: expiry is always read from the durable lease and a cache miss is never treated as expiry. Authorization-user identifiers also incorporate a fixed-width stable hash of the raw tenant name so tenants sharing one Realtime database cannot collide. Browser credentials are bound to an authoritative, server-owned agent media session derived from the authenticated user — a caller-supplied interaction id is non-authoritative metadata that can never authorize issuance — with a per-user cap on concurrent live credentials (oldest session revoked first) and immediate revocation of all of a user's credentials on soft-phone sign-out. The PJSIP Realtime table prefix is validated as a strict SQL identifier both when settings are saved and again at the SQL boundary to prevent identifier injection or cross-tenant table targeting in a shared Realtime database. Connecting a parked call to a browser-media agent (server-side ACD) now fails closed with a clear error until the .NET-side ARI originate/bridge lands, so no call reports a false success without bridged media. +- Completed the single-node server-side ACD originate/bridge that the browser-media foundation previously deferred. Connecting a parked caller to a browser-media agent now originates the agent leg into the tenant's own Stasis application with a deterministic ARI channel id, waits (through a tenant-isolated readiness signal, not a static field) for the agent channel to actually reach Stasis before bridging, and only then detaches the caller from the holding bridge and joins both legs into a mixing bridge. If the agent does not answer within the timeout, the attempt compensates — the ringing agent leg is hung up, the mixing bridge destroyed, the caller left parked — and the connect fails with an `agent_no_answer` result instead of reporting a false success. Terminal channel events (`StasisEnd`/`ChannelDestroyed`) now unconditionally tear down the mixing bridge, the caller's holding bridge, the peer leg, and both channel bindings on every call completion or hangup — idempotently and independently of interaction-completion projection — so normal call teardown no longer leaks bridges or channel bindings and a caller hangup can no longer strand the agent in a dead one-party bridge. +- Made the Asterisk Stasis application name explicitly required and fail closed. The tenant settings editor and the configuration-backed default provider no longer substitute the shared `crestapps-telephony` constant when the application name is blank; an unconfigured application name now leaves the provider unavailable (no inbound listener starts and outbound origination is rejected) instead of silently joining a well-known, server-wide ARI application. This closes a multi-tenant cross-talk path where two tenants that both left the field blank on one shared Asterisk server would have received each other's Stasis events. The settings UI now shows the former default only as a non-binding placeholder and documents that tenants sharing an Asterisk server must each choose a unique application name. +- Hardened the single-node server-side ACD connect path after an independent re-audit surfaced four residual correctness defects, now fixed. Terminal-channel teardown runs in a `finally` block, so a throwing event bridge can never skip releasing the mixing bridge, the holding bridge, the peer leg, and both channel bindings. The caller-to-agent ownership binding is persisted through an isolated, immediately committed tenant session and written **before** the live bridge is exposed — with compensation removing it on any later failure — closing a cross-scope visibility race that could otherwise strand an agent in a silent one-party bridge when a caller hung up at the moment of bridging. Outbound originations now carry the ownership marker as a Stasis application argument (not only as a channel variable), so an owned outbound leg is never misclassified as a new inbound call regardless of the Asterisk `ari.conf` `channelvars` configuration. The configuration-backed default Asterisk provider now stamps dial results with the same canonical provider identity the realtime listener uses, so soft-phone call-state updates are no longer rejected for calls placed through the default provider. +- Added Asterisk conversation recording over ARI bridge recording. The Asterisk Contact Center voice adapter now advertises the `Recording` capability and implements the executable recording contract, mapping start, pause, resume, and stop to the ARI bridge-recording primitive (`POST /bridges/{id}/record`, `POST`/`DELETE /recordings/live/{name}/pause`, `POST /recordings/live/{name}/stop`) instead of dialplan `MixMonitor`. The recording targets the canonical conversation (mixing) bridge resolved from the tenant-owned channel binding, so it fails closed when the call is not owned by the current tenant and stays on one continuous recording as the bridge persists across transfer and conference. The recording name is derived deterministically from the interaction id, so pause, resume, and stop address the same live recording without extra state; a stop of an already-gone recording is an idempotent success, and an ambiguous ARI outcome (no observed response) is reported as `OutcomeUnknown` rather than a false success. The recording service now persists the provider-reported recording metadata — recording name, storage reference, format, retrieval path, and duration when known — onto the interaction (setting the recording reference from the storage reference), closing the gap where this metadata was previously discarded. Governance is unchanged: the recording capability remains gated in the support matrix until the single-node audio and two-tenant proofs land. +- Added Asterisk supervisor monitoring over ARI snoop plus a real supervisor audio leg. The Asterisk Contact Center voice adapter now advertises the `Monitor`, `Whisper`, and `Barge` capabilities and implements the executable monitoring contract (extended with an idempotent `StopAsync`). Because a snoop channel alone gives the supervisor no audio, each listen-only or whisper engagement originates the supervisor's live browser softphone endpoint into the tenant's own Stasis application and bridges it to a snoop of the agent channel: `Monitor` snoops with `spy=both`/`whisper=none` (the supervisor hears both parties and stays silent), `Whisper` snoops with `spy=both`/`whisper=out` (the supervisor is heard by the agent only, never the customer), and `Barge` adds the supervisor endpoint directly into the main conversation bridge so all parties hear the supervisor (no snoop). The conversation topology (mixing bridge and agent channel) is resolved from the tenant-owned channel binding, so an engagement fails closed when the call is not owned by the current tenant or when the supervisor has no live registration. All supervisor resource ids (supervisor bridge, supervisor leg, and snoop) are derived deterministically from the interaction and supervisor identity, so retries are idempotent and a later stop addresses the same legs; a stop of an already-gone engagement is an idempotent success, and an ambiguous ARI outcome (no observed response) is reported as `OutcomeUnknown` rather than a false success. Failure compensation and stop release only the supervisor-owned legs and never touch the customer/agent call. Governance is unchanged: the monitoring capabilities remain gated in the support matrix until the single-node audio and two-tenant proofs land. +- Added Asterisk blind call transfer to an agent over ARI. The Asterisk Contact Center voice adapter now advertises the `CallTransfer` capability and implements the executable transfer contract for a blind (unattended) transfer of a live call to another agent; consultative transfers and queue, external, and entry-point destinations are rejected as confirmed (non-ambiguous) failures until their two-phase and re-route media paths land. The destination agent leg is originated into the tenant's own Stasis application with a deterministic ARI channel id derived from the interaction and destination identity, so a retried or concurrently duplicated transfer reuses the same id and Asterisk rejects the loser's originate — at most one destination leg is ever created. Ownership is enforced first: the current agent leg and canonical conversation (mixing) bridge are resolved from the tenant-owned channel binding before anything about the destination is resolved, so an unowned call always fails closed as `transfer_call_not_owned` without leaking whether the destination is registered, and a destination with no live softphone registration fails as `transfer_target_offline`. Adding the answered destination leg to the same canonical bridge the customer is already on is the single commit point: the customer never leaves the recorded conversation bridge, so recording and monitoring stay continuous across the swap. Before commit only this transfer's own destination leg is compensated (never the original call), and the previous agent-leg binding is retired before its channel is hung up so the old leg's terminal event cannot tear down the bridge the destination agent just joined. A destination that never answers leaves the original call fully intact and returns `transfer_no_answer`, a cancelled answer-wait propagates as a cancellation instead of a false no-answer, and an ambiguous ARI outcome is reported as `OutcomeUnknown` rather than a false success. Governance is unchanged: the transfer capability remains gated in the support matrix until the single-node audio and two-tenant proofs land. +- Hardened Asterisk blind call transfer against crashes and races with a durable, non-owning **joining** claim and an atomic owner swap, closing the residual caller-drop/leak window in the previous best-effort remove-then-recreate handoff. A transfer destination leg is now persisted up front — before the originate — as a new `Joining` binding on the deterministic destination channel id, which is simultaneously the exactly-once per-conversation transfer claim (a concurrent duplicate transfer to the same destination loses the serialized create and returns idempotent success without re-ringing the destination — but only while that claim is still an active joining leg; a lost create that instead finds a stale, already-terminating claim fails closed with a confirmed, retryable error rather than falsely reporting a completed transfer that has no live leg) and a non-owning marker: because a joining leg does not own the shared canonical conversation bridge, ANY death of the destination leg before the handoff commits — during originate, the answer wait, or the bridge add — is a teardown and reconciler no-op that leaves the customer with the current agent, and both the terminal-event teardown path and the aged-record reconciler now special-case a joining disposition to tear down nothing (never the shared bridge, never the caller) and only hang up the dangling destination channel. Ownership then transfers through a single atomic store operation that promotes the destination binding from `Joining` to `Connected` AND retires the previous agent-leg binding to a non-owning terminating state in one transaction. BOTH writes are version-checked: the destination promotion commits only while the destination is still `Joining` (fencing a racing teardown), and the previous-owner retirement is a version-checked transition — deliberately NOT an id-only delete, which has no version fence — committed only while the previous agent leg is still the live `Connected` owner, so two concurrent transfers of the same call to different destinations can never both promote (only the first can move the previous owner out of `Connected`; the loser's version-checked write fails, re-reads, and rejects). The canonical bridge is therefore owned by exactly one `Connected` binding at every instant — never a window with two owners (a double-teardown drop) or zero owners (an unowned live bridge). If a terminal event claims the destination leg for teardown before the swap commits, the swap does not commit and the previous agent is deliberately kept as the owner, so the customer never loses audio. The only remaining residual is a narrow, drop-free one: after the swap commits, the previous physical leg is briefly live until its best-effort hangup, but its binding is now retained as a non-owning `Terminating` recovery record (with a joining disposition, so teardown and the reconciler hang up only that channel, never the shared bridge) rather than deleted — so if the previous leg's hangup is unconfirmed the reconciler reclaims the channel instead of leaking it, closing the previously unrecorded-leak on an unconfirmed previous-leg hangup. Pre-commit compensation is likewise leak-safe: when a destination originate fails ambiguously (no observed ARI response) and its compensating hangup cannot be confirmed, the durable joining claim is **retained** as a `Terminating` recovery record — never hard-deleted — so the age-gated reconciler reclaims the possibly-dangling channel instead of leaking it. Governance is unchanged: the transfer capability remains gated in the support matrix until the single-node audio and two-tenant proofs land. +- Added Asterisk multi-party **conference** over ARI with a race-safe multi-party bridge-ownership model. The Asterisk Contact Center voice adapter now advertises the `Conference` capability and implements the executable conference contract for adding another agent as a participant on the canonical conversation (mixing) bridge. The extra agent's live browser softphone is originated into the tenant's own Stasis application with a deterministic ARI channel id derived from the interaction and agent identity, then added to the same bridge the customer is already on, so recording and monitoring stay continuous and the conversation is resolved from the tenant-owned channel binding (an unowned call or an offline destination fails closed rather than reporting a false success; an ambiguous ARI outcome is reported as `OutcomeUnknown`). Multi-party ownership is modeled to avoid the N-writer teardown race that a naive "count other connected legs" approach would introduce: a new non-owning `Participating` binding state (part of the provisioning set alongside `Pending`/`Joining`) marks each additional agent leg, and its terminal event hangs up only its own channel — never the shared bridge, the caller, or another agent. Exactly one agent leg remains the bridge's `Connected` owner and the sole party allowed to destroy the bridge and release the caller. The in-flight participant leg follows a `Joining` → (bridge add commit) → `Participating` promotion via the version-checked `TryPromoteJoiningToParticipatingAsync`. When the `Connected` owner departs while participants remain, ownership is handed off by `TryHandOffBridgeOwnershipAsync`, which — in a single version-checked transaction mirroring the transfer owner swap — promotes one remaining `Participating` leg to `Connected` **and** retires the departing owner's already-claimed terminating record (flipping its pre-teardown disposition from connected to joining) so a later teardown or reconciler sweep re-processing the retired owner can neither destroy the now-live promoted bridge nor promote a second owner. Only when no participant successor remains is the bridge destroyed and the caller released. Two concurrent owner departures contend on the owner document version, so exactly one commits and the loser re-reads the already-retired owner and safely no-ops, keeping the single-owner-destroyer invariant. An independent code review of the diff found the hand-off's promotion and owner-retirement were originally split across two transactions (a high-severity double-owner/bridge-drop window); this was fixed to the single-transaction atomic swap above and covered with real-store regression tests including the exact reviewer scenario and a two-departure race. The create → get → idempotent-destroy → 404 mixing-bridge lifecycle the teardown relies on was validated against a real Asterisk 22 instance over ARI. Governance is unchanged: the conference capability remains gated in the support matrix until the single-node audio and two-tenant proofs land. +- Added Asterisk **attended (consultative) transfer** over ARI as a three-phase provider contract (`IContactCenterVoiceAttendedTransferProvider`) so the initiating agent can consult privately with the destination agent before committing the handoff. **Begin consult** places the customer on hold over ARI (`POST /channels/{id}/hold`, music on hold) **before** ringing the destination agent, and holding fails closed — if the hold cannot commit the destination is never rung, so the customer can never overhear the consult and the original call is left fully intact. The destination is originated into the tenant's own Stasis application with a deterministic ARI channel id (derived from the interaction and destination-agent identity) and added to the same canonical conversation (mixing) bridge as a non-owning `Participating` leg, reusing the exact crash-safe originate/idempotency/compensation core extracted from the conference add, so a destination that never answers, is offline, or fails to originate resumes the customer with the initiating agent and leaves ownership unchanged. **Complete consult** resumes the customer (`DELETE /channels/{id}/hold`) **before** the ownership swap — so a lost hand-off leaves the customer live with the initiating agent rather than stranded on hold — then atomically promotes the stabilized destination participant from `Participating` to the `Connected` owner **and** retires the initiating agent leg to a non-owning terminating disposition in a single version-checked transaction (`PromoteParticipantToConnectedOwnerAsync`, the consult-complete analogue of the blind-transfer owner swap), before best-effort hanging up the initiating agent; if the consult leg is already gone the completion fails closed (`consult_complete_no_target`) and the customer safely keeps the initiating agent. **Cancel consult** drops the non-owning destination leg and resumes the customer with the initiating agent, whose ownership never changed. Every phase resolves the conversation from the tenant-owned channel binding and fails closed on an unowned call, and the `TransferAsync` blind path is unchanged. The conference add was refactored into a shared `AddAgentToConversationAsync` helper reused by both flows, proven behavior-preserving by the existing conference suite; the new vertical adds a dedicated attended-transfer test suite. `HoldChannelAsync`/`UnholdChannelAsync` were added to the tenant-scoped ARI client. An independent code review of the diff found one high-severity defect — `CancelConsultAsync` hung up the deterministic consult channel whenever a binding merely existed, but because `CompleteConsultAsync` promotes that exact channel in place to the bridge's sole `Connected` owner, a stale/duplicate/racing cancel after a completed transfer would have hung up the sole owner and dropped the live customer; it was fixed by making the cancel's consult-leg teardown a store-linearized atomic claim (`TryClaimProvisionalLegForTeardownAsync`, a version-checked `Joining`/`Participating` → `Terminating` transition) so a cancel can only ever hang up a leg that is still a non-owning consult leg — a cancel against a leg that completion already (or concurrently) promoted to `Connected` can never claim it and is a benign no-op that keeps the customer — and covered with regression tests including a concurrent claim-vs-complete race proving exactly one wins and no owner is dropped. Governance is unchanged: attended transfer is a facet of the already-advertised `CallTransfer` capability and remains gated in the support matrix until the single-node audio and two-tenant proofs land. +- Documented the provider webhook ingress posture and the forward design for reaching non-voice parity. The DialPad provider-owned voice webhook uses the full ingress-control stack — body/header limits, tenant-local rate and concurrency limiting, delivery freshness and replay rejection, and a durable at-least-once inbox that returns `2xx` only after commit. The non-voice Omnichannel webhooks (Twilio SMS, Twilio EventGrid, Azure EventGrid) are authenticated at the edge (Twilio `AuthToken` HMAC signature verification with `403` on mismatch; Azure EventGrid authentication plus a request-body cap) but do not yet use the durable inbox, and are outside GA-Core voice scope. Bringing them to full parity is a tracked follow-up delivered by promoting the reusable ingress primitives to a channel-neutral shared home at or below Omnichannel and migrating both voice and non-voice consumers onto it, sequenced only when a second non-voice channel is built. +- Defined per-entity data classification, retention, erasure, recording-access-audit, and backup/restore behavior for Contact Center. A new code-level, single-source-of-truth `ContactCenterDataGovernanceCatalog` classifies all fifteen persisted data categories — the interaction event log, interaction, call session, callback request, agent session/profile, event outbox, provider webhook inbox, provider command, queue item, activity reservation, event metric, projection checkpoint, processed-event ledger, and routing/dialing configuration — each with a privacy sensitivity (non-personal, personal, or sensitive personal), a recording-reference flag, a retention basis, and an erasure strategy (retention expiry, anonymize, cascade with interaction, external store, or not applicable). Call recordings are never stored inside Contact Center: the interaction and call-session entities hold only an opaque recording reference and state, so recording access must be brokered and audited by the owning media store and recording erasure is delegated there, while all durable Contact Center state is backed up via the tenant SQL database with the replay-horizon and legal-hold floors kept set so a point-in-time restore stays rebuildable. The catalog is unit-tested for integrity so a new persisted entity cannot ship without an explicit classification, and the authoritative per-entity governance table is published under Contact Center production support. +- Added an executable recording governance policy that gates recording before any provider media capture. A new tenant-scoped `ContactCenterRecordingSettings` section on the Contact Center settings screen configures whether recording is enabled, the consent model (all-parties or single-party), whether explicit consent must be captured on the interaction, the retention window in days, and whether captured recordings begin under legal hold. The recording service now evaluates `IRecordingGovernancePolicy` before invoking the provider on every start or resume: when recording is disabled for the tenant, or when the all-parties consent model requires explicit consent that has not been captured on the interaction, recording fails closed — the provider is never called — and a `RecordingDenied` interaction event is published carrying the machine-readable deny reason. Pause and stop are never gated, so a tenant can always halt capture. When recording is permitted, the interaction is stamped once at capture time with the resolved retention deadline (`RecordingRetainUntilUtc`, computed from the retention window through the injected clock, or indefinite when the window is zero) and, when configured, `RecordingLegalHold` is raised; the retention deadline is established a single time so it is not extended by a later resume, and legal hold is only ever raised — never silently cleared — by resuming a recording. This governance layer is additive to and independent of the provider capability and support-matrix gates, which remain the hard gate: the recording capability stays gated in the support matrix until the single-node audio and two-tenant proofs land. +- Added recording access audit and right-to-erasure governance through a new `IRecordingAccessGovernanceService`. Because the orchestration layer never stores recording media — the interaction holds only an opaque recording reference and its retrieval metadata — every recording retrieval is audited by `RecordAccessAsync`, which publishes a `RecordingAccessed` interaction event capturing who accessed the recording, the stated purpose, and the accessed reference (access is not audited when no recording is present). Right-to-erasure requests are served by `EraseAsync`: a recording under legal hold is exempt and the request is denied with the `legalHold` reason and a `RecordingErasureDenied` event, leaving the reference intact; otherwise the orchestration layer clears the opaque reference and its retrieval metadata, stamps the erasure instant (`RecordingErasedUtc`), and publishes a `RecordingErased` event carrying the original reference so the owning media store can delete the underlying media. Deletion of the media bytes is delegated to that store — the Contact Center layer never touches the media itself. +- Added an encrypted recording media store and secure Asterisk ingest pipeline with failed-upload recovery. A new provider-neutral `IRecordingMediaStore` abstraction in Telephony owns recording media by an opaque, deterministic storage key, and its default `LocalEncryptedRecordingMediaStore` persists each recording to a tenant-scoped application-data folder encrypted at rest with the ASP.NET Core data protection provider, so the bytes on disk are always ciphertext and the plaintext only ever exists in memory during ingest or read-back; the abstraction is pluggable so a deployment can swap in a cloud-backed store without touching ingest callers. For Asterisk, stopping a recording no longer captures bytes inline: it enqueues a durable, per-tenant ingest job keyed idempotently by the deterministic recording name, and a once-a-minute background task downloads the stored file through the ARI stored-file endpoint and hands it to the media store for encrypted persistence, then best-effort deletes the unencrypted ARI source file so plaintext media does not linger on the telephony host. A recording that is not yet flushed to disk — or a transient ARI or store failure — is retried with exponential back-off and dead-lettered after the attempt budget is exhausted, so a stopped recording's secure ingestion survives process restarts and transient outages and is neither retried forever nor silently lost. Governance is unchanged: this ingest and encryption layer is additive to the provider capability and support-matrix gates, which remain the hard gate until the single-node audio and two-tenant proofs land. +- Added operational failure runbooks for Contact Center covering SQL/primary-datastore, Redis/backplane, provider/telephony, node, and network-partition failures plus rolling and blue-green deployment. Each runbook is keyed to the shipped signals — the `contactcenter-storage`, `contactcenter-outbox`, and `contactcenter-provider-ingress` health checks on `/api/contact-center/health/dependencies`, the `contactcenter.outbox.redelivered` and `contactcenter.outbox.dead_lettered` counters, and the `CrestApps.OrchardCore.SignalR.Redis` backplane paired with `OrchardCore.Redis.Lock` — and documents how fence tokens, time-boxed leases, the durable event outbox and provider webhook inbox, and additive migrations keep single-node loss, lock loss, and rolling/blue-green cutover safe without double execution or committed-event loss. The runbooks are cross-linked from Contact Center production support. +- Documented the Contact Center expand-migrate-contract upgrade policy and audited the shipped schema migrations for rolling-deploy safety. The entire Contact Center migration surface is additive — every migration uses `CreateMapIndexTable`, `AddColumn` with a default or nullable value, and guarded `CreateIndex`/`CreateUniqueIndex` — with no `DropColumn`, `DropTable`, `RenameColumn`, `RenameTable`, or `AlterColumn` operations anywhere, so no shipped upgrade requires downtime. New NOT NULL columns ship with defaults during the expand phase, unique-constraint additions run a portable preflight that rejects pre-existing duplicate active claims with explicit repair guidance instead of corrupting data or failing opaquely, and destructive changes are deferred to a later contract-phase release once no node reads the old shape. Any future backward-incompatible change must either be phased through expand→migrate→contract or explicitly declare a downtime requirement in its release notes. +- Put the Contact Center reservation path under an enforced query-plan budget. Retention bounds how large a table becomes but not how much of it a query reads, and the two failures are not alike: an unbounded table eventually fills a disk and announces itself, while a query that reads a whole table returns exactly the right answer and merely gets slower in proportion to how much history a tenant has accumulated, so no functional test can see it. The statement routing runs before every offer — the per-agent count of live work — was answered by loading one row per interaction and grouping them in memory, over a predicate expressed as a chain of "not this status and not that status" that no index can satisfy, against a table whose only composite index leads with the YesSql join key and answers nothing about an agent. It is now a SQL `GROUP BY` over an inclusive `Status IN (…)`, served by a new covering index `IDX_InteractionIndex_ActiveByAgent (AgentId, Status, DocumentId)` added alongside the existing indexes rather than replacing them, so the count is answered from the index without reading the table at all. The statuses that occupy an agent are declared as named sets, and because an inclusive set is a risk an exclusive chain is not — a status added to the enum would silently fall out of "occupying", making a busy agent look idle and get handed more work — a gate requires the declared sets to partition `InteractionStatus` exactly. The statement runs on the calling session's own transaction and flushes first, so a caller that reserves an interaction and then asks for that agent's load in the same unit of work is not told the agent is free. The budget is enforced by two `EXPLAIN` gates that execute the statement the store executes, read from the same builder, against a schema built by the shipped migrations and seeded with enough rows for a planner to have a real choice: a SQLite gate on every build requiring a covering-index seek constrained by both the agent and the status, and a PostgreSQL gate in the operations-gates workflow requiring no sequential scan. Both are necessary, because the planners disagree while the results do not — the PostgreSQL gate caught a formulation that SQLite was perfectly happy to seek. Both gates measure the statement verbatim rather than rewriting the bound agent list first, and a PostgreSQL gate also *executes* the store method and asserts the counts, because a plan budget on a statement that cannot execute is worse than no gate: the data-access layer expands a bound collection into placeholders only for providers without native array binding, so on PostgreSQL the collection form was a syntax error on every call. The reservation path itself is counted through the query pipeline rather than the hand-written statement, so a recording connection captures the SQL the pipeline emits and puts those queries under the same budget too. See [Production support](../contact-center/production-support). +- Made upgraded tenants provably identical to fresh ones and moved upgrade backfills off a per-row loop. `OmnichannelActivityIndexMigrations` created five enum columns — `Kind`, `AssignmentStatus`, `UrgencyLevel`, `Status`, and `InteractionType` — as integers on a fresh tenant and as text on an upgraded one, and left `AssignmentStatus` out of `IDX_OmnichannelActivityMyActivities_DocumentId` on the upgrade path. The divergence was undetectable by any functional test: YesSql writes an enum as an integer whatever the column says, and SQLite's type affinity converts it on the way in and applies column affinity to comparisons, so an upgraded tenant behaves correctly on SQLite forever and fails only on an engine that does not coerce. A new `UpdateFrom4Async` reconciles all six differences without touching the historical steps, rebuilding each column by adding a replacement, copying values with a set-based `CASE`, dropping the original, and renaming the replacement into place — the only sequence available on SQLite, which has no `ALTER COLUMN` — and dropping and recreating the affected indexes, which SQLite requires before a column they reference can be dropped. Value translation accepts both a stored number and a stored member name and records anything else as unknown rather than rewriting it to the enum's first member. A gate now compares the table and index declarations of a tenant upgraded through every reachable historical schema against a freshly created tenant's, walking the migration chain by return value exactly as the host does. Separately, the call-session claim-key backfill and the provider webhook inbox canonicalization each read their entire table into memory and issued one `UPDATE` per row inside the transaction that gates tenant startup; both are now whole-table statements — canonicalization applied once per distinct alias present, claim keys derived by the database using the dialect's own concatenation, and duplicate preflights expressed as `GROUP BY … HAVING COUNT(*) > 1` over the same composed key the unique index enforces — and a gate requires their round-trip count to be unchanged when the tenant is ten times larger. The migration contract register gains a machine-checked in-place-rebuild justification that verifies the restoring operation is really present in the same step. Executing the upgrade against a real PostgreSQL instance then found two defects SQLite could not surface, because the same type affinity that hid the divergence also makes the correcting statement run: the historical version the correction starts from is reachable in two shapes, one whose enum columns are already correct, so the rebuild now probes the column's declared type and leaves a correct column alone rather than raising an undefined-operator error; and PostgreSQL and SQLite name only the index in a drop, so on a tenant whose tables live in a named schema the drop silently matched nothing and the recreation then reported the index already existed, which the step now avoids by issuing a schema-qualified drop first on exactly those engines. A third defect followed from the second: the qualified drop must name the index the way the data layer named it, since index names are prefixed on the engines that share one index namespace across tables and shortened where the engine has a name length limit, so a tenant that also sets a table prefix reproduced the same silent miss. All three are gated by executing the upgrade against PostgreSQL, with a schema and a table prefix both configured, from both reachable shapes. The reconciliation is also resumable, because MySQL commits each schema change on its own and an interrupted attempt would otherwise leave a tenant that can never activate: the replacement column is added only when absent, an attempt that stopped after the original column was dropped finishes with the rename instead of starting over, and the index drops are tolerant while the recreations are not. Authorizing that raw drop also made the register stricter: a removal written as SQL is reported by its leading verb rather than by an object name, so such an entry must now name every object it takes away and each is checked for a matching recreation in the same step. See [Production support](../contact-center/production-support). +- Aligned interaction-event retention with projection replay horizons and legal holds. Because purging the durable event log bounds how far projections can be rebuilt, retention no longer deletes purely by age: two new floors — `ProjectionReplayHorizonDays` (the minimum window the event log stays rebuildable) and `LegalHoldMinimumDays` (a legal/regulatory floor) — extend retention so the effective cutoff keeps events for the longest of the configured window and the floors. Raising a floor only keeps data longer and never causes an earlier purge, purging stays disabled when the retention window is zero, the daily metrics projection remains a durable snapshot that survives event purge, and a post-purge rebuild is guaranteed to cover at least the replay horizon. +- Documented the supported real-time backplane for Contact Center deployments. The Contact Center hub is backplane-agnostic — it is hosted through `HubRouteManager` and addresses tenant-qualified groups — so the same code serves single-node and multi-node topologies. The supported topology enables the `CrestApps.OrchardCore.SignalR.Redis` backplane feature (Redis-distributed real-time messages, channel-namespaced per tenant) together with `OrchardCore.Redis.Lock` for the distributed critical sections used by routing and provider ingress; a backplane without distributed locking, and multi-region active-active, are unsupported. +- Replaced the blanket single-node production prohibition with an earned `single-node-distributed` topology profile, and demoted multi-node to non-production until its capacity certification is earned. The support matrix previously declared production on a single application node prohibited and two-to-four nodes supported, which meant the only deployment shape the project could actually certify was the one it forbade. The new profile is production on exactly one application node while the full distributed contract stays **mandatory**: a shared relational database, `OrchardCore.Redis.Lock`, and the `CrestApps.OrchardCore.SignalR.Redis` backplane. One node is not an excuse to run process-local state, because one node already meets the distributed failure modes — a rolling restart overlaps two instances, and an Orchard shell reload tears down and rebuilds the shell in-process on every feature toggle. Scaling out later therefore becomes configuration plus capacity certification rather than a rewrite. Both GA-Core tenant profiles now reference the new topology, and the prohibited-combination list gains "production with a single application node without Redis distributed locking and a Redis SignalR backplane" and "production with more than one application node". Contract tests enforce the coherence the matrix previously lacked: every production topology must be consistent with every prohibition it publishes, so the matrix can no longer support what it also prohibits. +- Made the declared deployment topology enforceable at runtime rather than advisory. Each tenant declares its topology with `CrestApps_ContactCenter:Topology:ProfileId`, and shell activation verifies the running deployment actually satisfies that declaration: `single-node-distributed` requires the `Postgres` provider, the `OrchardCore.Redis`, `OrchardCore.Redis.Lock`, and `CrestApps.OrchardCore.SignalR.Redis` features, and a distributed lock that is not the process-local implementation — checked from the lock the container actually resolves, because a feature can be enabled while the container still hands out the local lock, and the injected lock is the one that decides whether two overlapping processes can enter the same critical section. A deployment that falls short logs every unmet requirement at once, refuses every Contact Center work admission through the single shared admission gate, and reports unready on the new `contactcenter-topology` readiness check. Three fail-open holes are closed deliberately: an unrecognized profile identifier is a failure rather than a fallback to the requirement-free development profile, an absent declaration is a failure when the host environment is `Production` so the check cannot be bypassed by configuring nothing, and a tenant is inadmissible until its verdict is recorded so no work is accepted during the window in which a shell is being reloaded. The topology check is the only readiness check that observes a fleet-wide condition, which is intentional: unlike a dependency blip it cannot self-heal, so draining is the intended outcome. The profile catalog the product enforces is a shipped mirror of `support-matrix.v1.json`, and a contract test asserts the two are identical so the application cannot enforce a private, more permissive definition of "production". +- Closed the supply chain gaps. Dependency vulnerability auditing was disabled on every CI workflow through `-p:NuGetAudit=false`, and turning it on revealed six real advisories that no build had ever reported: a moderate one against `AngleSharp` and five high-severity ones against `System.Security.Cryptography.Xml`, both arriving transitively. Both are now pinned to patched versions in `Directory.Packages.props`, auditing is enabled for every build at the lowest severity across the whole graph, and `NU1901`-`NU1904` are promoted to errors so a published advisory is a hard stop rather than a warning. A new `Supply Chain` workflow adds secret scanning over the full commit history with gitleaks, a third-party license inventory that fails when a package declares no license, SPDX SBOM generation retained as a build artifact, and Trivy container scanning uploaded as SARIF. Dependabot version updates, which were switched off entirely by `open-pull-requests-limit: 0`, are enabled weekly and grouped for NuGet, npm, and GitHub Actions, with Orchard Core packages still pinned. Two more latent defects surfaced while building this: the "owned" Asterisk image was based on `asterisk/asterisk:22.6.0`, which does not exist on Docker Hub and could therefore never have been built, now replaced with a real digest-pinned base; and two packages in the graph ship no license metadata at all, now recorded with the license read from the upstream repository each package names. See [Supply chain security](../supply-chain). +- Made deployment configuration fail closed instead of failing late. Contact Center retention, health-check, and topology options were registered with bare `Configure` and no validation at all, so a negative retention window or a mistyped topology profile was discovered by whichever code path happened to read it first — a mistyped profile silently downgraded the tenant out of its production topology. Every operator-supplied options class in the Contact Center, Telephony and Asterisk assemblies is now registered with a validator, and a reflection gate fails the build when a new one is added without one. Fixing this exposed a deeper problem: `ValidateOnStart()` does nothing inside Orchard Core. It records its rules against `IStartupValidator`, which the .NET generic host invokes only against the root container, while Orchard builds a service container per tenant — so four existing call sites in this repository had never run at startup. `ValidateTenantOptionsOnActivation()` closes that gap, and a tenant carrying an invalid configuration now refuses to activate rather than serving requests with nonsense settings. In production a tenant also refuses to activate while still carrying a credential this repository publishes for the Aspire development stack, including the Coturn `static-auth-secret` and the Asterisk ARI password; the same values keep working in development, and a test walks the tracked configuration files so a newly published sample credential cannot ship unguarded. Where a published secret only derives a client-visible artifact, the soft phone now receives STUN-only ICE rather than a forgeable TURN relay credential. Distributed-lock waits, lease expiries, the inbound reclamation threshold and the Asterisk HTTP request budget moved out of compiled constants into validated `Coordination` configuration sections, because a node under load needs different values than a developer laptop. See [Production support](../contact-center/production-support). +- Contact Center configuration now travels between environments through standard Orchard Core deployment steps and recipe steps rather than through a bespoke configuration-catalog framework. Each configurable entity — skills, queue groups, business hours calendars, queues, entry points, dialer profiles, and agent state reason codes — has its own **Add** deployment step under the **Contact Center** category and a matching recipe step (`ContactCenterSkill`, `ContactCenterQueueGroup`, `ContactCenterBusinessHoursCalendar`, `ContactCenterQueue`, `ContactCenterEntryPoint`, `ContactCenterDialerProfile`, and `AgentStateReasonCode`). Exports preserve each entry's identifier, so replaying a plan updates the entries a tenant already holds instead of duplicating them and keeps cross-references intact. The former `IConfigurationCatalog` abstraction, its writer, and the composite `ContactCenterConfigurationDeploymentStep` and `OmnichannelConfigurationDeploymentStep` were removed in favour of extending Orchard Core's own pipelines. Moving onto Orchard Core's pipelines exposed a defect the bespoke framework had been hiding: the per-entity recipe steps carried only the members each entity's handler happened to bind by hand, so most settings were silently dropped on import - an Omnichannel campaign arrived at the destination with its description missing, and a subject flow arrived so empty it failed its own validation. Import now binds every portable member by reflecting over the entity, matching what export writes, so a member added to an entity travels without touching the step; members owned by the environment - creation and modification stamps, and ownership - are never carried, and the destination stamps its own. Enabling **Omnichannel Activities** without **Omnichannel Managements** also threw at tenant activation, because `OmnichannelContentTypeProvider` was registered in the Managements feature while `SubjectFlowSettingsService` in the Activities feature depended on it; the registration moved to the feature that consumes it. +- Rebuilt the Contact Center tests that never executed the code they claimed to cover. `ContactCenterHubSecurityTests` read `ContactCenterHub.cs` as text and asserted the order of substrings, so emptying `OnConnectedAsync` left it green; it now constructs the real hub with a recording group manager and an authorization service that answers per permission, invokes `OnConnectedAsync`, and proves each permission is checked before its group join, that a connection with neither Contact Center permission aborts without joining anything, that every destination is tenant-qualified, and that a queue dropped by the fresh snapshot is left behind. `ContactCenterMigrationSqlTests` asserted a string produced by its own `Mock`; it now runs against the four real YesSql dialects and executes the generated statement on a live SQLite database to prove it rejects duplicates. Replacing the mock showed it had been describing an engine that does not exist: only PostgreSQL prefixes index names, and SQLite quotes with brackets rather than double quotes. `ActivityQueueServiceConcurrencyTests` performed no concurrency at all; it now races eight workers against one activity on a real store and proves exactly one queue claim survives. `ContactCenterOperationalLogPrivacyTests` pinned twenty exact source strings from ten hand-picked files; it now parses every logging call in the Contact Center, Telephony, Asterisk, DialPad, and Omnichannel SMS trees with Roslyn and fails when a sensitive identifier reaches a logger without being sanitized or redacted, so the rule also covers code written after the audit. +- Repaired the Contact Center CI gates themselves, and made the repairs enforceable rather than merely done. The Telephony browser suite was referenced by **no workflow at all**, so it had been silently carrying a failing test since the media-adapter registry became per-instance; it is now adopted by a new `contact_center_browser_gates.yml` workflow, the soft phone exposes a supported `registerMediaAdapter(name, adapter)` seam for registering a media provider on an instance, and a new test proves browser audio fails closed - never touching the microphone - when no adapter is registered. The Contact Center gate workflows filtered on individual module folders, so a pull request touching only a shared Core project skipped the only distributed gate; they now watch `src/**` and `tests/**`. `release_ci.yml` ran a single test project, so nothing stopped a release with failing distributed, feature-activation, or browser suites; it now runs every test project, including a Linux-only `distributed_test` job with a Redis service container that publishing depends on. `validate_docs.yml` piped its build through `tee` without `pipefail`, so the step reported `tee`'s exit status and a broken documentation build passed - it now propagates the failure and also runs on code pull requests, because this repository expects documentation to ship with behaviour changes. Running the previously unrun suites also surfaced a second regression: the Contact Center readiness activation test had been broken by the new deployment topology check, and it asserted only which checks ran rather than that readiness stayed healthy. It now pins the readiness set, asserts independently that no shared dependency check participates in readiness - the invariant that stops one degraded dependency draining an entire fleet - and asserts the healthy status its name promises. +- Added the Contact Center observability and health contract. A stable, process-wide OpenTelemetry `Meter` and `ActivitySource` (both named `CrestApps.OrchardCore.ContactCenter`) let operators subscribe to Contact Center metrics and traces from any exporter without depending on internal types; the durable event outbox emits `contactcenter.outbox.redelivered` and `contactcenter.outbox.dead_lettered` (tagged by reason) counters. Three dependency health checks — `contactcenter-storage`, `contactcenter-outbox`, and `contactcenter-provider-ingress` (registered by the Voice feature that owns the provider inbox) — register with the `contactcenter` and `contactcenter-dependency` tags and are surfaced on the authorized dependency probe. The two queue checks share one pure decision that maps dead-letter and overdue-backlog counts to Healthy, Degraded, or Unhealthy, where the overdue (past-due) backlog is the scheduler-lag signal and a stuck provider stream or expired listener lease surfaces as a growing ingress backlog. Thresholds are configurable under `CrestApps_ContactCenter:HealthChecks`. Three further dependency checks make the live-probe contract true rather than delegated: `contactcenter-distributed-lock` acquires and releases a dedicated probe lock (exercising the Redis-backed lock end to end in production and the process-local lock in development), `contactcenter-redis` pings the shared Redis connection, and `contactcenter-backplane` publishes and receives a token on a dedicated tenant-qualified channel — because Redis connectivity alone does not prove the SignalR pub/sub backplane can round-trip a message between nodes. The Redis and backplane probes report healthy with nothing probed when Redis is not enabled, since the topology validator, not a dependency probe, is what refuses a production deployment that omits Redis. All are `contactcenter-dependency`-tagged and never participate in readiness, so a shared-dependency blip alerts a human instead of draining the fleet. +- Consolidated the Contact Center migration raw-SQL into a single dialect-aware helper and documented the database-portability guarantees so the same migrations and queries run unchanged on every YesSql-supported engine (SQLite, SQL Server, PostgreSQL, MySQL). Enumerations are stored as their string names rather than ordinals, every migration quotes identifiers through the active SQL dialect and honors its index-prefix rule, all backfill and preflight literals are bound command parameters, duplicate-detection uses only ANSI SQL, and case-insensitive matching is normalized in application code instead of relying on database collation. Unique-index creation and existence preflights that were duplicated across the reservation and queue-item migrations now route through the shared helper, whose statement builder is unit-tested to prove it is generated entirely from the dialect. Per-engine validation of the full surface remains a CI and deployment-certification step. +- Made the daily event-metrics projection rebuildable, drift-checkable, and checkpointed so operators can recover it from the durable event log rather than trusting a write-once side effect of event delivery. A new durable projection checkpoint records the replay cursor, logic version, and last rebuild time per projection. `RebuildAsync` recomputes every per-day, per-event-type count from the source-of-truth event log and reconciles the stored metrics — creating missing rows, correcting wrong counts, and deleting orphaned rows — then advances the checkpoint, while `DetectDriftAsync` performs the same recomputation read-only and reports every discrepancy without changing state. The rebuild is deterministic: it counts events by their recorded occurrence date and skips events with no type or no occurrence time, because the live recording path substitutes the wall clock for a missing timestamp and that cannot be reproduced during a replay. +- Bounded and leased the callback and reconciliation background sweeps so they stay within their processing windows on large backlogs. Callback promotion now reads a bounded due batch whose store query excludes callbacks already held by an unexpired lease, and claims each due callback with an owner token, a fence token, and a five-minute lease before creating its outbound activity, clearing the lease on success. This keeps promotion durable and idempotent even if the background task's distributed lock expires mid-run and an overlapping pass starts, preventing a customer from being called back twice. Provider-call and telephony reconciliation no longer load every active interaction; both sweeps now request a bounded page of the oldest active interactions first so they make forward progress on the most likely-stale calls. The outbox dispatch and retention purge batches were already bounded and are unchanged. +- Replaced broad queue loads and per-queue N+1 counts with aggregate SQL and bounded top-1 queries. Waiting-count, longest-wait, and SLA-breach figures for reporting, real-time notifications, the supervisor dashboard, and the agent workspace are now computed with `CountWaitingAsync`, `CountWaitingOlderThanAsync`, and `FindLongestWaitingAsync` instead of materializing every waiting queue item (and, in the reporting path, loading each one) into memory. The SLA-aged next-item routing path is deliberately left unbounded because SLA aging can promote old low-priority items, so bounding its candidate set by base priority would break routing correctness. +- Normalized agent queue membership into a query-aligned index so routing selects the agents for a queue with a single indexed lookup instead of loading every available agent and filtering entitlement in memory. Each agent profile now projects one `AgentQueueMembershipIndex` row per queue it is both entitled to (the intersection of live sign-in and manager-owned allow-list) and signed in to, keyed by a lower-cased queue identifier for portable case-insensitive matching and carrying the agent's presence and configured capacity. `IAgentProfileStore.ListAvailableForQueueAsync` queries this index by queue and `Available` presence, keeping the fail-closed entitlement rule authoritative while removing the tenant-wide broad load from every routing decision. +- Closed and unroutable inbound entry points no longer leave pending CRM work. Closed voicemail/reject decisions and missing or disabled effective queues retain one provider-correlated audit record, make the CRM activity terminal and non-routable, and atomically persist one durable voicemail or rejection command for post-commit dispatch. The interaction remains open only while provider truth is pending and records the confirmed, failed, or unknown command outcome without requeueing; no queue item, reservation, or agent offer is created. +- Added durable inbound contact attribution. Voice routing now persists deterministic `Unresolved`, `Resolved`, or `Ambiguous` state on the CRM activity instead of selecting the first of multiple matches. Ambiguous calls remain routable, but the source-neutral disposition boundary rejects unresolved ambiguity and the resource-authorized activity-completion form requires an explicit persisted candidate selection with resolution audit metadata. Contact-bound Subject Actions are deferred for unresolved, ambiguous, and legacy unknown inbound attribution. +- Replaced fixed outbound calling-window hours with reusable business-hours calendars and added regulated abandonment, safe-harbor, and answering-machine-detection policy. Dialer profiles now select a default calling calendar plus optional per-region calendar overrides keyed by the destination's ISO 3166-1 alpha-2 region code, evaluated in the contact's own time zone, and fail closed when a required calendar is missing or disabled. A new fail-closed abandonment policy gates automated Power/Progressive dialing on a rolling live-answer/abandon rate that must stay at or below the profile's `MaxAbandonmentRatePercent` once `AbandonmentSampleFloor` live answers accumulate; automated profiles that enforce the cap require safe-harbor messaging with an announcement. The rolling window is configurable and validated under `CrestApps_ContactCenter:Compliance:AbandonmentRollingWindowMinutes` (30-minute default, 1-1440 range). Provider-reported AMD outcomes now map to a provider-neutral `AnswerClassification` stored on the call session and interaction technical metadata under the stable `amd_answer_classification` key. +- Gated the Power and Progressive automated pacing modes on the Contact Center Automated Dialer feature in the dialer-profile editor. When the feature is disabled, the mode picker offers only Manual and Preview and saving a Power or Progressive profile is rejected, so an automated profile can never be created that silently fails to pace. A shared `DialerMode` classifier now provides the single authoritative definition of an automated pacing mode used by the abandonment policy and the editor. +- Removed the unused `BidirectionalMedia` capability flag and made both Contact Center Voice Media and Asterisk Contact Center Media dependency-only features. Neither GA-Core profile enables or resolves media, and fresh-tenant activation proves no media provider or resolver is registered. The existing Asterisk RTP/UDP adapter remains a development foundation; secure network-boundary, loss/reorder/jitter, capacity, failover, and node-affinity certification is explicitly deferred to R9. +- Decoupled admitted PBX mutations from SignalR and HTTP request cancellation and added a validated server-owned command deadline. Telephony hub mutations, durable provider-command dispatch, recording, monitoring, and transfer now use `CrestApps_Telephony:Commands:Timeout` with a 10-second default and one-second-to-two-minute range. The boundary returns deterministically even when a provider ignores or swallows cancellation, maps sent-command timeout to durable `OutcomeUnknown`, honors host shutdown, and completes provider-confirmed local persistence with a non-request token so disconnects cannot discard interaction, recording, monitoring, transfer, or event state. +- Closed an outbound compliance bypass in which a destination written in national form could be dialed even when it was on a national do-not-call registry. The dialer, the local registry, the contact import, and the duplicate lookup each canonicalized phone numbers with their own fallback - one stripped everything but the digits, one kept the raw text, one dropped the number - so the same number reached the screening step in one shape and the registry lookup in another, matched nothing, and was reported as unlisted. Phone numbers are now represented by a `PhoneNumber` value object that is in E.164 form by construction, produced by one canonical parser (`IPhoneNumberService.TryParse`), and required by `INationalDoNotCallRegistry` at its boundary. Dialer profiles gained a **default region** used to resolve national-format destinations; a destination that cannot be resolved suppresses the attempt instead of being repaired into an unscreenable approximation, and a registry that cannot address a number in its own numbering plan skips it rather than sending an altered one. **Breaking:** `INationalDoNotCallRegistry.GetRegisteredNumbersAsync` now takes `IEnumerable` and returns `HashSet`; custom registries must accept the canonical type. A do-not-call row that the query matched is now reported as the number the caller asked about rather than the stored string parsed a second time, so a stored value the database still matches but the parser no longer accepts can no longer be dropped and read back as "not listed". Matching a number that was never canonical - an uploaded spreadsheet against contacts typed in over years - is a genuinely different question from identifying one, and it is answered by `PhoneNumberComparisonKey`, named for comparing rather than identifying so it cannot be mistaken for canonicalization the way the inline fallbacks it replaced were. +- Made national do-not-call screening fail closed. A registry that was unreachable, misconfigured, or rejecting requests previously logged the failure and returned an empty result, which is indistinguishable from the registry reporting the number as unlisted - so a network blip silently converted an unscreened number into a callable one, in both the outbound dialer and the contact import. Registries now raise `DoNotCallScreeningException` when they cannot answer. The dialer suppresses the attempt with the new, deliberately non-terminal `ComplianceScreeningUnavailable` reason, so the activity stays available for a later cycle rather than being cancelled because a registry had a bad minute, and the contact import skips the row with an explicit reason instead of importing an unscreened number. A registry with no credentials configured is treated as not participating and does not suppress anything. +- Contact Center state changes are now decided by the records that carry them. The interaction, call session, queue item, reservation, and routing work state each declare which status changes exist, and a change that no rule admits raises `InvalidStateTransitionException` instead of being written. Previously every status was publicly settable and the only ordering rule grouped call states into broad phases, so a call could be recorded as on hold without ever having been answered - the move was forward, so nothing refused it, and the talk and hold durations derived from that record were reported as real. Statuses that have settled are final, so an ended call cannot start ringing again and a completed queue item cannot return to the queue. Re-applying the status a record already holds is still accepted, so providers that deliver the same event more than once are unaffected. Returning an interaction to routing and re-offering it are now `Requeue()` and `Reoffer()`, both refused once the conversation has ended for the same reason everything else is - a settled status has nowhere to go. Where a provider is the authority for what a call is doing, the call session mirrors it rather than second-guessing it, the interaction mirrors that session, and a work state seeded from an activity adopts the routing status that activity already carries; all three say so in their names, none of them will reopen a record that has already settled, and the set of places allowed to use them is fixed by a build-time check. Two paths that could not usefully act on a refusal are exempted deliberately and say why: the reservation expiry sweep, which releases every due offer in the tenant and would otherwise abandon the whole batch when one customer had already hung up, and the provider-command failure projections, which are what let a compensating command finish rather than retry forever. Both complete the rest of their work and leave the already-recorded ending alone. +- Consolidated the three independent copies of the external-destination dialing safety rule - one in the transfer settings editor, one in the transfer resolver, and one in the dial command executor - into a single `ExternalDestinationPolicy`. The copies were identical but free to drift, and a drift would have shown up as a destination the settings screen refuses while a workflow can still dial it. +- Gave skill names one definition of identity through a `SkillTag` value object. A queue's required skills were trimmed by the administration form while an agent's assigned skills - which can arrive through a recipe, a deployment, or an import - were not, so an agent whose skill was stored with surrounding whitespace was silently ineligible for every queue that required it, with nothing anywhere reporting a problem. Both sides are now read through the same rule: surrounding whitespace is not part of the name and casing does not distinguish skills, while interior spacing still does. +- Made duplicate provider streams harmless across supported multi-node deployments. Provider voice events now serialize on a tenant-scoped, hashed canonical provider-call lock and commit interaction, call-session, event-log, command-intent, and outbox changes before releasing it, so overlapping Asterisk listeners or concurrent DialPad processing observe one committed result. Monotonic provider truth now rejects lifecycle-rank regression regardless of receipt time and refuses unsequenced events after a sequence high-water exists, preventing delayed or timestampless ringing events from reopening connected calls. +- Made voice-operation success provider-confirmed and unavailable controls fail closed. Recording, monitoring, and Contact Center transfer now require both the advertised capability and the matching executable provider contract, invoke the provider before changing domain state, and publish success events only after a successful provider result; failures and unknown outcomes cannot fabricate completion. The supervisor dashboard renders only executable monitoring modes, and the shared Telephony service enforces every operation capability server-side in addition to hiding unsupported soft-phone controls. +- Added the shared Contact Center call-control authorization boundary. Existing call actions resolve ownership through the typed `CallSession` mapping before provider execution, transfers require typed server-resolved destinations with emergency/premium denial and external-transfer RBAC, supervisor dashboard subscriptions and engagements are constrained to manager-owned queues, and first outbound dials create an owned call session before dispatching to a provider. +- Restricted external call transfers to a tenant-scoped approved-destination catalog. External transfer targets are no longer accepted as caller-supplied phone numbers; an operator curates a per-tenant catalog of approved destinations (stored as isolated Orchard Core site settings so it is never shared across tenants) in the **External transfer destinations** section of the **Settings → Contact Center** screen, and callers reference only an opaque catalog entry identifier. The resolver looks up the entry server-side, denies it when it is missing or disabled, and re-validates the stored E.164 address (emergency and premium ranges are always rejected) before the transfer proceeds, so a compromised or malicious client cannot dial an arbitrary external number even when it holds the external-transfer permission. +- Split Contact Center voice-provider metadata from executable provider operations. `IContactCenterVoiceProvider` now identifies a provider and its declared capabilities, while call control, queue/assignment, transfer, conference, recording, monitoring, and live media use focused optional contracts. Routing and server-side bridge staging fail closed when call-control metadata has no implementation; Asterisk and DialPad base adapters advertise only executable dialer support; and feature-gated media is discovered from a matching `IContactCenterVoiceMediaProvider` registration instead of a capability exposed when the media feature is disabled. +- Added active-work feature quiesce, bounded drain, and re-enable behavior for Voice, Dialer, Automated Dialer, Real-Time, base outbox dispatch, Asterisk and DialPad Contact Center adapters, and Asterisk media sessions. Feature disable rejects new tenant-shell work before descriptor mutation, attempts to drain admitted leases, aborts Contact Center hub connections, and keeps admission closed through shell teardown when Orchard continues after a non-fatal timeout. Provider-feature quiesce before provider contact returns a durable command to delayed `Pending`; inbox/outbox claims retain fenced recovery; and unavailable expected outbox handlers neither falsely complete messages nor consume their dead-letter attempt budget. Configure the 30-second default with `CrestApps_ContactCenter:FeatureLifecycle:DrainTimeoutSeconds` from 1 through 300 seconds. +- Added stable outbound provider-command intent before provider execution. Automated attempts accept reservations before dispatch, persist a durable command identifier, propagate it through provider metadata, and send it to DialPad through the HTTP `Idempotency-Key` header. Failed reservation acceptance cannot dial, while intent-commit failures compensate in a fresh Orchard scope. +- Added a durable provider-command state machine covering `Pending`, `Claimed/Fenced`, `Sent`, `OutcomeUnknown`, `Confirmed`, `Compensating`, and terminal states. Registration is serialized per command identity; dispatch, reconciliation, and compensation use fenced leases; stale callers hand ownership to recovery instead of signaling a safe redial; and due/reclaim indexes feed a bounded minute recovery task that isolates each CAS-sensitive command in a fresh Orchard scope. Pending crash recovery revalidates the current dialer profile and outbound compliance policy and fails closed without provider execution when policy cannot be proven. Unknown and terminal confirmation settlement commit atomically with interaction and activity projections. Transport failures and ambiguous HTTP 408, 429, and 5xx responses require reconciliation before retry, while current Asterisk and DialPad commands safely pause because those providers cannot query by command key. Definitive provider failures release reservation, queue, activity, and agent ownership without disturbing newer work. +- Made Contact Center mutations persist-only at their external boundary. Outbound attempts now commit the activity update, interaction, provider command, domain event, and event-outbox row as one tenant transaction before the durable processor contacts a provider. Server-side inbound acceptance, answered-outbound bridging, and reservation-timeout voicemail or rejection persist typed command intent instead of invoking providers inline; a definitive timeout-action rejection re-enqueues the still-live call while uncertain outcomes remain isolated for reconciliation. Inbound assignment no longer queries provider state or dispatches an offer transport before commit; declined or requeued offers continue routing through durable outbox handlers; and agent offers and entitlement changes reach SignalR through post-commit real-time projections. Domain-event outbox and provider-webhook inbox workers now persist expiring owner/fence claims before handler execution, isolate payload handlers in fresh Orchard scopes, and commit a concurrency-checked terminal state before deletion so an expired worker cannot delete work reclaimed by a newer fence. +- Added canonical provider identity, portable database-enforced uniqueness, provider-event ordering, and stable outbox handler ids. Provider modules contribute canonical identities and aliases through the implementation-free `IProviderIdentityProvider` contract (Asterisk maps `Default Asterisk` to `Asterisk`; DialPad declares its canonical name) and Contact Center resolves them through `IProviderIdentityResolver` before it builds any inbox, event, or call key, so an alias delivery no longer mutates stored provider identity. Normalized provider voice events carry an optional monotonic `SequenceNumber`; call sessions persist a `HighWaterSequence`, and ingestion rejects stale or equal-order deliveries by sequence when present and by a lifecycle-rank timestamp guard otherwise, so an equal-timestamp event can no longer regress a more-advanced call while providers that only supply timestamps or idempotency keys stay safe. Portable, non-null claim columns and a canonical composite provider-delivery index now database-enforce one call session per provider-call identity, one interaction event per provider-scoped idempotency key (canonical provider plus raw delivery id, so identical raw ids from different providers cannot collide), and one webhook inbox message per canonical provider delivery, via canonicalizing preflight/repair upgrade migrations that resolve legacy aliases before duplicate detection and unique-index creation and fail with actionable guidance on irreconcilable duplicates instead of deleting business data; the index providers also canonicalize `ProviderName` on reindex so legacy documents cannot recreate alias index values. Call sessions use optimistic concurrency, so concurrent provider-event ingestion is compare-and-set safe and a losing `ConcurrencyException` is treated as ownership loss that never reuses the canceled session and is reprocessed in a fresh scope. The domain-event outbox now checkpoints handlers by an explicit stable versioned `HandlerId` independent of CLR type name, assembly version, and registration order, with a deploy-safe legacy alias/repair path, and fails fast on a null, blank, or duplicate `HandlerId` so a rolling deployment, reorder, or duplicate id never replays or skips a handler. +- Added canonical Contact Center agent availability and deterministic after-call recovery. Routing now requires an entitled `Available` profile, selected session queue, online connection, fresh heartbeat, and remaining capacity; reservation creation rechecks the same boundary under its transition locks. Last-session disconnects and stale heartbeats stop new offers immediately, while a tenant background task releases orphaned or deadline-expired wrap-up capacity without completing the CRM activity or inventing a disposition. `HeartbeatTimeout` and `MaximumWrapUpDuration` are configurable under `CrestApps_ContactCenter:Availability`. +- Added database-authoritative Contact Center queue and reservation transitions. Queue-item, reservation, agent-profile, and CRM-activity updates use optimistic YesSql document concurrency; reservation lifecycle changes commit atomically and abort the operation scope when another writer wins; activity and agent locks reduce contention without serving as the correctness boundary; current agent presence and capacity are revalidated before reservation; portable unique claims prevent duplicate active queue items, duplicate active activity reservations, and concurrent pending reservations for one agent while retaining terminal history; and legacy migrations fail with actionable repair guidance when old data violates the new invariants. +- Added a fresh-tenant Contact Center feature activation matrix. Both finite GA-Core provider profiles now boot in isolated Orchard tenants with exact manifest dependency closure checks, key service and background-task resolution, idle provider disable/re-enable coverage, cross-tenant provider isolation, and retained Linux CI evidence. +- Added explicit Contact Center feature lifecycle orchestration. Orchard pre-disable events now lazily resolve, quiesce, and drain matching feature participants before descriptor mutation; fresh tenant shells register bounded reconciliation tasks without performing provider or database work inside feature activation; and a versioned ledger covers inactive/idle background tasks, hubs, listeners, adapters, and media providers while keeping active-work drain explicitly gated on R3. +- Added a fail-closed browser-audio foundation to the Telephony soft phone. Providers can now distinguish browser microphone/speaker audio from external devices through `ITelephonyAudioProvider`, automatically resolve single-mode providers, require a provider setting when both modes are supported, and register an executable browser media adapter. The widget lazily requests microphone permission before dial/answer, forwards authoritative call states to the adapter, plays its remote stream, and releases microphone/media resources when calls end. The current DialPad and Asterisk Telephony integrations remain explicitly external-device-only; Asterisk External Media remains a separate server-side Contact Center media capability rather than embedded WebRTC. +- Consolidated daily and administrative navigation under **Interaction Center**. Daily agent and supervisor pages remain directly accessible, while configuration and maintenance pages now appear under **Interaction Center → Management**. +- Fixed duplicate Bootstrap Select controls when the Contact Center Work tab and Orchard admin filters appear on the same page. The Work tab registers its named resources before footer rendering and initializes only its marked queue and campaign selectors. +- Contact Center reports now resolve campaign and campaign-group display text consistently, and user columns use Orchard's `UserDisplayName` shape. Agent entitlement screens also show the user's resolved display name instead of an opaque identifier. +- Improved the agent workspace recent-call rows with formatted phone numbers, a state badge beside the number, and activity time on a separate line. Phone fields now keep the input, verification indicator, and call action on one row, while display views use Orchard `ocat-*` label/value grouping. +- Reorganized Contact Center, Omnichannel, Telephony, Dialer, and phone-verification feature categories around user-facing capabilities so related features are easier to find on the Features page. +- Removed the reverse Omnichannel Management dependency on Contact Center implementation assemblies. Omnichannel Core now owns an optional dialer contributor contract, Contact Center Dialer supplies profile discovery and queueing through that contract, and Omnichannel Management remains independently activatable without service location or missing-service failures. +- Added `CrestApps.OrchardCore.ContactCenter.Recording` as the independent owner of recording orchestration over Contact Center Voice. Enabling Voice alone no longer registers the recording command service; provider-reported recording-state projection remains part of Voice until the R4 executable-capability gate is completed. +- Added `CrestApps.OrchardCore.ContactCenter.EntryPoints` as the independent owner of inbound entry-point administration, resolution, queue ingress, and entry-point-aware offer services under explicit Voice and Routing dependencies. Voice remains independently activatable by treating entry-point qualification as an optional contribution while retaining its provider-neutral inbound call service. +- Split regulated and automated outbound behavior into explicit features. `CrestApps.OrchardCore.ContactCenter.Compliance` owns outbound eligibility and attempt execution, while `CrestApps.OrchardCore.ContactCenter.Dialer.Automated` hard-depends on Compliance and owns Power/Progressive strategies, scheduled pacing, and automated batch registration. The base Dialer retains profiles, callbacks, and Manual/Preview policy surfaces without starting automated cycles. +- Added `CrestApps.OrchardCore.ContactCenter.Supervision` as the independent owner of the live supervisor dashboard, monitoring endpoints, controller, and navigation under explicit Real-Time and Voice dependencies. The Real-Time feature now owns only shared transport, projections, resources, and its SignalR hub. +- Added `CrestApps.OrchardCore.ContactCenter.AgentDesktop` as the independent owner of the CRM-integrated agent workspace, its endpoints, controller, and navigation. Real-Time now remains usable without exposing a workspace whose Voice Soft Phone or Omnichannel Management dependencies are absent. +- Added `CrestApps.OrchardCore.ContactCenter.Routing` as the independent owner of routing strategies and activity assignment. Voice now declares Routing instead of Queues, and Dialer explicitly declares both Voice and Routing so tenants can activate each capability with a complete dependency graph. +- Added `CrestApps.OrchardCore.ContactCenter.Availability` as the SignalR-independent owner of agent presence, durable sessions, heartbeat state, stale-session recovery, and logout synchronization. Queues and Real-Time now declare Availability explicitly, while Real-Time retains only hub and transport projection concerns. +- Added the independently selectable `CrestApps.OrchardCore.ContactCenter.Workflows` feature. It explicitly depends on base Contact Center and `OrchardCore.Workflows` and owns the Contact Center domain-event workflow activity and bridge instead of activating implicitly through `[RequireFeatures]`. +- Moved the Contact Center soft-phone display driver, named resources, agent endpoints, and call-state projection out of Queues and into `CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone`. The static feature architecture ledger now has zero known dependency violations. +- Split server-side Contact Center Voice from the Telephony soft-phone UI. `CrestApps.OrchardCore.ContactCenter.Voice` now depends on provider-agnostic Telephony, while `CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone` explicitly owns the Telephony soft-phone and Contact Center real-time call-state projection. +- Split the headless `CrestApps.OrchardCore.ContactCenter` feature from Omnichannel administration. The base feature now depends only on `CrestApps.OrchardCore.Omnichannel`; tenants that need the management experience enable the independently selectable `CrestApps.OrchardCore.ContactCenter.Admin` feature, which owns the `CrestApps.OrchardCore.Omnichannel.Managements` dependency. +- Contact Center aggregate report tables now append semantic grand totals calculated from the filtered raw population instead of summing formatted cells or averaging displayed rates. Queue-group, campaign-group, and daily timecard layers expose semantic subtotals; raw interaction detail, exception detail, and presence-audit tables remain unmodified. CSV and Excel retain all subtotal and grand-total rows, while the browser continues to color subtotal and grand-total rows distinctly. +- Hardened the Asterisk integration by removing ARI credentials from real-time listener log URIs, adding negative redaction coverage, and making the standalone Asterisk Web dashboard development-only with loopback defaults. Its ARI credentials now come from user secrets or environment variables rather than committed sample settings. +- Adopted Microsoft's `Microsoft.Extensions.Compliance.Redaction` for operational-log redaction rather than maintaining a bespoke redaction API, and migrated it across the Telephony hub and interaction reconciliation service, the Asterisk provider/listener/dispatcher, DialPad, the SMS Omnichannel event handler, and the Contact Center Core presence, provider-event, provider call-state synchronization, dialer attempt, outbox, queue/reservation assignment, agent-session, and background-task services. Identifiers and free-form text are passed through `SanitizeLogValue()`, which strips control characters and so keeps every log line safe from CR/LF forging while remaining correlatable. Customer/E.164 addresses, secrets, and metadata values are classified as `LogDataClassifications.AddressSet` and erased through an injected `Redactor` obtained from `IRedactorProvider`, registered with `services.AddRedaction(builder => builder.SetRedactor(LogDataClassifications.AddressSet))`. Exceptions are passed to `ILogger` as the first argument instead of being interpolated, so the framework controls how they are rendered. The former `OperationalLogFieldKind`, `OperationalLogIdentifierCategory`, and `OperationalLogRedactor` types were removed. `ContactCenterOperationalLogPrivacyTests` parses every logging call in the Contact Center, Telephony, Asterisk, DialPad, and Omnichannel SMS trees with Roslyn, rejects a sensitive value that reaches a logger unsanitized and any exception that is not the first argument, and drives the Telephony hub's request formatters with an erasing redactor to prove addresses and secrets never reach a log. +- Added tenant-local provider webhook concurrency limits before body buffering and authenticated per-provider token-bucket rate limits for the generic Contact Center and built-in DialPad endpoints. Unauthenticated requests cannot consume a provider's valid-delivery budget; rejected requests return HTTP 429 with `Retry-After` when available. Limits are configurable under `CrestApps_ContactCenter:WebhookIngress`. +- Added provider-signed webhook freshness enforcement for generic Contact Center and DialPad voice deliveries. Missing, malformed, non-UTC, stale, or excessively future event timestamps are rejected before state-changing processing; the accepted delivery-age and future-skew windows are configurable under `CrestApps_ContactCenter:WebhookIngress`. +- Added a tenant-local durable provider webhook inbox for generic Contact Center and DialPad voice deliveries. Authenticated normalized payloads are committed to YesSql under a provider-delivery idempotency lock before HTTP 2xx, then dispatched independently of caller cancellation with immediate processing, exponential-backoff recovery, poison-message isolation, and dead-lettering after ten failed attempts. Multi-node production now explicitly requires `OrchardCore.Redis.Lock` in addition to the Redis SignalR backplane. +- Contact Center and Telephony SignalR projections now route users, queues, and supervisors through Orchard-shell-qualified groups. Equal identifiers in separate tenants remain isolated when nodes share a SignalR backplane, and soft-phone connections must pass their feature permission check before joining their tenant user destination. +- The Omnichannel activity-completion workflow preview now constructs cards, titles, labels, and schedule inputs with safe DOM APIs instead of assigning stored values through `innerHTML`, preventing subject or action text from becoming executable browser markup. +- Generic Contact Center and DialPad voice webhook endpoints now enforce a 1 MiB request-body limit, return HTTP 413 for oversized deliveries, and keep authenticated state-changing processing independent of caller disconnect cancellation. +- Contact Center managers can now grant per-user queue and campaign entitlements under **Interaction Center → Management → Agent entitlements**. Agent sign-in and membership updates enforce those allow-lists centrally, soft-phone choices are filtered, entitlement removal prunes live session membership and active SignalR queue subscriptions, connected clients refresh their membership snapshot, and routing fails closed for stale or imported memberships without a matching manager grant. +- The Contact Center production contract now publishes a finite, machine-readable candidate GA support matrix. The initial blocked Tier-1 target is PostgreSQL 16, single-region two-to-four-node deployment with a Redis backplane, one Asterisk or DialPad profile per tenant, Manual/Preview dialing only, and up to 100 signed-in agents per tenant. +- Contact Center real-time isolation now has an R0a two-shell regression that drives two tenant identities through one hub context and proves user and supervisor destinations remain tenant-qualified. A source-level ordering guard requires queue/supervisor permission checks and unauthorized abort before the corresponding group joins; runtime authorization-denial coverage remains a separate gate. Production-backplane isolation remains an R0b release gate. +- The Contact Center production contract now publishes a machine-readable R0a feature-dependency ledger (`.github/contact-center/feature-dependency-violations.v1.json`) and an architecture characterization that statically parses the relevant manifests and every Contact Center `StartupBase` class. Recognized generic registrations are checked against manifest and `[RequireFeatures]` closures, and each Contact Center feature's transitive manifest-dependency closure is pinned to known features or accepted external leaves. The intentional external couplings it records — the headless base feature onto `Omnichannel.Activities` (the shared work-item services, not the administration experience), Real-Time onto `SignalR` (it hosts the agent/supervisor hub), Voice onto provider-agnostic `Telephony` (server-side orchestration, no soft-phone UI), the independently selectable Voice Soft Phone projection onto `Telephony.SoftPhone`, the Admin and Agent Desktop integrations onto `Omnichannel.Managements`, Analytics onto the shared `Reports` framework, and the Workflows bridge onto `OrchardCore.Workflows` — are recorded as accepted external dependencies rather than treated as findings, and any deliberate change to the manifest graph must update the ledger in the same change. +- Contact Center capability-truth tests explicitly reproduce the current recording and supervision capability-truth gaps: recording can publish success and change state without an executable provider recording operation, and supervisor monitoring can publish success from a capability flag without invoking a mode-specific provider contract. A later revision must invert these characterizations to fail closed or prove provider execution before GA. +- The Telephony soft phone now restores and displays all provider-authoritative active calls for the current user. Agents can hold calls, place additional calls, switch the selected call by phone number and state, conference any number of checkbox-selected calls without entering provider call ids, hang up the selected call, or disconnect every active call. The conference button appears only after at least two calls are selected, all merged rows show **In conference**, recent-call numbers are formatted, active-call rows show number and state on one line, the bounded Keypad view scrolls instead of growing, and dial input is cleared when dialing or returning to held-call dialing. Asterisk adds every selected channel to one bridge, clears stale hold markers, and tracks/deletes its owned mixing bridge when the last conference channel ends. DialPad merges additional calls sequentially into the primary call. Conference transfer stays hidden until one interaction is explicitly selected. A new capability-gated provider directory powers transfer selection from Asterisk ARI endpoints and DialPad company users while preserving manual number/extension entry. The provider-neutral contract still does not claim agent-only conference departure; Asterisk warm transfer and supervisor media execution remain separate provider gaps. +- The optional `IContactCenterVoiceMediaProvider` and `IContactCenterVoiceMediaSession` contracts expose negotiated PCM/G.711 formats, asynchronous incoming audio frames, outgoing audio injection, and media detachment without ending the provider call. `IContactCenterVoiceMediaProviderResolver` requires a matching base voice provider and executable media registration, preventing unsupported or partially registered providers from being selected for future AI voice sessions. +- Contact Center bidirectional media now activates through the independent `CrestApps.OrchardCore.ContactCenter.Voice.Media` feature. Asterisk separates its Contact Center voice adapter and RTP media adapter into `CrestApps.OrchardCore.Asterisk.ContactCenterVoice` and `CrestApps.OrchardCore.Asterisk.ContactCenterMedia`; DialPad exposes `CrestApps.OrchardCore.DialPad.ContactCenterVoice`. Provider base features retain only their Telephony dependency. +- Provider modules now compile only against `CrestApps.OrchardCore.ContactCenter.Abstractions`. Stable provider-facing contracts cover durable webhook acceptance and handlers, ingress limiting, normalized voice-event ingestion, provider-state reconciliation, and inbound voice routing without exposing Contact Center persistence models or implementation assemblies. +- Contact Center after-commit execution and Orchard child scopes now flow through `IContactCenterScopeExecutor`. Event dispatch, real-time projection, queued-work recovery, SignalR hub operations, and inbound routing lock release use typed operation contexts instead of scattered `ShellScope`, handler-level `IServiceProvider`, or ad hoc child-scope creation. +- Added the explicit `CrestApps.OrchardCore.SignalR.Redis` feature. It uses Orchard Redis configuration with a dedicated connection and shell-qualified channel prefix, and a dedicated Linux operations gate proves provider-listener delivery across two nodes without crossing tenant shells. +- Asterisk now implements the first bidirectional media adapter using ARI External Media over RTP/UDP. It attaches a G.711 mu-law external-media channel to the existing call bridge, parses and emits RTP frames, exposes asynchronous caller/application audio, and cleans up its external channel and owned bridge without ending the customer call. DialPad remains unavailable for AI media because its integration does not expose equivalent raw live-media injection. +- The standalone Asterisk dashboard can now originate two configurable endpoints into Stasis and connect them through a real ARI mixing bridge. Aspire supplies two synthetic Local endpoints with distinct tone patterns, while developers can replace them with registered PJSIP endpoints to ring two actual devices; partial setup failures clean up created channels and bridges. +- Scripted ARI lifecycle tests, real loopback RTP media-session tests, protocol edge-case tests, and an automated outbound reminder batch now validate the Asterisk media foundation and confirm that automated voice activity outcomes appear in Call Insights and Campaign Summary reports. +- Automated phone activities can persist AI profile, speech-to-text deployment, text-to-speech deployment, and voice selections with activity-to-subject-flow-to-site-default fallback. Completed AI calls retain their AI session reference, append the generated summary as disposition notes, and use the shared disposition lifecycle so configured subject actions and workflows run consistently. +- Campaign reports now render campaign and disposition display names instead of internal catalog identifiers. Historical rows whose catalog entry no longer exists use **Unknown campaign** or **Unknown disposition** rather than exposing an opaque id. +- Contact Center and Omnichannel report table cells now use Orchard Core's cached `UserDisplayName` shape, rendering the username by default and the configured `IDisplayNameProvider` value when **User Display Name** is enabled. Stable user identifiers remain the reporting/grouping keys, and usernames are resolved at report time instead of being duplicated in activity indexes. Browser rendering preserves the admin layout, while CSV and Excel export rendering suppresses it so exports receive only the resolved text value. Filters, workspace updates, and activity audit entries also use configured display names. +- Report export actions now appear in a report toolbar beside the first visible section heading instead of inside the filter controls. The toolbar and heading are excluded from exported data, CSV begins with the data columns, and Excel uses the report title for worksheet tab names while retaining active filters. +- The executive performance report is now an interactive stakeholder dashboard with KPI hero cards, a daily offered/answered/abandoned trend, channel-mix doughnut, queue service-level comparison, top-agent workload chart, and channel detail. The reusable Reports framework now depends on CrestApps Resources and loads its named `chart.js` resource when a report contains responsive line, bar, stacked-bar, or doughnut sections, with equivalent tabular data in CSV and Excel exports. +- Contact Center Analytics and Omnichannel Management now provide a 78-report enterprise catalog organized under Executive, Operations, Queue & Routing, Agent Performance, Workforce & Payroll, Billing & Usage, CRM & Campaigns, Compliance & Audit, and Technical & IT. The expanded catalog adds user productivity, presence-derived timecards, breaks, utilization, occupancy, payroll inputs, queue/provider/channel usage for billing reconciliation, campaign breakdowns, exception details, and call-leg operations while keeping monetary payroll/billing calculations out until rate and policy data exists. Shared KPI definitions and filters remain consistent across browser, CSV, and Excel output. +- Contact Center reports now declare the capabilities that produce the data they read, and say so instead of publishing a figure when the capability is absent. Reporting deliberately does not depend on **Voice** or **Recording**: a tenant running chat and CRM only should not have to run, secure and upgrade voice call handling to read a queue report. Transfer analysis, queue and agent transfer performance, provider performance, provider usage for billing, and call leg performance require **Voice**; recording coverage and agent recording coverage require **Recording**. When the producing capability is not enabled, those reports render a notice naming the missing feature and show no figures. Reports whose primary figures are real keep running and instead drop the columns and metrics the absent capability feeds: the executive dashboard omits **Transfer rate** and **Recording coverage**, interaction and exception detail omit **Provider** and **Transfers**, agent volume and outcome reports omit **Transfers**, **Recorded** and **Recording coverage**, and the queue, agent, channel and daily usage reports omit **Transfers** and **Recordings**. In both cases the reason is the same: zero is a measurement, and an absent capability took none. +- **Provider webhook bodies are now bounded by what arrives rather than by what the caller claims.** The voice webhook endpoints checked their 1 MiB ceiling against the request's declared content length, which a caller that wants to send more simply omits, and then read the whole body into a string in one go, so a chunked body was bounded only by whatever the web server happened to allow. The body is now read through a shared bounded reader that measures what actually arrives and refuses it the moment it passes the allowance, before it is ever turned into a string, so an oversized caller costs the process the bytes it takes to notice rather than the bytes it chose to send. The allowance is handed to the web server as well, so on hosts that support it those bytes are stopped before they reach the application, and the server's own refusal is reported the same way as the reader's. The permit that limits how many deliveries a tenant processes at once is still taken before the body is buffered, because that permit is what bounds how much memory concurrent deliveries can occupy; a caller that sends slowly is stopped by the web server's minimum request body data rate. The Azure Event Grid endpoint reads its body the same way. +- **A configuration entry is now judged by the same rules whichever way it is written.** Rules that decide whether a skill, queue, dialer profile, channel endpoint, campaign, disposition, subject flow or subject action is valid used to live in the admin editor's display driver, so they ran when an administrator typed the entry and did not run when a recipe or a deployment plan wrote it. An entry the editor would have refused could therefore be imported without complaint, producing a tenant no operator could have built by hand, and the defect surfaced later as traffic that did not route or a dialer that would not start. Those rules now live with the entry itself, where every write path runs them, and the editor reaches them through the same path a recipe uses, so both refuse the same entries with the same messages against the same fields. Two entities - subject flow settings and subject actions - had no rules at all on the recipe path and now do. Phone numbers on channel endpoints are normalised in the same place, so a number written by a recipe is stored in the same form as one typed into the editor and matches inbound traffic. Three dialer settings that the editor used to correct silently now report what is wrong instead: a value outside the permitted range is refused with a message rather than adjusted without telling anyone, which also means a recipe can no longer store the uncorrected value. +- **Contact Center configuration is now portable.** Skills, queue groups, business hours calendars, queues, entry points, dialer profiles and agent state reason codes are exported by a new **Contact Center configuration** deployment step and imported by matching recipe steps, so a contact centre can be built in staging, reviewed as a diff in source control, and replayed into production instead of being rebuilt by hand under a cutover window. Only the catalogs whose feature is enabled are exported, empty catalogs are skipped, and steps are written in the order they must be imported, because a queue references skills, a queue group and a calendar. Import is idempotent: an entry is matched by identifier, then by the members that identify it - a name, a display text, or, for subject flow settings and subject actions that carry neither, the subject and disposition that make one entry the same entry as another - so replaying a plan into a tenant whose own migrations already seeded the same reason codes updates them instead of duplicating them. A setting cleared at the source is cleared at the destination rather than quietly retained, and an entry the destination's own rules reject is reported and skipped without stopping the entries around it. Agent profiles deliberately do not travel: a profile names the person who holds it, carries their contact details and records the state they are in right now, so it is a record of who works in an environment rather than of how that environment is configured. Provider credentials are not part of a plan either - they are bound from configuration - so a plan committed to source control never carries a secret. See [Configuration deployment](../contact-center/configuration-deployment.md). +- **Recipes could not configure a Contact Center tenant before this release.** Only reason codes had a recipe step at all, and the handlers behind the other seven entities never read the recipe data, so a step that created a skill or a queue produced an entry with no name and no settings. Export and import are now driven by the shape of the entity rather than by a hand-written property list, which also means a property added to an entity later travels without anyone remembering to add it. The proof is anchored to the stored entities rather than to a second export, because comparing one export against another is satisfied by a step that drops the same property on both sides. +- **CRM configuration is now portable too.** Dispositions, channel endpoints, campaign groups, campaigns, subject flow settings and subject actions are exported by a new **CRM configuration** deployment step and imported by matching recipe steps, using the same shared machinery as the Contact Center step. Activities and activity batches are runtime state and deliberately stay in the environment that produced them. +- **Imported configuration now keeps the identifier it was exported with**, and the standard agent state reason codes seeded by the Contact Center migrations now use fixed identifiers rather than minting a new one per tenant. Configuration refers to configuration by identifier - a queue names its queue group, an entry point names its queue, a subject action names the disposition that triggers it - so an import that assigned new identifiers left every one of those references pointing at an entry that did not exist on the destination. Where the destination is not empty and an entry has to be reconciled with a copy that tenant created independently, the destination keeps its own identifier and the import repoints the plan at it - both the entries still to come and the entries it has already stored, because a queue overflows into another queue in its own step and references a channel endpoint that a different deployment step carries. The order in which an operator adds the two steps to a plan therefore does not change the result. A hand-written plan may also invent its own identifiers, such as `"support-queue"`, and reference entries by them; the store issues the real identifier and the import translates the invented one wherever the plan used it. Tenants seeded before this release keep the identifiers they were given and are reconciled by name on the next import. Reconciling by name claims each entry the destination already owned once, so a plan that carries two queues named "Support", or several subject actions on one disposition, lands both instead of the second overwriting the first. A hand-written step may spell its collection in any case, and a step naming a collection the destination does not recognise is now reported as an error rather than passing as a successful step that imported nothing. +- **Removed the Transcript coverage report** and the `Interaction.TranscriptReference` field it read. No code path in the product ever wrote that field, so the report published a confident 0% on every tenant. Transcripts remain a future quality-management capability; the report will return with the capability that produces the data. +- **Call leg performance now reads real data.** It previously read `Interaction.CallLegs`, which was declared and never written - call legs have always been projected onto the call session - so the report rendered an empty table on every tenant. It now reads the legs the voice topology projector maintains on `CallSession`. `Interaction.CallLegs` and the `InteractionCallLeg` type were removed. +- Dialer assignments now open the assigned record's shared **Complete activity** screen automatically, and successful completion returns the agent to **My workspace** through a local-only return URL. Manual activities retain **Activities** as the default destination, while Interaction Center orders **My workspace** and **Activities** first. +- Code-scanning log-forging findings were resolved by removing user-controlled queue, campaign, OAuth, and remote-agent values from diagnostic log entries. +- Provider reconciliation now repairs a nonterminal interaction from its already-terminal call session before querying or republishing provider state, then runs ended-offer cleanup. This closes the duplicate-idempotency drift that could leave an ended Asterisk call marked `Ringing`, consume the agent's only capacity slot, and keep later inbound calls queued. +- Provider-ended offer cleanup now distinguishes an agent-accepted assignment from a provider-connected inbound caller leg. Terminal work that is still waiting or reserved is removed and releases the agent even when Asterisk stamped an answered time before agent acceptance, allowing the bounded offer loop to skip dead queued calls and reach the next live call. +- Contact Center assignment and agent presence now emit structured diagnostics for queue lock contention, disabled/closed/empty queues, available-agent counts, routing candidate rejection reasons, reservation races, sign-in memberships, and live-session membership synchronization. +- The soft-phone Work tab no longer renders false `selected` or `disabled` boolean attributes on queue/campaign options, and empty selectors now show **Select queue(s)** and **Select campaign(s)** instead of appearing to choose the first membership. +- Soft-phone presence changes now run over the Contact Center SignalR hub without a full-page reload, while answered call termination enters Wrap-up and CRM activity completion returns the agent to Available or a previously requested break. +- Phone inputs now format national and international numbers for readability, dial a normalized value, and support Enter-to-dial without storing phone numbers in browser storage. +- The shared activity-completion screen now includes contact information, editable subject context, read-only activity metadata, notes, dispositions, and subject-action scheduling; custom phone fields also render correctly in display mode. +- Subject-action schedule dates submitted from the activity-completion screen are converted from the tenant's local time to UTC before follow-up activities are stored. +- Contact Center interactions now record wrap-up start and completion timestamps. Call Insights includes total wrap-up time and wrap-up in average handle time, while Agent Productivity includes average and total wrap-up time. +- The Asterisk development dashboard now records refresh source, lock wait, ARI snapshot time, SignalR broadcast time, and snapshot counts so delayed channel visibility can be traced to the exact stage instead of inferred from periodic reconciliation requests. +- Asterisk Web now observes a dedicated `crestapps-dashboard` ARI application instead of competing with the CMS telephony listener, closes ARI HTTP connections after each response to avoid stale pooled sockets, updates call/bridge badges from live snapshots, and uses consistent web JSON naming for the initial snapshot. Live channel changes now appear through event-triggered SignalR updates instead of waiting for periodic reconciliation. +- The Asterisk Contact Center voice adapter now resolves the tenant or configuration-backed provider, returns the actual executing provider identity, and persists that alias on outbound interactions so ARI events pass provider-safety checks. The Aspire dialplan supports E.164 `+` destinations and uses a generated tone sequence plus Echo instead of missing container sound files. +- Preview offer acceptance now starts the outbound dial attempt through the configured voice provider, dialer inventory is enqueued when loaded, and reservation state is persisted before an automated cycle selects more work, preventing duplicate reservations caused by same-scope read-after-write behavior. +- The soft phone keeps the unconfigured-provider warning compact at the top, dials when Enter is pressed in the number field, and restores the formatted connected number from authoritative active-call state across reloads and reconnects. +- The Automatic inventory-load AI profile selector now renders inside the **Inventory load settings** card directly under the campaign field, and the separate **AI settings** card (including its per-batch speech-to-text, text-to-speech, and voice overrides) was removed; those speech selections fall back to the subject flow and then the global AI site settings. The AI dependency stays optional, so the base management feature still does not require `IAIProfileManager`. The **Channel endpoint** field now shows only for the automatic source. Manage Activities also handles historical activities without a subject content type. +- The **Load Inventory** list now orders inventory loads by creation date with the newest first (backed by a new indexed `CreatedUtc` column), renders a pager, and wires up the standard admin bulk-selection script so the header checkbox selects every row on the page. A newly created inventory load now appears at the top of the list instead of being pushed onto a later, unlinked page. +- Admin list headers across the CrestApps modules (Load Inventory, campaigns, dispositions, channel endpoints, AI profiles, deployments, provider connections, AI tool instances, templates, data sources, MCP connections/resources/prompts, A2A connections, chat interactions, and time zones) now report the true total item count across all pages instead of only the current page count, and the "items X to Y" range is now page-aware. Previously a paged list showed, for example, "10 items / 10 items in total" even when more pages existed. +- The standalone Asterisk Web sample now builds `signalr.min.js` into its own physical `wwwroot` through the repository asset pipeline. The prior linked output file appeared in `bin` but resolved to a nonexistent source static-web-asset path at runtime, returned `404`, disabled SignalR, and forced the dashboard onto the 15-second polling fallback. +- Contact Center provider synchronization is now terminal-safe and order-aware: stale events cannot reopen ended calls, provider-derived semantic events use distinct idempotency keys, and interaction/call-session lookup is scoped by provider name plus call id to prevent cross-provider collisions. +- Contact Center domain events now create a durable pending outbox record before handler dispatch, survive restarts, and checkpoint successful handlers individually so partial retries do not repeat already-completed projections. +- Inbound routing now serializes each provider call through a distributed lock and returns the existing interaction when duplicate webhook deliveries race, preventing duplicate activities, queue items, and offers. +- Reservation safety now includes per-agent locking across queues and per-reservation locking for accept/reject/cancel/expiry; expiry revalidates pending state under the lock so it cannot requeue a concurrently accepted call. +- Queue overflow now preserves original SLA age while tracking per-queue dwell time separately, prevents cycles through previously visited queues, supports overnight business-hour windows, and treats enabled equal-time schedules as 24-hour days. +- Terminal dial failures and permanent suppressions now remove queue work instead of requeueing forever; calling-window and cool-down suppressions remain retryable, and equal calling-window start/end settings fail closed. +- Provider reconciliation is serialized across startup, scheduled, and reconnect-triggered sweeps; Asterisk reconnects reconcile only Asterisk interactions for that provider. +- Asterisk reconciliation now restores hold/mute state from ARI channel variables, refuses unknown channel states instead of assuming connected, maps bridge-leave events, and supervises each provider listener independently with reconnect backoff. +- DialPad call webhooks now fail closed when the signing secret is missing or cannot be decrypted, and richer event attributes participate in webhook idempotency keys. +- Queue-agent sign-out is now a full membership sign-out: leaving the Contact Center soft phone clears queue and campaign selections instead of leaving stale campaign membership behind, and Orchard logout now runs the same agent sign-out path before the authenticated session ends. +- Orchard logout synchronization now waits until the Orchard logout request completes successfully before clearing Contact Center membership, preventing the soft-phone sign-out flow from hanging the logout page. +- Contact Center queue sign-in and sign-out now synchronize the live agent session membership used by the real-time layer, and published Contact Center domain events now fan out through deferred outbox dispatch so slow workflow or notification handlers do not leave the soft-phone sign-out postback spinning. +- Queues now expose an **Unanswered offer action** policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; voicemail/reject automatically fall back to requeue when no active provider call is available. +- The Telephony soft phone now restores an in-progress call after a page reload or reconnect only after `ITelephonyCallStateProvider` confirms the provider still has it; a stale history record can no longer be assumed connected. +- Telephony reconciliation now also removes an active interaction whose stamped provider is no longer registered or enabled, preventing records created under an old provider selection from leaving the agent stuck after configuration changes. +- The Telephony soft phone now preserves the selected **Keypad**, **Recent**, or Contact Center **Work** tab across reloads, keeps a stable panel height while switching tabs, and waits for the real provider status before showing the **No provider is configured** warning so configured tenants do not see a false warning flash during page load. +- Contact Center now re-checks already-waiting inbound voice queues once the soft phone reconnects, so an agent who refreshes the page while signed in and available still receives queued calls without waiting for another inbound routing event. +- Contact Center now runs a shared self-healing pass on queue sign-in, queue sign-out, and queued-voice reconnect recovery, clearing ghost pending reservations and re-queuing orphaned assigned voice work before those stale records can block the next inbound offer. +- The Contact Center soft-phone **Work** tab now signs agents in and out over the SignalR hub instead of reloading the page, restores pending inbound offers from the hub on reconnect, and suppresses duplicate ringing-modal restores after an offer has already been accepted or while another call is active. +- New Contact Center queue offers now reopen the soft-phone inbound modal immediately from live hub events, and accepted server-side provider-only offers now answer the underlying telephony call during the authoritative accept so the connected call stays visible and can still be ended afterward. +- The Telephony soft phone now persists inbound calls into the Recent history as soon as they are offered, keeps an accepted inbound call active if the offer-revoked hub event races with the accept response, and shows the remote party number for the live side of the current call instead of the tenant DID on inbound calls. +- The Telephony soft phone keeps an accepted server-side inbound offer in its current state until a normalized provider event or authoritative provider lookup confirms the next state, and keeps the keypad **Hangup** control hidden until the provider reports that the call is connected or held. +- Contact Center call-session events for an assigned agent now upsert the Telephony soft-phone interaction history and push `CallStateChanged` back through the Telephony hub, so provider-side server disconnects and other normalized voice-state changes immediately clear or update the live soft-phone call instead of leaving it stuck on the last client action. +- The Asterisk provider now keeps a tenant-scoped ARI event listener inside the Orchard shell lifecycle, normalizes mapped server-side channel events in real time, and updates both persisted Telephony interaction history and the live soft phone when Asterisk ends or changes a plain soft-phone call outside the browser. +- Soft-phone call-control responses no longer mutate browser state or publish `CallStateChanged` directly from `TelephonyHub`; commands are acknowledgements, while provider events and provider-authoritative lookups drive the state machine. Telephony now reconciles plain in-progress interactions at tenant startup, every minute, after Asterisk listener reconnects, and during soft-phone restoration; confirmed missing provider calls delete the orphaned active record and disconnect the client, while lookup failures preserve the record for retry. +- Asterisk terminal event projection is now terminal-safe: duplicate hangup/Stasis events do not republish completed calls, ambiguous bridge-leave snapshots no longer emit false connecting transitions, and duplicate Contact Center provider events retain Contact Center ownership instead of falling through to the plain Telephony projection. +- Asterisk's long-running ARI listener now creates explicit tenant shell scopes for reconciliation and event dispatch, preventing the listener from repeatedly crashing after tenant activation and restoring immediate soft-phone updates when calls are disconnected from Asterisk. +- Asterisk Dial acknowledgements now remain `Connecting` until provider truth reports a connected call, and the soft phone disables controls while a command is pending so repeated clicks cannot submit duplicate originate or call-control requests. +- Soft-phone commands no longer trigger short active-call polling loops. Concurrent page-restoration lookups yield to newer provider events, stale terminal events cannot clear a newer call, and Asterisk verifies channel existence after multi-request state lookups so teardown races cannot resurrect a disconnected call. +- The local Asterisk inbound simulator now dispatches Stasis events concurrently with bounded Orchard forwarding, recovers a missed `StasisStart` from an authoritative matching ARI channel snapshot, detects tenant-prefixed failed logins, applies configured defaults on initial load, and uses the root Orchard URL plus **Default Asterisk** by default while still supporting named tenant URLs. +- Repository projects now use `TargetFramework` when `CommonTargetFrameworks` contains one framework and retain `TargetFrameworks` for semicolon-separated overrides, allowing the Aspire AppHost to launch the default `net10.0` projects without breaking supported cross-targeting builds. +- The normalized Contact Center provider voice-event contract now carries richer live state such as mute, recording lifecycle, conference flag, and participant count, and provider-event ingestion now emits more detailed internal events such as `CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, and `CallConferenceChanged`. +- Telephony providers can now implement `ITelephonyCallStateProvider` so Contact Center can query current provider call truth during authoritative offer accept, tenant-activation recovery, and periodic reconciliation. Device-native voice accepts no longer mark a call connected before the provider says it is connected, and a provider-ended pre-connect offer is now removed from the queue instead of being re-offered as stale work after a restart or missed event. +- The Contact Center docs now include a dedicated technical voice-routing architecture guide covering inbound routing, outbound dialer flow, provider-truth synchronization, and restart reconciliation so custom-provider authors and operators can trace how queueing and call-state recovery work end to end. +- The Telephony docs now include a provider-authoring guide that explains how custom providers implement telephony call control, optional Contact Center voice orchestration, and provider event ingress through webhooks or live streams. +- The Contact Center and Telephony provider docs now also spell out the network protocol requirements for real-time voice, including HTTPS webhook ingress, SignalR browser connectivity, and the WS/WSS or HTTP/HTTPS requirements for provider event streams and control APIs such as Asterisk ARI. +- The Telephony SignalR hub now logs each soft-phone action with the acting user, connection id, call id, and Contact Center correlation metadata so Asterisk call-control failures can be traced end to end when an accepted inbound offer keeps pointing at an expired provider channel. +- Expired queue reservations now return agents to the correct default ready state, so a signed-out agent stays `Offline` when a previously offered reservation times out instead of bouncing back to `Available`. +- Contact Center provider-event ingestion now repairs stale, unregistered provider identities by falling back to the provider call id and canonicalizing the interaction/session to the live event provider, while refusing the fallback when the stored provider is still active so cross-provider call-id collisions remain isolated. +- Answered inbound terminal calls now complete their assigned queue item while preserving the accepted reservation and CRM activity for normal wrap-up/disposition history, so stale assigned queue work cannot block later routing. +- Asterisk hangup now treats `404 Channel not found` as idempotent disconnected success, avoiding a false soft-phone error when the PBX ended the channel first. +- The Asterisk development dashboard now uses a shorter live-event coalescing window and performs independent ARI reads plus per-channel enrichment concurrently, reducing the delay between an Asterisk event and the SignalR dashboard update. +- The Asterisk inbound simulator now enforces its configured provider identity on every post and renders it read-only, keeping normalized ingress records aligned with the active ARI provider. +- Contact Center recovery now reconciles stale provider-backed interactions through the tenant's current default provider before changing routing state, terminalizes calls the provider no longer reports, and never requeues a provider-confirmed connected call merely because an agent signs out or signs back in. +- The soft phone now revalidates provider truth when a terminal event arrives for a different call id than the stale call held by the browser, clearing zombie state without allowing an old terminal event to dismiss a genuinely newer active call. +- Inbound offers are now validated against provider truth before an agent is offered a queued call: `VoiceContactCenterCallRouter.OfferNextAsync` refreshes each queued interaction from the provider and, when the provider confirms the call no longer exists, removes it from the queue and releases the reservation and agent before moving on to the next call, so a "zombie" call whose provider channel is already gone can never be offered. +- Accepting an inbound offer whose media then fails to connect or answer now re-checks provider truth and, when the provider confirms the call is gone, reconciles the offer (removing it from the queue and releasing the agent) instead of leaving the agent stuck on an accepted reservation for a dead call. +- Provider voice-event ingestion now seeds a freshly created call session with the interaction's pre-event state instead of the incoming provider state, so a first-observed terminal event (for example, a reconciliation sweep that discovers a queued call already ended) still records a real non-terminal to terminal transition, publishes `CallEnded`, and runs the ended-offer cleanup instead of silently leaving the interaction queued. +- The Asterisk development dashboard ARI stream now subscribes to all Asterisk resources visible to its application, so channel and bridge changes trigger immediate SignalR snapshots instead of waiting for the periodic reconciliation interval. +- The Asterisk provider live event listener now dispatches each event in isolation, so a malformed payload, an unroutable event, or a transient tenant-scope failure while the shell is reloading is logged and skipped instead of tearing down the WebSocket and triggering a reconnect storm that dropped subsequent real-time events. The provider listener also now subscribes to all application events (`subscribeAll`), so every channel state change reaches provider-truth ingest rather than only events for channels the app already owns. +- Ended-offer reconciliation now cancels **every** non-terminal reservation bound to an activity instead of only the queue-referenced one, so reject/re-offer cycles that accumulated multiple accepted reservations can no longer keep an agent's active-reservation pointer aimed at dead work. Answered calls also clear a lingering reservation pointer while leaving the wrap-up cascade to own the presence transition. +- The work-state healer no longer requeues a provider-backed **ringing** interaction on its own; a live ringing call is left under provider control and released only when provider truth confirms it ended, which prevents both yanking a genuinely live call from an agent and resurrecting a dead call in an endless offer loop. Only non-provider-backed ringing work is still requeued locally. +- Manual agent presence changes now self-heal against provider truth: when an agent parked in an on-call state (Reserved/Busy/WrapUp or holding a reservation) asks to return to a ready state, the agent is reconciled against the provider first. A call that no longer exists on the provider is released so the requested change applies immediately, while a genuinely live provider-backed call is preserved and the change is deferred, so an agent can no longer get stuck as **Busy** and unable to go **Available** after a call the provider already ended. +- Recorded the public surface of every assembly another module compiles against, so changing it is a decision somebody made rather than one that happened. A type turning public, a member changing shape, a class losing `sealed`, or a parameter changing type are compatibility decisions, but in a diff they read like ordinary edits, and the first report that one of them was breaking usually arrives from a deployment that no longer builds. The generated surface of each governed assembly is now checked in and compared on every build, so a change to it fails until a reviewer accepts it on purpose. Which assemblies are governed is derived from the project graph rather than listed by hand - a project in the Contact Center, Telephony, or Omnichannel families is governed as soon as a project other than the packaging target or the web host compiles against it - because a hand-written list is one somebody has to remember to add to, and the assembly nobody remembers is the one that breaks. That rule governs eight assemblies today and correctly leaves out the modules nothing compiles against. Because a recorded surface is still only text, and text is easy to accept in bulk, two conclusions of the audit are stated as rules as well: a public class is sealed unless it is abstract, declares something overridable, or is a view model the display framework must build a proxy from, and no public type exposes static state that anything in the process can reassign. Nothing had to be resealed to satisfy either rule - the surface was already disciplined - and the classes the first run flagged were read rather than sealed, which is how the view models the framework requires to stay open, and the deliberately inheritable default inventory loader, kept working. The static-state rule did find real shared mutable state and it was fixed rather than exempted: `InteractionStatuses.OccupyingAgent`, `.Unsettled`, and `.Settled` were public static arrays that any code in the process could rewrite an element of, which would have silently changed what counts as occupying an agent for every tenant on every query that selects on them, and `TelephonyCapabilityContracts.ContractsByCapability` was a `Dictionary` handed out behind a read-only interface that a caller could cast back and edit. Both are now genuinely immutable. See [Public API surface](../contact-center/public-api-surface.md). +- The agent workspace poll no longer costs more as an agent covers more queues or handles more work. It is the most frequently executed read in the product - every signed-in agent runs it continuously - and it asked the database once per queue the agent belonged to, once per interaction the agent had recently ended, and then read that agent's recent work a second time because the active-interaction panel and the history panel each fetched it for themselves. An agent covering eight queues paid sixteen queries a poll to render state identical to what one batched read returns, which is why no functional test could see it. Queue depth is now answered by a single grouped statement for all of an agent's queues, the activities behind recent work are resolved in one read, and recent interactions are read once and shared. Batching alone would only have relocated the growth, because no existing index answers the question - the composite leads with `DocumentId` and says nothing about a queue, and the retention index leads with `Status`, so the planner seeks it and then walks every waiting item in the tenant - so `IDX_QueueItemIndex_WaitingByQueue` is added alongside the existing indexes and the depth count is served from the index without touching the table. Two independent gates hold this: the round trips one poll issues are counted at the handler, since a well-planned statement run once per queue costs the same as a scan, and the plan of the batched statement is asserted on SQLite on every build and on real PostgreSQL in the operations-gates workflow, where it is executed as well as explained. `IQueueItemStore`, `IQueueItemManager`, `IOmnichannelActivityStore`, and `IOmnichannelActivityManager` each gained one batch method, which is a breaking change for an external implementer of those interfaces. See [Query-plan budgets](../contact-center/production-support.md#query-plan-budgets). + ### Artificial Intelligence #### Core platform @@ -105,6 +311,7 @@ At a high level, the platform changes are: - Admin index action bars now use the tighter `gx-1` search-row gutter consistently across the list-management screens, so attached search and action buttons align more uniformly across AI, MCP, A2A, Omnichannel, Time Zones, and phone-verification pages. - Shared client-side AI admin lists now keep `ignore-elements` header rows from promoting the first actual result to a rounded top boundary, and filtered no-result states now give that header the closing bottom radius as well, fixing the visual gap on screens such as **A2A Connections**, **Data Sources**, **Deployments**, **MCP Hosts**, **MCP Prompts**, and **MCP Resources**. - The admin AI chat session page now keeps the placeholder element rendered even when a historical session already has messages, which prevents the shared chat UI runtime from aborting initialization when users reopen an existing session from history. +- AI Chat now packages the upstream `ai-chat.css.map` and `chat-widget.css.map` files so browser developer tools no longer report source-map 404 warnings. - See [AI Chat](../ai/chat.md) and [AI Chat Analytics](../ai/chat-analytics.md). #### Chat.Claude @@ -194,6 +401,209 @@ At a high level, the platform changes are: - This enables AI-driven responses, deferred external processing, live-agent handoff, and hybrid AI and human support scenarios. - See [Response Handlers](../ai/response-handlers.md) and [Chat UI Notifications](../ai/chat-notifications.md). +### Contact Center + +#### New contact center orchestration layer + +- `v2.0.0` introduces the `CrestApps.OrchardCore.ContactCenter` feature, the base of a new contact center orchestration layer that sits between the Omnichannel CRM and the Telephony module. +- The contact center owns orchestration while the CRM owns business work data and Telephony owns media execution. `OmnichannelActivity` remains the universal work item for queues, dialers, wrap-up, disposition, and workflows. +- Omnichannel activities now include Contact Center classification and assignment metadata: activity kind, activity source, assignment status, reservation id, reserved-by actor, reservation time, and reservation expiry. This lets activities exist without an owner until a queue or dialer safely reserves and assigns them. +- This release adds the `Interaction` communication-history aggregate and store, a durable, append-only domain **event log** (`InteractionEvent`) with idempotency support, an `IContactCenterEventPublisher` that records events and dispatches them to decoupled handlers, the source-neutral `IActivityDispositionService` contract, and baseline permissions. +- Contact Center now defines optional `IContactCenterVoiceProvider` abstractions for PBX providers that can participate in dialer dialing, provider-side call assignment, provider-side queue placement, queue events, and PBX presence synchronization while Contact Center remains the routing/dialer brain. +- The shared Contact Center abstractions now also include the real-time notifier contract (`IContactCenterRealTimeNotifier`) and its notification payload models so non-module consumers can depend on the real-time broadcast contract without taking a dependency on the Orchard module assembly. +- The feature is delivered as `CrestApps.OrchardCore.ContactCenter.Abstractions`, `CrestApps.OrchardCore.ContactCenter.Core`, and the `CrestApps.OrchardCore.ContactCenter` module. Additional capabilities (queues, routing, presence, voice, dialer, wrap-up, supervision, and analytics) are delivered in later phases. +- See [Contact Center](../contact-center/index.md). + +#### Agents, queues, reservations, and voice-routed dialing + +- Adds the `CrestApps.OrchardCore.ContactCenter.Agents` feature: agent profiles, presence states (offline/available/reserved/busy/wrap-up/break), capacity, skills, and queue/campaign sign-in through the Telephony soft phone. + +### Telephony + +- Soft Phone settings now include a configurable **Recent calls count** from `1` through `200`; the widget loads `30` recent calls by default. +- The Telephony site settings tabs now use clean provider tab keys for Asterisk and DialPad, which fixes the Orchard Core settings screen when those provider features are enabled. +- The Asterisk provider settings editor now renders only the field markup inside its tab so the site-settings tab shell remains owned by the display driver and Orchard Core settings UI. +- The Asterisk settings hint now escapes the literal `{number}` endpoint-template token correctly, fixing the `System.FormatException` that previously broke the Telephony settings page while rendering the Asterisk tab. +- The Asterisk tab now hides its connection and dialing fields until **Enable Asterisk provider** is checked, reducing noise on the Telephony settings page when the tenant-specific provider is off. +- Adds the **Asterisk Web** startup sample (`src/Startup/CrestApps.OrchardCore.Asterisk.Web`), a small Razor Pages app that signs in to Orchard, originates Asterisk Stasis channels, and forwards normalized inbound voice events to `POST /api/contact-center/voice/inbound` so local tenants can test queueing and agent routing through a real local Asterisk flow. +- The Telephony soft phone now keeps the main floating toggle in its normal accent color during active calls and only shows mute/unmute and merge after the current call reaches the connected state. +- The bundled Aspire Asterisk local `Local/{number}@default` test path is still treated as connected as soon as ARI accepts the originate request, which keeps the local soft-phone call-control flow usable against the demo dialplan while still exposing advanced controls once the call stays inside Stasis. +- The Telephony soft phone recent-calls list now uses a simpler direction badge and no longer adds a separate **In progress** label for active calls. +- Asterisk soft-phone failures now stay provider-neutral in the browser while the server logs the ARI response body for rejected dial, hold, mute, merge, and similar actions. +- The local Asterisk Aspire loopback now originates into the configured Stasis application even when it uses the bundled `Local/{number}@default` demo dialplan route, which keeps the same live channel under ARI control so local soft-phone hold, resume, mute, merge, inbound answer/reject, and Local-route blind transfer stay available during development. +- Telephony call actions now support a provider-neutral metadata bag on `CallReference` / `TelephonyCall`, which lets modules carry voicemail-routing or similar hints without adding provider-specific properties to the shared contracts. +- The Asterisk provider now supports soft-phone voicemail routing when its voicemail context and extension-template settings are configured. It copies provider-neutral call metadata into `CRESTAPPS_METADATA_*` channel variables and continues the call into the configured dialplan target so Asterisk can route the caller to the called user's mailbox or a fallback mailbox. +- The Asterisk Web sample separates the live **Asterisk Dashboard** from the **Inbound Simulator** page, and the dashboard now refreshes ARI state immediately from Stasis events, pushes the updated snapshot over SignalR, keeps a slower reconciliation poll for recovery, shows active calls / channels / bridges in real time, and exposes the raw `asterisk/info`, `channels`, and `bridges` payloads for deeper troubleshooting. +- The Inbound Simulator now drives its local load through a Stasis-managed Asterisk flow: it originates a channel into the configured Stasis application, waits for the matching `StasisStart` event, and only then forwards the normalized inbound event to Contact Center using the real Asterisk channel id. +- The Asterisk Dashboard now groups raw Local channel legs into logical call rows, shows inferred direction and leg counts, surfaces provider-tracked hold / mute flags with estimated party counts from bridge membership, and lets operators disconnect a live Asterisk channel from the dashboard to simulate caller hangup. +- The Inbound Simulator now uses clearer field labels and explicitly documents that **To address** drives Contact Center inbound entry-point or queue routing while **Caller number seed** only generates unique caller identities. +- The Asterisk Dashboard layout now gives the active call and bridge tables more width by moving live notifications beside the raw ARI payload drill-down, and the sample now uses live SignalR push from Stasis events with a 15-second reconciliation cadence instead of one-second steady polling. +- Contact Center voice agents now get already-waiting inbound calls as soon as they sign in to a queue or return to `Available`, and the soft-phone incoming-call modal now accepts or declines the authoritative reservation correctly from its offer-lifecycle endpoints. +- Queue sign-in no longer eagerly resolves the Contact Center Voice re-offer graph; the queued-voice reconnect sync resolves it only when needed, which keeps queue sign-in responsive while still re-checking already-waiting inbound calls after refresh or reconnect. +- Re-queued voice offers now clear the stale ringing interaction assignment when a reservation expires, so a missed offer no longer leaves the agent falsely at capacity and blocks the next inbound call from being offered. +- Queued-voice recovery now also clears an orphaned ringing interaction before it re-checks waiting calls, and capacity counting no longer treats unassigned `Created` interactions as active work, so older abandoned offers cannot keep blocking new inbound routing. +- Contact Center now restores the current ringing inbound offer from the active reservation after a soft-phone refresh or reconnect and keeps the incoming-call modal visible until the offer is accepted, declined, or its reservation timeout expires. +- Queued-voice recovery now also cancels a stale pending reservation when its queue item or interaction no longer represents a live ringing offer, so a ghost reserved item cannot silently block new inbound assignments after a restart or partial failure. +- New inbound work is flushed before the immediate assignment query, fixing a read-after-write gap that could leave a call waiting even while an eligible signed-in agent was available. Ingress results now distinguish `queued` from `routed`, and calls with no immediately eligible agent remain waiting instead of being presented as rejected. +- The Contact Center soft-phone Work tab now validates that at least one queue or campaign is selected, lists the current signed-in memberships by name, supports signing out of one membership without leaving the others, and retains a separate **Sign out of all** action. +- Supervisor queue tiles now show signed-in, available, busy/reserved/wrap-up, and other not-ready staffing counts alongside waiting work, longest wait, and SLA breaches. +- The Asterisk Web dashboard now serves the SignalR browser client locally and disables reconciliation polling while SignalR is connected, using polling only as a fallback during startup, disconnection, or reconnection. +- Contact Center offer actions in the soft phone no longer send a duplicate raw telephony reject after a reservation decline, and offers without a registered Contact Center voice provider no longer default to device-answer required, which fixes the recent Asterisk reject/accept race. +- The Telephony soft phone now measures the **Keypad** view and reuses that natural height across **Recent** and contributed tabs, with thin in-panel scrolling when those tabs need more room, so the floating panel no longer changes height or clips content while switching tabs. +- The Voice admin entry-point surface is now labeled **Inbound entry points** in the Interaction Center to make the purpose clearer without changing the underlying routing model. +- Contact Center's JSON-oriented voice / workspace / supervisor / webhook routes now use Minimal API endpoint mappings while preserving their existing URLs and permissions, bringing them in line with the repository's other endpoint-based integrations. +- Adds the `CrestApps.OrchardCore.ContactCenter.Queues` feature: work queues, queue items, the reservation lifecycle, and availability-based assignment that pairs the highest-priority waiting activity with the longest-idle available agent. A background task expires stale reservations and assigns queued work every minute. +- Adds the `CrestApps.OrchardCore.ContactCenter.Dialer` feature: dialer profiles, dialing modes, pacing, and dialer inventory loads. Outbound dialing now routes voice calls through the Voice Contact Center Call Router and `IContactCenterVoiceProvider`, so the Contact Center owns assignment, queue, pacing, and compliance while provider modules execute calls. +- Adds the `CrestApps.OrchardCore.DialPad.ContactCenterVoice` feature as the DialPad implementation of the Contact Center voice provider boundary over the existing DialPad telephony provider. +- Adds the first routing strategy layer: required queue skills filter eligible agents, longest-idle scoring selects among eligible candidates, and each assignment publishes an auditable routing-decision event with candidate scores and reasons. +- Adds `IContactCenterVoiceProviderResolver` so PBX/voice providers that implement Contact Center queueing or call-assignment operations can be resolved consistently by technical name. +- Contact Center management entries now live under **Interaction Center**. Adds a managed **Skills** CRUD screen, and queue and dialer profile CRUD screens follow the Omnichannel Campaigns display-driver UI pattern with root edit wrappers. +- Contact Center **My workspace** now uses the shared Omnichannel activity completion page for active assigned work, so agents complete preview, power, and other dialer interactions with the same contact context, subject editor, activity details, notes, disposition, and subject-flow behavior used by CRM activities. Agent and supervisor surfaces now resolve user names through the shared display-name provider instead of falling back to raw user identifiers, and the live dashboard renders agent status as a separated badge. +- Telephony soft-phone extensibility now uses `DisplayDriver` contributed tabs/views/header actions. Contact Center injects a **Work** tab for queue/campaign sign-in and sign-out, plus a soft-phone header presence dropdown with system-approved **Request break** behavior. Routing skills are administrator-owned eligibility data and are no longer self-selected by agents from the soft phone. +- See [Agents, Queues & Dialer](../contact-center/agents-queues-dialer.md). + +#### Inbound voice routing and the incoming-call modal + +- Adds the `CrestApps.OrchardCore.ContactCenter.Voice` feature and the Voice Contact Center Call Router. Inbound provider calls are normalized into a CRM activity (`Kind = Call`, `Source = Inbound`) with its Subject, recorded as a voice `Interaction`, enqueued, and routed to the longest-idle available agent signed in to the inbound queue; outbound dialer calls also route through this voice boundary. The optional `CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone` feature projects those calls into the Telephony soft-phone incoming-call experience. +- Queues gain an optional inbound channel endpoint mapping (`InboundChannelEndpointId`) so a dialed number routes to a specific queue; a single unmapped enabled queue is used as the default inbound queue. +- A provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`, requiring `Manage interactions`) accepts a normalized `InboundVoiceEvent`; provider webhooks can also call `IVoiceContactCenterCallRouter` directly. +- Adds the **Asterisk Web** startup sample to drive that ingress endpoint with one or more concurrent Asterisk-backed inbound calls for local routing and agent-offer testing. +- Implements the source-neutral `IActivityDispositionService` and routes CRM activity completion through it, so inbound and outbound calls are dispositioned through the same Subject workflow. +- See [Inbound voice](../contact-center/index.md#inbound-voice). + +#### Inbound entry points (Phase 8) + +- Adds a reusable **inbound entry point** catalog under **Interaction Center → Inbound entry points** (Voice feature). An entry point maps one or more dialed numbers (DIDs) to a target queue and priority, gates the call with a business-hours calendar, and defines a **closed action** (hold in queue, voicemail, overflow, or reject) with welcome/closed messages. +- `IEntryPointResolver` matches the dialed number to an entry point and, using the business-hours calendar, produces an `EntryPointRoutingPlan` (open/closed, effective queue, priority). The Voice Contact Center Call Router now consults the resolver first: open calls route to the entry point's queue at its priority; closed calls hold, overflow, or are sent to voicemail/rejected per the closed action. When no entry point matches, routing falls back to the existing endpoint-to-queue mapping. +- See [Inbound entry points](../contact-center/agents-queues-dialer.md#queues-reservations-and-assignment). + +#### Voice foundation hardening: media delivery, call sessions, and normalized provider events + +- Voice providers now declare a **delivery model** (`VoiceProviderDeliveryModel`): `AgentDeviceNative` (the provider rings the agent's own device, like DialPad's WebRTC soft phone) or `ServerSideAcd` (the provider parks the call and the Contact Center bridges it to the agent). `IContactCenterVoiceProvider` gains a `DeliveryModel` property and a `ConnectToAgentAsync` operation, plus an `AgentConnect` capability flag. +- Adds `IContactCenterCallCommandService`, a single authoritative server-side command for the agent offer lifecycle. Accepting an offer now accepts the reservation, connects the media to the agent for server-side ACD providers, and advances the interaction and call session together, so the orchestration state and the provider media state can no longer diverge. The result reports whether the agent's device must still answer the media (`RequiresDeviceAnswer`). The Contact Center `VoiceController` accept/decline actions delegate to this command. +- Adds the `CallSession` aggregate: the Contact Center's business-oriented projection of a voice call, mapping a provider call to an interaction, agent, and queue and tracking the normalized call lifecycle and talk/hold durations without owning media execution. +- Adds normalized provider voice events: `ProviderVoiceEvent` and `IProviderVoiceEventService` ingest provider/PBX call-state changes, match them to the interaction and call session by provider call id, advance state and timestamps, bridge the agent for answered outbound calls on server-side ACD providers, and publish Contact Center domain events. Events carrying an already-seen idempotency key are ignored, so duplicate or out-of-order webhook deliveries are safe. +- See [Voice provider integration](../contact-center/index.md#voice-provider-integration). + +#### Assignment safety: capacity enforcement and concurrency-safe assignment + +- Agent capacity is now enforced during routing. A capacity routing strategy counts each candidate agent's active (not ended and not failed) interactions and rejects agents that have reached their configured `MaxConcurrentInteractions`, so an agent is never offered more concurrent interactions than they can handle. +- Assignment for a queue now runs under a per-queue distributed lock. Two nodes — or the reservation-expiry background task running alongside an inbound call — can no longer double-assign the same queue item or reserve the same agent twice. +- Adds a transfer/conference taxonomy. `IContactCenterTransferService` records blind or consultative transfers to an agent, queue, external destination, or entry point on the interaction's transfer history, publishes `InteractionTransferred`, and re-enqueues the activity for queue transfers; `ContactCenterVoiceProviderCapabilities` gains `CallTransfer` and `Conference` flags so provider modules can advertise media handoff support. +- See [Queues, reservations, and assignment](../contact-center/agents-queues-dialer.md#queues-reservations-and-assignment). + +#### Routing depth: routing policy, sticky agent, SLA aging, business hours, and overflow + +- Each queue now selects a primary **routing strategy**: *Longest idle* (default), *Round robin* (offers to the agent who least recently received an assignment, tracked on `AgentProfile.LastAssignedUtc`), or *Least busy* (offers to the agent handling the fewest active interactions). Only the selected strategy scores candidates; skill and capacity filtering still run first, and assignment still publishes an auditable routing-decision event. +- Queues can **prefer the sticky agent**. The activity's assigned user is captured on the queue item when it is enqueued, and routing boosts that agent when they are eligible and available, so returning work prefers the agent the customer already worked with. The preference is additive and never overrides eligibility. +- Queues can enable **SLA aging**, which raises a waiting item's effective priority by one step for every SLA-threshold interval it waits past the threshold, so aging work is routed ahead of newer higher-priority work instead of starving. +- Adds a reusable **business-hours calendar** catalog under **Interaction Center → Management → Business hours** (Queues feature). A calendar defines a time zone, a weekly open window per day, and all-day holiday dates. A queue can reference a calendar; while it reports the queue closed, assignment pauses, and the queue's **after-hours action** either holds items or overflows them. +- Adds queue **overflow**: a queue can set an overflow queue and an overflow-after threshold so items waiting too long (or all items while the queue is closed and set to overflow) move to a broader team's queue. Overflow runs each minute alongside reservation expiry and assignment. +- See [Routing policy](../contact-center/agents-queues-dialer.md#routing-policy) and [Business hours and overflow](../contact-center/agents-queues-dialer.md#business-hours-and-overflow). + +#### Subject configuration moved to content-type part settings + +- Subject flow configuration now lives on the `OmnichannelSubjectPart` **content-type part settings** (`OmnichannelSubjectPartSettings` plus, when the AI feature is enabled, `OmnichannelSubjectAISettings`), edited from the standard Orchard Core content type editor the same way `TitlePartSettings` is edited. The dedicated **Configure** screen, the `SubjectFlowSettings` catalog, its handlers, recipe step, and deployment step were removed; subject configuration now travels with the content type definition through the standard **Content Definition** deployment step. +- `SubjectFlowSettings` is now a read-only data-transfer object assembled at read time by `ISubjectFlowSettingsService`, which moved to `CrestApps.OrchardCore.Omnichannel.Core.Services` so both the Managements and SMS features can resolve it without referencing each other. +- A subject's **direction** (`Outbound` by default, or `Inbound`) drives the editor: outbound subjects hide the interaction type and channel because those are resolved when inventory is loaded, while inbound subjects expose the interaction type, channel, and (for automated work) a channel endpoint. +- The per-run **campaign, channel, and channel endpoint** are now chosen on the activity batch when inventory is loaded, falling back to the subject's part settings. The interaction type is derived from the load source (**Automatic** creates **Automated** activities; other sources create **Manual** activities). +- The **Subject Flows** list is now a read-only overview per subject content type with an **Edit content type** shortcut (shown when the user can edit content type definitions) and the **Manage Flow** button; the **Configure** button was removed. +- The part-settings editor now progressively discloses its fields. Changing the direction, interaction type, or channel immediately shows or hides the settings that apply, so an outbound subject no longer displays inbound-only fields and an inbound manual subject no longer displays the AI section at all. +- The AI settings are grouped into a dedicated **AI configuration** card and shown only for **inbound automated** subjects. Outbound subjects no longer display the card because outbound AI configuration is part of the inventory-load process controlled by the **Automatic** source; inbound manual subjects hide it because they are always handled by an agent. +- Within the AI card, **voice call automation** (speech-to-text deployment, text-to-speech deployment, and voice) is shown only for the **Phone** channel and **SMS automation** (no-response timeout, response delay, and opt-out keywords) only for the **SMS** channel. Hidden fields retain their stored values, so switching direction, interaction type, or channel never discards configuration. +- The **Subject Flows** list badges now use the standard `badge ta-badge` styling for consistency with the rest of the admin. Each subject shows its direction, and automated subjects additionally show an **Automated** badge and the channel in use; the disposition-required tag was removed from the list. +- The **Default campaign** hint now explains its real purpose: it is applied to activities created outside an activity batch — manually created activities, inbound activities, and activities moved by the **Change Subject** bulk action — and is the fallback an activity batch uses when it does not choose its own campaign. +- Removed the orphaned `SubjectFlowSettings.Edit.cshtml` view left behind by the removal of the **Configure** screen. + +#### Subject Flow required-disposition policy + +- A subject can now be marked as **requiring a disposition** (`SubjectFlowSettings.RequireDisposition`, edited on the `OmnichannelSubjectPart` settings). When set, an activity using that subject cannot be completed until a disposition is selected. +- **Breaking change:** requiring a disposition is now **enabled by default**, because the disposition is what triggers the subject flow actions. Subjects that should complete without an outcome — fire-and-forget notification subjects such as a one-way SMS alert — must now clear the option explicitly. +- The rule is enforced centrally in `IActivityDispositionService`, so it holds for every completion path — the CRM completion screen, dialer, and automation — and for both inbound and outbound work. Completion now also skips Subject Actions when no disposition is selected instead of failing. +- Automated SMS conversations now honor this policy. When the AI reports a conversation concluded but returns no disposition and the subject flow requires one, the SMS handler no longer completes the activity directly; it leaves the activity open so the required-disposition rule is not bypassed, and the activity closes through the existing no-response timeout or opt-out paths. +- This keeps the Subject Flow as the single decision controller for inbound and outbound work; a separate contact-center wrap-up concept is not needed. +- See [Subject Flow is the single decision controller](../contact-center/index.md#subject-flow-is-the-single-decision-controller). + +#### Dialer safety: per-mode strategies and an outbound compliance gate + +- Each automated dialing mode is now a dedicated `IDialerStrategy` resolved by mode, so unsupported modes are withheld instead of falling through to an unsafe default. **Predictive dialing is disabled**: the editor hides it, saving a Predictive profile is rejected, and the dialer refuses to run it until answer-rate forecasting and abandonment controls exist. **Power dialing is hard-capped** at a safe number of calls per agent (`PowerDialerStrategy.MaxCallsPerAgent`). +- Adds `IDialerEligibilityService`, an outbound compliance gate that runs before every attempt and records an auditable `DialSuppressed` event when an attempt is blocked. It enforces a present destination, the attempt limit, the `RetryDelayMinutes` retry cool-down, the contact's do-not-call communication preference, a configurable calling window evaluated in the contact's local time zone, and any registered national do-not-call registry (for example USA FTC or Canada DNCL). +- Dialer profiles gain calling-window settings (*Enforce a calling window*, start/end local hour, and a default time zone). Do-not-call and registry suppressions cancel the activity, while calling-window and cool-down suppressions release the reservation and leave the activity available for a later cycle. +- The single-attempt logic moved into `IDialerAttemptService`, and `DialerService` now validates the profile and delegates pacing to the resolved strategy. +- Successful outbound dial attempts now accept the reservation before returning, so the reservation-expiry task cannot later release the agent or queue item while the provider call is still active. +- See [Dialer](../contact-center/agents-queues-dialer.md#dialer). + +#### Callbacks (Phases 5/10) + +- Adds a **callback** model. `CallbackRequest` records a scheduled call-back (destination, contact, campaign, optional queue/agent, requested and due times, status, attempts, notes). `ICallbackService.ScheduleAsync` records a pending callback and publishes `CallbackScheduled`; `PromoteDueAsync` (run every minute by the `CallbackDispatchBackgroundTask`) turns each due callback into an outbound CRM activity (`Kind = Call`, `Source = Callback`), enqueues it when a queue is set, marks it `Scheduled`, and publishes `CallbackPromoted`. +- See [Dialer](../contact-center/agents-queues-dialer.md#dialer). + +#### Agent state reason codes + +- Adds the **Agent states** catalog (`AgentStateReasonCode`) under **Interaction Center → Management → Agent states** (Agents feature). A reason code has a unique name, description, the presence state it sets (`AppliesTo`), a sort order, and an enabled flag, and is managed with the same display-driver CRUD pattern as Skills and queues, gated by `ManageContactCenterAgents`. +- The soft-phone presence dropdown now lists the enabled reason codes (ordered by sort order); choosing one sets the agent's presence to the reason's `AppliesTo` state and records the reason on the agent profile and the `AgentPresenceChanged` event. When no reason codes exist, the built-in not-ready states are shown. +- The Agents feature seeds a standard set of reason codes at setup (short break, lunch, away from desk, team meeting, training, coaching, system issue) by executing the `agent-state-reason-codes` module recipe, and the `AgentStateReasonCode` recipe step makes reason codes importable and portable between tenants. +- See [Agent state reason codes](../contact-center/agents-queues-dialer.md#agent-state-reason-codes). + +#### Real-time agent and supervisor layer + +- Adds the `CrestApps.OrchardCore.ContactCenter.RealTime` feature (depends on **Queues** and the **SignalR** module): a dedicated SignalR `ContactCenterHub` for agent desktops, supervisor dashboards, and queue monitors. Agents join their own tenant-qualified user group plus a tenant-qualified group per signed-in queue; supervisors join the tenant-qualified supervisor group and can `WatchQueue`/`UnwatchQueue` individual queues for wallboards. +- Adds a live `AgentSession` aggregate, separate from the administrator-owned `AgentProfile`, that tracks an agent's open SignalR connections and last heartbeat. Splitting volatile live state from durable configuration means a closed browser no longer leaves an agent `Available`. +- The client sends a `Heartbeat` every 30 seconds; a per-minute background task signs out any agent whose heartbeat is older than 90 seconds and removes the session, so routing stops targeting a dead client. A brief disconnect (such as a page refresh) is tolerated by the grace window. +- The hub's `GetSnapshot` returns an `AgentDesktopSnapshot` combining the durable profile (presence, reason, queue/campaign membership, active reservation) with live session state, so a reconnecting desktop restores itself without replaying the event history. +- `ContactCenterRealTimeEventHandler` projects the durable domain event log onto the real-time layer, broadcasting presence changes, work offers (`OfferReceived`/`OfferRevoked`), and queue depth (`QueueStatsChanged`) to the affected agent, queue watchers, and supervisors. A `contact-center-realtime` client script resource (depending on `signalr`) wraps connection, heartbeats, snapshot retrieval, and the strongly-typed callbacks. +- Adds the `MonitorContactCenter` permission for supervisor real-time visibility, and a default **Supervisor** role stereotype. +- See [Real-time experience](../contact-center/index.md#real-time-experience). + +#### Reliable event dispatch (outbox) + +- Contact Center domain-event dispatch is now **at-least-once**. `IContactCenterEventPublisher` records the immutable `InteractionEvent` and hands it to the new `IContactCenterOutbox`, which runs handlers inline for low latency and, when a handler fails, persists a durable `ContactCenterOutboxMessage` instead of silently dropping the event. +- A per-minute `OutboxDispatchBackgroundTask` retries failed dispatches with exponential back-off (30s, 60s, 120s, … capped at 30 minutes) and **dead-letters** a message after ten failed attempts for inspection. +- Handler dispatch and the per-handler failure isolation moved out of the publisher into the outbox, so a transient failure in a downstream projection (SignalR, indexing, or an external service) no longer loses work. +- Closed the poison-message and replay-safety gaps for the domain-event outbox and provider webhook inbox. Each due message runs in its own fresh Orchard child scope so a failure cannot block later work, and each event handler runs in a nested isolated scope so its effect and replay marker commit or roll back together. Every event and inbox handler must declare `ContactCenterHandlerReplaySafety` (`NaturallyIdempotent`, `DeduplicatedByEventId`, or `GuardedByDurableStore`); construction rejects `Unspecified`, blank routing identities, and duplicate identities. `IContactCenterEventDeduplicationService` persists a unique `(HandlerId, EventId)` marker for non-idempotent effects. +- See [Domain events and reliable dispatch](../contact-center/index.md#domain-events-and-reliable-dispatch). + +#### Analytics: daily event metrics (Phase 12) + +- Adds a daily metrics projection. A `ContactCenterMetricsProjectionHandler` (running through the outbox on every domain event) records a per-day, per-event-type count in the `ContactCenterEventMetric` projection, and `IContactCenterMetricsService.GetSummaryAsync` returns export-ready totals by event type over a date range. The handler uses durable event-id deduplication, optimistic concurrency, and portable date/event uniqueness so duplicate delivery and concurrent writers cannot lose or double-count increments. Versioned checkpoints, rebuild tooling, and drift detection remain an R6 gate. The projection lives in the always-on base feature. + +#### Reports framework (Phase 12) + +- Adds a reusable **Reports** module (`CrestApps.OrchardCore.Reports`) that provides a single admin **Reports** area and a small contract any module can implement to surface an industry-standard report. It defines `IReport` (name, category, permission, and a `RunAsync` that returns a uniform `ReportDocument` of metric, table, and bar sections), a display-driver-extensible `ReportFilter` with a built-in from/to date range, a top-level Reports admin menu that groups registered reports by category and gates each by its own permission, and a pluggable `IReportExportFormat` (CSV built in). Report-specific filters are added by registering a `DisplayDriver` and gating it on `filter.ReportName`. +- The **Contact Center Reports & Analytics** feature (`CrestApps.OrchardCore.ContactCenter.Analytics`) now contributes its **Call insights**, **Agent productivity**, **Queue usage**, **Campaign summary**, and **Subject inventory** reports to this framework (they moved from **Interaction Center** to the top-level **Reports** menu). The reports are still computed by `IContactCenterReportingService` from the durable interaction history and the CRM activity inventory; access is gated by **View Contact Center reports** (`ViewContactCenterReports`). +- **Omnichannel Management** contributes Omnichannel reports to the shared Reports area whenever `CrestApps.OrchardCore.Reports` is enabled: **Activity summary** (volume and completion broken down by source, channel, and status, with a daily trend), **Campaign performance** (per-campaign completed vs pending), and **Disposition breakdown** (how completed activities were dispositioned). Access is gated by **View Omnichannel reports** (`ViewOmnichannelReports`), which is implied by **Manage activities**. + +#### Recording and live monitoring (Phase 9) + +- Adds recording orchestration. `IContactCenterRecordingService` tracks the interaction's `RecordingState` (none/recording/paused/stopped) through start, pause, resume, and stop, and publishes the matching `Recording*` domain events; provider modules execute the media capture. +- Adds supervisor live monitoring. `IContactCenterMonitoringService.EngageAsync` performs a scoped, audited monitor, whisper, or barge engagement, gated by the voice provider's capabilities, and publishes a `SupervisorMonitorStarted` event carrying the mode and supervisor. `ContactCenterVoiceProviderCapabilities` gains `Recording`, `Monitor`, `Whisper`, and `Barge` flags so provider modules advertise support and unsupported engagements are refused. + +#### Optional Workflows bridge (Phase 11) + +- When the **OrchardCore.Workflows** feature is enabled alongside Contact Center, a **Contact Center Event** workflow event activity becomes available. It starts or resumes a workflow when a Contact Center domain event is published, optionally filtered to a single event type, and exposes the event type, interaction id, aggregate, actor, and source component as workflow input. A feature-gated `ContactCenterWorkflowEventHandler` (running through the outbox) triggers the activity for every published domain event and uses durable event-id deduplication so replay cannot start a duplicate workflow instance. Tenants can automate pre-call routing enrichment, post-call automation, and callback scheduling without custom code. + +#### Data governance: interaction-event retention (Phase 13) + +- Adds a data-retention purge. `IContactCenterRetentionService.PurgeInteractionEventsAsync` deletes durable interaction events older than a cutoff in bounded batches, and a daily `ContactCenterRetentionBackgroundTask` computes the cutoff from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (bound from the `CrestApps_ContactCenter:Retention` configuration section; `0` keeps events indefinitely). This lets operators cap how long the durable event log is retained for privacy and storage governance. + +#### AI assist seam (Phase 14) + +- Adds a pluggable AI assist seam. `IContactCenterAssistProvider` lets AI modules contribute interaction summarization and disposition suggestions, and `IContactCenterAssistService` orchestrates the registered providers by order, returning the first result. Assistance is only available when a provider is installed (`IsAvailable`), so the Contact Center stays decoupled from any specific AI provider while supporting summaries, disposition suggestions, and future AI routing recommendations. + +#### Agent desktop and supervisor dashboard (Phase 7) + +- Adds the **Agent Workspace** (Contact Center Real-Time feature) under **Interaction Center → My workspace** — the full-screen desktop where an agent works the shift. It binds to the real-time hub and a live state endpoint to show presence with reason-code control, live queue depth, a ringing **offer card** with a response countdown, one-click **Accept** (which drives the accept-and-connect call command) and **Decline** (which re-offers the work), the **active interaction** panel with a live talk timer and a customer-360 link, an inline **disposition + notes** wrap-up, and recent history. +- Wrap-up completes through the source-neutral `IActivityDispositionService`, so the chosen disposition, any required-disposition rule, and the subject flow's follow-up actions run exactly as they do everywhere else in the CRM. The desktop uses change-detection rendering, so a live refresh never clobbers an agent's in-progress disposition or notes. +- Adds the **Supervisor Dashboard** under **Interaction Center → Live dashboard** (gated by `MonitorContactCenter`): live summary metrics, per-queue SLA-health tiles (waiting count, longest wait, and SLA breaches, colored as waits approach and then breach the threshold), and an agent presence board. It updates from the real-time hub with a periodic safety refresh so it can run on a wallboard. +- Adds supervisor live-engagement actions on active agent cards. **Monitor**, **Whisper**, and **Barge** call the audited monitoring service and are gated by the active voice provider's capabilities. +- Hardens agent offer and wrap-up ownership. Accepting or declining an offer now verifies that the reservation belongs to the current agent, completing work verifies that the activity belongs to the active or wrap-up interaction for that agent, and call end events move agents into wrap-up until completion releases them to their next presence state. +- Completes the agent accept-then-answer coordination: the Telephony soft phone now awaits the Contact Center accept before answering the agent's device, and only answers the device when the accepted offer reports `RequiresDeviceAnswer`. A call that has already been re-offered to another agent is never answered locally. +- See [Agent desktop & supervisor dashboard](../contact-center/agent-desktop.md). + +#### Cleanup: a single voice-provider boundary + +- Removes the superseded outbound `IDialerProvider` / `IDialerProviderResolver` provider abstraction and its `DialerDialRequest`, `DialerDialResult`, and `DialerProviderCapabilities` models. Outbound dialing already routes through `IContactCenterVoiceProvider`, so the Contact Center now exposes a single voice-provider boundary instead of two overlapping ones. Provider modules such as DialPad implement only `IContactCenterVoiceProvider`. + ### Content Access Control #### Role-based content restrictions @@ -278,46 +688,44 @@ At a high level, the platform changes are: #### Omnichannel Management -- Omnichannel Management moves disposition-driven behavior out of campaigns and into **Subject Flows** and **Subject Actions** under **Interaction Center -> Subject Flows**. +- **Breaking change:** Subject content types are now identified by attaching `OmnichannelSubjectPart`; the `OmnichannelSubject` stereotype is no longer recognized. Existing definitions must attach the new marker part before they appear in Subject Flows. +- The default content editor action buttons (**Publish**, **Save Draft**, and **Preview**) are now hidden on the editor of any content type that has `OmnichannelSubjectPart` attached, restoring the button suppression that the `OmnichannelSubject` stereotype previously provided. A cached set of subject content types is warmed once from the content definitions and then kept current through content-definition notifications, so the buttons hide as soon as the part is attached and return when it is detached, without any placement configuration. +- Manage Activities now groups contact and activity filters into labeled fieldsets, and activity/subject configuration surfaces are organized under **Interaction Center → Management**. +- Manage Activities filters now render a label for every field, including a screen-reader-only label for the phone match-type selector, and the filter card is no longer sticky. The card can be collapsed from a **Filters** toggle in its header, and the expanded or collapsed state is remembered per browser through local storage so more of the viewport stays available for results. +- Campaign Groups add a catalog-backed **Interaction Center → Management → Campaign Groups** management surface. Campaigns can optionally belong to a group, report filters can select a group, and campaign reports retain campaign rows while adding group aggregates across their member campaigns. Group membership is resolved when a report runs, so reassigning a campaign changes historical group aggregation. +- Omnichannel Management moves disposition-driven behavior out of campaigns and into **Subject Flows** and **Subject Actions** under **Interaction Center → Management → Subject Flows**. - **Breaking change:** The `OrchardCore.Workflows` dependency has been removed from the Omnichannel Management module. `CompletedActivityEvent`, `TryAgainActivityTask`, `NewActivityTask`, and `SetContactCommunicationPreferenceActivityTask` have been removed. Existing workflow-driven or campaign-action-driven setups must be migrated to subject flows and subject actions. - Subject flows now separate the **read-only overview** and **Manage Flow** responsibilities, add search-driven subject and action lists, highlight subjects with a **Missing flow** badge when no subject actions exist, and keep campaign editing focused on campaign metadata only. - Automated subject flows now select a chat AI profile instead of configuring provider, connection, deployment, prompt, and tuning settings inline. - Subject pickers show content types that attach `OmnichannelSubjectPart`, and activities and activity batches now resolve `CampaignId`, channel, interaction type, and channel endpoint from the batch selections, falling back to the subject's part settings. - Omnichannel dispositions now use unique immutable names after creation, the delete action is wired correctly, activity batches no longer ask for a campaign, and user pickers now use the named user-search route instead of hard-coded admin API paths. - Activity batch inventory loads expose a source-selection modal with the **Manual** and **Automatic** sources. Automatic inventory loads load unassigned AI-ready activities for the background processor to drive over the configured channel (for example, AI-driven SMS); the interaction type is derived from the source rather than from the subject. +- When Contact Center dialer services are available, inventory loads also expose a **Dialer** source. Dialer inventory loads require a dialer profile, hide the user selector, and load activities as unassigned dialer work for later reservation; the loader applies the profile's dialing mode and campaign to the created activities. +- Automatic inventory loads require a chat AI profile with **Add initial prompt** enabled so the first outbound message can start the conversation reliably, and can select an AI profile override from chat profiles with **Add initial prompt** enabled; loaded automated activities store that profile and SMS automation falls back to the subject-flow profile when no override is selected. +- The automated-activities background processor now pages due activities with pure keyset pagination (the previous query mixed a document-id cursor with an offset skip and silently skipped every other page of due activities), commits each batch before sending the next, and stops each run at a wall-clock budget (60% of the distributed-lock lease, raised above the schedule interval) so a still-running node never hands an uncommitted backlog to a peer — a crashed node re-sends at most one in-flight batch instead of the whole backlog. A per-invocation cap (1,000 activities, i.e. up to ~12,000/hour on the default 5-minute schedule; a 100,000-message campaign drains in roughly 8 hours) keeps each run bounded, and failed sends now increment an attempt counter, reschedule with backoff, and are marked failed after the maximum attempts so a permanently failing activity can no longer occupy a processing slot and stall all healthy outbound. The no-response expiry pass was likewise made keyset-paged so no-timeout activities can no longer block expiry of the rest. +- The inbound **Twilio SMS webhook** now validates the request signature through the same helper as the Event Grid endpoint, honoring the site **base URL** and reverse-proxy path base so genuine deliveries behind a TLS-terminating proxy are no longer rejected, and returns `403` (instead of a `500`) when the signature header is missing. Because the signature is now verified against the configured site base URL, that base URL must match the public URL registered with Twilio for the number; a mismatch fails closed (deliveries are rejected) rather than silently accepting unverified requests. +- Inventory load sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. +- Inventory loading is now extensible through the `IActivityBatchLoader` abstraction. The load algorithm moved out of `ActivityBatchesController` into an `IActivityBatchLoadCoordinator` that resolves a source-specific loader (falling back to the reusable, inheritable `DefaultContactActivityBatchLoader`), so a module can register its own source and fully control how leads are queried, filtered, and turned into activities. A failed load now returns the inventory load to the `New` state so it can be retried. +- Plain `dotnet build` now includes the Reports and Reports.Abstractions projects in the Debug solution configuration again, fixing the 45-error failure caused by excluding those shared reporting projects from the default solution build. - Editing a completed omnichannel activity now suppresses workflow preview UI and does not imply that disposition changes will create new follow-up work. - A new **Manage Activities** page (`Admin/omnichannel/manage-activities`) supports page-size selection, separate Contact and Activity filter groups, urgency icons, and six bulk actions against selected or filtered manual activities. - The **Manage Activities** filters now replace every separate _from_/_to_ date input — the contact **Do not call** window, and the activity **Scheduled** and **Created** windows — with the shared `DateRangePicker` control, so each date filter offers the same presets, custom range, and on-or-before/on-or-after date-time bounds used across reports. -- Admin editors in Omnichannel Management now use the current `ocat-*` admin layout classes instead of older generic Bootstrap field markup. +- Bulk inventory management now spans broader editable activity states and adds source, interaction-type, status, assignment-status, and campaign filters so operators can work mixed manual, automated, and dialer inventory from one screen. +- Bulk actions now also support clearing assignment and reservation state, changing activity source and interaction mode, and, when Contact Center dialer services are available, applying a dialer profile to move inventory to another outbound campaign path. +- Scheduled activities on a contact profile now expose an irreversible single-activity **Purge** action to authorized users. Purging records the UTC time and current user's identifier and username for auditing, clears reservation state while preserving assignment, and applies one shared timestamp and actor to every activity in a bulk purge. A dedicated **Purge activity** permission controls both single and bulk purge operations, and **Manage activities** implies that permission. +- **Try Again** and **New Activity** subject actions now support explicit **Same owner** and **Specific owner** assignment modes. Same owner uses the user completing the current activity, while Specific owner requires a selected Orchard user. +- Phone filtering in Load Inventory and Manage Activities now defaults to contains matching and accepts formatted national-number fragments without requiring E.164. A leading `+` explicitly searches E.164 values, and Content Admin adds `phone:`, `phone-exact:`, `phone-starts:`, and `phone-ends:` terms for primary Cell and Home numbers. Content Admin evaluates the displayed version, Load Inventory honors **Only published leads**, and Manage Activities uses the latest saved contact values. The shared contact index reuses its primary phone columns for national digits while its normalized columns retain E.164 values. +- Admin editors in Omnichannel Management now use the current `ocat-*` admin layout classes instead of older generic Bootstrap field markup, and the Manage Activities and Load Inventory time-zone multiselects now load the registered `crestapps-bootstrap-select` script/style pair so only one selector is visible. +- The shared Complete Activity view now handles activities whose contact or composed subject/activity shape is unavailable, avoiding a null dynamic binding error and returning users to the main Activities list when no contact-specific list can be resolved. - Omnichannel configuration now travels between environments through standard Orchard Core deployment steps and recipe steps. Each configurable entity — dispositions, channel endpoints, campaign groups, campaigns, and subject actions — has its own **Add** deployment step under the **Omnichannel** category and a matching recipe step (`OmnichannelDisposition`, `OmnichannelChannelEndpoint`, `OmnichannelCampaignGroup`, `OmnichannelCampaign`, and `OmnichannelSubjectAction`). Exports preserve each entry's identifier, so re-importing into another tenant updates the existing entry and keeps cross-references intact. Import binds every portable member by reflecting over the entity, matching what export writes, so a member added to an entity travels without touching the step; members owned by the environment - creation and modification stamps, and ownership - are never carried, and the destination stamps its own. Binding the members by hand had made the import lossy: a campaign arrived at the destination with its description missing. Enabling **Omnichannel Activities** without **Omnichannel Managements** also threw at tenant activation, because `OmnichannelContentTypeProvider` was registered in the Managements feature while `SubjectFlowSettingsService` in the Activities feature depended on it; the registration moved to the feature that consumes it. - -#### Subject configuration moved to content-type part settings - -- Subject flow configuration now lives on the `OmnichannelSubjectPart` **content-type part settings** (`OmnichannelSubjectPartSettings` plus, when the AI feature is enabled, `OmnichannelSubjectAISettings`), edited from the standard Orchard Core content type editor the same way `TitlePartSettings` is edited. The dedicated **Configure** screen, the `SubjectFlowSettings` catalog, its handlers, recipe step, and deployment step were removed; subject configuration now travels with the content type definition through the standard **Content Definition** deployment step. -- `SubjectFlowSettings` is now a read-only data-transfer object assembled at read time by `ISubjectFlowSettingsService`, which moved to `CrestApps.OrchardCore.Omnichannel.Core.Services` so both the Managements and SMS features can resolve it without referencing each other. -- A subject's **direction** (`Outbound` by default, or `Inbound`) drives the editor: outbound subjects hide the interaction type and channel because those are resolved when inventory is loaded, while inbound subjects expose the interaction type, channel, and (for automated work) a channel endpoint. -- The per-run **campaign, channel, and channel endpoint** are now chosen on the activity batch when inventory is loaded, falling back to the subject's part settings. The interaction type is derived from the load source (**Automatic** creates **Automated** activities; other sources create **Manual** activities). -- The **Subject Flows** list is now a read-only overview per subject content type with an **Edit content type** shortcut (shown when the user can edit content type definitions) and the **Manage Flow** button; the **Configure** button was removed. -- The part-settings editor now progressively discloses its fields. Changing the direction, interaction type, or channel immediately shows or hides the settings that apply, so an outbound subject no longer displays inbound-only fields and an inbound manual subject no longer displays the AI section at all. -- The AI settings are grouped into a dedicated **AI configuration** card and shown only for **inbound automated** subjects. Outbound subjects no longer display the card because outbound AI configuration is part of the inventory-load process controlled by the **Automatic** source; inbound manual subjects hide it because they are always handled by an agent. -- Within the AI card, **voice call automation** (speech-to-text deployment, text-to-speech deployment, and voice) is shown only for the **Phone** channel and **SMS automation** (no-response timeout, response delay, and opt-out keywords) only for the **SMS** channel. Hidden fields retain their stored values, so switching direction, interaction type, or channel never discards configuration. -- The **Subject Flows** list badges now use the standard `badge ta-badge` styling for consistency with the rest of the admin. Each subject shows its direction, and automated subjects additionally show an **Automated** badge and the channel in use; the disposition-required tag was removed from the list. - For **Automatic** inventory loads, the AI profile selector now renders inside the **Inventory load settings** card directly under the campaign field, and the separate **AI settings** card (including its per-batch speech-to-text, text-to-speech, and voice overrides) was removed; those speech selections fall back to the subject flow and then the global AI site settings. The AI dependency stays optional, so the base management feature still does not require `IAIProfileManager`. The **Channel endpoint** field now shows only for the automatic source. - The **Load Inventory** list now orders inventory loads by creation date with the newest first (backed by a new indexed `CreatedUtc` column), renders a pager, and wires up the standard admin bulk-selection script so the header checkbox selects every row on the page. A newly created inventory load now appears at the top of the list instead of being pushed onto a later, unlinked page. - Admin list headers across the CrestApps modules (Load Inventory, campaigns, dispositions, channel endpoints, AI profiles, deployments, provider connections, AI tool instances, templates, data sources, MCP connections/resources/prompts, A2A connections, chat interactions, and time zones) now report the true total item count across all pages instead of only the current page count, and the "items X to Y" range is now page-aware. Previously a paged list showed, for example, "10 items / 10 items in total" even when more pages existed. -- The **Default campaign** hint now explains its real purpose: it is applied to activities created outside an activity batch — manually created activities, inbound activities, and activities moved by the **Change Subject** bulk action — and is the fallback an activity batch uses when it does not choose its own campaign. - -#### Subject Flow required-disposition policy - -- A subject can now be marked as **requiring a disposition** (`SubjectFlowSettings.RequireDisposition`, edited on the `OmnichannelSubjectPart` settings). When set, an activity using that subject cannot be completed until a disposition is selected. -- **Breaking change:** requiring a disposition is now **enabled by default**, because the disposition is what triggers the subject flow actions. Subjects that should complete without an outcome — fire-and-forget notification subjects such as a one-way SMS alert — must now clear the option explicitly. -- The rule is enforced centrally in `IActivityDispositionService`, so it holds for every completion path — the CRM completion screen and automation — and for both inbound and outbound work. Completion now also skips Subject Actions when no disposition is selected instead of failing. -- Automated SMS conversations now honor this policy. When the AI reports a conversation concluded but returns no disposition and the subject flow requires one, the SMS handler no longer completes the activity directly; it leaves the activity open so the required-disposition rule is not bypassed, and the activity closes through the existing no-response timeout or opt-out paths. -- This keeps the Subject Flow as the single decision controller for inbound and outbound work. #### SMS - `v2.0.0` adds SMS channel support as part of the Omnichannel platform. +- Automated SMS handling now uses subject-flow AI settings and initial-prompt chat profiles instead of legacy campaign AI fields, supports response delays and no-response timeouts, and treats opt-out keywords as compliance events that update **Do not SMS** even when general contact updates are disabled. - The Twilio webhook now reuses the parsed form payload instead of reading the request body twice. ### Phone Number Verifications @@ -341,7 +749,7 @@ At a high level, the platform changes are: - Verification data is stored directly on content items through the new `PhoneNumberVerificationPart`, which retains the submitted and normalized phone number, full normalized provider response, last verification, verifying user id, attempt count, and next due date. User identifiers are stored instead of usernames because usernames can change over time. - Omnichannel contacts now trigger phone-number verification automatically after create or update when their preferred phone number is new or changed. The handler first stores the changed number as `Unverified` during the current content save, then uses deferred work to call the provider only after the pending status is persisted. If no provider is currently available, the record remains pending for later revalidation. - A `PhoneNumberVerificationPartIndex` SQL index exposes the commonly queried fields for reporting, dashboard widgets, revalidation jobs, and administrative searches, while provider-specific metadata stays in the stored JSON payload. -- A report dashboard under **Reports** -> **Phone Number Verifications** surfaces operational metrics such as verified, unverified, invalid, mobile, landline, and VoIP counts, numbers pending verification or requiring revalidation, the verification success rate, failures, and provider usage. Access to the dashboard is controlled by the dedicated `RunPhoneNumberVerificationsReport` permission. +- The operational phone-number verification report now registers automatically from **Phone Number Verifications** whenever `CrestApps.OrchardCore.Reports` is enabled, contributing the report through the shared admin **Reports** area. It surfaces verified, unverified, invalid, mobile, landline, and VoIP counts, numbers pending verification or requiring revalidation, the verification success rate, failures, and provider usage. Access to the report is controlled by the dedicated `RunPhoneNumberVerificationsReport` permission. #### Revalidation and cost optimization @@ -385,25 +793,27 @@ At a high level, the platform changes are: - Added the optional `CrestApps.OrchardCore.Reports.OpenXml` feature for Excel (`.xlsx`) exports. CSV export remains available from the base Reports feature. - Omnichannel Management contributes activity summary, campaign performance, and disposition breakdown reports. Phone Number Verifications contributes its operational verification report through the same Reports area. -### Resources - -- Added a reusable `DateRangePicker` view component (backed by the `date-range-picker` script resource) to the **CrestApps Resources** feature. It renders a Bootstrap date-range dropdown — presets grouped into relative days, calendar periods, and rolling months, a Flatpickr custom range, and single on-or-before/on-or-after date-time bounds — over two machine-formatted date/time inputs. Render it with `@await Component.InvokeAsync("DateRangePicker", ...)` or require the resource directly with ` + +``` + +The [agent desktop and supervisor dashboard](agent-desktop.md) build directly on this layer. + +For a technical deep dive into the live voice paths, see [Inbound and Outbound Voice Routing Architecture](voice-routing.md). For which document owns assignment, reservation, and attempt state — and which one a routing decision must read — see [Routing Work State and the CRM Activity](routing-work-state.md). For how a live call describes who is actually on it — legs, bridges, consults, and supervisor engagements — see [Live Call Topology](live-call-topology.md). + +## Domain events and reliable dispatch + +Everything the Contact Center does is recorded as an immutable `InteractionEvent` in a durable, ordered event log, and published through `IContactCenterEventPublisher`. Handlers (`IContactCenterEventHandler`) react to those events — for example the real-time projection that broadcasts presence, offers, and queue depth — without being coupled to the component that raised the event. + +Dispatch is **at-least-once**. The publisher records the event and schedules `IContactCenterOutbox` dispatch after the current Orchard shell transaction commits; callers outside a shell transaction dispatch immediately. A single `IContactCenterScopeExecutor` owns after-commit scheduling and child-scope creation, while typed operation contexts expose only the dependencies required by event dispatch, real-time projection, queued-work recovery, hub operations, and inbound routing lock release. This prevents handlers and hubs from locating services through `IServiceProvider` or directly coupling themselves to Orchard's ambient `ShellScope`. + +The outbox runs every handler for low latency. If a handler throws, it persists a durable `ContactCenterOutboxMessage` instead of silently dropping the event, and the per-minute `OutboxDispatchBackgroundTask` retries it with exponential back-off (30s, 60s, 120s, … capped at 30 minutes). After ten failed attempts the message is **dead-lettered** for inspection rather than retried forever. A retry re-runs the handlers that have not yet completed for the event, so every handler must declare and honor a machine-readable replay-safety contract. + +Each handler is checkpointed by an explicit stable **`HandlerId`** (for example `ContactCenter/RealTimeProjection/v1`) rather than its CLR type name and registration index, so a rolling deployment, a handler rename, an assembly version bump, or a change in registration order never re-runs a handler that already completed for a pending message. The outbox refuses to construct if any handler exposes a null, blank, or duplicate `HandlerId`, so a duplicate id can never let one handler's checkpoint skip a different, still-failed handler. Checkpoints written by earlier builds (which stored `{AssemblyQualifiedName}:{index}`) are transparently recognized and repaired to the stable id on the next dispatch. Idempotency-key uniqueness for the interaction event log is database-enforced through a portable, non-null claim column, so even a racing concurrent publish of the same provider event cannot persist a duplicate. + +Each due message runs in its own fresh Orchard child scope, and each event handler runs in a nested isolated child scope. A handler exception therefore rolls back its effect and replay marker together without canceling the message session, while a poison message or concurrency loss leaves that message pending and does not block later due work. + +Every `IContactCenterEventHandler` and `IProviderWebhookInboxHandler` declares `ContactCenterHandlerReplaySafety`. `NaturallyIdempotent` handlers converge when repeated, `DeduplicatedByEventId` handlers reserve a durable `(HandlerId, EventId)` marker in the same transaction as their effect, and `GuardedByDurableStore` handlers rely on a downstream unique constraint or compare-and-set invariant. Registration rejects `Unspecified`. The metrics projection and Workflows bridge use durable event-id deduplication so replay cannot double-count a metric or start a duplicate workflow; daily metric rows also use optimistic concurrency and portable date/event uniqueness so concurrent distinct events cannot lose increments. + +Because the event log is the source of truth and dispatch is durable, transient failures in a downstream projection (a momentary SignalR, index, or external-service hiccup) no longer lose work — the event is redelivered until it succeeds or is dead-lettered. + +Because the daily event-metrics projection derives entirely from the durable event log, it is also **rebuildable and auditable**. A durable projection checkpoint records the replay cursor, projection logic version, and last rebuild time per projection. `IContactCenterMetricsProjectionMaintenanceService.RebuildAsync` recomputes every per-day, per-event-type count from the event log and reconciles the stored metrics — creating missing rows, correcting wrong counts, and deleting orphaned rows — then advances the checkpoint, while `DetectDriftAsync` runs the same recomputation read-only and reports any discrepancy without changing state. Rebuild is deterministic: it counts events by their recorded occurrence date and skips events that carry no type or no occurrence time, because the live recording path substitutes the wall clock for a missing timestamp and that substitution cannot be reproduced during a replay. + +## Agent soft-phone work controls + +Agents receive Contact Center work inside CRM-integrated surfaces while the Telephony soft phone stays the home for availability and call-adjacent actions. When Contact Center is enabled, it adds a **Work** tab to the floating soft phone where agents sign in to allowed queues and outbound campaigns and sign out. Presence lives in a dropdown button on the soft-phone header so agents can change availability without switching tabs. It supports available, request break, away, meeting, training, do not disturb, after-hours unavailable, and offline states. This avoids a separate sign-in navigation page and keeps availability changes next to call handling. + +Break requests are approved by the routing system, not by another user. If nothing is currently being routed to the agent, **Request break** is granted immediately as `Break`. If a reservation or route is already in progress, the request stays pending, the in-flight assignment continues, and `Break` is granted automatically when that work is released. Agents in request-break or break states are ineligible for new routing decisions. + +The [Agent Workspace](agent-desktop.md) is the full-screen desktop where agents spend the shift: it handles activity offers, accept/reject actions, active CRM activity context, interaction history, and completion handoff to the shared Omnichannel activity completion page. When an agent accepts a ringing offer, the workspace (and the soft-phone incoming modal) drive a single server-side command that accepts the reservation and connects the media before the agent's device answers, so the same live call is never answered while it is being re-offered to another agent. + +Managers configure queue membership, campaign assignment, dialer mode, priority, capacity, and compliance rules. Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all offer Activities through the same real-time agent-offer model. + +The current soft-phone **Work** tab lets agents choose queues and campaigns. Campaigns come from the Omnichannel Management **Interaction Center → Management** campaign catalog. Routing skills come from **Interaction Center → Management → Skills**, but they are assigned by administrators/supervisors rather than self-selected by agents. Skill, queue, and dialer profile admin screens use display drivers and extensible summary/editor shapes so providers and future desktop panels can extend the model without replacing the base UI. + +## Voice provider integration + +The Telephony module continues to own soft-phone call control and media execution. Contact Center adds optional voice-provider abstractions for PBX providers that can participate in contact-center orchestration beyond basic call control: + +- Dial on behalf of a dialer. +- Assign an existing provider call to an agent after Contact Center chooses the assignment. +- Place or move provider calls in provider-side queues after Contact Center chooses the queue. +- Publish queue events and synchronize PBX presence when supported. + +Contact Center remains the brain: the **Voice Contact Center Call Router** selects or receives the Activity, queue, agent, campaign, dialer mode, and compliance gates, then sends provider-neutral voice intents to the provider adapter. + +Providers register `IContactCenterVoiceProvider` implementations and are resolved through `IContactCenterVoiceProviderResolver`. The router uses those providers for outbound dialing and keeps provider-side queue placement and call assignment behind the same voice boundary. + +### Call delivery models + +Every voice provider declares a **delivery model** so the orchestration layer knows whether it must bridge media to the agent itself: + +- `AgentDeviceNative` - the provider rings the agent's own registered device or soft-phone client (for example WebRTC). The live call already reaches the agent, so the Contact Center reserves, offers, and tracks the work, and the agent answers the media on their device. The DialPad provider uses this model. +- `ServerSideAcd` - the provider parks or queues the live call server-side. The Contact Center explicitly asks the provider to connect (bridge) the call to the selected agent through `ConnectToAgentAsync` once the offer is accepted (inbound) or the dialed call is answered (outbound). + +Providers advertise `ContactCenterVoiceProviderCapabilities.AgentConnect` when they can bridge calls. The agent desktop and supervisor UI hide or disable actions the active provider cannot perform, the same way the Telephony soft phone gates controls on `TelephonyCapabilities`. + +### Unified call commands + +Accepting or declining an offered call is a single, authoritative server-side command rather than several uncoordinated client actions. `IContactCenterCallCommandService` accepts the reservation, connects the media to the agent (for `ServerSideAcd` providers), and advances the interaction and call session together, so the orchestration state and the provider media state can never diverge. The result tells the soft phone whether the agent's device still has to answer the media (`RequiresDeviceAnswer` is `true` only for `AgentDeviceNative` providers). Declining rejects the reservation, returns the work to its queue, and re-offers it to the next available agent. + +### Call sessions and normalized provider events + +A **call session** (`CallSession`) is the Contact Center's business-oriented projection of a voice call. It maps a provider call to an interaction, agent, and queue and tracks the normalized call lifecycle (`Planned`, `Dialing`, `Ringing`, `Connected`, `OnHold`, `Ending`, `Ended`, `Failed`, `NoAnswer`, `Rejected`, `Canceled`, `Transferred`) plus talk and hold durations, without owning media execution. + +Providers and PBX webhooks feed call-state changes in as normalized `ProviderVoiceEvent` instances through `IProviderVoiceEventService`. The service matches the event to the interaction and call session by provider call identifier, advances their state and timestamps, bridges the agent for answered outbound calls on `ServerSideAcd` providers, and publishes the corresponding Contact Center domain events. Each canonical provider-call stream is serialized with Orchard's tenant-scoped distributed lock, and the domain/session/outbox transaction commits before the lock is released. Multiple application nodes may therefore receive the same Asterisk stream payload or DialPad delivery without applying it concurrently. Each event's idempotency key is scoped by its canonical provider (`{provider}|{deliveryId}`) before the duplicate check and publication, so the serialized retry observes the first commit and exits without republishing. + +Before any interaction, call, or event key is built, the service resolves the event's provider name to a single **canonical identity** through `IProviderIdentityResolver`. Provider modules contribute their canonical name and aliases through the implementation-free `IProviderIdentityProvider` contract — for example the Asterisk adapter maps its configuration-backed `Default Asterisk` runtime name to the canonical `Asterisk` — so an alias delivery resolves to the same stored call session instead of mutating the stored provider identity, and Contact Center never references provider implementation assemblies to do it. + +A `ProviderVoiceEvent` may carry an optional monotonic `SequenceNumber`. When a provider supplies it, the call session persists it as a `HighWaterSequence`, rejects any delivery at or below the high-water mark, and rejects later unsequenced deliveries because they cannot safely advance the established ordering domain. Providers that only supply timestamps or idempotency keys are still monotonic: ingestion rejects older timestamps and any lifecycle-rank regression regardless of receipt time, so a delayed or timestampless `Ringing` event cannot reopen a `Connected`, `OnHold`, `Ending`, or terminal call. `Connected` and `OnHold` share one lifecycle rank so legitimate hold/resume transitions remain available. Call session writes also retain optimistic concurrency and one call session per **canonical provider-call identity** is database-enforced through a portable, non-null claim column. A canonicalizing upgrade migration resolves legacy provider aliases, preflights and repairs legacy rows, and fails with actionable guidance on irreconcilable duplicates rather than deleting call history. + +Provider webhook inbox deliveries are likewise keyed by canonical provider name, and one inbox message per canonical provider delivery is database-enforced. A canonicalizing upgrade migration rewrites legacy alias-stored deliveries to their canonical provider before the unique index is created, and the inbox index provider canonicalizes `ProviderName` on reindex so legacy documents cannot recreate alias index values. + +### Recording governance + +> **Feature ID** `CrestApps.OrchardCore.ContactCenter.Recording` + +Recording orchestration is governed by a tenant-scoped policy that is enforced server-side **before** any provider media capture. The **Recording governance** section on the Contact Center settings screen configures whether recording is enabled for the tenant, the consent model (all parties must consent, or single-party consent is sufficient), whether explicit consent must be captured on the interaction before recording may start, the retention window in days, and whether captured recordings begin under legal hold. + +On every start or resume, the recording service evaluates `IRecordingGovernancePolicy` before invoking the provider. Recording **fails closed** — the provider is never called — when recording is disabled for the tenant, or when the all-parties consent model requires explicit consent that has not yet been captured on the interaction; a `RecordingDenied` domain event is published carrying the machine-readable deny reason. Pause and stop are never gated, so a tenant can always halt an in-progress capture. + +When recording is permitted, the interaction is stamped once at capture time with the resolved retention deadline (`RecordingRetainUntilUtc`, computed from the retention window through the injected clock, or left indefinite when the window is zero) and, when configured, its legal-hold flag is raised. The retention deadline is established a single time so a later resume never extends it, and legal hold is only ever raised — never silently cleared — by resuming a recording. This governance layer is additive to, and independent of, the provider capability and support-matrix gates, which remain the hard gate on whether recording can execute at all. + +#### Access audit and right to erasure + +Because the Contact Center orchestration layer never stores recording media — the interaction holds only an opaque recording reference and the retrieval metadata needed to fetch the media from the owning media store — post-capture governance is handled by `IRecordingAccessGovernanceService`. Every recording retrieval is audited: `RecordAccessAsync` publishes a `RecordingAccessed` domain event capturing who accessed the recording, the stated purpose, and the reference that was accessed. Access is not audited when the interaction has no captured recording. + +Right-to-erasure requests are served by `EraseAsync`. A recording under **legal hold** is exempt from erasure until the hold is released, so an erasure request against a held recording is denied with the `legalHold` reason and a `RecordingErasureDenied` event, leaving the reference intact. Otherwise the orchestration layer clears the opaque recording reference and its retrieval metadata, stamps the erasure instant (`RecordingErasedUtc`), and publishes a `RecordingErased` event that carries the original reference so the owning media store can delete the underlying media. Deletion of the media bytes is delegated to that store — the Contact Center layer never touches the media itself. + +#### Encrypted media store and secure ingest + +The media the orchestration layer references is owned entirely by the media-execution layer, never by Contact Center. That ownership is expressed through the provider-neutral `IRecordingMediaStore` abstraction in Telephony, which stores a completed recording under an opaque, deterministic storage key and later opens it for reading or deletes it by that same key. The default `LocalEncryptedRecordingMediaStore` persists every recording to a tenant-scoped application-data folder and encrypts it at rest with the ASP.NET Core data protection provider, so the bytes on disk are always ciphertext and the plaintext recording only ever exists in memory while it is being ingested or read back. The abstraction is pluggable, so a deployment can substitute a cloud-backed store without changing any ingest caller. + +For the Asterisk provider, the recording bytes are not captured inline when a recording stops. Instead, stopping a recording enqueues a durable, per-tenant **ingest job** keyed by the deterministic recording name; enqueueing is idempotent, so a given recording is only ever queued once. A background task drains due jobs once a minute: it downloads the stored file from Asterisk through the ARI stored-file endpoint and hands the bytes to the media store for encrypted persistence, then best-effort deletes the unencrypted source file from Asterisk so plaintext media does not linger on the telephony host. Because a freshly stopped recording may not be flushed to disk yet, a download that returns nothing — or a transient ARI or store failure — is retried with exponential back-off; a recording that never becomes ingestible is dead-lettered after the attempt budget is exhausted, so it is neither retried forever nor silently lost. This is the **failed-upload recovery** guarantee behind recording governance: once a recording stops, its secure ingestion survives process restarts and transient outages. + +For the Asterisk provider, a recording captures the **mixed audio of the conversation bridge as a single mono WAV file** — every party in that bridge is summed into one channel. It is **not** a dual-channel (stereo) recording that separates the caller onto one track and the agent onto another. Supervisor **monitor** and **whisper** legs run on a separate supervisor bridge and are therefore **not** captured in the recording (a whispering supervisor's coaching audio never appears in the recording); a supervisor **barge**, which joins the conversation bridge, **is** captured. Tooling that expects per-party channel separation — for example, speech analytics or QA scoring that attributes talk-time to a specific speaker by channel — must account for this: speaker separation, when needed, has to be derived by diarization rather than by reading a dedicated channel. The recording format is WAV. + +## Inbound voice + +> **Feature ID** `CrestApps.OrchardCore.ContactCenter.Voice` + +The **Contact Center Voice** feature adds the server-side Voice Contact Center Call Router for inbound and outbound voice work. It depends on the Queues feature and provider-agnostic [Telephony](../telephony/index.md), so provider routing and webhook processing can run without the Telephony soft-phone UI or the Contact Center real-time experience. + +Enable `CrestApps.OrchardCore.ContactCenter.Voice.SoftPhone` when agents should receive Contact Center call-state projections in the Telephony soft phone. That integration feature explicitly enables Contact Center Real-Time and `CrestApps.OrchardCore.Telephony.SoftPhone`, and exclusively owns the Contact Center soft-phone display driver, resources, and agent endpoints. + +When a normalized inbound call arrives, the feature: + +1. Resolves the dialed number to an Omnichannel **channel endpoint**, then resolves the configured **subject flow** for that endpoint to obtain the subject content type and campaign. +2. Looks up the **contact** by the caller's phone number (matched against the contact's normalized primary cell and home numbers). +3. Creates an `OmnichannelActivity` (`Kind = Call`, `Source = Inbound`) with its **Subject** content item, and an `Interaction` (`Voice`, `Inbound`) linked to that activity. +4. Enqueues the activity into the inbound **queue** and reserves the longest-idle available agent who is signed in to that queue. +5. Offers the ringing call to that agent through `IIncomingCallDispatcher`, which raises the soft-phone modal. + +When the agent accepts the offer, the [unified call command](#unified-call-commands) accepts the reservation, connects the media to the agent for `ServerSideAcd` providers, and creates the [call session](#call-sessions-and-normalized-provider-events) and marks the interaction connected in one server-side step. + +### Routing the dialed number to a queue + +Each queue has an optional **inbound channel endpoint** (`InboundChannelEndpointId`). Calls received on that endpoint are queued there. When no queue maps the endpoint and exactly one enabled queue has no endpoint mapping, that queue is used as the default inbound queue, so a single-queue tenant works without extra configuration. + +### Matched customers in the modal + +The feature contributes an `IIncomingCallContextProvider` to the Telephony modal. For a ringing inbound call offered to an agent, it lists the customers matched by the caller's number - each card links to the contact content item and offers an **Answer & open** shortcut - and wires the accept and decline offer-lifecycle actions back to the reservation. The agent's signed-in inbound queue scopes the context shown. See [Incoming calls](../telephony/index.md#incoming-calls) for the modal contract. + +### Ingress + +A provider or PBX integration posts a normalized `InboundVoiceEvent` to the authenticated ingress endpoint: + +```text +POST /api/contact-center/voice/inbound +``` + +The endpoint requires the `Manage interactions` permission. Provider-specific webhooks that validate their own provider signature can instead call `IVoiceContactCenterCallRouter` directly, the same way the Omnichannel SMS webhook handles inbound messages. + +For local burst testing, the repository includes the **Asterisk Web** startup sample at `src\Startup\CrestApps.OrchardCore.Asterisk.Web`. It signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, then forwards the normalized inbound voice events after Asterisk confirms the channels entered Stasis. That lets you exercise queueing, routing, and agent offers with a provider call id that comes from the real Asterisk channel the dashboard is already showing. The sample splits the experience into an **Asterisk Dashboard** page for live ARI telemetry and an **Inbound Simulator** page for burst generation. In that simulator, **To address** is the dialed service address that Contact Center uses to resolve the inbound entry point or queue mapping, while **Caller number seed** only generates distinct caller identities for the burst. + +### Shared disposition for inbound and outbound + +Both inbound and outbound work is an `OmnichannelActivity` with a Subject, and both are dispositioned through the single, source-neutral `IActivityDispositionService`. Completing an activity records the disposition and completion metadata and then runs the configured Subject Actions, so inbound and outbound calls are wrapped up through the same subject workflow. + +## Subject Flow is the single decision controller + +The decision of "what happens when this work completes" is owned by the **Subject Flow**, and it is the same for CRM, inbound, and outbound work — there is no separate wrap-up concept: + +- The **Subject** (any content type that attaches `OmnichannelSubjectPart`) and its **Subject Flow settings** define what the work is (channel, endpoint, interaction type, campaign, AI configuration). +- The **Manage Flow** screen defines disposition-driven **Subject Actions** (`(Subject, Disposition) → Finish / Try again / New activity` plus communication-preference updates). This is the decision logic. +- A **Disposition** is the outcome that selects which Subject Action branch runs. +- **`IActivityDispositionService.ApplyAsync`** is the one completion path. It applies the disposition, marks the activity `Completed`, and runs the Subject Flow. The disposition can be applied by an agent, AI, or the system — the path is identical, so inbound and outbound complete the same way. + +### Require a disposition + +A Subject Flow can mark a subject as **requiring a disposition**. When set, an activity using that subject cannot be completed until a disposition is selected — enforced centrally in `IActivityDispositionService` so the rule holds for every completion path (CRM screen, agent desktop, dialer, or automation) for both inbound and outbound work. This replaces the need for a separate wrap-up entity: "must disposition" is a property of the subject's decision flow, and after-call agent timing/capacity release is handled by the agent desktop and agent presence (see the agent desktop phase). + +## Reports and analytics + +The optional **Contact Center Reports & Analytics** feature (`CrestApps.OrchardCore.ContactCenter.Analytics`) contributes contact center reports to the reusable [Reports](../modules/reports.md) framework, so they appear under the top-level admin **Reports** menu (grouped under **Contact Center**) alongside CRM and other reports. Every report shares the standard from/to date-range filter and a CSV export. + +The combined Contact Center and Omnichannel Management catalog now includes 31 runnable executive, interaction, queue, agent, transfer, recording, campaign, subject, and CRM activity reports. See the [Enterprise report catalog](report-catalog.md) for the exact formulas, columns, filters, grouping, sorting, drill paths, permissions, export behavior, validation rules, and known data limitations. + +- **Call insights** - inbound/outbound volume, answered, abandoned, and failed counts; average handle time and speed of answer; breakdowns by channel and status; and a daily volume trend. +- **Agent productivity** - per-agent handled volume (inbound/outbound), talk time, average handle time, and completed activities. +- **Queue usage** - per-queue handled volume, answered, abandoned, average handle time and speed of answer, current waiting depth, and the configured SLA threshold. +- **Campaign summary** - per-campaign *completed vs pending* progress across the activity inventory, with in-progress, failed, cancelled, attempt, and completion-rate columns so administrators can see what is done versus what still needs to be worked. +- **Subject inventory** - the same completed-vs-pending progress grouped by subject type. + +Reports are derived read models built directly from the durable interaction history and the CRM activity inventory, so they are always consistent with the underlying data. Access is gated by the **View Contact Center reports** (`ViewContactCenterReports`) permission, which is granted to the built-in **Supervisor** role and to administrators by default. + +Report tables resolve campaign and campaign-group catalog identifiers to their current display text. User columns render Orchard's `UserDisplayName` shape so an enabled user display-name provider can replace the username without changing the stable grouping key. + +## UI extensibility + +All Contact Center UI is built with Orchard Core display management: shapes, display drivers, placement, templates, and shape alternates. The skill, queue, and dialer profile CRUD screens follow the Omnichannel Campaigns UI pattern: controllers load catalog entries through managers and build summary/editor shapes with `IDisplayManager`. Activity screens remain Omnichannel screens that Contact Center augments with display drivers for reservation state, interaction history, dialer controls, wrap-up, and supervisor decorations. + +## Status + +The Contact Center now has a usable voice contact-center MVP: managers can configure agents, skills, queues, business hours, entry points, campaigns, dialer profiles, callbacks, reason codes, and optional workflow automation; agents can sign in, receive offers, handle inbound and outbound calls, disposition work, and review recent history in the CRM; supervisors can monitor queue health and start provider- gated live engagements from the dashboard. The design still deliberately leaves advanced capabilities such as multi-step IVR decision trees, predictive pacing, abandonment caps, quality scorecards, and provider-specific recording storage for later phases. + +See [Agents, Queues & Dialer](agents-queues-dialer.md) for the setup reference, [Agent desktop & supervisor dashboard](agent-desktop.md) for day-to-day agent and manager workflows, and [Configuration deployment](configuration-deployment.md) for exporting a configured tenant and replaying it into another environment. diff --git a/src/CrestApps.Docs/docs/contact-center/live-call-topology.md b/src/CrestApps.Docs/docs/contact-center/live-call-topology.md new file mode 100644 index 000000000..3583cacfd --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/live-call-topology.md @@ -0,0 +1,70 @@ +--- +sidebar_position: 12 +--- + +# Live call topology + +A voice interaction is not one line between two people. A customer calls, an agent answers, the agent puts the customer on hold and consults a specialist, a supervisor listens in, the specialist joins and the original agent drops off. Every one of those moments changes *who is on the call*, and a contact center that cannot describe that shape cannot report on it, supervise it, or recover it after a failure. + +Contact Center describes the shape of a live call with a topology on the call session rather than with a handful of scalar fields. + +## What the topology contains + +| Concept | What it records | +| --- | --- | +| **Leg** | One party's connection to the call: the provider leg identifier, the part the party plays (customer, agent, consult, supervisor, external), a normalized lifecycle status, the address, and the times the leg started, answered, and ended. | +| **Bridge** | The media the parties share. It carries a participant list where each entry has a join time and, once the party leaves, a leave time. | +| **Consult** | A private call an agent placed before deciding whether to complete a warm transfer, with its own lifecycle from initiated through connected to completed or cancelled. | +| **Monitor session** | A live supervisor engagement — monitor, whisper, or barge — with the supervisor, the agent being supervised, and the times it started and ended. | +| **Retained bridge** | A bridge the call has been moved off. It keeps its full membership history so a media move does not erase who was on the call before it. | +| **Relationship** | A typed link to another call: transferred from, transferred to, consult of, conferenced with, or callback of. | + +## Membership history is append-only + +When a party leaves the bridge, its entry is **not removed**. The leave time is stamped on the entry it already had. + +That is what makes `Bridge.ParticipantsAt(instant)` able to answer "who was on this call at 14:32?" long after the call ended. A list that is edited in place can only ever answer "who is on this call now", which is useless the moment the call is over — and after-the-fact review of a disputed call is exactly when the question gets asked. + +```csharp +var atTheTimeOfTheComplaint = callSession.ParticipantsAt(complaintUtc); +``` + +`CallSession.ParticipantsAt` searches the current bridge **and** every retained one. When a provider reports a new topology identifier the parties have been moved to different media, so the previous bridge is closed and retained in `CallSession.PriorBridges` rather than continued under the new identifier. Reusing the old bridge would attribute the new membership window to media that no longer exists, and dropping it would erase the earlier window entirely. + +## Reported counts and observed membership are kept apart + +Some providers publish only a participant count for a conference; they do not say who those parties are. `Bridge.ReportedParticipantCount` carries that count, separately from `Bridge.Participants`. + +Fabricating participant entries to make the observed list match a reported count would make the membership history a fiction — it would show parties that were never identified, at times that were never observed. `CallSession.ParticipantCount` prefers the provider's reported count when there is one, so live displays stay accurate, while the membership history stays factual. + +## One writer + +`CallTopologyProjector` is the only place in the product that mutates legs, bridges, bridge membership, consults, monitor sessions, or relationships. Every service that observes a change to the call — the provider event stream, the transfer service, the monitoring service, the agent-connect command executor — calls into it. + +Keeping the rules in one place is what makes them enforceable: that a leg cannot end before it started, that a participant cannot leave before it joined, that a destroyed bridge has no live members, that a supervisor cannot monitor themselves, and that the same supervisor cannot hold two live engagements on one call. A build gate scans every Contact Center, Telephony, and provider source file and fails the build if any of them mutates the topology directly. + +## Ending a leg says why it ended + +`CallTopologyProjector.EndLeg` sets the leg's status to `Failed` when the leg never reached an answered time, and to `Ended` when it did. + +That single distinction is what lets abandon and no-answer reporting separate from normal hang-ups without any provider-specific vocabulary leaking into reports. + +A leg's end time is clamped so it never precedes its start. A provider may stamp a hangup behind the state change that preceded it, and a leg started from the application clock can be ended from the provider's, so the two timestamps can invert without anything being wrong upstream. An inverted leg is not a fact, and persisting one would be rejected by the store, leaving the call stuck mid-teardown with its agent still occupied. + +When the call itself reaches a terminal state, **every** leg still open is ended and the bridge is destroyed — not only the leg the provider named on the hangup. A terminal call session accepts no further provider deliveries, so a leg left open at that moment stays open permanently and the bridge goes on claiming a party who has already gone. + +## Supervisor engagements + +Starting a monitor, whisper, or barge records a `MonitorSession` on the call session. Barge — and only barge — also joins the supervisor to the bridge as a party, because barge puts the supervisor into the conversation while monitoring and whispering let the supervisor hear the call without being one of the parties on it. + +A supervisor is always a user but is not always an agent, so the engagement records both identifiers: `SupervisorUserId` is the identity the engagement is started and stopped under, and `SupervisorAgentId` is the supervisor's agent profile when they have one. Keeping them apart is what makes the "a supervisor cannot monitor their own agent leg" rule enforceable, because that rule compares the supervisor against `CallSession.AgentId`, which is an agent-profile identifier — comparing a user identifier against it could never match, and the rule would never fire. + +A second engagement by the same supervisor on the same call is refused. Two engagements by one supervisor would be indistinguishable from each other, so a later stop would release an arbitrary one and leave the other listening. + +When the provider does not report a leg identifier for the engagement, the monitor session is still recorded with a null provider leg. The engagement is on the record, but a barge cannot place the supervisor on the bridge without a leg to place. + +## Consultative transfers + +A consultative (warm) transfer records a `ConsultCall` on the source session and a typed relationship to the destination. This is what lets a supervisor see that a customer is on hold while their agent talks to someone else, and lets reporting tell a completed warm transfer apart from a consult the agent abandoned. + +When a consult reaches a terminal state, its leg is ended and its bridge membership released, so an abandoned consult never leaves a leg open on the topology. diff --git a/src/CrestApps.Docs/docs/contact-center/production-support.md b/src/CrestApps.Docs/docs/contact-center/production-support.md new file mode 100644 index 000000000..76d0b6a3a --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/production-support.md @@ -0,0 +1,747 @@ +--- +sidebar_label: Production support +sidebar_position: 20 +title: Contact Center production support +description: Finite production support matrix, initial capacity tier, and prohibited Contact Center deployment combinations. +--- + +The Contact Center commercial release remains blocked until remediation phases R0 through R8 and their release evidence pass. The versioned machine-readable contract is `.github/contact-center/support-matrix.v1.json`; unlisted combinations are unsupported. + +Step-by-step responses for dependency and node failures, the supported deployment strategies, and the [Voice listener handover and rollback](runbooks.md#voice-listener-handover-and-rollback) procedure are in the [Failure runbooks](runbooks.md). + +## Initial GA-Core profiles + +The first release targets two provider-specific tenant profiles. A tenant selects one profile and one voice provider; mixing both provider profiles in one tenant is unsupported. + +| Profile | Provider | Included voice scope | +| --- | --- | --- | +| `ga-core-asterisk` | Asterisk | Inbound voice plus Manual and Preview dialing | +| `ga-core-dialpad` | DialPad | Inbound voice, Manual and Preview dialing, and call transfer | + +The feature identifiers in the matrix describe the implemented R2 feature graph. A dedicated Linux gate now creates fresh Orchard tenants from the Blank recipe for both profiles, enables only the profile seeds plus their declared dependency closure, runs migrations, resolves key services and background tasks, disables and re-enables the idle provider adapter, and verifies Asterisk and DialPad tenants can coexist without provider leakage. The gate retains TRX evidence from `.github/workflows/contact_center_feature_activation_matrix.yml`. Commercial readiness remains blocked on later remediation and certification phases. + +## Feature lifecycle contract + +Feature-owned background tasks, SignalR hubs, provider listeners, provider adapters, media providers, and shell singletons have a versioned lifecycle contract in `.github/contact-center/feature-lifecycle-contracts.v1.json`. Before Orchard disables a feature, Contact Center invokes every matching lifecycle participant in two phases: quiesce all participants first, then drain. Orchard logs non-fatal feature-event exceptions and continues descriptor mutation, so a drain timeout is a bounded best-effort signal rather than a veto. Admission remains closed during teardown, and durable ownership/fencing protects work that outlives the bounded drain. + +R3 adds tenant-shell admission leases for base Contact Center outbox dispatch, Dialer callbacks, Automated Dialer pacing, Voice ingress/routing/reconciliation/provider commands, Contact Center Real-Time connections, Asterisk and DialPad Contact Center provider adapters, and Asterisk Contact Center media sessions. Quiescing atomically rejects new work. Already admitted work may settle, and disable waits for its leases up to the configured timeout. Contact Center hub connections are aborted so disconnect cleanup releases their leases; open media sessions retain a lease until cleanup succeeds. Pending provider commands and claimed inbox/outbox rows remain durable and continue to use owner/fence validation rather than being redelivered blindly. A command rejected before provider contact because the provider feature is quiescing returns from `Sent` to delayed `Pending` instead of compensating business work or becoming an unknown outcome. Outbox rows persist the handler ids expected when the message was created, so temporarily disabled feature handlers cannot disappear and cause false completion or consume the poison-message dead-letter budget. + +Configure the tenant drain timeout under `CrestApps_ContactCenter:FeatureLifecycle:DrainTimeoutSeconds`. The default is `30` seconds; startup validation accepts values from `1` through `300` seconds. The gate is tenant-shell-local. Multi-node correctness continues to rely on Orchard shell invalidation plus the relational command, inbox, and outbox ownership/fencing boundaries; node-crash and rolling-deployment certification remains part of R8. + +## Database and topology + +- PostgreSQL 16.x is the only initial production database target. +- SQLite is for local development, demonstrations, and tests only. +- The supported production topology is `single-node-distributed`: one region, exactly one application node, a shared relational database, the `CrestApps.OrchardCore.SignalR.Redis` feature, and the `OrchardCore.Redis.Lock` feature. The node count is one, but the distributed contract is mandatory rather than optional, because a single node already meets the distributed failure modes: a rolling restart overlaps two instances, and an Orchard shell reload tears down and rebuilds the shell in-process on every feature toggle. +- Multi-node operation is **not** production-supported in this release. Two to four nodes remain the architectural direction and the code path is backplane- and lock-agnostic, but multi-node capacity certification has not been earned, so `single-region-multi-node` is declared non-production in the matrix. Scaling out later is configuration and certification, not a rewrite. +- Production without the backplane or Redis distributed locking, and multi-region active-active operation, are unsupported. + +### The declared topology is enforced at startup + +The support matrix above is not advisory. Each tenant declares the topology it intends to run, and the tenant refuses to admit Contact Center work unless the running deployment actually satisfies that declaration: + +```json +{ + "CrestApps_ContactCenter": { + "Topology": { + "ProfileId": "single-node-distributed" + } + } +} +``` + +- When the declared profile is `single-node-distributed`, activation verifies the tenant is on the `Postgres` database provider and that the `OrchardCore.Redis`, `OrchardCore.Redis.Lock`, and `CrestApps.OrchardCore.SignalR.Redis` features are enabled. It also verifies that the distributed lock the container actually resolves is not the process-local implementation, because a feature can be enabled while the container still hands out the local lock, and the lock that is injected is the one that decides whether two overlapping processes can enter the same critical section. The **number of running application nodes itself is not verified at runtime** — nothing performs a node census — so the single-active-process constraint is only *mitigated* by the distributed lock serializing the critical sections that take it (it does not make multi-node operation safe, which remains uncertified above), and running exactly one active background-processing node remains an operator responsibility. +- Every unmet requirement is reported at once. Fixing one requirement per deployment would make each intermediate deployment another unsupported production release. +- An unrecognized profile identifier is a validation failure rather than a fallback to the development profile, so a typo cannot silently downgrade a production deployment to the profile that requires nothing. +- Omitting `ProfileId` is normal for development, tests, and demonstrations and imposes no requirements. It is a validation failure when the host environment is `Production`, because otherwise the entire check could be bypassed by setting nothing. +- Declaring a non-production profile such as `single-node-development` imposes no infrastructure requirements. It is a statement that the deployment is not claiming production support. + +A tenant that does not satisfy its declared topology logs a critical message naming each unmet requirement, refuses every Contact Center work admission, and reports **unready** on the tenant readiness probe. Validation runs once per shell activation, because every input to the verdict — declared profile, database provider, enabled features, resolved lock — can only change by rebuilding the shell. Until the verdict is recorded the tenant is treated as inadmissible; starting admissible and tightening afterwards would open a window in which an unverified deployment accepts work, and that window is exactly when a shell reload is in progress. + +The topology profiles the product enforces are a shipped mirror of the governance matrix in `.github/contact-center/support-matrix.v1.json`, which is not deployed with the product. A contract test asserts the two are identical, so the running application cannot enforce a second, more permissive definition of what "production" means. + +Queue and reservation correctness does not depend on Redis lock exclusivity. YesSql document versions provide compare-and-set updates, and portable unique claim keys enforce active queue-item and reservation ownership in the relational database. Upgrade migrations reject missing identifiers or duplicate legacy active claims with explicit repair guidance instead of failing later with an opaque unique-index error. SQLite regression tests force overlapping lock holders and synchronized stale reads and retain exactly one reservation; production certification still requires the planned database matrix to repeat the invariant on PostgreSQL and any subsequently supported database. + +### Database portability + +Contact Center persistence is engine-portable by construction, so the same migrations and queries run unchanged on every YesSql-supported relational engine (SQLite, SQL Server, PostgreSQL, and MySQL): + +- **Enumerations are stored as their string names, never as ordinals**, so reordering or inserting an enum value never silently remaps existing rows, and status filters read the same on every engine. +- **Every raw-SQL migration quotes all identifiers through the active `ISqlDialect`** (`QuoteForTableName`, `QuoteForColumnName`, `FormatIndexName`) and honors `PrefixIndex`; no migration hardcodes an engine-specific quote character, table prefix, or index-naming rule. Unique-index creation is centralized in the single dialect-aware `ContactCenterMigrationSql` helper, and a unit test pins that the generated `CREATE UNIQUE INDEX` statement is produced entirely from the dialect. +- **All literal values in backfill and preflight statements are passed as bound command parameters**, never string-concatenated, so they are engine-quoting- and injection-safe. +- **Every identifier in a hand-written statement is quoted through `SchemaBuilder.Dialect`**, never with a literal quote character. A hardcoded double quote delimits an identifier on some engines and a string literal on others, so a filter that quotes its own column name becomes a comparison between a constant and a set of values on the engines that read it as a literal: it matches nothing, succeeds, and leaves the rows it was meant to touch untouched with nothing to indicate it did nothing. A gate fails the build when a migration that writes raw SQL contains one. +- **Backfill and duplicate-detection statements use only ANSI SQL** (`UPDATE`/`CASE`/`IN`/`GROUP BY`/`HAVING`/`COUNT`) that every supported engine implements identically. +- **Case-insensitive matching is normalized in application code** (for example, queue membership keys are lower-cased before they are stored and queried) rather than relying on a database's default collation, so routing behaves the same regardless of the engine's collation configuration. + +Because no local environment can host every engine, per-engine validation of the full migration and query surface is a CI and deployment-certification responsibility; the guarantees above keep that validation a verification step rather than a porting exercise. + +Provider stream correctness uses the supported Redis distributed-lock topology. Every canonical provider-call stream is serialized before interaction, call-session, event-log, and outbox changes are read or written, and the YesSql transaction commits before the lock is released. This makes duplicate Asterisk listeners and concurrent DialPad delivery processing harmless across supported nodes without requiring a renewable long-lived socket lease. Lifecycle rank cannot move backward, an established provider sequence high-water cannot be advanced by an unsequenced event, and terminal state remains final. + +PBX mutations use a tenant-scoped server execution boundary instead of the SignalR connection or HTTP request cancellation token. The default 10-second command deadline is configurable with `CrestApps_Telephony:Commands:Timeout` and accepts values from one second through two minutes. Deadline or host-shutdown cancellation produces an unknown provider outcome rather than a safe-to-retry success or failure. Durable provider commands persist that ambiguity as `OutcomeUnknown`; synchronous Telephony operations return an unknown result. After the provider confirms success, local interaction, transfer, recording, monitoring, and event persistence uses a non-request, non-expiring token so a browser disconnect or exhausted provider deadline cannot discard the confirmed projection. This outer command deadline intentionally supersedes longer provider-specific retry budgets. + +## Observability and health + +The Contact Center module exposes a stable OpenTelemetry contract and operational health checks so operators can wire dashboards, alerts, and orchestrator probes without depending on private types. + +### Telemetry contract + +`ContactCenterDiagnostics` publishes a single `Meter` and a single `ActivitySource`, both named `CrestApps.OrchardCore.ContactCenter`. These names are a public integration surface and change only through a documented migration. Register them with any OpenTelemetry exporter: + +```csharp +builder.Services.AddOpenTelemetry() + .WithMetrics(metrics => metrics.AddMeter("CrestApps.OrchardCore.ContactCenter")) + .WithTracing(tracing => tracing.AddSource("CrestApps.OrchardCore.ContactCenter")); +``` + +Current instruments: + +| Instrument | Kind | Meaning | +| --- | --- | --- | +| `contactcenter.outbox.redelivered` | Counter | Domain events successfully redelivered from the durable outbox. | +| `contactcenter.outbox.dead_lettered` | Counter | Domain events dead-lettered after exhausting their retry budget, tagged by `reason`. | + +On tenants that enable Voice, the Asterisk provider publishes a second `Meter` named `CrestApps.OrchardCore.Asterisk`, whose instruments are all tagged by `provider`. The `provider` tag is the provider technology name (a compile-time constant), not a per-tenant or per-shell dimension, so on a node that hosts more than one Asterisk tenant these counters aggregate per node/process and cannot be split by tenant. + +| Instrument | Kind | Meaning | +| --- | --- | --- | +| `asterisk.realtime.ingestion.saturated` | Counter | Real-time ingestion buffer saturation episodes on the listener (see [Ingestion backpressure and its limits](#ingestion-backpressure-and-its-limits)). | +| `asterisk.realtime.connected` | Counter | Successful ARI event-stream connections. Counts both the first connection and every reconnection, so it is the connectivity signal for the listener. | +| `asterisk.realtime.reconnect_attempted` | Counter | Times the listener re-entered its loop to re-establish the ARI event-stream connection after a connection ended or a connect attempt failed. A sustained non-zero rate means connection churn — the listener is repeatedly losing and reacquiring the stream — and each reconnection triggers a reconciliation sweep. Because the stream is not lossless across a reconnect, a rising rate here is an early warning that events may be being missed between sweeps. | + +### Health checks + +Three probes answer three different questions. They are separate on purpose, and the separation is a reliability requirement rather than a stylistic one. + +| Route | Scope | Contract | Selects | Wire to | +| --- | --- | --- | --- | --- | +| `/health/process` | Host | Liveness. Returns `Healthy` whenever the process can serve the request. It consults nothing, by design. | Nothing. | The orchestrator's liveness probe / restart policy. | +| `/api/contact-center/health/ready` | Tenant | Readiness. Reports whether *this node* should receive traffic for this tenant. | Checks tagged `contactcenter-ready`. | The load balancer and the orchestrator's readiness probe. | +| `/api/contact-center/health/dependencies` | Tenant | Dependency report. Per-check status for the tenant's dependencies. | Checks tagged `contactcenter-dependency`. | Dashboards and alerting. **Never** an orchestrator probe. | + +The liveness and readiness routes allow anonymous requests so an orchestrator can reach them, and both return only the aggregate status word — never per-check detail — so they disclose nothing useful to an unauthenticated caller. The dependency route discloses which dependency degraded, so it requires the `MonitorContactCenter` permission. + +#### Why liveness is served by the host, not a tenant + +Liveness answers exactly one question: *should this process be restarted*. No tenant-scoped route can answer it. A route mapped inside a tenant shell returns **404** whenever that tenant is disabled, renamed, given a different request URL prefix, or fails to start — and an orchestrator reads 404 as a probe failure. A healthy process would be restarted forever because of a tenant-level problem, and the restart would not fix the tenant. + +`/health/process` is therefore answered by host middleware installed ahead of the Orchard Core pipeline: + +```csharp +builder.Services + .AddContactCenterProcessLiveness() + .AddOrchardCms(); + +var app = builder.Build(); + +app.UseContactCenterProcessLiveness(); + +app.UseOrchardCore(); +``` + +It must be registered before `UseOrchardCore`, and it must be middleware rather than a mapped endpoint: endpoints registered on a `WebApplication` are executed by terminal middleware appended *after* everything the application added, so an endpoint would be evaluated only after Orchard Core had already handled the request. Only `GET` and `HEAD` are answered; every other verb falls through to the normal pipeline. + +:::warning +The path is `/health/process`, not `/health/live`, and that is deliberate. `/health/live` is the default route of the `OrchardCore.HealthChecks` module. Because this middleware short-circuits *before routing*, taking that path would silently shadow that module's endpoint for every tenant in the process — including tenants that never enable Contact Center — and answer an unconditional `200 Healthy` in its place. A health endpoint that can only ever report success is worse than none at all. `AddContactCenterProcessLiveness` also registers a startup validator that reads **every configured tenant's** health-check route — tenant configuration is not host configuration, so a tenant could otherwise claim the path unseen — and fails the host with an explanatory error naming the tenant. A tenant created *after* startup is not covered by that validator; tenants that enable Contact Center are covered by the shell-level guard, and for tenants that do not, treat the liveness path as reserved. + +To move the probe, pass the path to `AddContactCenterProcessLiveness("/probe/alive")`. `UseContactCenterProcessLiveness` deliberately takes no path: the path is validated against every tenant at registration, so accepting it again at the pipeline would let the probe answer on a path no tenant was ever checked against — the exact collision the validator exists to prevent. Registering it twice on different paths fails startup for the same reason. +::: + +The readiness tag is namespaced (`contactcenter-ready`, not `ready`) so that a check contributed by another module — which conventionally uses the bare `ready` tag — can never silently join the Contact Center readiness verdict. + +#### Why readiness does not check dependencies + +This is the single most important thing to understand about these probes, and it is the opposite of what most health check examples show. + +Readiness removes a node from the load balancer. A dependency shared by every node — the database, Redis, the provider, the outbox backlog — is observed identically by every node. If readiness consulted it, every node would fail readiness at the same instant, the load balancer would have no healthy target, and a *degraded* dependency would become a *total* outage. The system would take itself down in response to a problem it could otherwise have served through, and it would stay down because no node can pass a probe that depends on something still broken. + +Readiness must therefore only reflect conditions that genuinely differ between nodes. Two apply on every deployment: + +- The node has not finished starting. During a rolling deployment a new instance must not receive calls before its shells are initialized. +- The node is shutting down. Reporting unready on `SIGTERM` is what lets the load balancer evict the node *before* the process stops accepting connections. Without it, every deployment drops in-flight calls. + +A third, the [node serving gate](#optional-the-node-serving-gate), is available where a node's *own* ability to reach a shared dependency can fail independently of its peers. It is opt-in because it re-introduces fleet-wide risk when the dependency itself is down. + +Dependency health is still observed — that is what the dependency probe and the metrics are for — but it is an **alerting** signal that pages a human, never a **routing** signal that drains capacity. + +The topology check and the base-voice verification check are the two deliberate exceptions to the "readiness must differ between nodes" rule, and the distinction is between a *live dependency* and a *static verdict*. A dependency probe is transient and self-healing, so draining every node on it turns a recoverable blip into a total outage. A topology violation — or an unverified base-voice media path — is fixed configuration that no amount of waiting repairs, there is no degraded-but-serviceable state to preserve, and continuing to serve on such a deployment is precisely the failure being prevented. Draining is the intended outcome, not collateral damage. The narrower invariant still holds without exception: readiness never consults a dependency check. + +:::tip +The rule generalizes: an orchestrator probe may only consult state that differs between instances, or a static support verdict that cannot self-heal. If two healthy instances would always answer identically *and* the condition can recover on its own, the check belongs on the dependency probe. +::: + +#### The tenant probes inherit the tenant prefix + +The readiness and dependency routes are mapped inside the Contact Center feature, so they exist on every tenant that enables it and they inherit that tenant's request URL prefix. A tenant reachable at `/support` answers on `/support/api/contact-center/health/ready`; a tenant with no prefix answers on `/api/contact-center/health/ready`. + +:::warning +Probing an unprefixed tenant route when Contact Center runs on a prefixed tenant reaches a shell that does not map these routes, and returns **404**. An orchestrator treats 404 as a probe failure. Always include the tenant prefix, and verify the probe returns 200 before relying on it. `/health/process` is the only probe that is prefix-independent, because it is served by the host. +::: + +With the default configuration readiness is node-local, so probing a single Contact Center tenant is sufficient and correct — every tenant on the node reports the same node state. This is what makes a single Kubernetes `readinessProbe` correct even when the pod hosts several tenants. If you enable the node serving gate below, readiness becomes genuinely per tenant and each tenant you care about must be probed. + +Scrape the **dependency** probe per tenant, since dependency health genuinely is per tenant. + +#### Optional: the node serving gate + +Readiness being purely lifetime-based is deliberately shallow, and that shallowness has a cost. "Shared dependency" does not mean "fails identically on every node": a node with an exhausted connection pool, a stale DNS entry, an expired TLS trust store, or exhausted outbound ports fails every store call while its peers are perfectly healthy. Nothing in the default readiness contract notices, so that node keeps taking its share of traffic and failing all of it. + +The node serving gate closes that hole. It runs a cheap store probe on readiness and drains the node after `ConsecutiveFailuresBeforeUnready` consecutive failures, returning it after `ConsecutiveSuccessesBeforeReady` consecutive successes. The hysteresis is what makes it usable: a single transient failure never costs capacity, and a node does not flap back into rotation on one lucky call. + +It is **disabled by default**, because when the store itself is down every node observes the same failure and the gate drains the whole fleet — the exact failure mode the readiness split exists to prevent. + +:::warning +Enable it only when your load balancer fails open once too few targets remain healthy — for example an Envoy or Istio panic threshold, which by default routes to all hosts when fewer than 50% are healthy. On a plain Kubernetes `Service`, all-pods-NotReady means zero endpoints and a total outage. If you are unsure, leave it disabled and rely on the dependency probe and alerting instead. +::: + +```json +{ + "CrestApps_ContactCenter": { + "HealthChecks": { + "EnableNodeServingGate": true, + "ConsecutiveFailuresBeforeUnready": 3, + "ConsecutiveSuccessesBeforeReady": 2 + } + } +} +``` + +When disabled the check performs no I/O and reports healthy immediately, so readiness stays free. + +#### Which checks a tenant registers + +The dependency report contains only what the tenant's enabled features registered. `contactcenter-provider-ingress` needs the provider webhook inbox, which the Voice feature owns, so it is registered by the Voice feature and is absent on tenants that do not enable Voice. Do not assert a fixed check count in monitoring. + +| Check | Probe | Registered by | Signal | Degraded | Unhealthy | +| --- | --- | --- | --- | --- | --- | +| `contactcenter-topology` | Readiness | Contact Center | Whether this deployment satisfies the [topology it declared](#the-declared-topology-is-enforced-at-startup). A static verdict established once per shell activation; performs no I/O. | — | Validation has not run yet, or a declared requirement is unmet. | +| `contactcenter-node` | Readiness | Contact Center | Node-local lifetime: startup complete and not shutting down. Consults no dependency and performs no I/O. | — | Startup incomplete, or shutdown in progress. | +| `contactcenter-node-serving` | Readiness | Contact Center | Opt-in node serving gate (see above). Disabled by default, in which case it performs no I/O and is always healthy. | — | Enabled, and this node failed `ConsecutiveFailuresBeforeUnready` consecutive store probes. | +| `contactcenter-base-voice-verification` | Readiness | Contact Center Voice | Whether the operator has acknowledged the [base-voice deployment acceptance](#base-voice-deployment-acceptance) for this deployment. A static verdict read from configuration; performs no I/O. Registered by the Voice feature, so it is absent on tenants without Voice. Always healthy outside a production host. | — | Running in a production host with `AudioVerificationAcknowledged` unset or `false`. | +| `contactcenter-storage` | Dependency | Contact Center | A cheap store query proving the tenant database and Contact Center collection are reachable. | — | Query throws. | +| `contactcenter-outbox` | Dependency | Contact Center | Dead-lettered count and overdue (past-due pending/claimed) backlog. The overdue backlog is the scheduler-lag signal: a sustained non-zero value means the dispatch background task is not keeping up. | Dead-letters or overdue backlog reach the degraded threshold. | Either reaches the unhealthy threshold, or the store is unreadable. | +| `contactcenter-active-calls` | Dependency | Contact Center | A live gauge, not a verdict: reports `active_calls`, the number of call sessions that have not ended, in the check's `Data`. This is the count of live calls a node drain would interrupt. Stays healthy at any count because the acceptable ceiling is deployment specific. | — | The store is unreadable. | +| `contactcenter-queue-backlog` | Dependency | Contact Center Queues | A live gauge, not a verdict: reports `queued_interactions`, the number of interactions waiting for an agent across every queue, in the check's `Data`. Registered by the Queues feature, which owns the queue item store, so it is absent on tenants without Queues. Stays healthy at any count. | — | The store is unreadable. | +| `contactcenter-provider-ingress` | Dependency | Contact Center Voice | Provider webhook inbox dead-letter and overdue backlog. A stuck provider stream or an expired listener lease surfaces here as a growing ingress backlog. | Same thresholds as the outbox. | Same thresholds as the outbox. | +| `contactcenter-distributed-lock` | Dependency | Contact Center | Acquires and releases a dedicated probe lock within a bounded time. In a production topology this exercises the Redis-backed lock end to end; in a development topology it exercises the process-local lock and is trivially satisfied. | — | The probe lock cannot be acquired within the timeout, or the lock backend throws. | +| `contactcenter-redis` | Dependency | Contact Center | Pings the Redis connection shared by the distributed lock and the SignalR backplane. Reports healthy with nothing probed when Redis is not enabled. | — | The ping fails or times out while Redis is enabled. | +| `contactcenter-backplane` | Dependency | Contact Center | Publishes a token on a dedicated, tenant-qualified channel and waits to receive it back — the only signal that a message published on one node would reach subscribers on another. Redis connectivity alone does not prove this. Reports healthy with nothing probed when Redis is not enabled. | — | The publish/subscribe round-trip does not complete within the timeout while Redis is enabled. | + +The Redis connectivity and backplane probes report **healthy with nothing probed** when Redis is not enabled: a deployment that declares no Redis dependency has none to be unhealthy about, and the [topology validator](#the-declared-topology-is-enforced-at-startup) — not these probes — is what refuses a production deployment that omits Redis. This keeps a supported development or single-node deployment from alerting as broken while still surfacing a real Redis, lock, or backplane outage in production. + +With the serving gate disabled, readiness performs no I/O at all and is safe to scrape at orchestrator frequency; with it enabled, readiness costs one store query per scrape. The dependency probe additionally exercises the store, the outbox and (on Voice) the ingress inbox, plus the distributed-lock, Redis-connectivity, and backplane round-trip probes when Redis is enabled — so scrape it on the order of seconds, not milliseconds. + +:::warning +Do not point a liveness or readiness probe at the `OrchardCore.HealthChecks` module's endpoint. That module maps a single route with no registration predicate, so it aggregates every check contributed by every enabled module regardless of tag — including the dependency checks. Despite its default `/health/live` route it is neither a liveness nor a readiness signal, and wiring a probe to it reintroduces exactly the fleet-wide-drain and restart-loop failures described above. + +Because documentation cannot stop a deployment chart from wiring that route, the Contact Center module fails tenant startup when `OrchardCore.HealthChecks` is enabled and its route still claims liveness. Resolve it by moving the shared endpoint off a liveness name: + +```json +{ + "OrchardCore_HealthChecks": { + "Url": "/health/aggregate" + } +} +``` + +If you have deliberately accepted the aggregate-on-liveness-route behavior, acknowledge it explicitly instead: + +```json +{ + "CrestApps_ContactCenter": { + "HealthChecks": { + "AllowUnsafeSharedEndpointRoute": true + } + } +} +``` +::: + +Thresholds are configured under `CrestApps_ContactCenter:HealthChecks` and are normalized so an unhealthy bound can never fall below its degraded bound: + +```json +{ + "CrestApps_ContactCenter": { + "HealthChecks": { + "DeadLetterDegradedThreshold": 1, + "DeadLetterUnhealthyThreshold": 25, + "OverdueBacklogDegradedThreshold": 50, + "OverdueBacklogUnhealthyThreshold": 500 + } + } +} +``` + +SignalR backplane health is owned by the backplane provider rather than the Contact Center module. When a Redis backplane is configured, register the Redis/backplane connectivity check with the `contactcenter-dependency` tag so it appears on the dependency probe and can be alerted on. + +:::danger +Never tag a Redis, database, provider, or backplane connectivity check `contactcenter-ready`. Redis is shared by every node, so every node would fail readiness at the same instant, the load balancer would be left with no target, and a degraded backplane would become a total outage that cannot self-heal. Shared dependencies are alerting signals, not routing signals. +::: + +On a single node the in-memory backplane needs no separate check. + +## Base-voice deployment acceptance + +The Asterisk voice provider advertises `Recording | Monitor | Whisper | Barge` and the snoop/bridge implementations are unit- and cassette-tested, but whether the **end-to-end WebRTC audio path** works — trusted WSS/DTLS certificates, TURN relay, direct-ICE media, restart drain, and a measured capacity floor — is a property of a *deployment* and its infrastructure, not of the capability code. It cannot be proven in the application build, so it is proven once against the reference topology and then declared. Inbound, outbound, transfer, and conference calls all ride the same conversation-bridge audio path, so proving it once covers them all. Supervisor **monitor, whisper, and barge** ride a *separate* snoop bridge that this acceptance run does not exercise; they stay advertised and enabled, but do not read the acknowledgment as evidence that supervision audio was verified — add a monitor/whisper/barge tone check to the run if your deployment relies on them. + +Until an operator declares the base-voice path verified, a production host **withholds readiness for the tenant** — the base-voice check is tagged `contactcenter-ready`, so an unhealthy verdict fails the aggregate `/api/contact-center/health/ready` probe and the orchestrator drains the node for *all* Contact Center traffic on that tenant, not voice alone. This is the same fail-closed rule as the topology check: an unverified media path is fixed infrastructure that no amount of waiting repairs, so serving traffic from it is the failure being prevented. Outside a production host environment the deployment is only warned, so development and test hosts are not blocked. Note the gate has readiness teeth only: unlike the topology check it does not also refuse work admission, so a deployment whose orchestrator never probes readiness would still serve unverified voice — probe readiness, or gate rollout on it. + +The verdict is surfaced by the `contactcenter-base-voice-verification` readiness check (registered by the Contact Center Voice feature and tagged `contactcenter-ready`). In a production host it reports: + +- **Unhealthy** — `CrestApps_ContactCenter:BaseVoiceVerification:AudioVerificationAcknowledged` is unset or `false`. Readiness is withheld. +- **Healthy** — the flag is `true`. Acknowledging requires a non-empty `AudioVerificationEvidenceReference` — every host rejects an acknowledgment that cites no retained evidence at startup, regardless of environment — and that reference is echoed in the verdict so an operator can trace the acknowledgment to its evidence. + +Outside a production host the check is always `Healthy`; the accompanying startup log entry warns (`Warning` outside production, `Critical` in production) whenever the path is unverified. + +### The acceptance procedure + +Perform this against the reference topology before declaring a deployment production-supported, and retain the captured evidence: + +1. **Direct-ICE media** — place a WebRTC call agent-to-Asterisk with TURN disabled and confirm two-way audio, so the browser and Asterisk establish media directly over ICE. No automated test exercises this browser↔Asterisk WebRTC signaling or media path in CI — the scaffolded proof `AsteriskBrowserAudioE2ETests.BrowserToAsteriskWebRtcAudio_WithDirectIceAndForcedTurn_VerifiesReceivedToneFrequencies` is skipped because it requires a real Asterisk, coturn, browser WebRTC, trusted WSS/DTLS certificates, and direct-ICE/forced-TURN tone verification, so the path is proven only by this deployment acceptance step (or by unskipping that test on the dedicated media runner). CI covers *adjacent* surfaces: the Asterisk ARI control plane via recorded-cassette provider-contract tests (`AsteriskAriRestContractTests`, `AsteriskAriEventContractTests`, `AsteriskCallStateReconciliationContractTests`), and the soft-phone UI/adapter contract via the Playwright suite (`contact_center_browser_gates.yml`) running against a *stubbed* media adapter. Neither reaches live WebRTC signaling or media. +2. **Forced-TURN relay** — repeat with direct ICE blocked so all media is relayed through coturn, confirming the relay path and its credentials work. +3. **Restart drain and dependency failure** — trigger a rolling restart / `SIGTERM` during a live call and confirm the node reports unready before it stops accepting connections (see [Voice listener handover and rollback](runbooks.md#voice-listener-handover-and-rollback)); confirm a transient dependency failure degrades rather than crashes. +4. **Capacity floor** — establish the deployment's measured concurrent-call floor against the [Tier-1 capacity target](#tier-1-capacity-target). + +### Evidence template + +Retain a record of the run and reference it from the acknowledgment. A minimal template: + +| Field | Value | +| --- | --- | +| Deployment / environment | *e.g. `prod-eu-west`* | +| Reference topology profile | `single-node-distributed` | +| Date and operator | *date, who ran it* | +| Direct-ICE two-way audio | *pass/fail, notes* | +| Forced-TURN relay audio | *pass/fail, notes* | +| Restart-drain unready-before-stop | *pass/fail, notes* | +| Dependency-failure degradation | *pass/fail, notes* | +| Measured concurrent-call floor | *number* | +| Evidence artifact | *link / identifier* | + +Declare the result once the run passes: + +```json +{ + "CrestApps_ContactCenter": { + "BaseVoiceVerification": { + "AudioVerificationAcknowledged": true, + "AudioVerificationEvidenceReference": "https://…/base-voice-proof/prod-eu-west-2026-07" + } + } +} +``` + +### Recording ships off by default + +Because the unverified-audio risk compounds with the recording media-lifecycle and erasure risk, **recording is disabled by default** (`ContactCenterRecordingSettings.RecordingEnabled` defaults to `false`). A fresh tenant records nothing until an operator enables it in the Recording governance section of the Contact Center settings screen (which requires the separate **Contact Center Recording – Administration** feature to be enabled), which should happen only after the base-voice acceptance run passes for the deployment. The `Recording` provider capability stays advertised — the media path is implemented — and `Monitor`, `Whisper`, and `Barge` remain advertised and enabled; only the recording *policy* defaults off. + +## Multi-node real-time backplane + +The Contact Center real-time hub is backplane-agnostic. It is hosted through `HubRouteManager.MapHub` and addresses connections through tenant-qualified `TenantSignalRGroupName` groups, so the same code path serves both single-node and multi-node deployments without change. What makes it correct across nodes is the shared backplane, not the hub. + +The supported production real-time topology is: + +- Enable `CrestApps.OrchardCore.SignalR.Redis` on every tenant that must exchange real-time messages. It wires the SignalR Redis backplane (`AddStackExchangeRedis`) using the `OrchardCore_Redis` connection settings and a dedicated SignalR connection, and it namespaces the backplane channel with both `InstancePrefix` and the immutable shell name so two nodes serving one tenant share a channel while different tenants never do. See [SignalR module — Redis backplane](../modules/signalr.md#redis-backplane) for configuration. +- Enable `OrchardCore.Redis.Lock` as well. The SignalR backplane distributes real-time messages, but Contact Center routing, provider webhook inbox acceptance, and other distributed critical sections require the Redis distributed lock independently of the backplane. A backplane without distributed locking is an unsupported configuration. +- Use a deployment-unique `InstancePrefix` (application, environment, region) whenever Redis infrastructure is shared, so tenants with the same shell name in different deployments cannot merge backplane channels. + +Both features are required in production even on the single supported node. The in-memory backplane and local lock are development-only: a rolling restart overlaps two instances, and an Orchard shell reload rebuilds the shell in-process, so process-local state is not a safe substitute for the distributed contract. Production without the backplane, without Redis distributed locking, on more than one node, or in a multi-region active-active configuration is unsupported. + +## Configuration validation + +Every operator-supplied option in the Contact Center, Telephony and provider modules is validated, and an invalid value stops the tenant instead of being discovered later by whichever code path happens to read it first. + +Validation runs when the tenant activates, which is what the first request to that tenant triggers. Declaring `ValidateOnStart()` alone is not sufficient here: it records its rules against `IStartupValidator`, which the .NET generic host invokes only against the root container, while Orchard Core builds a service container per tenant. `ValidateTenantOptionsOnActivation()` closes that gap by invoking the validator during tenant activation, so a tenant carrying an invalid configuration fails to activate and never serves a request. A misconfigured tenant does not degrade other tenants on the same host. + +Validated settings: + +| Section | Rule | +| --- | --- | +| `CrestApps_ContactCenter:Retention` | Every window and floor is non-negative, and so are the purge batch size and the per-entity batch budget, where zero means "use the default". | +| `CrestApps_ContactCenter:HealthChecks` | Every threshold is at least one, and each unhealthy bound is at or above its degraded bound. | +| `CrestApps_ContactCenter:Topology` | A declared profile identifier resolves to a known topology profile, so a typo is refused rather than silently falling back to a weaker topology. | +| `CrestApps_ContactCenter:BaseVoiceVerification` | An acknowledgment (`AudioVerificationAcknowledged`) must be accompanied by a non-empty `AudioVerificationEvidenceReference`, so the base-voice path can never be declared verified without citing the retained evidence. | +| `CrestApps_ContactCenter:Coordination` | Lock waits are positive and each lease expiry exceeds its acquisition timeout. | +| `CrestApps_Telephony:Commands` | The command timeout is between one second and two minutes. | +| `CrestApps_Telephony:Coordination` | Lock waits and the new-interaction grace period are positive, and the lease expiry exceeds its acquisition timeout. | +| `CrestApps:Asterisk:Default` | Numeric settings are sane whenever the configuration-backed provider is enabled. | +| `CrestApps:Asterisk:Coordination` | Lock and HTTP timings are positive, the lease expiry exceeds its acquisition timeout, the total request budget exceeds a single attempt, the real-time buffer capacity is positive and no larger than 100000, and the real-time backpressure timeout is positive. | + +Each lease expiry must exceed its acquisition timeout because otherwise the lease can lapse while a peer is still waiting to take it, and two nodes then act on the same call, credential or reconciliation sweep at once. + +### Development credentials are refused in production + +This repository publishes working credentials so the Aspire stack runs without an operator inventing their own — including the Coturn static authentication secret and the Asterisk ARI password. Outside a development environment those values authenticate nobody, because anyone who has read this repository holds them. + +When `ASPNETCORE_ENVIRONMENT` is `Production`, a tenant refuses to activate if `CrestApps:Asterisk:Default` supplies a known development value for `Password`, `TurnSharedSecret`, `UserName` or `PjsipRealtimeConnectionString`. The same values keep working in development, so the workflow they were published for is unaffected. Obvious placeholders that were never replaced, such as `changeme` or ``, are refused on the same terms. + +The register of known development values stores SHA-256 digests rather than the secrets themselves, and a test walks the tracked configuration files in this repository to assert that every credential they publish is recognised — so adding a new sample credential without registering it fails the build rather than shipping an unguarded value. + +Where a credential is only used to derive a client-visible artifact, the guard degrades rather than fails the tenant. A TURN relay credential derived from a published shared secret is withheld in production and the soft phone receives STUN-only ICE servers, which reduces connectivity through restrictive networks but never hands out a forgeable relay credential. + +### Timings are configuration, not constants + +Distributed-lock waits, lease expiries, the inbound reclamation threshold and the Asterisk HTTP request budget are deployment characteristics: a node under heavier load, or one further from its database or from Asterisk, needs different values than a developer laptop. They are settable under the `Coordination` sections above and validated on the same terms as everything else. + +## Retention, legal holds, and replay horizon + +Every table that grows with traffic is aged out by a retention policy. A table without one is not a small oversight: it is the table that eventually fills the disk, and it is invisible until it does. Retention therefore covers the whole database rather than the event log alone, and a table that is deliberately *not* aged out has to say so. + +Each entity declares a policy that answers three questions: which timestamp the record is aged from, what makes the record finished, and which floors hold it beyond its configured window. + +Retention is configured under `CrestApps_ContactCenter:Retention`. Every window defaults to `0`, which means "keep indefinitely": + +| Setting | Entity | +| --- | --- | +| `InteractionEventRetentionDays` | Interaction events | +| `InteractionRetentionDays` | Interactions | +| `CallSessionRetentionDays` | Call sessions | +| `QueueItemRetentionDays` | Queue items | +| `ActivityReservationRetentionDays` | Activity reservations | +| `OutboxMessageRetentionDays` | Event outbox messages | +| `WebhookInboxMessageRetentionDays` | Provider webhook inbox messages | +| `ProviderCommandRetentionDays` | Provider commands | +| `AgentSessionRetentionDays` | Agent sessions | +| `CallbackRequestRetentionDays` | Callback requests | +| `EventMetricRetentionDays` | Daily event metrics | +| `ProcessedEventRetentionDays` | Processed-event markers | +| `WorkStateRetentionDays` | Routing work state | + +### Records are aged from when they settled + +A record is aged from the moment it reached a final state, never from when it arrived, when it was last retried, or when it was due: + +| Entity | Aged from | Finished when | +| --- | --- | --- | +| Interaction event | `OccurredUtc` | Always — an event is an immutable fact | +| Interaction | `EndedUtc` | The conversation ended | +| Call session | `EndedUtc` | The call ended | +| Queue item | `DequeuedUtc` | Completed or removed | +| Activity reservation | `ModifiedUtc` | Rejected, expired or canceled | +| Routing work state | `ModifiedUtc` | Never — see below | +| Event outbox message | `CreatedUtc` | Completed or dead-lettered | +| Provider webhook inbox message | `ProcessedUtc` | Completed or dead-lettered | +| Provider command | `CompletedUtc` | Confirmed, compensated or failed | +| Agent session | `LastHeartbeatUtc` | The session stopped reporting | +| Event metric | `Date` | Always — a closed day is final | +| Processed-event marker | `ProcessedUtc` | Always — the event was handled | +| Callback request | `ModifiedUtc` | Completed, canceled or failed | + +Ageing from arrival time would delete exactly the work that waited longest — the calls that sat in queue for an hour, the commands that retried for a day — which are the records an operator most needs when explaining what went wrong. Ageing from a *scheduled* time is worse still: a callback booked three weeks out carries a future timestamp, and a command in backoff carries a retry time that keeps moving. + +The status condition is what keeps a live record alive. Age alone never makes an in-flight record safe to delete, so a queue item that is still waiting, a command whose outcome is still unknown, or a conversation that has not ended survives regardless of how old it is. + +### Floors extend retention, never shorten it + +| Setting | Meaning | +| --- | --- | +| `ProjectionReplayHorizonDays` | Minimum days the event log must remain rebuildable. Applies to the interaction event log, the only table projections replay from. | +| `LegalHoldMinimumDays` | Legal-hold / regulatory floor. Applies to communication history: interaction events, interactions, call sessions and callback requests. | +| `ProcessedEventDeliveryEnvelopeDays` | How long a provider may still redeliver an event. Applies to processed-event markers, which suppress a redelivery. | + +An accepted reservation is deliberately absent from that list. Accepting is the live claim an agent holds: it is the state that keeps the unique activity claim in place and that tells the agent what they are working on. Deleting one would release the claim so the same work could be reserved twice, and would strip the agent's own assignment out from under them. A reservation only settles when it is rejected, expires or is canceled. + +Routing work state is the one entity with no finished state at all, because whether the work is over is owned by the CRM activity rather than by the routing document. It is aged from its last mutation alone. That is safe only because the document is reconstructible: a work state that no longer exists is recreated on next access and re-seeded from the activity projection, which re-adopts the assignment status, reservation and attempt counts. This is the same adoption path a tenant that predates the document takes, so a purged work state is recoverable rather than lost. Without a policy it was the one table that grew by a row for every activity ever routed and was never deleted by anything. + +A settlement column is only useful if something writes it. Reservations and callbacks are aged from their last modification, and nothing was stamping that field on the transitions that settle them, so both policies would have matched no row at all — the entity would have reported itself drained every cycle while its table grew. The reservation service now stamps the modification time on every release and cancellation, and the dialer stamps it when a callback is promoted to an activity. A callback counts as settled once it has been scheduled, because from that point the promoted activity is the durable record of the work; the remaining outcome statuses are kept in the policy for completeness. A gate asserts that every settlement column an active policy reads is stamped by a named statement in a named method on each path that settles the record. Naming the method matters: a type usually has several settlement methods, and a file-wide search is satisfied by any one of them, so deleting the stamp from the single path that ends in a status no other method produces would leave the build green while every record settled that way became immortal. + +Where a settlement time is added to an existing index by an upgrade, that upgrade also backfills it. Adding a column to an index does not re-project the documents that already exist, and a settled record is never written again, so every row that predated the upgrade would keep a null settlement time and be rejected by its own policy forever. The backfill dates those rows from the upgrade — later than the truth, so it can only ever delay a purge, never bring one forward — and is restricted to rows already in a settled status so nothing in flight is given a false completion time. + +Every purge predicate is backed by an index that leads with the timestamp it selects on. The drain loop asks for a batch at a time with no ordering, so an unindexed predicate turns each terminating batch into a full scan of the very table retention exists to bound — worst on the largest tables, and on every cycle once the table has reached steady state. A gate asserts the covering index exists for each policy. + +The effective cutoff for an entity keeps records for `max(window, applicable floors)` days, so raising a floor extends retention and never causes an earlier purge. Purging stays disabled for an entity whose window is `0`, which is the default — an unconfigured tenant deletes nothing. + +Floors are scoped rather than global. Applying legal hold to delivery bookkeeping would hold outbox rows for years without holding anything a regulator asked for, and applying it to processed-event markers would trade a disk problem for a much larger one. The redelivery floor exists for the opposite reason: purging a deduplication marker while its event can still be redelivered makes the redelivery look new, and the side effect runs a second time. A completed webhook inbox row is such a marker: its payload is cleared at settlement and the row is kept only so a repeated provider delivery is recognised as a duplicate, so it is aged from settlement rather than from receipt — settlement lags receipt by the whole retry envelope, so ageing from receipt would silently shorten the guarantee. Its floor is the seven-day duplicate-detection horizon the inbox itself enforces, not a configurable envelope, because the inbox already sweeps its own settled rows at that horizon on every dispatch pass. Two purges targeting one table means the shorter of the two decides the real window, so the sweep and the retention policy are reconciled in both directions: the policy may never delete inside the seven-day horizon, and the sweep never deletes before `WebhookInboxMessageRetentionDays` — otherwise raising that setting would change nothing and the operator would be configuring a value the sweep silently overruled. Leaving the setting at `0` keeps the seven-day sweep, because an inbox row exists only to bound a duplicate window that is already bounded. + +### Purging drains, and says so when it cannot + +Each cycle drains an entity in batches until the table is empty rather than deleting a fixed number of rows and stopping. The default batch size (`PurgeBatchSize`) and per-cycle budget (`MaxPurgeBatchesPerCycle`) allow five million rows per cycle, so a database that has accumulated a large backlog returns to steady state within one cycle. The session is committed between batches so a large drain never accumulates one unbounded transaction. + +| Setting | Meaning | +| --- | --- | +| `PurgeBatchSize` | Rows deleted per batch. | +| `MaxPurgeBatchesPerCycle` | Batches per entity per cycle, bounding a single cycle's work. | + +The budget is spent per entity rather than shared across the cycle. A shared budget would let whichever policy runs first consume all of it whenever its table is large, and every entity behind it would never be purged at all while the cycle still reported success. + +If the budget runs out, the cycle logs a warning naming the entities that still have work rather than completing quietly — an operator who has outgrown the budget finds out from a log line instead of from a full disk. One entity failing does not stop the others: a single unhealthy table would otherwise keep every other table growing. A batch that fails partway through has already staged some of its deletes into a session every entity shares, so those deletes are committed and counted against the entity that produced them before the cycle moves on; left staged they would be flushed by whichever entity ran next, committed under that entity's transaction and attributed to nobody. + +### Tables that are deliberately never purged + +Configuration and reference data — queues, queue groups, skills, entry points, dialer profiles, business-hours calendars, agent profiles, agent queue memberships and reason codes — are bounded by tenant setup rather than by traffic, so ageing them out would delete a working configuration. Projection checkpoints hold one row per handler, and deleting one replays that projection from the beginning. + +Each exemption is recorded with its reason and is checked by a gate, so a new table is either covered by a policy or exempted on purpose. Adding an index without doing either fails the build. + +Each policy is registered by the feature that owns its data — queue items and reservations by **Queues**, callbacks by **Dialer**, provider commands and webhook inbox messages by **Voice**, agent sessions by **Availability** — so a tenant purges exactly the tables it actually writes to, and enabling a feature brings its retention with it. + +Behavior guarantees: + +- **Retained snapshot** — the daily metrics projection is a durable aggregate that survives event purge, so reporting figures remain available after the raw events are gone. +- **Post-purge rebuild** — after a purge, a projection rebuild (`RebuildAsync`) recomputes counts only from the events that remain; the replay-horizon floor guarantees that window is at least `ProjectionReplayHorizonDays`. +- **Legal hold** — set `LegalHoldMinimumDays` above the retention window to hold events for a case or regulatory obligation without changing the operational retention setting. + +## Per-entity data governance + +Every persisted Contact Center data category is classified in code by `ContactCenterDataGovernanceCatalog`, the single source of truth this table renders. Each category declares its privacy sensitivity, whether it references call recordings, what governs its retention, and how an erasure (right-to-be-forgotten) request is satisfied. The catalog is unit-tested for integrity — keys are unique, personal categories always declare a concrete erasure strategy, non-personal categories never anonymize, and any recording-bearing category is always classified as personal — so a new persisted entity cannot ship without an explicit classification. + +| Data category | Sensitivity | Recording ref | Retention basis | Erasure | +| --- | --- | --- | --- | --- | +| Interaction event log | Personal | No | `InteractionEventRetentionDays`, floored by replay-horizon and legal-hold | Retention expiry | +| Interaction | Sensitive personal | Yes | `InteractionRetentionDays`, floored by legal-hold, once ended | Anonymize (+ external recording erasure) | +| Call session | Sensitive personal | Yes | `CallSessionRetentionDays`, floored by legal-hold, once ended | Anonymize (+ external recording erasure) | +| Callback request | Personal | No | `CallbackRequestRetentionDays`, floored by legal-hold, once resolved | Anonymize | +| Agent session | Personal | No | `AgentSessionRetentionDays`, from last heartbeat | Anonymize | +| Agent profile | Personal | No | Agent account lifecycle | Anonymize | +| Event outbox message | Personal | No | `OutboxMessageRetentionDays`, once completed or dead-lettered | Retention expiry | +| Provider webhook inbox message | Personal | No | `WebhookInboxMessageRetentionDays`, once completed or dead-lettered, floored by the seven-day duplicate-detection horizon | Retention expiry | +| Provider command | Non-personal | No | `ProviderCommandRetentionDays`, once settled | Retention expiry | +| Queue item | Non-personal | No | `QueueItemRetentionDays`, once dequeued | Cascade with interaction | +| Activity reservation | Non-personal | No | `ActivityReservationRetentionDays`, once rejected, expired or canceled | Retention expiry | +| Routing work state | Non-personal | No | `WorkStateRetentionDays`, from last mutation; recreated and re-seeded on next access | Retention expiry | +| Event metric | Non-personal | No | `EventMetricRetentionDays` | Not applicable | +| Projection checkpoint | Non-personal | No | Operational; updated in place | Not applicable | +| Processed-event ledger | Non-personal | No | `ProcessedEventRetentionDays`, floored by the redelivery envelope | Retention expiry | +| Routing and dialing configuration | Non-personal | No | Administrator-managed | Not applicable | + +**Erasure strategies.** *Retention expiry* removes the record automatically when it ages past its window (no per-subject action). *Anonymize* clears the personal fields — the customer/caller addresses and free-text notes — while keeping the record so aggregate metrics and audit history survive. *Cascade with interaction* erases the record together with its parent interaction. *External store* delegates erasure to the system that holds the payload. *Not applicable* means the category holds no personal data. + +**Call recordings.** Recordings are never stored inside Contact Center. The `Interaction` and `CallSession` entities hold only a `RecordingReference` (an opaque pointer) and a `RecordingState`; the media itself lives in the telephony provider or a configured media store. Consequently: + +- **Access audit** — recording playback and download must be brokered by, and audited in, the system that holds the media. Contact Center exposes the reference under the same permission and content-access-control checks as the owning interaction; every access decision is logged through the operational log with the identifier taxonomy (recordings are treated as sensitive personal data). Wiring a specific media store's access log is a deployment integration. +- **Recording erasure** — recording/media erasure is a first-class, durable operation (it is a component of a GDPR Art. 17 / CCPA response, not a general cross-entity subject-erasure feature). See [Recording media erasure](#recording-media-erasure) below. +- **Omnichannel/CRM data is out of scope for this catalog** — this classification and its erasure strategies cover Contact Center entities only. The omnichannel/CRM layer stores message content and contact addresses **plaintext** and has **no automated per-contact subject erasure** (a general-availability blocker): a Contact Center `Interaction` is deleted outright when its retention window elapses, but an `OmnichannelMessage` has no retention window at all and is retained plaintext until the operator removes it directly. See [Data at rest and privacy](../omnichannel/management.md#data-at-rest-and-privacy). + +### Recording media erasure + +Recording media erasure clears every pointer to a recording, records a durable tombstone, and deletes the underlying media through the store that owns it — atomically, so a partial erasure can never leave a pointer without its media or media without its tombstone. + +- **Authorized command** — an admin-only endpoint (`POST {admin}/contact-center/recordings/erase`, permission `ManageInteractions`, antiforgery-validated) takes an interaction id and a required, non-empty reason. The reason is required because an erasure with no recorded justification is indistinguishable from an accident. The durable writes are bound to `CancellationToken.None`, not the request abort token, so a caller who disconnects mid-request cannot tear the unit of work. +- **One unit of work** — in a single YesSql transaction the erasure clears the `Interaction.RecordingReference` and its retrieval metadata, clears the mirrored `CallSession.RecordingReference` (a second pointer that would otherwise resurrect access to the deleted media), stamps the `RecordingErasedUtc` tombstone, and enqueues durable media deletion on the existing Contact Center outbox. Pointer clears, tombstone, and outbox enqueue commit together or not at all. +- **Media deletion is durable, not inline** — the outbox handler performs the actual media-store delete in a fresh scope. Delivery is at-least-once, so the media store treats an already-absent recording as successfully erased; an unconfirmed delete throws so the outbox retries rather than reporting a false success. On confirmed deletion the handler publishes a `RecordingMediaDeleted` receipt keyed deterministically off the erasure event id. +- **Retention deletes media before the row** — the interaction retention path enqueues media deletion (and clears the mirrored call-session pointer) *before* it deletes the interaction row that carries the only reference, so age-based retention can never orphan media. A per-record `RecordingLegalHold` spares a held recording from age-based deletion entirely: held records are excluded by the purge query itself (an indexed flag), so a page of held recordings can never stall the drain, and the per-record check remains as a safety net. The policy-level legal-hold floor only widens the time window and never, on its own, protects a specific held record. +- **Late ingest cannot resurrect erased media** — the Asterisk recording ingest job consults the erasure tombstone before downloading and again after storing media (an erasure can land inside the download/store window). Both checks read the tombstone through a fresh scope so a per-cycle ingest sweep observes an erasure committed by another scope mid-window rather than a cached read. If the recording was erased — or the interaction no longer exists — any media already written is deleted (by the deterministic storage key, so media a prior attempt stored before crashing is cleaned up too) and the ingest job is cancelled. +- **Tenant removal fails closed** — decommissioning a tenant blocks removal unless the configured media store completes tenant-wide media cleanup. A store that does not implement tenant-wide purge, or a purge that returns unsuccessfully or throws, blocks tenant removal with an operator-visible error rather than silently orphaning media. +- **Human-visible receipt** — when Orchard's Audit Trail feature is enabled, confirmed deletion (not merely request acceptance) is recorded under the **Contact Center** category as **Recording media deleted**. Audit is a convenience receipt, not the durable proof: the outbox completion plus the tombstone remain authoritative if the audit category is disabled or trimmed. + + +**Backup and restore.** All durable Contact Center state lives in the tenant SQL database (see the [failure runbooks](runbooks.md)); back it up with the engine's native, point-in-time-capable mechanism. Because the interaction event log is the projection-rebuild source, keep `ProjectionReplayHorizonDays` and `LegalHoldMinimumDays` set so a point-in-time restore retains enough history to rebuild projections — after a restore, run the metrics projection rebuild to reconcile any drift. Provider-held recordings are backed up by their owning store, not by the Contact Center database backup, so a full restore must coordinate the database restore with the media store's own retention and restore policy. + +## Data residency + +The platform does **not** constrain where customer data is physically located; residency is an operator responsibility. Customer content and personal data are held or processed by several distinct systems, and an operator with a jurisdictional obligation must site **every** one of them in the required region — placing only the primary database does not satisfy a residency requirement. + +| System | What it holds | Notes | +| --- | --- | --- | +| Tenant SQL database (PostgreSQL in the `single-node-distributed` profile) | Interactions, call sessions, routing/queue state, and the omnichannel message content and contact addresses | The omnichannel message body and addresses are stored **plaintext** (see [Data at rest and privacy](../omnichannel/management.md#data-at-rest-and-privacy)); protect them with database- or disk-level encryption. | +| Recording media store | Completed call recordings | The default `LocalEncryptedRecordingMediaStore` writes to a tenant-scoped application-data folder, encrypted at rest. A cloud-backed store places media wherever that store is configured. | +| Asterisk telephony host | The **unencrypted** source recording file, transiently | Ingest best-effort deletes it after upload; if that delete keeps failing, plaintext media can linger on the telephony host, so the host is in residency scope. | +| Redis backplane | Call-state payloads in transit on the SignalR backplane, plus distributed-lock keys | Mandatory for the `single-node-distributed` profile; site the Redis instance in-region. It carries messages in transit and lock keys rather than persisted state. | +| Third-party SMS / email provider (Twilio, Azure Communication Services) | Message content that transits and is retained by the third party | ACS supplies email and SMS providers; there is no ACS voice module. Governed by the provider's own residency and retention terms, outside the operator's encryption controls. | +| Third-party voice provider (DialPad) | **All** call audio and any provider-side recordings | DialPad uses the agent-device-native model, so Contact Center never bridges its media — the audio never enters Orchard and lives entirely in DialPad's cloud. | +| AI completion provider (OpenAI, Azure OpenAI, Claude, Ollama, or the configured provider) | Inbound SMS message bodies | When a subject flow uses an AI profile, the inbound customer SMS content is sent to the configured AI provider for completion, outside the tenant database and the operator's encryption controls. | + +Because the SMS/email, voice, and AI providers process and may retain customer content outside the tenant's infrastructure entirely, a residency or data-processing obligation covering those flows must be satisfied through each provider's own regional configuration and contractual terms, not by the CrestApps modules. + +## Configuration portability and preview data + +Contact Center configuration — queues, queue groups, skills, routing entry points, dialer profiles, business-hours calendars, and agent state reason codes — is exported and imported through the standard Orchard Core **Deployment** and **Recipes** mechanisms, exactly like every other tenant setting. Each operator-authored data set has a dedicated deployment step (for example the queue, skill, entry point, dialer profile, business-hours calendar, queue-group, and reason-code steps), so an operator builds a deployment plan, exports it, and replays it as a recipe on another tenant. There is no bespoke Contact Center export format to learn or maintain, and no parallel import path: the sanctioned Orchard pipeline is the single source of truth for configuration portability. + +Operational data — interactions, activities, assignments, agent sessions, dialer records, and the event/metric ledgers — is **not** configuration and is deliberately excluded from Deployment/Recipes. Its lifecycle is governed two ways: + +- **Ongoing minimization** by the retention windows and per-entity governance categories described above, which age records out automatically. +- **Backup and restore** by the tenant SQL database's own point-in-time mechanism (see the **Backup and restore** guidance above), which is the only mechanism that captures operational content faithfully. + +Contact Center intentionally ships **no destructive "reset all operational data" admin action**. Clearing a preview tenant is a database-lifecycle operation (drop or restore the tenant database), not an in-app button, because an in-app bulk delete cannot offer the atomicity or point-in-time recoverability that the database engine already provides, and a count-based "receipt" is not a real backup. This keeps the destructive surface out of the running application entirely rather than gating it behind flags. + +## Query-plan budgets + +Retention bounds how large a table becomes. It does not bound how much of that table a query reads, and the two failures look nothing alike: a table that grows without limit eventually fills a disk and announces itself, while a query that reads a whole table returns exactly the right answer every time and simply gets slower in proportion to how much history the contact center has. Nothing in a functional test can see the difference, so the plan itself has to be asserted. + +The statement under budget is the one routing runs before every offer: how much live work each candidate agent is already holding. Three things make it cheap, and each is enforced. + +**The predicate has to be answerable from an index.** Asking for the interactions that are *not* finished — a chain of inequalities — cannot be satisfied by an index on the status column, because an index orders values and an inequality does not name the ones it wants. The statuses that occupy an agent are therefore declared as a set and tested with `IN`. Because that set is inclusive, a status added to the enum and not classified would silently fall out of it, an agent holding work in the new status would look idle, and they would be handed more: a build gate requires the declared sets to partition `InteractionStatus` exactly, so adding a status forces a decision about it. + +**The index has to lead with the predicate.** The interaction index already carried a composite index leading with `DocumentId`, which is the YesSql join key and serves join-back and delete-by-document, but answers nothing about an agent. `IDX_InteractionIndex_ActiveByAgent (AgentId, Status, DocumentId)` is added alongside it rather than replacing anything, and it covers the count outright, so the number comes from the index without touching the table at all. + +**The count has to happen in the database.** Loading one row per interaction and grouping them in memory makes the cost of a routing decision scale with how busy the contact center is, which is precisely when a routing decision must be cheapest. The count is a SQL `GROUP BY`, issued on the calling session's own transaction so that a caller who reserves an interaction and then asks for that agent's load in the same unit of work is not told the agent is free. + +**How the budget is enforced.** Two gates run `EXPLAIN` against the statement — read from the same builder the store executes, so a plan cannot be proven for a query nobody runs — against a schema built by the shipped migrations, so it cannot be proven for indexes nobody deploys, and against enough seeded rows for a planner to have a real choice, since a planner reads a small table end to end because doing so is genuinely cheaper. The SQLite gate runs on every build and requires the plan to seek the covering index with both the agent and the status as seek constraints, rather than seeking by agent and then testing the status row by row. The PostgreSQL gate runs in the operations-gates workflow against a real PostgreSQL service and requires no sequential scan of the interaction index table. Neither gate rewrites the statement before measuring it, and the PostgreSQL job additionally executes the store method and asserts its counts, so a statement that plans well but cannot run is not mistaken for a passing budget. Reservation itself counts live work through the query pipeline rather than the hand-written statement, so a recording connection captures the SQL the pipeline emits for those queries and holds them to the same budget. + +Both are needed, because the planners disagree and the results do not. PostgreSQL will choose to read a table end to end where SQLite seeks, so only the plan on the engine a deployment actually runs is evidence for that deployment. + +### The agent-workspace poll + +The second statement under budget belongs to the read that runs most often in the whole product: the workspace state every signed-in agent polls continuously. Its cost used to grow in three separate places, none of which changed a single byte of what the agent saw. + +**Queue depth was asked once per queue.** An agent who covers eight queues issued eight counts a poll, and no index could answer any of them: the composite index leads with `DocumentId`, which serves join-back and delete-by-document and says nothing about a queue, and the retention index leads with `Status`, so the planner seeks that and then walks every waiting item in the tenant to find the ones in the queue being asked about. Depth is now one grouped statement covering all of an agent's queues at once, and `IDX_QueueItemIndex_WaitingByQueue (QueueId, Status, DocumentId)` is added alongside the existing indexes so it is answered from the index alone. Batching without the index would only have moved the growth: one statement that walks every waiting item in the contact center is no cheaper than forty that do. + +**The work behind recent interactions was resolved one at a time**, so the wrap-up check cost a query per interaction the agent had recently ended. Those activities are now fetched in a single read. + +**Recent interactions were read twice**, once for the active-interaction panel and once for the history panel, because each panel fetched what it needed for itself. The list is now read once and shared. + +**Both failures are gated, because either alone leaves the other free.** The round trips a single poll issues are counted at the handler, so a caller that loops over the single-item APIs fails on the count rather than on its output — a plan budget cannot see this, since a well-planned statement run once per queue costs the same as one scan. The plan of the batched statement is then measured on SQLite on every build and on real PostgreSQL in the operations-gates workflow, with the seek required to be constrained by both the queue and the status: an index that leads with the queue and tests the status row by row still names the index in the plan while walking every item that queue has ever held. Neither gate is written as "no table scan", because losing the covering index does not produce one — the planner reverts to the `Status`-led retention index, which is a seek by name and unbounded work in fact — so both require that no other index answers this question. As with the routing statement, the plan is measured on the SQL the store actually sends, and the PostgreSQL case executes it as well as explaining it. + +### Daily event counts + +Counting an event is the most frequent write in the product, and it used to be the narrowest. Each day and event type had a single row, and recording an event read that row, added one and wrote it back. That makes one row the meeting point for every handler counting the same kind of event on the same day: two that create it concurrently collide on its unique constraint, and two that update it concurrently fail the optimistic-concurrency check, so one of them either loses its whole request or overwrites a count it never read. Both outcomes are produced by load, not by code, so neither shows up outside production. + +A recorded count is now appended as its own contribution and never updated. Nothing serializes, because no two writers touch the same row. A background roller, holding the same distributed lock every other Contact Center background task holds, folds the contributions into the daily totals a batch at a time. + +Moving the write is the easy half. Four things have to hold afterwards or the totals are quietly wrong: + +- **The roller deletes exactly the contributions it read.** Deleting by predicate would also destroy anything appended between the read and the delete, and that event would be counted by nobody. +- **A fold adds to the total already stored.** Replacing it looks correct after the first fold of the day and discards every earlier hour. +- **A reader adds the contributions not yet folded, and stops once they are.** Otherwise a summary read a moment after the traffic it describes is behind by whatever the roller has not reached, or — if it keeps adding them after the fold — reports double. +- **Drift detection and rebuild count the contributions not yet folded — without folding them.** Ignoring them reports every one as a projection that has lost counts, and a real loss cannot be told apart from the roller not having run in the last minute. Folding them instead is worse: detecting drift is a read, and folding inside it commits the unit of work of whoever asked for the report, while folding before a rebuild reads the event log makes anything recorded in between counted by the recompute and folded again on top, inflating a business number permanently. Drift detection therefore adds the pending contributions to the stored totals in memory, and a rebuild subtracts them from the totals it writes so that folding them afterwards lands on the right number. **A rebuild is a repair that converges, not a snapshot that is exact.** It cannot read the event log, the contributions and the totals in one snapshot, so a rebuild run against live traffic leaves a residual, and the residual is not all in one direction. Reading the log before the contributions removes the case where an event recorded between the two reads is counted by the recompute and folded again on top of it. Two gaps remain that leave a day *high* rather than short. A contribution is not written in the unit of work that writes the event: the projection handler runs in a post-commit scope and is redelivered by the outbox, so an event is in the log for a window before its contribution exists at all, and a rebuild inside that window subtracts nothing for it while the later fold adds it a second time. Document identifiers are also allocated before the transaction that commits them, so a contribution can become visible below a position the walk has already passed and is missed for the same reason. Neither is silent — the next drift check reports the difference — and a rebuild run once the projection is settled, meaning the outbox drained and the roller caught up, writes exactly the log. Run a rebuild against a settled projection when the number has to be right immediately; run it against live traffic to repair, then re-run it once traffic has settled. Reading those pending contributions is itself a walk of the whole contribution table, and it resumes from a position rather than from an offset: the roller deletes the rows it folds from anywhere in that table, so an offset would step over rows that are still waiting once earlier ones are gone and the counts they carry would simply be absent, leaving the rebuild to write a total that is permanently short with nothing in the data to show it happened. +- **The roller belongs to the feature that does the counting.** Registered under a feature only some tenants enable, the totals of every other tenant are never folded, and retention purges the contributions before anything reads them — the counts are gone with no error anywhere. + +The contribution table is drained rather than accumulated, so it is sized by one drain interval rather than by traffic, and it carries a retention policy for the contributions a roller could never fold. The drain is deliberately unordered. The document query groups by document identity, and no ordering over the contribution columns can satisfy that grouping, so asking for one makes the engine sort the entire backlog before it can return a single batch — draining one batch would then cost as much as everything waiting. The roller sums whatever it is handed and removes exactly those rows, so no order is needed. That also means the contribution table carries no index for the roller at all: the document identity index YesSql already maintains answers the drain, and an index the drain never uses would only be another cost paid on the append path this design exists to keep free. It does carry an index on the day, because adding the contributions not yet folded to a summary is a request-path read that asks for them by day, and leaving that to walk a table written to on every recorded event would make reading a summary cost more the busier the deployment is. That read is also bounded: a backlog larger than a single drain is one no reader can report exactly anyway, so it truncates rather than growing without limit, with the same transient shortfall a lagging roller already produces. + +## Agent presence + +Presence is what tells routing an agent is there to be offered work, and it is maintained by the two cheapest-looking operations in the product: a timer in the browser that stamps the session, and a timer on the server that signs out the sessions that stopped being stamped. Both scale with the number of agents signed in rather than with the work they do, so both are held to a budget. + +**A heartbeat cannot undo a connect, and cannot fail the agent.** A heartbeat rewrites the whole session document to move one timestamp, and it arrives from every connected agent on a timer, which makes it the most frequent write the desktop performs. The document it rewrites also carries the connection list that connect and disconnect maintain, so two requirements apply at once and pull against each other: the heartbeat must not write back a connection list it read before a concurrent connect committed, which is exactly what the store's document-version check prevents; and losing that version check must not surface to the agent, whose hub call would otherwise fail on a timer over a write carrying nothing the agent needs. + +Neither a lock nor a second read delivers this, and it is worth being precise about why, because both look like they would. Writes are staged, not committed — the version check runs when the shell scope commits, which is after any lock taken inside the method has already been released, so two heartbeats can serialize perfectly against each other and still collide at commit. And a second read taken on the same unit of work is answered from its identity map, so it returns the instance already read rather than the row a concurrent connect committed; it is a round trip that cannot observe what it exists to observe. + +The stamp therefore runs in its own unit of work. A child scope has its own session, so its read reflects what is committed and the connection list it writes back is the current one; it commits before returning, so a lost version check is raised where it can be handled rather than thrown at the agent. Losing it is treated as success and not retried, for a reason that differs by writer. Connect and the cleanup pass carry a newer heartbeat, so retrying would write an older timestamp over a newer one. Disconnect and a membership sync do not advance the heartbeat at all, so a heartbeat lost to one of those records no liveness; that is tolerated rather than repaired, because neither fires on a timer and the stale threshold spans several heartbeat intervals, so a single loss cannot expire a live agent. The heartbeat takes no distributed lock at all, which also removes two round trips per agent per interval. It remains a full-document write; making it narrower would mean moving the heartbeat out of the session document, which is a schema change deferred to a later release. + +**The pass that signs agents out seeks rather than scans, and is bounded.** The cleanup pass runs every minute against a table that holds a row for every agent who has ever connected, so its cost has to be set by the size of the page it takes, not by how long the deployment has been running. The cut-off is a range over the heartbeat time, which `IDX_AgentSessionIndex_Retention (LastHeartbeatUtc, DocumentId)` leads with. + +Getting that seek required reading the index rather than the documents, and the reason generalizes to any bounded read in this module. A document query always groups by document identity, and no ordering over the index columns can satisfy that grouping — so bounding one makes the engine materialize and sort every matching row before it can honor the limit, and the pass still pays for the whole backlog. Worse, the bound is not free to add: when a page is requested and no ordering is given, an ordering by document identity is supplied, so the sort appears whether or not anyone asked for it. Selecting the stale sessions from the index alone carries no such grouping, so ordering by the heartbeat time is answered by the index that leads with it and the limit stops the read early. The documents are then fetched by a single bounded read. + +Three properties are gated, each on the statement the store actually issues rather than on one written to resemble it. The plan must not scan the session index — asserted against the alias the plan reports, because the physical table name never appears in a plan and an assertion written against it can never fail. It must seek `IDX_AgentSessionIndex_Retention`. And it must build no temporary tree, because a bound is only worth having if the engine can stop once the page is full. Separately, the read must take the oldest heartbeats first: an arbitrary page leaves which sessions a pass expires up to the engine, so an agent whose heartbeat stopped can sit unexpired behind a page that keeps being answered with someone else while the pass reports that it is working. Draining oldest-first is what makes consecutive passes finish a backlog rather than revisit it. + +The bound matters because every session in a tenant goes stale together whenever a deployment drops every connection at once, and the caller takes a distributed lock, re-reads and deletes for each session it is handed. Unbounded, that single event becomes one pass doing all of it while the next pass is already due. Bounded, the backlog drains over consecutive passes, which is the same shape retention purging uses — so `ExpireStaleAsync` reports what one pass expired, not that the backlog is gone. + +## Reservation transitions and lock leases + +Every reservation transition is taken under one or two distributed locks with a fixed thirty-second expiration, and those leases are never renewed. A critical section that outruns its lease keeps working under a lock it has already lost while a second caller is admitted, and that is a property of leases rather than a defect to be tuned away: renewal shortens the window, it cannot close it. The module therefore does not rely on the lease for correctness, and treats the lease as a way to avoid wasted work rather than as the thing that makes a transition safe. + +**The compare-and-set at commit is the safety mechanism.** Each transition commits under a document version check, so when two callers are admitted at once, exactly one commit is accepted and the other is rejected by the database. This is gated for creating a reservation and for accepting one, in both cases by granting the lock to both callers — the worst case an expired lease can produce — and requiring that exactly one result reaches storage. The two are rejected by different mechanisms, and the distinction matters when reading the gates. Two concurrent acceptances write the same reservation document with identical index values, so no unique constraint can fire and only the version check can discriminate: removing it makes both callers succeed. Two concurrent creations insert two different reservations carrying the same activity and agent claim keys, so the unique claim indexes reject the second as well, independently of the version check. + +**Availability is the single authority for whether an agent may take work.** Producing that answer already reads the agent profile and counts the agent's active interactions, so the reservation path uses the snapshot it receives instead of reading either again. Re-deriving the decision inside the lock cost two further round trips while the lease runs, and put a second, weaker derivation — one that never checked queue entitlement or live session state — in the path of a decision that already had an owner. The duplicate checks were applied conjunctively, so they could only ever add a redundant rejection and never admitted an agent the authority had refused. A round-trip budget gates this, measured through the real availability service, because measuring it through a double would hide exactly the reads that make up the cost. + +Removing the duplicate read also removed a fallback to the caller-supplied in-memory agent. That fallback was unreachable: availability reads the same document through the same session, so when the profile cannot be read the reservation was already refused. Its removal is a simplification, not a change in behaviour. + +## Hub cancellation convention + +A hub connection's group membership decides which events reach it, so the token a hub hands to a piece of work is a correctness decision rather than a detail. The convention is the same in every hub: + +- Work whose only product is a value returned to the calling connection honours the connection's own token. If the caller is gone the answer has nowhere to go, and abandoning it is correct. +- Work that changes durable state or SignalR group membership runs to completion under a token that never cancels, named `HubConnectionWork.MustComplete` so the choice is visible rather than inferred from an omission. + +The first rule is about what a method returns, not what it is called. Three Telephony hub methods that read like queries fall under the second rule instead. Two refresh interactions through a store that opens its own session and commits per interaction, so cancelling mid-loop leaves a partial durable write. The third refreshes the provider's OAuth tokens when they are near expiry, and because refresh-token rotation spends the old token at the identity provider, losing the replacement locks the agent out of the provider until they authenticate again. + +The second rule exists because a half-applied membership change is not self-correcting. The agent hub joins one group per signed-in queue; if the token trips part-way through, the connection is in some of those groups and not others, while the durable session still says the agent is signed into all of them. The agent stays connected, appears available, and silently receives no work for the queues whose joins never happened. The moment the connection token is most likely to trip — a flaky or reconnecting client — is exactly when that costs the most. A gate scans every SignalR group membership call under `src` and `tests` and fails the build if any is passed the connection token, so the convention holds for hubs that do not exist yet. It enumerates the calls rather than the hubs, because a hub need not be named or shaped like one, and because group membership is also changed outside hub classes. + +**A connection that cannot be fully registered is aborted.** The same silently-deaf outcome was reachable without any cancellation: when registration threw, the hub logged the failure and carried on reporting a successful connection, so a store outage produced an agent who looked available and received nothing. Registration failure now aborts the connection. + +The abort is not load-bearing because the client comes back. The desktop's automatic reconnect is bounded — a few attempts over roughly forty seconds — and when it gives up it does not tell the user, so an outage longer than that leaves the agent disconnected and unaware. What the abort guarantees is that the client stops heartbeating, and the availability service refuses an agent whose last heartbeat is older than the heartbeat timeout. The agent therefore stops being assignable instead of appearing available and receiving nothing. Marking the session offline on disconnect is the fast path that applies when the store is reachable; it cannot be the guarantee, because in the store outage that caused the registration failure the disconnect write fails for the same reason and is logged and swallowed. That is the trade being made: a visibly broken desktop in place of a silently deaf one. It also aborts a user who is both an agent and a supervisor, whose agent capability is broken either way. + +## Real-time voice event fan-out + +A live channel emits a continuous stream of events — state changes, DTMF, variable sets — and ends exactly once. Call teardown, which releases ARI bridges, channels, and ownership bindings, is therefore invoked only for the events that report the channel has ended, and that decision is made by the dispatcher where the fan-out happens rather than by each registered teardown service. The seam is an extension point, so leaving the test to each implementation would make the per-event cost of the pipeline depend on every implementer remembering to refuse non-terminal work before reading a store. + +The set of events treated as terminal for teardown is deliberately narrower than the set the state mapper treats as terminal when projecting call state. The mapper also treats a hangup **request** as terminal, because it should project the call as ending; teardown does not, because destroying bridges or hanging up the peer leg on a request would tear a conversation down before the channel actually ended. + +Teardown remains independent of the call-control pipeline: a terminal event still reaches it when a bridge absorbs the event, and when a bridge throws. That independence is what keeps a bridge failure from leaking ARI bridges and channels, and it is now gated with a real terminal event — the test named for it was previously dispatching a non-terminal one, so it would have passed with terminal teardown removed entirely. + +### Ingestion backpressure and its limits + +The listener buffers received events in a bounded in-memory channel between the WebSocket receive loop and the dispatcher, sized by `CrestApps:Asterisk:Coordination:RealtimeEventBufferCapacity` (default 1000, validated to be greater than zero and no larger than 100000 so a saturated buffer cannot exhaust process memory). When the buffer fills — the dispatcher stays slower than the provider long enough to exhaust it — the receive loop stops reading the socket and awaits a bounded window for space instead of dropping the event or the connection on the first full write. While it is not reading, TCP flow control pushes back on the provider, but only for as long as the provider tolerates a stalled reader. Asterisk's ARI WebSocket enforces its own `websocket_write_timeout` (100 ms by default): once its send queue cannot drain within that window it closes the connection and discards the events it had queued. Backpressure therefore only reaches the provider as genuine flow control when `websocket_write_timeout` is set to exceed `RealtimeEventBackpressureTimeout` (as the bundled `ari.conf` does); with the stock 100 ms timeout the provider tears the socket down first and the listener degrades to the reconnect-and-reconcile path below, with the loss moving to Asterisk's discarded send queue rather than being held at the OS socket buffer. The first wait of each saturation episode increments the `asterisk.realtime.ingestion.saturated` counter on the `CrestApps.OrchardCore.Asterisk` meter (tagged by provider), so an operator sees sustained pressure as it happens rather than only its aftermath; the episode ends only once the buffer has fully drained, so a buffer oscillating near full is counted as one episode rather than many. Only if the buffer stays full for the whole `RealtimeEventBackpressureTimeout` (default 5 seconds, validated greater than zero) does the listener give up, reconnect, and reconcile; repeated saturation timeouts are treated as failures and back off exponentially rather than hot-looping the reconnect. + +That reconciliation is pointer-driven and best-effort, not a lossless replay. It reconciles calls the tenant already knows about, at most 200 per invocation, and it is coalesced behind a distributed lock so an overlapping sweep no-ops rather than double-processing. It therefore restores each known call's current state but does not reconstruct every intermediate hold or resume transition that may have been skipped while the buffer was saturated, and a call the tenant never learned about — because the event that would have opened it was the one dropped — is not recovered. The state it does restore is read from live ARI — the provider's own channel lookup, not any local projection — and that liveness is pinned by a contract test that replays the recorded channel of the pinned Asterisk release, so a provider that quietly began trusting local state would issue no channel query and fail this contract test. On reconnect the post-disconnect drain of any events still buffered is bounded by both drain progress and an overall budget: a dispatcher that keeps clearing the buffer is allowed to finish, up to a 30-second budget, while one that stalls has its cancellation requested and is left behind so the listener can reconnect, with the abandoned events falling to the same reconciliation. A dispatch already wedged inside non-cancellable work (such as acquiring a tenant scope) can still delay the reconnect until it returns, but it can no longer discard the whole buffer. Sizing the buffer higher absorbs longer dispatch stalls and forces backpressure onto the provider sooner; it does not make the event stream lossless, and no configuration makes it so. + +## Upgrade and migration safety + +Contact Center follows an expand → migrate → contract policy so a rolling or blue-green deployment never runs an old and a new node against a schema either cannot use: + +- **Expand** — a release only adds schema. New columns are additive and ship with a default (or are nullable), so an old node keeps writing valid rows while the new node populates the new column. +- **Migrate** — backfill and any new unique constraint run inside the upgrade migration against the module's own index tables. Unique-constraint creation is preceded by a portable preflight that detects pre-existing duplicate active claims and fails with explicit repair guidance instead of silently corrupting data or throwing an opaque unique-index error later. +- **Contract** — destructive changes (dropping or renaming a column or table, narrowing a type, or removing a default) are deferred to a later release, after every node is known to no longer read the old shape. + +Audit of the shipped Contact Center migrations: every migration is additive — `CreateMapIndexTable`, `AddColumn` with a default or nullable value, and guarded `CreateIndex`/`CreateUniqueIndex` — except for two upgrades that rebuild a column in place: a type reconciliation and a provider-call length widening. Because SQLite has no `ALTER COLUMN`, each rebuild removes and recreates its column (a `DropColumn` and a `RenameColumn`) together with the indexes that name it, all within a single migration step. Every such destructive step is authorized in the machine-checked in-place-rebuild register, which verifies the object is restored in the same method, so the column and its indexes exist again before the step returns and no node is ever left reading a shape it cannot use. No shipped upgrade defers a destructive change across a release boundary, so none requires the old shape to be retired in an earlier release. A rebuild does rewrite its table and briefly drops and recreates the indexes over the rebuilt column, so the size-dependent cost and the transient uniqueness window are the operator considerations for those two steps (see the widening and rolling-upgrade notes below); they are not a cross-release contract-phase requirement. Any future backward-incompatible change must either be restructured into the expand/migrate/contract phases above or explicitly declare a downtime requirement in its release notes. + +**How the contract phase is enforced.** The policy above is a build gate, not a convention. Every Orchard data migration in the repository is parsed, and three oracles look for a destructive step: schema-builder calls such as `DropColumn`, `DropIndex`, `DropTable`, `RenameColumn`, `RenameTable`, and `AlterColumn`; raw SQL passed as an argument to any synchronous or asynchronous execution method; and raw SQL assigned to a command's text. The raw-SQL oracles reconstruct the statement across string concatenation, interpolation, single-assignment locals, and read-only query-builder composition, then classify it, so a statement built at runtime is judged on what it does rather than on whether a literal happens to match. Classification does not stop at the leading verb, because a destructive statement need not lead: a common table expression begins with `with` and a batch can hide a second statement after a semicolon, so a destructive verb anywhere in the statement is a finding. Quoted values are removed before that scan so a literal that merely reads like a verb is not mistaken for one, and a statement that can execute another statement — `EXEC`, `sp_executesql`, or a procedural `DO`/`BEGIN` block, wherever it appears — is treated as unreadable rather than as safe, because the gate can see the wrapper but not what it runs. A statement the gate cannot read is itself a finding: it must either be written so the verb is visible or be recorded, per call site, with what it does and why it cannot be destructive. Such a recorded approval is pinned to a fingerprint of the type that declares it, so changing what the statement builds invalidates the approval and forces a fresh review. + +Every destructive step needs a register entry that authorizes one operation against one named object, and an entry that matches no step or several steps fails, so an authorization cannot go stale or quietly widen. Justifications are checked rather than trusted: a contract-phase removal must name a strictly older release as the one that introduced the object, which makes expand and contract landing in the same release impossible; and a claim that an object never reached a customer must name the database object it is about, which is then searched for in the source of every stable release tag. The claim fails if the object is present in any released tree, if it cannot be bound to the object the entry authorizes, or if the released source cannot be read. The claim also has to be bound to the object the entry actually operates on. A schema operation names its object directly, so the claim must equal it. Raw SQL is read at the operand position — the identifier that follows `drop table`, `alter table`, `delete from`, and the like — rather than anywhere in the statement, so an object named in a trailing comment cannot stand in for the one being dropped. Reconstruction is what makes that position readable: it resolves constants, interpolation holes, table quoting, schema qualification, and index-table naming conventions. Every operand in the statement must be the claimed object, not merely the first, so a batch that drops an authorized table and then a second, unauthorized one is rejected instead of being covered by a single claim. An operand the gate cannot read is a finding rather than a pass. Without that binding, changing the constant that names the dropped table would leave the statement classification, the authorization, and the claim all unchanged while dropping something else entirely. Checking the claim against the shipped source is what turns it into evidence: a version number, or a commit the author chooses, is an assertion the gate cannot verify. `UninstallAsync` is exempt, and only `UninstallAsync`, because feature uninstall is not an upgrade path. + +The gate's scope is Orchard data migrations. Destructive DDL executed from a background task, a recipe, a feature event handler, or an ordinary service is outside it, and a prerelease-to-prerelease upgrade is outside it as well: the never-released justification is evaluated against stable releases only, because upgrading from a preview or release candidate is not a supported path. + +**Why the static gate is not sufficient on its own.** The contract-phase gate reads migrations without running them, so it can only reject what is visibly destructive. Adding a non-nullable column with no default is entirely additive, passes that gate, and still breaks every write a still-running previous version performs, because that version supplies no value for a column it does not know about. Only executing both write shapes against one real upgraded database can catch it, so the migrations are additionally exercised as a rolling upgrade. Two databases are built from the shipped migrations: one takes the fresh-installation path, and one is installed at the previous schema version and then upgraded. A previous-version writer and a current-version writer then both insert into the upgraded tables, and the previous version's projection is read back. + +The same harness compares the two databases column by column and constraint by constraint, because a fresh installation and an upgraded installation of the same release must not be distinguishable. That comparison is what makes the previous-version write assertion trustworthy over time, and it has already found a real divergence: the reservation and queue-item claim keys were declared with an inline unique constraint and no default on the create path, but with a named unique index and an empty-string default on the upgrade path, so the same release produced two different schemas depending on when the tenant was installed. The create steps now build the constraints exactly the way the upgrade path builds them. + +**An upgraded tenant must be indistinguishable from a fresh one, and the declaration is the only evidence.** A rolling-upgrade harness that compares values proves the two tenants agree today; it does not prove they agree on an engine nobody ran it against. YesSql writes an enum index property as an integer whatever the column is declared as, and SQLite applies type affinity in both directions — converting the integer on the way in and applying column affinity to comparisons — so a column that should be an integer and was created as text behaves correctly on SQLite indefinitely and fails only on an engine that does not coerce. A gate therefore compares the declarations themselves: the table and index declarations of a tenant upgraded through every reachable historical schema must be identical to those of a tenant created fresh. Which historical schemas are reachable is decided by walking the migration chain by return value, exactly as the host walks it, so a step that no released version can reach is not mistaken for one that runs. + +**A type reconciliation is a rebuild, not an `ALTER`.** SQLite has no `ALTER COLUMN`, so correcting a column's declared type means adding a replacement, copying the values into it with a set-based statement, dropping the original, and renaming the replacement into its place — and dropping any index over the column first, because SQLite refuses to drop a column an index refers to, then recreating it. Those removals are destructive in the letter of the contract phase while being safe in substance, because the object exists under the same name before and after within one step. They are authorized as in-place rebuilds, and that authorization is machine-checked rather than trusted: the entry must name the operation that puts the object back, and that operation must really be present in the same migration step, so deleting the recreation fails the build. A removal written as SQL is reported by its leading verb rather than by an object name, so such an entry additionally names every object it takes away and each is looked for in the same step. Value translation accepts both a stored number and a stored member name and records anything else as unknown, so an unreadable legacy value is never silently rewritten to the enum's first member. + +**Widening a column's declared length is the same rebuild for the same reason.** A provider call identifier arrives verbatim from an external switch and can be long — a SIP Call-ID is not bounded by anything Contact Center controls — but the call-session column declared it at a length too short for it. An engine that enforces a declared length rejects an over-length write outright (PostgreSQL `22001`, SQL Server `8152`, MySQL `1406` under the default strict mode), so the call session is never persisted and a reconciliation lookup can never find it; only non-strict MySQL truncates, and a truncated identifier — once the claim key composed from it outgrows its own column — forges a collision between two distinct calls, the exact opposite of what the unique claim exists to guarantee. The widening is forward-only: it lets the full identifier be stored going forward and repairs no row already rejected or truncated under the narrow length. Correcting the length is a rebuild rather than an `ALTER` for the same reason a type correction is: SQLite has no `ALTER COLUMN`, so the column is widened by adding a replacement at the wider length, copying the values across, dropping the original, and renaming the replacement into its place, with the unique claim index and the covering index that name a rebuilt column dropped first and recreated afterwards. The identifier is widened to 256 — a deliberate ceiling, comfortably longer than any real provider call identifier — and the claim-key length is derived from the two parts it concatenates — the provider technical name plus a separator plus the widened identifier — so the composed key can never truncate a value its source columns can hold, and it stays within the 900-byte unique-index key limit SQL Server imposes. SQLite stores every text column as unbounded `TEXT`, so it never rejected or truncated and the rebuild is a value-preserving no-op there; the widening exists for the engines that do enforce the length, and the rolling-upgrade harness — which runs on SQLite — proves only that the rebuild mechanics preserve values and leave the declaration unchanged, while the enforcing-engine width itself is proven by executing the upgrade against PostgreSQL in the distributed CI gate, which asserts both columns reach the wider length, that a seeded value survives, and that the claim-key unique index still rejects a duplicate. SQL Server and MySQL enforce the same declared length but are not exercised in the Postgres-only gate, so their behaviour is correct by construction rather than by test. Before it recreates the unique claim index, the widening step re-checks that no two rows share a claim key and aborts activation with the same repair guidance the initial claim-key upgrade uses, so a duplicate written into the brief window while the index is absent (possible only on an engine that autocommits each schema change) surfaces as an actionable message rather than an opaque index-creation error. The rebuilds authorize each destructive step as an in-place rebuild in the same machine-checked register the type reconciliation uses, and the recorder that captures the upgrade for the shape comparison skips the transient replacement column, because the finished rebuild has already renamed it away and a model that still carried it would name a column the real table does not have. + +**The declaration gate proves the shape; only the engine proves the upgrade runs.** Comparing declarations on SQLite finds the divergence but cannot prove the correction is executable, because the same affinity that hid the divergence also makes the correcting statement run. Three defects survived the SQLite gate and were found only by executing the upgrade against a real PostgreSQL instance. The first is that the historical version the correction starts from is reachable in two different shapes — one whose enum columns are text and one whose enum columns are already correct — so the rebuild probes the column's declared type by reading an empty result set and returns without touching a column that is already right; running the text-to-number translation over an integer column coerces silently on SQLite and raises an undefined-operator error on PostgreSQL. The second is that PostgreSQL and SQLite name only the index in a drop, so the name resolves against the connection's search path rather than the table's schema: a tenant whose tables live in a named schema drops nothing, and because the statement carries `IF EXISTS` the miss is silent until the recreation reports the index already exists and activation fails. The migration therefore issues a schema-qualified drop first on exactly those engines. The third is that such a drop has to name the index the way the data layer named it rather than the way the migration spells it, because the data layer prefixes index names on the engines that share one index namespace across tables and shortens names the engine cannot hold; naming the migration's own spelling reproduces the same silent miss on any tenant that also sets a table prefix, so the gate configures a schema and a table prefix together. The upgrade is executed against PostgreSQL from both reachable shapes as a gate, since a migration that produces the right declarations on SQLite and cannot run on the production engine has fixed nothing. + +**The reconciliation is resumable, because not every engine can roll a schema change back.** MySQL commits each schema change on its own, so an attempt that fails part-way leaves the completed changes in place while the migration version is never recorded, and the next activation runs the step again from the top. A step that is not resumable turns one interrupted upgrade into a tenant that can never activate. The replacement column is therefore added only when it is not already present; an attempt that stopped after the original column was dropped finishes by renaming the replacement rather than starting over and destroying the translated values it already holds; and the index drops are tolerant while the recreations are not, so a drop that genuinely fails is still reported by the recreation that follows it. + +**Backfills are set-based because they run inside tenant startup.** An upgrade backfill executes in the transaction that gates the tenant's activation, so its cost is startup time. Reading a table into memory and issuing one statement per row is invisible on a developer database and fatal on a tenant with a million rows, which never finishes activating. Canonicalizing a value that comes from a finite set is done once per distinct value the table actually holds rather than once per row; derived keys are computed by the database from columns it already has, using the dialect's own string concatenation because `||`, `+`, and `concat()` are not interchangeable across the supported engines; and duplicate preflights are `GROUP BY … HAVING COUNT(*) > 1` over the same composed key the unique index will enforce, so a pair that collides only once composed is still caught. Where a preflight and its constraint differ they differ in the safe direction: a missing value is folded to an empty one, which is stricter than the engines that treat nulls as distinct, so an upgrade refuses with repair guidance rather than creating an index that hides the ambiguity. This is preferred to a batched background backfill, which would leave a window in which the unique constraint the backfill exists to enable cannot yet be created. A build gate runs each backfill against tenants that differ ten-fold in row count and requires the number of database round trips to be identical, because wall-clock in CI is dominated by the machine and would either flap or be set so loose it proves nothing. + +**One rolling-upgrade hazard is an operator constraint rather than a gate.** When an upgrade adds a non-nullable column with a shared default and then places a unique constraint on it, only one previous-version write can succeed, because every previous-version node writes that same default. There is no portable fix inside a single release: filtered indexes are unavailable or incompatible across the supported engines, and a nullable column does not help because not every engine treats nulls as distinct in a unique index. The supported answer is to expand and contract across two releases — add the column nullable and unconstrained in one release, then backfill and constrain in the next, once every node is known to write it. Until then, treat a release that introduces a uniquely constrained claim key as requiring drained queues rather than a live rolling upgrade. + +### Stored events are converted on read, not on write + +A durable event log outlives the code that wrote it. Contact Center does not hand the published object to its handlers: post-commit dispatch, outbox redelivery, the provider-voice reader, and projection maintenance all reload the event from storage by identifier, so a handler always sees JSON that was serialized by whichever release wrote it, deserialized into the type the running release declares. That deserialization does not fail when a payload property is renamed, split, or re-united. It succeeds and substitutes a default, so an event redelivered from last month is acted on with an absent reason, a zero duration, or an empty identifier, and nothing reports a problem. + +Every event therefore carries the schema version it was written at, and that version is read on the way out. The payload is converted one version step at a time until it reaches the version the running release understands. The conversion is applied at the event store rather than at each reader, because a reader that forgot to call it would not fail — it would return stale data as though it were current, which is the silence the mechanism exists to remove. + +Three situations are refused rather than absorbed: + +- **A version step with no conversion registered fails the read**, naming the step. Returning the payload unconverted is precisely the misreading being prevented. +- **An event stored at a version above the one the running release understands fails the read.** A node cannot convert forwards, and during a rolling upgrade the newer node is already writing that version, so an older node must refuse the record rather than guess at it. +- **An event with no recorded version is treated as the first version, not as already current.** Assuming current is the same silent default-substitution in a different place. + +The conversion serves the reader and never rewrites the stored row. Rewriting history from whichever node happened to read it first would destroy the only record of what was actually published, and would make a rollback to the previous release unrecoverable. + +Because the conversion lives at the event store, it only reaches a reader that goes through the event store. Reading the log directly from the session bypasses it and is invisible to the coverage gate, which knows only the store's own read paths, so a build gate refuses any code outside the store that queries the event log directly. + +**Raising the schema version without a conversion fails the build.** A build gate requires the registered conversions to cover every version step from the first version to the current one, so the omission is caught at the moment it is introduced — which is the only moment it is visible, because at that moment every event already on disk becomes unreadable and nothing about a bumped constant looks wrong in review. A second gate seeds a stored event at an unreadable version and requires every read path on the event store, discovered by reflection rather than listed by hand, to refuse it, so a read path added later that bypasses the conversion fails without anyone remembering to extend the gate. + +`InteractionEvent` is currently the only persisted Contact Center document that carries a schema version. The day another document needs one, the seam to reuse is the loading hook on the shared document catalog, which every catalog in the product already inherits. + +## Tier-1 capacity target + +R8 must prove the entire envelope rather than extrapolating from a smaller test: + +| Limit | Per tenant | Per deployment | +| --- | ---: | ---: | +| Concurrent signed-in agents | 100 | 250 | +| Concurrent voice interactions | 50 | 100 | +| New interactions per second | 10 | N/A | +| Tenants | N/A | 5 | + +These are acceptance ceilings for the first certified tier, not architectural maximums. Higher tiers require separate load, soak, failure, and dependency-limit evidence. + +## Provider webhook ingress + +Inbound provider webhooks are split by channel by design. + +- **Voice provider webhooks** (DialPad's provider-owned endpoint) use the full ingress-control stack: body/header limits, tenant-local rate and concurrency limiting, delivery freshness and replay rejection, and a durable at-least-once inbox that returns `2xx` only after the delivery is committed. Processing is decoupled from the request lifecycle, so a client disconnect after commit never drops or double-executes a delivery. +- **Non-voice provider webhooks** (Twilio SMS, Twilio EventGrid, and Azure EventGrid) are authenticated at the edge — Twilio requests are verified against the account `AuthToken` HMAC signature and rejected with `403` on mismatch; Azure EventGrid requests are authenticated and bounded by a request-body cap — but they do not yet use the durable inbox. They are outside the GA-Core voice scope. + +Bringing the non-voice webhooks to full parity is a tracked R9 item. Because the durable inbox is intentionally coupled to Contact Center orchestration (its scope executor, provider-identity canonicalization, and persisted inbox index), parity is delivered by first promoting the reusable ingress primitives to a channel-neutral shared home at or below Omnichannel, then migrating both voice and non-voice consumers onto it — an expand-migrate-contract refactor sequenced only when a second (non-voice) channel is actually built. + +## Prohibited capabilities and combinations + +- Power, Progressive, and Predictive dialing. +- Recording, monitor, whisper, barge, and bidirectional media. +- More than one voice provider profile in one tenant. +- Production on SQLite. +- Production on a single application node without Redis distributed locking and a Redis SignalR backplane. +- Production on more than one application node, until multi-node capacity certification is earned. +- Elasticsearch in routing, assignment, provider ingest, or another correctness path. +- Any feature, provider, database, or topology combination not listed in the versioned matrix. + +Unsupported controls are hidden and rejected server-side. Supervisor engagement modes are returned to the dashboard only when the active provider advertises the mode and implements the executable monitoring contract; recording and Contact Center transfer likewise fail closed without their executable contracts. Provider failure or an unknown outcome never writes successful recording, monitoring, or transfer state. Telephony soft-phone commands also repeat capability enforcement on the server. Enabling an implementation that has not passed the profile's release gates does not make that capability supported. + +Bidirectional media is excluded more strongly: the legacy capability flag has been removed, the Contact Center and Asterisk media features are dependency-only and hidden from direct feature selection, and neither GA-Core tenant profile enables the media resolver or a media provider. The Asterisk RTP/UDP implementation remains development-only until R9 certifies a secure private-network boundary, packet loss/reordering/jitter behavior, capacity, failover, and node affinity. + +Search independence is enforced rather than asserted, and it is enforced against all three mechanisms that can introduce a search dependency. A build gate reads the PE metadata of every shipped Contact Center, Telephony, Asterisk, and DialPad assembly and walks the transitive reference closure, failing if it reaches an Elasticsearch or OpenSearch client, so no supported deployment can be made to require a search cluster. A direct-reference gate additionally rejects any search or indexing API referenced by those assemblies themselves, so a correctness path cannot be written against search in the first place. Because an Orchard feature dependency is a string in a manifest and creates a runtime dependency with no assembly reference at all, a third gate rejects any Contact Center feature that declares a dependency on a search-backed feature. A fourth starts each supported profile in a real tenant and fails if the resulting enabled-feature set contains a search engine. A fifth executes the correctness paths themselves — routing selection, assignment through a real reservation, outbox dispatch, and provider ingest through the normalized voice-event seam every PBX adapter funnels into — against persisted state inside a real supported-profile tenant, and fails if any outbound HTTP request is issued, which catches a regression that reached a cluster through an ordinary HTTP client without referencing a search assembly or enabling a search feature. Search-backed capability belongs in a separate opt-in module that a supported topology leaves disabled. diff --git a/src/CrestApps.Docs/docs/contact-center/public-api-surface.md b/src/CrestApps.Docs/docs/contact-center/public-api-surface.md new file mode 100644 index 000000000..98156e2e3 --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/public-api-surface.md @@ -0,0 +1,65 @@ +--- +title: Public API Surface +--- + +# Public API surface + +Everything the Contact Center, Telephony, and Omnichannel assemblies expose publicly is a promise to whoever compiles against them. Other modules in this repository compile against them, and so does whatever a deployment builds beside them. A type turning public, a member changing shape, a class losing `sealed`, or a parameter changing type are all compatibility decisions, but in a diff they read like ordinary edits - and the first report that one of them was breaking usually arrives from a deployment that no longer builds. + +The public surface of every governed assembly is therefore generated and checked in. A change to it fails the build and arrives as a diff that a reviewer has to accept on purpose. + +## Which assemblies are governed + +The governed set is derived from the project graph rather than listed by hand, because a list is something a contributor has to remember to add to, and the assembly nobody remembers is the one that breaks. + +A project is governed when both of the following are true: + +- Its name is in the Contact Center, Telephony, or Omnichannel families - that is, it matches `CrestApps.OrchardCore.(ContactCenter|Telephony|Omnichannel)` optionally followed by a suffix. +- Some project other than the packaging target (`CrestApps.OrchardCore.Cms.Core.Targets`) or the web host (`CrestApps.OrchardCore.Cms.Web`) references it. Those two reference every module in order to package or host it rather than in order to compile against it, so counting them would make every module look like a contract. + +Adding a project reference from one module to another is therefore enough to bring the referenced assembly under the gate, and the build will ask for its baseline on the next run. + +## The recorded baselines + +Baselines live in `tests/CrestApps.OrchardCore.Tests/PublicApi/Baselines`, one `.approved.txt` per governed assembly, and are produced by `PublicApiGenerator` from the compiled assembly. + +`PublicApiApprovalTests` compares each governed assembly against its baseline and fails in three directions, so the recorded set cannot drift away from the governed set: + +| Situation | What happens | +| --- | --- | +| A governed assembly has no baseline | The test fails and writes the baseline. Read it, decide whether every member on it is meant to be public, then commit it. | +| A governed assembly's surface differs from its baseline | The test fails, prints the added and removed lines - each qualified by the type that encloses it, so a member removed from one of many look-alike types is still named - and writes `.received.txt` beside the approved file. | +| A baseline exists for an assembly nothing compiles against any more | The test fails as orphaned, so stale baselines are deleted rather than left to look like coverage. | + +`*.received.txt` is ignored by Git and is only ever a build output. + +Baselines must be reproducible on any machine, so a recorded surface that contains a filesystem path fails as well. Assembly attributes that embed the build directory - `ModuleAssetAttribute` and `RazorCompiledItemAttribute`, both emitted once per view - are excluded for that reason. If a future attribute starts carrying a path, that check fails rather than leaving every contributor with a baseline only its author can reproduce. + +## Accepting a deliberate surface change + +When a surface change is intended, accept it explicitly: + +```bash +# 1. Run the gate. It writes the received file and prints what changed. +dotnet test tests/CrestApps.OrchardCore.Tests -c Release --filter "FullyQualifiedName~PublicApiApprovalTests" + +# 2. Read the diff it printed, then replace the approved file with what was received. +cd tests/CrestApps.OrchardCore.Tests/PublicApi/Baselines +mv CrestApps.OrchardCore.Telephony.received.txt CrestApps.OrchardCore.Telephony.approved.txt + +# 3. Re-run to confirm the surface and the baseline now agree. +dotnet test ../../../CrestApps.OrchardCore.Tests -c Release --filter "FullyQualifiedName~PublicApiApprovalTests" +``` + +Commit the updated `.approved.txt` in the same change as the code that moved the surface, and say in the pull request why the change is safe for callers. The point of the gate is not to prevent the surface from changing; it is to make sure somebody decided that it should. + +## Rules the baseline does not enforce on its own + +A recorded surface is still only text, and text is easy to accept in bulk. Two conclusions of the surface audit are therefore stated as rules, so that breaking one arrives as a named failure rather than as one more line in a diff: + +- **A public class is sealed unless inheriting from it is the point.** A class is accepted when it is `sealed`, when it is `abstract`, when it *introduces* an overridable member, or when it lives in a `ViewModels` namespace - the display framework builds a runtime proxy from view models and cannot do that from a sealed type. Overriding an inherited member does not count: a driver that overrides one framework method has said nothing about whether anyone may derive from it. Members the compiler synthesizes do not count either, which is why a public `record` is not automatically treated as an extension point by virtue of its generated equality members. +- **No public type exposes mutable static state.** Public static state is shared by every tenant in the process and by every test in the run, so a value one of them changes is a value all of them see. Public static fields must be `const` or `readonly`, and public static properties must not have a public setter - but `readonly` only freezes a reference, so static collections are judged by the value they actually hold rather than by the type they are declared as. A `List` handed out behind an `IReadOnlyList` fails, because a caller can cast it back and rewrite it; an array fails, because it reports itself as read-only while still allowing element assignment; an immutable or frozen collection passes. + +## Related + +- [Supply chain security](../supply-chain.md) - the equivalent controls for dependencies. diff --git a/src/CrestApps.Docs/docs/contact-center/report-catalog.md b/src/CrestApps.Docs/docs/contact-center/report-catalog.md new file mode 100644 index 000000000..3cc94d761 --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/report-catalog.md @@ -0,0 +1,238 @@ +--- +sidebar_label: Enterprise report catalog +sidebar_position: 5 +title: Enterprise Contact Center Report Catalog +description: Definitions, formulas, filters, drill paths, exports, permissions, and limitations for the built-in Contact Center and CRM reports. +--- + +# Enterprise Contact Center Report Catalog + +The Contact Center Analytics and Omnichannel Management features contribute 78 immediately runnable reports to the shared **Reports** area. The admin menu organizes them into **Executive**, **Operations**, **Queue & Routing**, **Agent Performance**, **Workforce & Payroll**, **Billing & Usage**, **CRM & Campaigns**, **Compliance & Audit**, and **Technical & IT** groups. The catalog intentionally reports only facts represented by durable Contact Center or CRM data. It does not infer schedules, pay rates, quality scores, survey responses, or customer-resolution outcomes that have not been collected. + +## Shared report behavior + +| Capability | Behavior | +| --- | --- | +| Reporting population | Unless a report says otherwise, one interaction or CRM activity enters the report when its `CreatedUtc` is within the inclusive UTC bounds resolved from the selected tenant-local date and time range. This cohort rule prevents the same item from moving between periods when it later ends or completes. Backlog and aging are exceptions: they include currently nonterminal activities created on or before **To**, including older open work. | +| Default filters | Every report has tenant-local **From** and **To** date/time controls. Each report declares its supported dimensions, and the filter editor renders only those controls. Interaction reports commonly add queue group, queue, agent, channel, and direction. Workforce reports add agent. Contact Center campaign/subject reports add campaign group, campaign, channel, source, and activity status. Omnichannel reports add campaign group, campaign, channel, source, and activity status. The same filter values are applied to browser results and exports. | +| Time grouping | Durable source timestamps are stored and compared in UTC. Date/time controls are displayed in the tenant time zone and converted to UTC before the query runs, including daylight-saving transitions. Daily rows currently use UTC dates. No percentage or average is averaged across displayed rows; totals are recalculated from raw counts and durations. | +| Sorting | Summary tables default to descending population. Daily tables sort chronologically. Interaction detail sorts newest first. Aging and attempt reports sort by ascending bucket. | +| Visualizations | The shared renderer supports KPI cards, tables, horizontal bars, and responsive Chart.js line, bar, stacked-bar, and doughnut charts. The **Executive performance dashboard** currently provides the interactive Chart.js visualizations. Other chart types described in the catalog are planned unless their report output explicitly includes a chart section. Heat-map, gauge, funnel, Sankey, and timeline renderers remain recommended extensions where noted below. | +| Aggregate rows | Every Contact Center aggregate table appends a semantic grand-total row recalculated from the filtered raw counts and durations. Layered tables add semantic subtotals: Queue Usage identifies queue-group subtotals, Campaign Summary identifies campaign-group subtotals, and Daily Agent Timecard identifies each UTC day's subtotal. Interaction detail, presence audit, and exception-detail tables remain raw lists and do not receive artificial totals. The browser renderer uses distinct background colors for subtotal and grand-total rows. | +| Queue-group membership | Queue groups are reporting/catalog metadata and use current-membership semantics. Reports resolve an interaction's stored queue identifier against the queue's current `QueueGroupId`; moving a queue changes historical group attribution without changing the interaction or any routing/SLA/entitlement behavior. Deleting a group makes its assigned queues ungrouped. | +| Export | CSV is built in. Excel is available when `CrestApps.OrchardCore.Reports.OpenXml` is enabled. Export actions appear in a report toolbar at the right edge of the first visible section heading and retain the active filter values. Table exports retain detail, subtotal, and grand-total rows in display order; the semantic row kind controls browser color only and does not remove rows from CSV or Excel. The toolbar and visible report heading are not exported; CSV starts with the data headings, and Excel uses the report title for worksheet tab names. PDF and JSON are not currently provided. | +| Scheduling | Reports are currently interactive and exportable. Scheduled delivery by email, collaboration channel, or SFTP is not yet implemented; daily, weekly, and monthly schedules are the recommended baseline when scheduling is added. | +| Permissions | Contact Center reports require **View Contact Center reports** and are granted to supervisors and administrators by default. CRM reports require **View Omnichannel reports** and are granted to administrators by default. Tenant isolation is enforced by Orchard shell scope and tenant-local YesSql collections. | +| Capability requirements | A report only measures data some capability writes. Reporting deliberately does not depend on the capabilities it measures, so that a tenant running chat and CRM only is never made to run, secure and upgrade voice call handling to read a queue report. Degradation happens at two levels. A report whose whole subject belongs to an absent capability renders a single notice naming the missing feature and shows no figures: transfer analysis, queue and agent transfer performance, provider performance, provider usage for billing, and call leg performance require **Voice**, and recording coverage and agent recording coverage require **Recording**. A report whose primary figures are real still runs, but drops the columns and metrics the absent capability feeds rather than rendering them as zeroes - the executive dashboard omits **Transfer rate** and **Recording coverage**, interaction and exception detail omit **Provider** and **Transfers**, agent volume and outcome reports omit **Transfers**, **Recorded** and **Recording coverage**, and the queue, agent, channel and daily usage reports omit **Transfers** and **Recordings**. In both cases the reason is the same: zero is a measurement, and an absent capability took none. | +| Drill-down | The current renderer presents report sections on one page. The drill paths below define the stable implementation target for linked drill-down navigation. | + +Campaign, campaign-group, and disposition dimensions render their configured display names. Campaign reports retain campaign-level rows and add campaign-group aggregate sections where applicable. Because activities store a campaign rather than a campaign-group snapshot, reports resolve the campaign's current group at execution time; moving a campaign changes historical group aggregation. If a referenced catalog entry has been deleted, the report shows an unknown label instead of exposing the stored identifier. User table cells render through Orchard Core's cached `UserDisplayName` shape: the account username is the default text, and the **User Display Name** feature replaces it through the configured `IDisplayNameProvider`. Browser rendering preserves the enclosing admin layout, while the export resolver suppresses layout rendering so CSV and Excel contain only the resolved text value and never the surrounding admin-page HTML. Usernames are resolved from stable user identifiers when a report runs and are not duplicated in activity indexes. Deleted or unavailable users are shown as **Unknown user** or **Unknown agent**. + +## Canonical KPI definitions + +These definitions are authoritative across the catalog. + +| KPI | Exact definition and edge cases | +| --- | --- | +| Interactions | Count of interaction records in the report cohort. Every transfer, consult, conference, callback, or requeue remains part of the same interaction unless it creates a separate communication attempt and therefore a separate interaction record. | +| Inbound offered | Count of inbound interaction records. Queue overflows and requeues do not create another offered interaction. An inbound callback promoted into an outbound attempt is counted as an outbound interaction, not another inbound offered interaction. | +| Answered | Count of interactions with `AnsweredUtc`. `Inbound answered` restricts the numerator to inbound interactions. | +| Answer rate | `Answered interactions / all interactions` in all-direction reports. Inbound answer rate is `Inbound answered / Inbound offered`. A zero denominator produces zero. | +| Abandoned | Inbound interactions with no `AnsweredUtc` and final status `Ended`. Failed interactions are excluded. All abandons are included because a configurable short-abandon threshold is not yet stored. Transfers, consults, and conferences after answer cannot be abandoned. | +| Abandonment rate | `Abandoned / Inbound offered`. Percentages are recalculated from counts at every aggregation level. | +| Average speed of answer (ASA) | `Sum(AnsweredUtc - CreatedUtc for inbound answered interactions) / Inbound answered`. Negative durations are clamped to zero. Abandoned, failed, and outbound interactions are excluded. | +| Connected duration | For a completed answered interaction, `EndedUtc - AnsweredUtc`. This interval includes hold because interaction history does not yet persist separate channel-neutral hold segments. | +| Wrap-up duration | `WrapUpCompletedUtc - WrapUpStartedUtc` when both values exist and completion is not before start. Incomplete or invalid intervals contribute zero seconds and remain visible in the wrap-up completion counts. | +| Average handle time (AHT) | `Sum(connected duration + completed wrap-up duration) / handled interactions`, where handled means answered with a valid end time. This is operationally equivalent to talk + hold + after-contact work with the current channel-neutral timestamps. Abandoned and failed interactions are excluded. | +| Transfer rate | `Answered interactions with at least one transfer history entry / Answered interactions`. Multiple transfers increase transfer volume but not the interaction-level transfer-rate numerator. Consultative and blind transfers are included when recorded; conferences are not transfers unless a transfer history entry also exists. | +| Recording coverage | `Answered voice interactions with a non-empty recording reference / Answered voice interactions`. Paused or stopped recordings count as covered when a recording reference exists. | +| Service level | Per queue: `Inbound interactions answered within the queue SLA threshold / (Inbound answered + abandoned)`. Failed interactions are excluded. All abandons remain in the denominator because no short-abandon threshold is stored. The original interaction is counted once after overflow or requeue. A queue without a positive threshold displays no service-level percentage. | +| Completion rate | `Completed CRM activities / Activities in the group`. Failed, cancelled, purged, pending, and in-progress activities remain in the denominator. Reopened work is represented by a new or nonterminal activity and is not treated as completed. | +| Average attempts | `Sum(max(Attempts, 0)) / Activities in the group`. | +| Overdue | A nonterminal activity whose `ScheduledUtc` is before the report's `ToUtc` as-of boundary. | +| Observed signed-in time | Sum of clipped agent-presence intervals whose current status is not `Offline`. The interval begins at the durable presence transition time, ends at the next transition, and is clipped to the selected report boundaries. Time before the first known transition is unknown and excluded. | +| Productive presence | `Available + Reserved + Busy + WrapUp` observed presence duration. This is a presence classification, not proof of payroll eligibility or schedule adherence. | +| Agent utilization | `(Busy + WrapUp) / Observed signed-in time`. Break, away, meeting, training, do-not-disturb, and other signed-in not-ready states remain in the denominator. | +| Agent occupancy | `(Busy + WrapUp) / (Available + Reserved + Busy + WrapUp)`. Offline and explicitly not-ready states are excluded. This is a presence-derived operational occupancy measure and does not use workforce schedules. | +| Activity cycle time | For completed CRM activities, `CompletedUtc - CreatedUtc`, clamped to zero. Median is recalculated from the sorted raw duration population. | +| Usage for billing | Raw interaction count and measured connected, wrap-up, queue-wait, transfer, and recording usage. The platform does not apply prices, contracts, taxes, minimum billing increments, or currency conversion. | + +## Report catalog + +Each report uses the shared date/time filter, export, permission, scheduling, and aggregation behavior above. “Columns/KPIs” names the complete default output. + +## Running and filtering a report + +1. Enable **Reports** and the feature that contributes the report: **Contact Center Reports & Analytics** or **Omnichannel Management**. +2. Open **Reports** in the admin menu and select a report. +3. Set **From** and **To** in the tenant's local time zone. The default is the last 30 days through the end of the current tenant-local day. +4. Narrow the population with the displayed dimensions. Interaction reports commonly offer queue group, queue, agent, channel, and direction; workforce reports offer agent; CRM reports offer campaign, channel, source, and status. +5. Select **Show**. Every metric, table row, total, percentage, and duration is recalculated from the filtered raw population. +6. Select **Export CSV** or an enabled Excel export. Export actions submit the same filter form, so downloaded data matches the visible report. + +An empty dimension means **All**. If **From** is later than **To**, the report swaps the resolved UTC boundaries before querying. Filters combine with logical AND; for example, selecting Queue A, Agent B, Voice, and Inbound returns only interactions matching all four dimensions. + +### Executive and operational reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | Executive performance dashboard | Gives executives and directors a concise, presentation-ready view of demand, accessibility, responsiveness, efficiency, transfers, recording coverage, channel adoption, queue SLA health, and agent workload. Enables capacity, provider, customer-experience, and operating-model investment decisions. | Historical interactive dashboard; enterprise cohort plus daily, channel, queue, agent, and channel-detail views. | Date/time, queue, agent, channel, and direction; day/channel/queue/agent; chronological trend, highest-volume queues, and highest-volume agents. | Interactions, inbound offered, inbound answered, inbound answer rate, abandoned, abandonment rate, failed, ASA, AHT, transfer rate, recording coverage, daily offered/answered/abandoned, channel volume, queue service level, handled by agent. | KPI hero cards + daily multi-series line chart + channel-mix doughnut + queue service-level bar chart + top-agent workload bar chart + channel detail table; enterprise → channel/queue/agent → interaction detail → recording. | +| 2 | Call insights | Gives operations leaders a broad interaction outcome and duration summary with channel/status breakdowns and daily volume. Supports trend and exception review. | Historical dashboard; enterprise cohort and one day per row. | Channel, direction, status, provider; day/channel/status; chronological daily rows. | Total, inbound, outbound, answered, abandoned, failed, answer rate, abandonment rate, AHT, ASA, connected duration, wrap-up duration. | KPI cards + bars + daily trend; day → channel/status → interaction detail. | +| 3 | Interaction volume trend | Shows demand and outcome movement over time for directors, workforce planners, and analysts. Supports staffing and anomaly detection without claiming a forecast. | Historical trend; one UTC day per row. | Channel, direction, queue, campaign; day/week/month; chronological. | Date, interactions, answered, abandoned, failed. | Trend line + stacked bars; day → interval → interaction detail. | +| 4 | Interval performance | Combines daily workload, outcomes, rates, ASA, and AHT for operations reviews. Enables interval-level staffing and service remediation. | Historical interval report; one UTC day per row. | Queue, channel, direction, provider; day/week/month; chronological or worst abandonment/ASA. | Date, interactions, answered, abandoned, answer rate, abandonment rate, ASA, AHT. | Trend line + table; day → queue/channel → interaction detail. | + +### Queue and routing reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 5 | Queue usage | Shows handled outcomes, duration, configured SLA threshold, and current waiting depth. Supervisors use it to rebalance live operations. | Historical plus near-real-time snapshot; per-queue detail plus current queue-group aggregates and a grand total. | Queue group, queue, channel, direction; current queue membership; waiting then handled volume. | Queue/group, handled, answered, abandoned, AHT, ASA, waiting now, and per-queue SLA threshold. | Queue table + queue-group aggregate table; queue → interval → interaction detail. | +| 6 | Queue service level | Measures threshold attainment with a mathematically stable denominator. Enables SLA governance and queue-policy changes. | Historical; one queue per row. | Queue, channel; queue/day/week; ascending service level. | Queue, SLA threshold, eligible offered, answered within SLA, service level, ASA. | Gauge + table; queue → interval → answered/abandoned detail. | +| 7 | Queue abandonment analysis | Identifies queues where customers leave before answer and quantifies wait before abandonment. Supports staffing and routing remediation. | Historical; one queue per row. | Queue, entry point, channel; queue/day; descending abandonment rate or volume. | Queue, inbound offered, answered, abandoned, abandonment rate, average wait before abandon. | Heat map + table; queue → interval → abandoned interaction detail. | +| 8 | Channel performance | Compares interaction outcomes and durations across supported media. Helps allocate channel capacity and identify channel-specific failures. | Historical; one channel per row. | Channel, direction, queue; channel/day; descending interactions. | Channel, interactions, answered, abandoned, failed, answer rate, abandonment rate, ASA, AHT. | Stacked bars + table; channel → queue/agent → interaction detail. | +| 9 | Direction performance | Separates inbound and outbound operating behavior without mixing inbound SLA semantics with outbound attempts. | Historical; one direction per row. | Direction, channel, provider; direction/day; descending interactions. | Direction, interactions, answered, abandoned, failed, answer rate, abandonment rate, ASA, AHT. | Side-by-side bars; direction → outcome → interaction detail. | +| 10 | Provider performance | Compares normalized outcomes and duration across communications providers. Supports provider reliability and sourcing decisions. | Historical; one provider per row. | Provider, channel, direction; provider/day; descending failures or interactions. | Provider, interactions, answered, abandoned, failed, answer rate, abandonment rate, ASA, AHT. | Scatter plot + table; provider → status → interaction detail. | +| 11 | Interaction outcome summary | Shows volume and response metrics by normalized lifecycle status. Helps operations teams isolate failures and unfinished work. | Historical; one status per row. | Status, channel, direction, provider; status/day; descending interactions. | Outcome, interactions, answered, abandoned, failed, answer rate, abandonment rate, ASA, AHT. | Stacked bar + table; outcome → provider/queue → interaction detail. | + +### Agent reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 12 | Agent productivity | Gives supervisors handled volume, channel mix, connected time, wrap-up, AHT, and completed CRM work. Enables balanced coaching based on workload and outcomes. | Historical; one agent per row. | Agent, team, queue, channel; agent/team/day; descending handled. | Agent, handled, inbound handled, outbound handled, connected duration, wrap-up duration, average wrap-up, AHT, activities completed. | Table + scatter plot; agent → day → interaction → activity. | +| 13 | Agent handle time analysis | Separates connected and wrap-up contributions to handle time by agent. Supports targeted process and coaching review without treating lower AHT as inherently better. | Historical; one agent per row. | Agent, queue, channel, direction; agent/day; descending AHT or volume. | Agent, handled, average connected duration, average wrap-up, AHT, total handle time. | Scatter plot (volume vs AHT) + table; agent → interaction detail → recording. | +| 14 | Agent wrap-up performance | Shows wrap-up starts, completions, completion rate, and duration. Helps supervisors identify incomplete after-contact work and workflow friction. | Historical; one agent per row. | Agent, queue, channel; agent/day; ascending completion rate or descending duration. | Agent, wrap-up started, wrap-up completed, completion rate, average wrap-up, total wrap-up. | Table + bars; agent → incomplete/completed wrap-up interactions → CRM activity. | + +### Interaction, transfer, and recording reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 15 | Interaction detail | Provides the auditable source row behind summary reports. Analysts and supervisors use it to reconcile totals and investigate individual contacts. | Historical drill-down; one interaction per row. | Interaction, queue, agent, channel, direction, provider, status; optional grouping by day; newest first. | Started UTC, interaction id, channel, direction, status, queue, agent, provider, wait, connected duration, wrap-up, transfer count. | Table/timeline; interaction → event lifecycle → CRM activity → recording. | +| 16 | Transfer analysis | Quantifies transfer outcomes, destination types, completion, and latency. Supports routing improvement and first-owner coaching without claiming FCR. | Historical; one target-type/result pair per row. | Target type, result, queue, agent, channel; target/result/day; descending transfers. | Target type, result, transfers, completed, completion rate, average completion time. | Sankey + table; target/result → interaction → transfer history. | +| 17 | Recording coverage | Finds answered voice interactions lacking a recording reference and compares provider coverage. Supports compliance and troubleshooting. | Historical; one provider per row. | Provider, queue, agent; provider/day; ascending coverage. | Provider, answered voice interactions, with recording, without recording, coverage. | Gauge + table; provider → uncovered interaction → event/audit detail. | + +### Campaign and subject reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 18 | Campaign summary | Shows Contact Center campaign inventory progress and attempts. Directors use it to compare throughput and unresolved workload. | Historical; campaign rows plus campaign-group aggregate rows. | Campaign group, campaign, status, source, channel; campaign or group/day; descending total. | Campaign or campaign group, total, completed, pending, in progress, failed, cancelled, attempts, completion rate. | Stacked bar + table; campaign group → campaign → status → activity → interaction. | +| 19 | Subject inventory | Shows progress by CRM subject content type. Business owners use it to understand which workflows generate backlog or failures. | Historical; one subject type per row. | Subject type, status, source; subject/day; descending total. | Subject type, total, completed, pending, in progress, failed, cancelled, attempts, completion rate. | Stacked bar + table; subject → activity → interaction/disposition. | + +### CRM activity reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 20 | Activity summary | Gives CRM operations a top-level inventory and completion view with source, channel, status, and daily creation breakdowns. | Historical dashboard; cohort plus one day per row. | Source, channel, status, campaign; day/source/channel/status; chronological daily rows. | Total, completed, pending, in progress, failed, cancelled, completion rate, daily created. | KPI cards + bars + trend; dimension → activity list → activity. | +| 21 | Activity backlog | Quantifies currently nonterminal, unassigned, overdue, and reserved work created on or before **To**. Enables backlog clearance and assignment decisions without dropping older open work. | Current-state dashboard constrained by creation cutoff; one status per row. | Status, source, channel, campaign; status; descending open count. | Open, unassigned, overdue, reserved; status totals and progress columns. | KPI cards + stacked bars; status → activity. | +| 22 | Activity aging | Places currently open work created on or before **To** into stable age bands and highlights unassigned and overdue work. Supports backlog risk and staffing decisions. | Current-state aging report; one age bucket per row. | Source, channel, campaign, status; age bucket; oldest first. | Age bucket, activities, share, unassigned, overdue. | Aging histogram + table; bucket → activity detail. | +| 23 | Activity source performance | Compares manual, automatic, dialer, callback, inbound, workflow, and API work progress. Supports automation and workload-source decisions. | Historical; one source per row. | Source, status, channel; source/day; descending total. | Source, total, completed, in progress, pending, failed, cancelled, completion rate, average attempts. | Stacked bars + table; source → status → activity. | +| 24 | CRM channel performance | Shows CRM work progress by communication channel independent of interaction session outcomes. Helps identify channel workflow bottlenecks. | Historical; one channel per row. | Channel, source, campaign, status; channel/day; descending total. | Channel and standard activity progress columns. | Stacked bars + table; channel → campaign/status → activity. | +| 25 | Activity kind performance | Compares calls, messages, meetings, tasks, and future work kinds. Supports workload-mix planning. | Historical; one activity kind per row. | Kind, source, status; kind/day; descending total. | Activity kind and standard activity progress columns. | Donut + table; kind → source/status → activity. | +| 26 | Activity assignment performance | Shows progress by assignment lifecycle state and surfaces work stalled before ownership. Supports reservation and routing improvements. | Historical; one assignment status per row. | Assignment status, assignee, queue, source; assignment/day; descending total. | Assignment status and standard activity progress columns. | Funnel + table; assignment status → activity/reservation detail. | +| 27 | Activity attempt analysis | Shows outcome by nonnegative attempt count. Helps tune retries and identify diminishing returns. | Historical; one attempt count per row. | Attempt count, source, campaign, status; attempts; ascending attempts. | Attempts, activities, completed, failed, completion rate. | Funnel + table; attempt count → campaign/source → activity interactions. | +| 28 | Contact type workload | Compares activity workload across configured CRM contact content types. Supports business-unit and data-model planning. | Historical; one contact content type per row. | Contact type, source, campaign, channel; contact type/day; descending total. | Contact type and standard activity progress columns. | Bars + table; contact type → activity → contact timeline. | +| 29 | Activity urgency performance | Shows whether urgent work is completing or accumulating. Supports priority-policy and staffing decisions. | Historical; one urgency level per row. | Urgency, status, source, channel; urgency/day; urgency then total. | Urgency and standard activity progress columns. | Heat map + table; urgency → status → activity. | +| 30 | Campaign performance | Gives CRM campaign owners completed-versus-pending progress across activity inventory. Supports campaign pacing and remediation. | Historical; campaign rows plus campaign-group aggregate rows. | Campaign group, campaign, source, channel, status; campaign or group/day; descending total. | Campaign or campaign-group display name, total, completed, pending, in progress, failed, cancelled, completion rate. | Stacked bars + table; campaign group → campaign → status → activity. | +| 31 | Disposition breakdown | Shows how completed activities were dispositioned and each disposition's share. Enables outcome governance and workflow review. | Historical by completion date; one disposition display name per row. | Disposition, campaign, subject, channel, agent; disposition/day; descending completed. | Disposition display name, completed, share of completed activities. | Donut + table; disposition → completed activity → interaction history. | + +### Additional operations, queue, and interaction reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 32 | Hour-of-day performance | Identifies recurring demand and service patterns for intraday operating decisions. | Historical; one UTC hour per row. | Standard interaction filters; hour; chronological. | Standard interaction performance columns including volume, outcomes, rates, ASA, and AHT. | Table; hour → interaction detail. | +| 33 | Day-of-week performance | Compares recurring weekday workload and outcomes for staffing-pattern review. | Historical; one weekday per row. | Standard interaction filters; weekday; descending volume. | Standard interaction performance columns. | Table; weekday → interaction detail. | +| 34 | Queue performance summary | Gives floor managers one consistent comparison of workload, outcomes, ASA, and AHT by queue. | Historical; one queue per row. | Date/time, queue, agent, channel, direction; queue; descending volume. | Queue, interactions, answered, abandoned, failed, answer rate, abandonment rate, ASA, AHT. | Table; queue → interaction detail. | +| 35 | Queue wait time analysis | Quantifies customer waiting effort and queue-time consumption. | Historical; one queue per row. | Standard interaction filters; queue; descending total wait. | Queue, interactions, total wait, average wait, maximum wait. | Table; queue → high-wait detail. | +| 36 | Queue handle time analysis | Quantifies connected plus after-contact work time consumed by each queue. | Historical; one queue per row. | Standard interaction filters; queue; descending total handle time. | Queue, interactions, total handle time, average handle time, maximum handle time. | Table; queue → interaction detail. | +| 37 | Queue transfer performance | Finds queues that transfer work frequently and supports routing redesign. | Historical; one queue per row. | Standard interaction filters; queue; descending transfer volume. | Queue, handled, transferred interactions, transfer events, transfer rate. | Table; queue → transfer detail. | +| 38 | Interaction lifecycle duration | Separates wait, connected, wrap-up, and end-to-end duration by final state. | Historical; one status per row. | Standard interaction filters; status; status order. | Status, interactions, average wait, connected, wrap-up, end-to-end duration. | Table; status → interaction detail. | +| 39 | Long interaction detail | Supports cost, coaching, and exception review for sessions lasting at least 15 connected minutes. | Historical detail; one interaction per row. | Standard interaction filters; newest first. | Standard interaction detail columns. | Table; interaction → recording. | +| 40 | Failed interaction detail | Gives IT and operations an auditable list of failed communication attempts. | Historical detail; one failed interaction per row. | Standard interaction filters; newest first. | Standard interaction detail columns. | Table; interaction → provider/event history. | +| 41 | Abandoned interaction detail | Gives queue managers the source rows behind abandonment totals. | Historical detail; one abandoned interaction per row. | Standard interaction filters; newest first. | Standard interaction detail columns. | Table; interaction → queue history. | +| 42 | High-wait interaction detail | Identifies interactions with at least 60 seconds of observed wait. | Historical detail; one interaction per row. | Standard interaction filters; newest first. | Standard interaction detail columns. | Table; interaction → queue history. | + +### Additional agent performance reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 43 | Agent interaction volume | Compares workload and supporting outcomes by user. | Historical; one agent per row. | Date/time, agent, queue, channel, direction; agent; descending handled. | Agent, handled, answered, failed, transfers, recorded, AHT. | Table; agent → interaction detail. | +| 44 | Agent outcome performance | Surfaces agents with high failed interaction volume without treating failure as a quality score. | Historical; one agent per row. | Standard interaction filters; agent; descending failures. | Agent and standard agent performance columns. | Table; agent → failed interactions. | +| 45 | Agent inbound performance | Isolates inbound workload and outcomes by agent. | Historical; one agent per row. | Standard interaction filters; agent; descending inbound volume. | Agent and standard agent performance columns. | Table; agent → inbound detail. | +| 46 | Agent outbound performance | Isolates outbound workload and outcomes by agent. | Historical; one agent per row. | Standard interaction filters; agent; descending outbound volume. | Agent and standard agent performance columns. | Table; agent → outbound detail. | +| 47 | Agent transfer performance | Supports coaching and routing review with transfer volume by agent. | Historical; one agent per row. | Standard interaction filters; agent; descending transfers. | Agent, handled, answered, failed, transfers, recorded, AHT. | Table; agent → transfer detail. | +| 48 | Agent recording coverage | Finds agent-associated answered interactions lacking recording references. | Historical; one agent per row. | Standard interaction filters; agent; descending recorded volume. | Agent and standard agent performance columns. | Table; agent → uncovered interaction. | +| 49 | Assigned user performance | Compares CRM activity progress and attempts by assigned user. | Historical; one user per row. | CRM filters; assigned user; descending activity count. | Assigned user and standard activity progress columns. | Table; user → activity detail. | +| 50 | User completion time | Measures average, median, and maximum CRM activity cycle time by assigned user. | Historical; one user per row. | CRM filters; assigned user; descending average cycle time. | User, completed, average cycle time, median cycle time, maximum cycle time. | Table; user → completed activities. | +| 51 | Daily user productivity | Shows daily completed work, attempts, and cycle time by assigned user. | Historical; one user/day row. | CRM filters; day/user; chronological. | UTC date, user, completed, average cycle time, attempts. | Table; day/user → activity detail. | +| 52 | Overdue workload by user | Supports supervisor intervention by showing overdue volume and age per owner. | Current-state as-of report; one user per row. | CRM filters; user; descending overdue count. | User, overdue, unassigned, average overdue age, maximum overdue age. | Table; user → overdue activities. | + +### Workforce and payroll reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 53 | Agent time summary | Provides observed on-duty and state-duration inputs for workforce and payroll review. | Historical presence-duration report; one agent per row. | Date/time and agent; agent; descending signed-in time. | Agent, signed-in, available, busy, wrap-up, break, other not-ready, utilization. | Table; agent → presence audit. | +| 54 | Daily agent timecard | Provides day-level observed timecard inputs without applying schedules or pay rules. | Historical; one UTC day/agent row, one semantic subtotal per UTC day, and one grand total. | Date/time and agent; day/agent; chronological. | Date, agent, signed-in, productive presence, busy + wrap-up, break + away, first observed, last observed. | Table; day/agent → presence audit. | +| 55 | Presence status duration | Shows how signed-in time is distributed across every presence state. | Historical; one status per row. | Date/time and agent; status; descending duration. | Presence status, duration, share of signed-in time, intervals. | Donut/table; status → agent intervals. | +| 56 | Agent break and away analysis | Quantifies break frequency and duration for workforce review. | Historical; one agent per row. | Date/time and agent; agent; descending break time. | Agent, breaks, total break time, average break, longest break. | Table; agent → presence audit. | +| 57 | Ready versus not-ready time | Separates ready, actively working, and not-ready time. | Historical; one agent per row. | Date/time and agent; agent. | Agent, ready time, working time, not-ready time, ready share. | Stacked bars/table; agent → status durations. | +| 58 | Agent utilization | Measures busy plus wrap-up time as a share of all observed signed-in time. | Historical; one agent per row. | Date/time and agent; agent; descending utilization. | Agent, working time, signed-in time, utilization. | Bar/table; agent → time summary. | +| 59 | Agent occupancy | Measures busy plus wrap-up time against available handling time. | Historical; one agent per row. | Date/time and agent; agent; descending occupancy. | Agent, working time, available handling time, occupancy. | Bar/table; agent → time summary. | +| 60 | Presence reason breakdown | Quantifies time associated with configured break/not-ready reasons. | Historical; one status/reason row. | Date/time and agent; status/reason; descending duration. | Status, reason, duration, intervals. | Table; reason → presence audit. | +| 61 | Agent presence audit | Provides the durable transition ledger used to reconcile time reports. | Historical detail; one transition per row. | Date/time and agent; newest first. | Changed UTC, agent, previous/current/requested status, reason, queue count, campaign count, event. | Table; transition → event record. | +| 62 | Queue signed-in hours | Attributes observed signed-in duration to queue memberships active during each presence interval. | Historical; one queue per row. | Date/time and agent; queue; descending duration. | Queue id, signed-in time, agent intervals. | Table; queue → agent presence audit. | +| 63 | Campaign signed-in hours | Attributes observed signed-in duration to campaign memberships active during each presence interval. | Historical; one campaign per row. | Date/time and agent; campaign; descending duration. | Campaign id, signed-in time, agent intervals. | Table; campaign → agent presence audit. | +| 64 | Payroll timecard inputs | Exports observed on-duty and state-classification durations for payroll review. It intentionally does not calculate wages. | Historical; one agent per row. | Date/time and agent; agent. | Agent, observed on-duty, productive presence, break + away, meeting + training, other not-ready. | Table/export; agent → daily timecard. | + +### Billing and usage reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 65 | Queue usage for billing | Supplies queue-level measured usage for invoice and client chargeback reconciliation. | Historical; one queue per row. | Standard interaction filters; queue; descending connected time. | Queue, interactions, answered, connected, wrap-up, queue wait, transfers, recordings. | Table/export; queue → interaction detail. | +| 66 | Agent usage for billing | Supplies agent-level measured service time for payroll and internal allocation. | Historical; one agent per row. | Standard interaction filters; agent; descending connected time. | Agent and standard usage columns. | Table/export; agent → interaction detail. | +| 67 | Provider usage for billing | Reconciles provider invoices against normalized platform usage. | Historical; one provider per row. | Standard interaction filters; provider; descending connected time. | Provider and standard usage columns. | Table/export; provider → interaction detail. | +| 68 | Channel usage for billing | Allocates measured usage across voice, SMS, email, chat, and future channels. | Historical; one channel per row. | Standard interaction filters; channel; descending connected time. | Channel and standard usage columns. | Table/export; channel → interaction detail. | +| 69 | Daily usage for billing | Supplies invoice-period daily usage totals for reconciliation. | Historical; one UTC day per row. | Standard interaction filters; day; chronological. | Date and standard usage columns. | Trend/table/export; day → interaction detail. | + +### Additional CRM and campaign reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 70 | Activity creation by user | Audits which user or system actor created CRM work and its eventual outcomes. | Historical; one creator per row. | CRM filters; creator; descending total. | Creator and standard activity progress columns. | Table; creator → activity detail. | +| 71 | Campaign source mix | Compares campaign workload and outcomes by activity source. | Historical; one campaign/source row. | CRM filters; campaign/source; descending total. | Campaign, source, activities, completed, failed, completion rate. | Stacked table; campaign → source → activities. | +| 72 | Campaign channel mix | Compares campaign workload and outcomes by channel. | Historical; one campaign/channel row. | CRM filters; campaign/channel; descending total. | Campaign, channel, activities, completed, failed, completion rate. | Stacked table; campaign → channel → activities. | +| 73 | Campaign disposition mix | Shows campaign results by durable disposition. | Historical; one campaign/disposition display-name row. | CRM filters; campaign/disposition; descending total. | Campaign display name, disposition display name, activities, completed, failed, completion rate. | Table; campaign → disposition → activities. | +| 74 | Campaign attempt performance | Shows how campaign outcomes vary by attempt count. | Historical; one campaign/attempt row. | CRM filters; campaign/attempt; descending total. | Campaign, attempts, activities, completed, failed, completion rate. | Funnel/table; campaign → attempt → activities. | +| 75 | Channel endpoint usage | Supports technical capacity and configuration review by endpoint. | Historical; one endpoint per row. | CRM filters; endpoint; descending total. | Endpoint and standard activity progress columns. | Table; endpoint → activities. | +| 76 | Customer workload | Shows CRM work volume, outcomes, and attempts per customer record. | Historical; one customer per row. | CRM filters; customer; descending total. | Customer and standard activity progress columns. | Table; customer → CRM timeline. | +| 77 | Scheduled completion performance | Compares activities completed by their scheduled time with late completions. | Historical; one schedule-result row. | CRM filters; result; result order. | Schedule result, activities, share, average absolute variance. | KPI/table; result → activities. | + +### Additional compliance and technical reports + +| # | Report | Purpose and business value | Type and granularity | Filters; grouping; sorting | Columns/KPIs | Visualization and drill-down | +| --- | --- | --- | --- | --- | --- | --- | +| 78 | Call leg performance | Gives IT teams provider-leg volume, answer state, status, and duration. Legs are read from the call session the voice topology projector maintains, so the report requires the **Voice** capability and states that requirement when it is absent. | Historical; one leg status per row. | Standard interaction filters; leg status. | Leg status, legs, answered, average duration. | Table; status → interaction/provider detail. | + +## Data validation and reconciliation + +Use the following acceptance dataset whenever report projections or formulas change: + +1. Include inbound answered, inbound abandoned, inbound failed, and outbound answered/failed interactions. +2. Include one interaction with multiple transfers, one consultative transfer, one conference without a transfer, one callback promoted to outbound work, one queue overflow, and one requeue. +3. Include voice, chat, email, SMS, and social messaging interactions where adapters provide those channels. +4. Include completed, pending, in-progress, failed, cancelled, purged, unassigned, reserved, overdue, and multi-attempt CRM activities. +5. Reconcile interaction-detail counts to agent, queue, channel, direction, provider, and enterprise totals. Reconcile CRM activity rows to source, campaign, subject, status, and disposition totals. +6. Recalculate every rate from raw numerator and denominator counts. Never average displayed percentages. +7. Test tenant-local date selection across UTC date boundaries and daylight-saving transitions. Persist and compare source timestamps in UTC. +8. Test with paged/projection-backed queries before production use at multi-million-row scale. The current first implementation aggregates the selected cohort in tenant memory and is intended for bounded operational date ranges. + +## Known data limitations + +The following common report families are intentionally not emitted until their source data exists: + +- **Workforce planning:** forecast accuracy, staffing requirement, schedule adherence, conformance, shrinkage, overtime, and paid-hours compliance require forecasts, schedules, employment calendars, and pay policies. The included occupancy, utilization, and timecard reports use durable observed presence only. +- **Quality management:** evaluation scorecards, calibration, coaching completion, compliance failures, and evaluator productivity require the planned quality module. +- **Customer experience:** CSAT, NPS, customer effort, and first-contact resolution require survey responses and a durable case/resolution-reopen correlation model. Disposition alone is not a valid substitute for FCR. +- **Advanced interaction analytics:** sentiment, topic, silence, interruption, script adherence, and AI summaries require transcript analytics and an enabled provider. +- **IVR journey analytics:** menu path, containment, self-service completion, and opt-out require multi-step IVR event capture. +- **Historical backlog reconstruction:** activity reports use the current persisted activity status. Backlog and aging include all currently nonterminal work created by the selected **To** boundary, but they cannot reconstruct whether an activity that is completed today was still open at a past boundary without a dedicated activity-state history projection. +- **Payroll and billing money:** reports provide measured durations and counts, but wages, premiums, contracted rates, billing increments, taxes, discounts, currency, and invoice totals require organization-specific rate and policy data that is not persisted by these modules. + +These exclusions prevent plausible-looking but operationally false enterprise KPIs. diff --git a/src/CrestApps.Docs/docs/contact-center/routing-work-state.md b/src/CrestApps.Docs/docs/contact-center/routing-work-state.md new file mode 100644 index 000000000..de12cb10b --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/routing-work-state.md @@ -0,0 +1,66 @@ +--- +sidebar_label: Routing Work State +title: Routing Work State and the CRM Activity +description: Which document owns assignment, reservation, and attempt state for contact center work, why the CRM activity still carries the same fields, and which of the two a routing decision must read. +--- + +The CRM `OmnichannelActivity` is the universal work item. It is created by the CRM, edited by people in the admin UI, listed and filtered by CRM screens, and reported on by the enterprise report catalog. The contact center also needs to record, for the same piece of work, who currently holds it, which reservation offered it, when that offer expires, and how many dial attempts it has consumed. + +Those two responsibilities used to share one document, and that is a problem rather than a convenience. The reservation loop and a person editing the activity in the admin UI both wrote the same optimistic-concurrency document, so one of the two writes was lost, or the routing commit failed with a concurrency exception. Losing a routing commit strands live work with a caller on the line. + +## Who owns what + +| Concern | Owner | Document | +| --- | --- | --- | +| Assignment status, reservation identity and expiry, reserving and assigned agent, attempt count | Contact Center | `ContactCenterWorkState` | +| Activity status, terminal reason, scheduling, subject, contact resolution, disposition, notes | CRM | `OmnichannelActivity` | +| Communication history for one attempt | Contact Center | `Interaction` | + +`ContactCenterWorkState` is keyed one-to-one to the activity by a unique index on `ActivityItemId`, and it lives in the Contact Center collection. Every contact center writer mutates it through `IContactCenterWorkStateService`: + +```csharp +await _workStateService.MutateAsync(activityItemId, workState => +{ + workState.AssignmentStatus = ActivityAssignmentStatus.Reserved; + workState.ReservationId = reservation.ItemId; + workState.ReservedById = agent.UserId; + workState.ReservationExpiresUtc = reservation.ExpiresUtc; +}, cancellationToken); +``` + +The routing transaction commits without touching the CRM activity at all. + +## The activity still carries the same fields + +`OmnichannelActivity` keeps `AssignmentStatus`, `ReservationId`, `ReservedById`, `ReservedByUsername`, `ReservedUtc`, `ReservationExpiresUtc`, `AssignedToId`, `AssignedToUsername`, `AssignedToUtc`, and `Attempts`, but only as a **read model**. They are reconciled by `ContactCenterWorkStateActivityProjection` after the routing transaction has committed, in its own scope, with a bounded retry on conflict. + +They were retained rather than deleted because they are load-bearing outside routing. `OmnichannelActivityAuthorizationHandler` decides activity ownership from `AssignedToId`, and the CRM activity list, bulk-manage filters, and enterprise reports query the same columns through `OmnichannelActivityIndex`. Deleting them would either remove CRM function or invert the layering by making the CRM query a Contact Center store. + +Because the read model is reconciled after the fact, it can lag. That leads to one rule. + +## Which one to read + +Read `IContactCenterWorkStateService` for anything that decides routing: whether work may be offered, who holds it, whether an offer has expired, and whether the dialer's attempt cap has been reached. Read the activity's projected columns only for CRM presentation and bulk reporting, where a per-row work state lookup would be a query per row. + +`IContactCenterActivityWriter` is the counterpart for CRM-owned fields. Contact center code that has to set an activity's terminal status schedules the write through the writer rather than writing the activity inside the routing transaction: + +```csharp +await _activityWriter.ScheduleUpdateAsync(activityItemId, activity => +{ + activity.Status = ActivityStatus.Cancelled; + activity.TerminalReasonCode = reasonCode; + activity.CompletedUtc = endedUtc; +}, cancellationToken); +``` + +The activity is therefore written twice for a terminal transition — once to reconcile the read model and once to apply the CRM-owned status — and both writes happen after the routing transaction has committed rather than inside it. + +## Upgrading + +No backfill job is required. Work that was already in flight when the feature was upgraded has no work state document yet, so the first read or mutation adopts the projected fields the activity already carries. Reporting that work as unassigned with no attempts would re-offer work an agent already holds and reset the dialer's attempt cap, so adoption is the default rather than an option. + +## Enforcement + +`ContactCenterWorkStateAuthorityTests` scans every Contact Center, Telephony, and provider source file and fails the build if any of them stores into one of the ten routing-owned fields of a CRM activity outside `ContactCenterWorkStateProjector`, which is the single definition of what the read model contains. Every storing form is covered: assignment, compound assignment, increment and decrement — which is how the attempt count is written — and object initializers, including target-typed ones. The scan classifies the receiver of a write rather than the field name, because a queue item and a provider command both carry a reservation identifier they legitimately own. It does so by compiling the sources and asking the compiler for the receiver's type, and it is fail-closed: a receiver whose type cannot be resolved is reported as a violation rather than assumed to be some other document, because a scan that recognizes receivers by the shape of the identifier naming them proves only that nothing writes routing state onto a receiver it happened to recognize — aliasing the activity through a single local defeats it. Known-positive and known-negative controls sit either side of that rule: the projector must be reported for all ten fields and each report must have recognized the receiver as the CRM activity rather than merely failed to resolve it, and the queue-item and provider-command writers must not be reported at all. The same suite proves the behaviour against a real database: a reservation running while the CRM holds an earlier read of the same activity commits without either writer conflicting and without either write being lost. + +Two deviations are recorded rather than hidden. `ActivityStatus` remains CRM-owned and is not extracted. `AgentWorkspaceEndpoints` still reads the projected `AssignedToId` as a fallback beside the authoritative interaction agent, and `ContactCenterReportingService` reads the projected columns in bulk reporting queries. diff --git a/src/CrestApps.Docs/docs/contact-center/runbooks.md b/src/CrestApps.Docs/docs/contact-center/runbooks.md new file mode 100644 index 000000000..d9e328aa4 --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/runbooks.md @@ -0,0 +1,151 @@ +--- +sidebar_label: Failure runbooks +sidebar_position: 23 +title: Contact Center failure and deployment runbooks +description: Operational runbooks for SQL, Redis/backplane, provider, node, and network failures plus rolling and blue-green deployment and the voice listener handover and rollback. +--- + +These runbooks cover the dependency and node failures an operator must handle for a Contact Center deployment, plus the two supported deployment strategies. They assume the health, telemetry, retention, and upgrade contracts described in [Production support](production-support.md). + +Every runbook uses the same signals so responders do not have to learn per-incident tooling: + +- **Health probes.** `/api/contact-center/health/dependencies` (requires `MonitorContactCenter`) reports per-check status for `contactcenter-storage`, `contactcenter-outbox`, `contactcenter-active-calls`, and — on tenants that enable Voice — `contactcenter-provider-ingress`, plus `contactcenter-queue-backlog` on tenants that enable Queues. The `contactcenter-active-calls` and `contactcenter-queue-backlog` checks are **live gauges**, not verdicts: they stay healthy at any count and carry the count (`active_calls`, `queued_interactions`) in the check's `Data` so a drain or handover can be sized before it is started. **This is the probe to read during an incident.** The anonymous `/api/contact-center/health/ready` and `/health/process` probes are orchestrator signals only: readiness reports node-local state and deliberately stays healthy while a dependency is degraded, so that a shared outage cannot drain every node at once, and `/health/process` reports only that the process is scheduling requests. Never diagnose a dependency from them. Readiness and the dependency report are per tenant and inherit the tenant's request URL prefix; `/health/process` is served by the host and is prefix-independent. See [Production support](production-support.md) for the full probe contract. +- **Metrics** from the `CrestApps.OrchardCore.ContactCenter` meter: `contactcenter.outbox.redelivered` and `contactcenter.outbox.dead_lettered` (tagged by `reason`). On tenants that enable Voice, the `CrestApps.OrchardCore.Asterisk` meter adds three counters tagged by `provider`: `asterisk.realtime.ingestion.saturated` counts real-time buffer saturation episodes (a sustained rise means the dispatcher is falling behind the provider event stream); `asterisk.realtime.connected` counts successful ARI event-stream connections (first connect plus every reconnect), so it is the listener connectivity signal; and `asterisk.realtime.reconnect_attempted` counts reconnection attempts, so a sustained rate is connection churn and an early warning that events may be missed between reconciliation sweeps. +- **Traces** from the `CrestApps.OrchardCore.ContactCenter` activity source. + +Thresholds are configurable under `CrestApps_ContactCenter:HealthChecks`; tune them per deployment before relying on the states below. + +## General triage + +1. Read `/api/contact-center/health/dependencies` for the affected tenant and identify which check is `Degraded` or `Unhealthy`. Corroborate with the `contactcenter.outbox.redelivered` and `contactcenter.outbox.dead_lettered` counters. +2. Correlate with the outbox counters. A rising `contactcenter.outbox.dead_lettered{reason="retry_exhausted"}` means downstream dispatch is failing; a rising `redelivered` with no dead-letters means transient retries are recovering. +3. Pick the matching runbook below. Storage failures cascade into every other subsystem, so always rule out SQL and Redis first. + +## SQL (primary datastore) failure + +**Detection.** `contactcenter-storage` reports `Unhealthy`; store operations throw; the outbox and provider-ingress checks may also fail because they read the same database. + +**Impact.** Contact Center is stateful in SQL: interactions, queue items, call sessions, the durable event outbox, the provider webhook inbox, projection checkpoints, and the interaction event log all live there. A total SQL outage stops routing, disposition, and provider command execution. No data is lost that was committed, because the outbox and inbox are durable and replayed after recovery. + +**Response.** + +1. Confirm the outage is the database, not the app tier, using the storage health check and the database provider's own metrics. +2. Fail the affected nodes out of the load balancer so they stop returning 5xx to agents and providers. Provider webhooks continue to be accepted only if at least one healthy node remains; otherwise providers will retry per their own policy and the inbox replays on recovery. +3. Restore or fail over the database. Contact Center makes no assumption about the engine beyond YesSql portability, so follow the runbook for the deployed engine (SQLite file restore, or SQL Server / PostgreSQL / MySQL HA failover). +4. After the database is healthy, bring nodes back. The outbox dispatch loop resumes and redelivers pending messages; idempotency keys and fence tokens make redelivery safe. Watch `contactcenter.outbox.redelivered` drain to steady state. +5. If projections look stale after a restore from backup, run the metrics projection rebuild — it recomputes every per-day, per-event-type count from the durable event log and reconciles the stored metrics. + +**Prevention.** Provision the database for HA (managed failover or a replica), and keep `ProjectionReplayHorizonDays` and `LegalHoldMinimumDays` set so the event log stays rebuildable after a point-in-time restore. + +## Redis / backplane failure + +**Detection.** Real-time updates (agent state, queue counts, supervisor dashboard) stop propagating across nodes; distributed-lock acquisition fails. SignalR backplane liveness is delegated to the backplane provider's own health check, not the Contact Center checks. + +**Impact.** Redis is used for two distinct things in a multi-node deployment: the SignalR backplane (`CrestApps.OrchardCore.SignalR.Redis`) and distributed locks (`OrchardCore.Redis.Lock`) that guard routing and provider-ingress critical sections. A backplane outage degrades cross-node real-time fan-out; a lock outage stops the background sweeps that require mutual exclusion. + +**Response.** + +1. Confirm whether the backplane, the lock service, or both are affected. +2. **Backplane only:** each node still serves its own connected clients correctly; only cross-node fan-out is degraded. This is a degradation, not an outage — routing correctness does not depend on the backplane. Restore Redis; no replay is required because real-time messages are transient. +3. **Lock service down:** the callback-promotion and reconciliation sweeps use owner tokens, fence tokens, and time-boxed leases, so an expired or unavailable lock cannot cause double work — an overlapping pass is rejected by the fence/lease, and a customer is not called back twice. Forward progress pauses until locking recovers, but no corruption occurs. +4. Restore Redis and confirm the backplane feature and `OrchardCore.Redis.Lock` are both healthy. + +**Prevention.** Run Redis in HA. Never enable the backplane without `OrchardCore.Redis.Lock`; a backplane without distributed locking is an unsupported topology. + +## Provider (telephony / channel) failure + +**Detection.** `contactcenter-provider-ingress` reports a growing backlog; provider webhook processing lags; a provider's outbound command stream stalls. A stuck provider stream or an expired listener lease surfaces as a growing ingress backlog. + +**Impact.** Inbound provider events are accepted into the durable provider webhook inbox and processed asynchronously; outbound provider commands are queued in a fenced, leased command store. A provider outage therefore does not lose events — it delays them. + +**Response.** + +1. Identify the failing provider and whether the problem is inbound (webhook inbox backlog) or outbound (command lease not advancing). +2. **Inbound backlog:** confirm the provider is actually delivering webhooks. Accepted-but-unprocessed messages drain automatically once the app tier recovers; the inbox is idempotent, so provider retries of the same event are de-duplicated. +3. **Outbound stall:** provider commands carry a fence token and a lease. If a node died mid-command, the lease expires and another node re-leases and retries with the same fence token, so a superseded command cannot overwrite a newer one. Check `contactcenter.outbox.dead_lettered{reason="retry_exhausted"}` for commands that exhausted retries and need manual attention. +4. Once the provider recovers, watch the ingress backlog and dead-letter counter return to baseline. + +**Prevention.** Alert on the provider-ingress health state and on `dead_lettered` growth. Keep provider credentials and endpoints in configuration/secret storage so a provider failover does not require a code change. + +### Asterisk single-active-process listener ownership + +**Constraint.** The Asterisk real-time voice listener claims ownership of each ARI `(BaseUrl, ApplicationName)` pair in **process-local state on the node that starts it** — there is no distributed lock coordinating ownership across nodes. This is correct only under a single-active-process deployment: exactly one application node may run the listener for a given Asterisk ARI application at a time. On startup, each node logs the number of Asterisk listeners it is starting and this constraint at information level. + +**Requirement.** Do not run two nodes that both start the Asterisk listener against the **same** Asterisk server and Stasis application concurrently. Overlapping nodes would each open a WebSocket to the same ARI application and cross-deliver Stasis events, double-processing calls. + +**Deployment.** Because ownership is process-local, telephony listeners must use a **non-overlapping** cutover rather than a side-by-side rolling or blue-green swap: + +1. Stop the old node's listener (drain, then terminate the tenant/shell so `ReleaseGeneration` runs) before the new node starts its listener against the same ARI application. +2. Only one overlapping node may enable the listener for a given `(BaseUrl, ApplicationName)` pair. Configuring a unique ARI application per tenant only prevents cross-tenant collisions **within a single process** — it does not make two nodes safe, because the same tenant configuration runs on both nodes and both would subscribe to the same application. To run overlapping application nodes safely, give each listening node a **distinct** ARI application (with matching dialplan segregation on the PBX) or front the listeners with an external single-writer mechanism. +3. A blank or misconfigured ARI application is denied at the claim path (the listener does not start) and logged as a warning, so an unconfigured provider never silently competes for events. + +## Node failure + +**Detection.** A node stops passing `/api/contact-center/health/ready`; the load balancer removes it; connected agents reconnect elsewhere. + +**Impact.** Contact Center nodes are stateless beyond in-flight requests — all durable state is in SQL, all cross-node coordination is in Redis. A single node loss does not lose committed work: outbox messages, inbox messages, and leased commands held by the dead node time out and are re-leased by survivors. + +**Response.** + +1. Confirm the load balancer has evicted the node (readiness probe on `/api/contact-center/health/ready`). +2. Let leases held by the dead node expire; survivors re-acquire them via fence tokens and continue. No manual intervention is required for correctness. +3. Replace the node. New nodes pick up the backplane and lock service automatically once their features are enabled. + +**Prevention.** Run at least two nodes behind a load balancer probing `/api/contact-center/health/ready` so a single failure is transparent to agents and providers. + +## Network partition + +**Detection.** Nodes cannot reach SQL, Redis, or providers; health checks flap; cross-node real-time updates stop. + +**Impact.** A partition looks like a combination of the failures above. The system is designed to fail safe rather than double-act: fence tokens and leases prevent split-brain double execution, and the outbox/inbox prevent event loss. + +**Response.** + +1. Determine which dependency is partitioned (SQL, Redis, provider) and follow the matching runbook. +2. Do not force-release locks or manually replay the outbox during a partition — fencing already prevents double work, and manual replay can defeat idempotency assumptions. +3. When the partition heals, verify the outbox and provider-ingress backlogs drain and the redelivered/dead-lettered counters stabilize. + +**Prevention.** Co-locate nodes, SQL, and Redis within a single region and availability-zone-redundant network. Multi-region active-active is an unsupported topology for this release. + +## Rolling deployment + +Contact Center supports zero-downtime rolling deployments because every shipped schema migration is additive (see the expand-migrate-contract policy in [Production support](production-support.md)). + +1. Confirm the release contains only additive migrations. If a release declares a downtime requirement, use a maintenance window instead of a rolling deploy. +2. Drain and replace nodes one (or one batch) at a time. Each replaced node runs the additive migration; old nodes keep running against the expanded schema because new columns are defaulted or nullable. +3. Wait for each replaced node to report `/api/contact-center/health/ready` healthy before draining the next. +4. Leases and outbox/inbox messages held by a draining node expire and are re-acquired by the nodes that remain, so in-flight work is not lost. +5. After the last node is replaced, confirm the outbox backlog is drained and no health check is degraded. + +The stateless tier rolls with zero downtime, but a tenant that enables Voice cannot swap its single-active-process listener side by side: follow [Voice listener handover and rollback](#voice-listener-handover-and-rollback) for that part of the deploy. + +## Blue-green deployment + +1. Stand up the green environment against the **same** SQL database and Redis instance as blue, with the additive migration applied. +2. Because migrations are additive, blue keeps operating correctly while green runs the expanded schema. +3. Warm green and verify `/api/contact-center/health/ready` plus a synthetic routing and disposition flow. +4. Cut the load balancer from blue to green. In-flight leases and outbox/inbox messages are keyed in the shared database and are picked up by green. +5. Keep blue on standby until green is confirmed stable, then decommission blue. Defer any contract-phase (destructive) migration to a later release, after blue is retired and no node reads the old schema shape. + +On a tenant that enables Voice, the blue-to-green cutover cannot carry the single-active-process listener side by side; sequence that part as a non-overlapping handover per [Voice listener handover and rollback](#voice-listener-handover-and-rollback). + +## Voice listener handover and rollback + +The rolling and blue-green strategies above are zero-downtime for the **stateless app tier, SQL, and Redis**. They are **not** zero-downtime for the **Voice real-time listener**, because that listener is single-active-process (see [Asterisk single-active-process listener ownership](#asterisk-single-active-process-listener-ownership)): exactly one node may hold the ARI event stream for a given `(BaseUrl, ApplicationName)` pair, so a new listener cannot attach until the old one has released it. The voice handover is therefore a **bounded interruption of call control**, and this runbook does not claim otherwise. Plan it for a low-traffic window and size it first. + +**Size the interruption before starting.** Read `/api/contact-center/health/dependencies` and note `contactcenter-active-calls` (`active_calls` in its `Data`) — this is the number of live calls whose control the handover will briefly suspend — and `contactcenter-queue-backlog` (`queued_interactions`) for the routed work still waiting. If `active_calls` is high, wait for a quieter window; there is no configuration that makes the cutover lossless while a call is mid-control. + +**Perform a non-overlapping cutover** (never side-by-side, or the two listeners cross-deliver Stasis events and double-process calls): + +1. Drain the old node and terminate its tenant/shell so `ReleaseGeneration` runs and the old listener closes its ARI WebSocket. Only after it has released the `(BaseUrl, ApplicationName)` pair may the new node start its listener against the same application. +2. Watch `asterisk.realtime.connected` tick up on the new node (the new listener attached) and `asterisk.realtime.reconnect_attempted` settle to flat (it is not churning). The new listener runs a reconciliation sweep on connect, which restores each known call's current state — pointer-driven and best-effort, at most 200 known calls per sweep, coalesced behind a distributed lock. Do not expect `active_calls` to "recover": it counts sessions with no `EndedUtc`, so it stays flat through the gap (no events are processed to end anything) and reconciliation only ever moves sessions **toward** terminal. After the sweep completes, `active_calls` should therefore **drop** to the true count of calls still live, and any residue above that is your stranded-session signal (it is also what the retention sweep will not purge, since retention keys on a non-null `EndedUtc`). +3. Confirm `contactcenter-provider-ingress` and the outbox backlog are not growing, and that `asterisk.realtime.ingestion.saturated` stays flat. + +**What happens to live calls during the handover gap.** While no listener holds the ARI stream, no Contact Center control events are processed for that application: + +- **Media already bridged between two parties keeps flowing.** Asterisk retains the bridge at the media layer, so a connected caller and agent continue to hear each other during the gap. What stops is **control**: holds, transfers, supervisor actions, hangup handling, and disposition are not processed until a listener reattaches and reconciliation runs. +- **Channels parked in the Stasis application are not advanced.** A channel that is in the app's `Stasis()` application when the last subscriber disconnects stays parked there with nothing driving it. If the tenant's dialplan has **no continuation after `Stasis()`**, the parked channel is stranded until it either hangs up or a listener returns and reconciliation picks it up. If you need parked calls to fall through during the gap — to a queue announcement, voicemail, or a carrier retry — provide a dialplan continuation after the `Stasis()` line so a channel that loses its application is not left stranded. +- **New inbound calls that enter Stasis during the gap are not recoverable by reconciliation.** Reconciliation is store-driven: it walks interactions and bindings already persisted in the database, and the ARI client has no channel-enumeration API. A call whose `StasisStart` was never received has no interaction and no binding, so nothing discovers it on reconnect — it is parked with nothing driving it, exactly like the stranded case above. The only mitigation is a dialplan continuation after the `Stasis()` line (so the channel falls through to a queue announcement, voicemail, or hangup instead of parking) plus reliance on the upstream PBX or carrier's own retry/queue behavior for admission during the window. Do not rely on Contact Center holding or later adopting these calls. + +**Canary and rollback (single-node-distributed).** Bring the new build up on the target node with its Voice feature enabled but perform the listener cutover last, during the sized window. Verify a synthetic inbound call, a routing and disposition flow, and that the three `asterisk.realtime.*` counters are healthy before declaring the canary good. If the new build misbehaves, **roll back with the same non-overlapping cutover in reverse**: stop the new listener (drain, `ReleaseGeneration`), then restart the previous build's listener against the same ARI application. Keep the previous build ready to restart for exactly this reason. Rollback is itself a bounded interruption, so size it against `contactcenter-active-calls` the same way. Run at least two app nodes so the stateless tier and SQL/Redis coordination survive the swap (N-1) even though the single listener does not overlap. + diff --git a/src/CrestApps.Docs/docs/contact-center/single-node-completion.md b/src/CrestApps.Docs/docs/contact-center/single-node-completion.md new file mode 100644 index 000000000..d0665a8f0 --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/single-node-completion.md @@ -0,0 +1,74 @@ +--- +sidebar_label: Single-Node Completion Roadmap +sidebar_position: 1 +title: Single-Node Completion Roadmap (Plan 2) +description: The phased roadmap that makes the bundled Asterisk provider carry real browser audio and run a credible contact center on a single node first, built entirely on Orchard Core's multi-tenant infrastructure. +--- + +This page is the public, forward-looking roadmap for completing the **Contact Center** so that a single Orchard Core node runs a *credible*, end-to-end contact center — real browser audio, inbound routing, supervisor monitoring, and recording — with distributed (multi-node) hardening following as a second phase. It complements the architecture overview on the [Contact Center](index.md) page and the candidate GA profiles on the [Production support](production-support.md) page. + +:::note +This is a roadmap. Capabilities described as *planned* or *in progress* are not yet supported for production and may change. The authoritative, contract-tested capability matrix ships with the release that delivers each capability. +::: + +## Goals + +1. **Single node, fully functional first — but distributed by construction.** The immediate objective is a certified single-node profile in which every advertised capability actually works and is proven by an automated test that plays and verifies real audio. The services are built on Orchard Core's distributed primitives (distributed cache, distributed locks, and the Redis-backed SignalR backplane), so the platform is *distributable* by design; for this phase it is delivered and **fully tested on a single node** — reliable, secure, and distributable — using Docker containers orchestrated by the project's **.NET Aspire host** with the Redis feature enabled, so those distributed paths are actually exercised on one node. **Multi-node and high-availability testing follows as a secondary phase.** +2. **Provider-agnostic browser media.** Browser audio is delivered through a pluggable media-adapter abstraction. Asterisk is the first concrete adapter (WebRTC via `chan_pjsip` over secure WebSockets with DTLS-SRTP and ICE/TURN); other providers can add their own adapters later. +3. **Built on the Orchard Core foundation.** Every new service extends Orchard Core's own infrastructure — `IDistributedCache`, `IDistributedLock`, the `OrchardCore.Redis` feature, and the tenant-qualified SignalR backplane — so the platform stays multi-tenant safe with no cross-tenant data sharing. These distributed services are switched **on** in the single-node reference host so they are exercised and verified, not bypassed. All work is additive: it extends existing Orchard Core and module patterns and must not regress the current multi-tenant architecture or existing telephony/omnichannel behavior. +4. **Honest verification.** "Done" means a runnable end-to-end proof of real bidirectional audio through a bundled Asterisk, plus supervisor monitor/whisper/barge and recording verified against that same live call. + +## Multi-tenancy and the Orchard Core foundation + +The Contact Center is a multi-tenant platform, and Plan 2 treats tenant isolation as a first-class, non-negotiable requirement: + +- **No parallel infrastructure.** Distributed cache, locking, and real-time messaging use Orchard Core's own primitives and the per-tenant Redis-backed features, rather than a separate stack. +- **No shared mutable state across tenants.** Registries, credential caches, provider-settings caches, and media-session tables are tenant-scoped (shell-scoped services or tenant-keyed distributed cache invalidated through Orchard Core's signaling), never process-global. +- **Tenant-namespaced telephony.** When multiple tenants share one Asterisk or TURN server, every telephony identity — application name, dialplan context, endpoints, bridges, recordings, and TURN credentials — is namespaced per tenant, and inbound provider events are bound to and resolved through the owning tenant so one tenant can never route into, monitor, or record another tenant's call. +- **Per-tenant provider configuration.** Each tenant can point at its own provider. An unconfigured provider appears *unavailable* rather than causing errors. + +These commitments are validated by adversarial two-tenant tests (identical logical identifiers across two tenants sharing one PBX, including a negative test proving one tenant can never receive, route, monitor, or record another tenant's telephony events) and by automated architecture guards in CI. + +## Capability status (Asterisk reference provider) + +| Capability | Today | Plan 2 target | +| --- | --- | --- | +| Browser audio (WebRTC) | Not available | Delivered (single-node certified) | +| Inbound routing to a queue and agent | Not available | Delivered | +| Server-side connect / bridge on accept | Not available | Delivered | +| Call recording | Not available | Delivered, with consent/retention governance | +| Supervisor monitor / whisper / barge | Not available | Delivered | +| Blind transfer | Not available | Delivered | +| Attended/consult transfer and conference | Not available | Delivered | +| Multi-node / high-availability media | Not claimed | Gated by the platform's multi-node release program | + +The DialPad provider remains an external-device call-control integration for this phase; a DialPad browser-media adapter is a later addition. + +## Phased roadmap + +The work is sequenced so that correctness and safety land before power: + +1. **Foundations.** A runnable reference stack (Asterisk + TURN + Redis + CMS), orchestrated by the project's **.NET Aspire host** with a Docker Compose variant for headless CI, the automated audio proof (initially failing), and the two-tenant isolation harness. Two single-node profiles are exercised — one with the Redis-backed distributed cache, locking, and real-time backplane **enabled**, and one with those features **disabled** — so the platform is proven correct both with and without the backplane, and the digest-pinned Asterisk image is checked at startup for the WebRTC modules it must carry. +2. **State authority and consistency.** A canonical live-call ownership model with optimistic-concurrency protection and single-writer guarantees, so call, interaction, queue, and agent state cannot diverge under load — even on one node. +3. **Security and authorization.** A single call-control authorization boundary that verifies ownership and entitlement for every call action and every supervisor operation, with server-resolved, policy-checked transfer and dial destinations. +4. **Browser media.** The WebRTC media adapter and the Asterisk WebRTC endpoint lifecycle (secure credential provisioning, rotation, and revocation). +5. **Connect and inbound routing.** Real server-side bridging on agent accept and idempotent inbound routing from Asterisk into the queue-and-offer pipeline. +6. **Supervisor and recording.** Recording, listen/whisper/barge with a real supervisor audio path, transfer, and conference — with recording continuity across transfers. +7. **Reliability and observability.** Health, graceful degradation, reconnection and in-flight call recovery, and media-quality telemetry with tenant-correlated tracing. +8. **Test honesty and proof.** Real audio-content assertions (tone injection and frequency analysis), a real relational-database test matrix, and CI architecture guards; the end-to-end audio proof goes green. +9. **Documentation and capability matrix.** Honest, code-matching capability documentation, a secure deployment guide, and a single-node quickstart. +10. **Multi-node testing and distributed hardening (secondary).** The distributed code already runs and is exercised on the single node; this phase adds multi-node *testing* and hardening — readiness gating, single-consumer election for provider event streams, distributed rate limiting, and scale-oriented queries — feeding the platform's multi-node release program. +11. **Future capability (post-GA).** A unified work-state consistency root, real IVR, first-class non-voice channels, and additional provider adapters. + +## Verification and support + +- **Exit bar.** The single-node profile is considered complete only when the automated end-to-end test — run on the .NET Aspire host (or the Docker Compose CI variant) with Redis enabled — proves real, audible, bidirectional audio (over both direct and relayed media paths), supervisor audibility, and a retrievable recording of that same call. +- **Support gating.** The certified single-node profile, with a published concurrent-call capacity ceiling, is the configuration to trust for live audio at general availability. Multi-node and media high-availability claims are gated by the platform's separate multi-node release program and are not published ahead of their proof. + +## Tracking + +Detailed, evidence-based execution is tracked internally alongside the master Contact Center plan. This public page summarizes intent and progress; the shipped capability matrix in each release is the authoritative statement of what is supported. + +### Current status (single-node) + +Roadmap Waves 1 and 2 have landed on a single node: the reference-stack **foundations** (an Aspire-orchestrated Asterisk + TURN + Redis stack with a Docker Compose CI variant, a digest-pinned media image, and the adversarial two-tenant isolation harness), the canonical **state-authority** model (single-writer call ownership with optimistic-concurrency protection, atomic queue enqueue, and idempotent webhook handling), the shared **authorization boundary**, and the **browser media** foundation are in place. Call control now resolves server-side call ownership from `CallSession`, transfer uses typed server-resolved destinations with emergency/premium denial and external-transfer RBAC, supervisor queue access is checked per queue, and first outbound dials create owned call sessions before provider execution. Browser audio is delivered through a page-local SIP.js WebRTC adapter backed by tenant/session/expiry-bound PJSIP Realtime credentials with rotation, revocation, a scheduled cleanup sweep, and orphaned-credential reclamation, so agent RTP never traverses .NET. A data-layer **storage-portability** pass also landed: every status/kind index column is now declared with its enum type so the underlying store creates a true integer column on every supported database (SQLite, PostgreSQL, SQL Server), keeping the reporting and bulk-management queries identical across providers. Server-side connect and inbound routing, supervisor and recording, and the end-to-end audio proof remain in later waves. All additions extend Orchard Core's own distributed primitives with tenant-qualified keys, so multi-tenant isolation is preserved throughout. diff --git a/src/CrestApps.Docs/docs/contact-center/voice-routing.md b/src/CrestApps.Docs/docs/contact-center/voice-routing.md new file mode 100644 index 000000000..ef1a31ac5 --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -0,0 +1,492 @@ +--- +sidebar_label: Voice Routing Architecture +sidebar_position: 3 +title: Inbound and Outbound Voice Routing Architecture +description: Technical deep dive into Contact Center inbound and outbound voice routing, provider-truth synchronization, and restart reconciliation. +--- + +# Voice Routing Architecture + +This guide explains how the Contact Center routes **inbound** and **outbound** voice work, how it stays synchronized with the telephony server, and how it recovers when the Orchard Core application or tenant restarts. + +The key rule is constant across every flow: + +- the **provider/server is the source of truth for live call state** +- Contact Center owns **routing, reservation, assignment, and call-session orchestration** +- the soft phone mirrors the **server-projected state** instead of inventing its own truth + +## Network and protocol requirements for real-time voice + +Real-time voice depends on both **provider-to-Orchard** and **browser-to-Orchard** connectivity. In production, prefer encrypted transports everywhere, and do not assume the hosting platform allows outbound sockets by default. + +| Path | Typical protocol(s) | Direction | Why it is needed | +| --- | --- | --- | --- | +| Browser soft phone ↔ Orchard app | `https` + `wss` | Bidirectional | The soft phone loads over HTTPS and receives live SignalR call-state updates over secure WebSockets. | +| Browser soft phone ↔ Orchard app fallback | `https` | Bidirectional | SignalR may fall back to SSE or long polling when WebSockets are unavailable, so normal HTTPS traffic must also remain allowed. | +| DialPad → Orchard webhook | `https` | Inbound to Orchard | DialPad posts signed call events to Orchard webhook endpoints. | +| Orchard → DialPad REST API | `https` | Outbound from Orchard | Orchard may query DialPad for current call truth or execute provider API operations. | +| Orchard → Asterisk ARI REST API | `http` / `https` | Outbound from Orchard | Orchard uses ARI HTTP(S) for dial, hangup, hold, mute, and per-call state lookup. | +| Orchard → Asterisk ARI event stream | `ws` / `wss` | Outbound from Orchard | Orchard keeps a live ARI socket open so server-side call changes reach the app immediately. | + +### Production guidance + +1. Use **`https`** for every public webhook or browser endpoint. +2. Use **`wss`** for every production WebSocket connection. +3. Allow plain **`http`** or **`ws`** only for trusted local development or lab environments where TLS termination is handled elsewhere. +4. If a reverse proxy or firewall sits in front of Orchard, it must allow **WebSocket upgrade** requests for SignalR and provider stream listeners. +5. If a reverse proxy or firewall sits between Orchard and Asterisk, it must allow Orchard's long-lived outbound `ws`/`wss` ARI event-stream connection in addition to normal ARI HTTP(S) requests. +6. If the app runs on a host that restricts outbound traffic by default, such as some Azure topologies or locked-down App Service / container-network deployments, explicitly allow Orchard's outbound `https`, `ws`, or `wss` connections to provider APIs and live event streams or the app will not receive real-time provider state. + +## The moving parts + +| Layer | Owns | +| --- | --- | +| Omnichannel CRM | Contacts, activities, campaigns, subject flows, dispositions | +| Contact Center | Queues, routing, reservations, assignment, interactions, call sessions, supervisor/agent events | +| Telephony | Provider resolution, soft-phone hub, call-control execution, provider call-state lookup | +| Provider | Live call media, native device state, provider webhooks, provider APIs | + +## PBX mutation execution boundary + +Read-only Telephony operations remain cancellable when the SignalR connection closes. Once a PBX mutation is admitted, however, the server executes it with its own deadline rather than `Context.ConnectionAborted` or an HTTP request token. The same boundary covers durable provider commands and Contact Center recording, monitoring, and transfer. + +Configure the deadline in tenant shell configuration: + +```json +{ + "CrestApps_Telephony": { + "Commands": { + "Timeout": "00:00:10" + } + } +} +``` + +The default is 10 seconds; valid values range from one second through two minutes. The boundary returns on time even if a provider implementation fails to observe cancellation, and host shutdown also cancels the owned token. A timeout after provider contact is ambiguous: durable commands persist `OutcomeUnknown` with reconciliation required, while synchronous Telephony commands return an unknown result. The application must never interpret the timeout as proof that the PBX did not execute the command. + +Only provider execution consumes this deadline. After provider-confirmed success, Orchard persists interaction history, recording state, monitoring events, and transfer state with a non-request, non-expiring token. A browser disconnect or deadline expiration therefore cannot create a confirmed PBX mutation followed by canceled local persistence. + +## Inbound routing flow + +### 1. A provider event reaches Orchard + +Inbound voice can enter the Contact Center through one of two server-side paths: + +1. A provider or simulator posts a normalized `InboundVoiceEvent` to `POST /api/contact-center/voice/inbound`. +2. A provider-owned webhook endpoint receives the provider's native payload, validates it, and normalizes it into Contact Center events first. + +Examples: + +- Provider-owned webhook path (each provider maps its own route): `POST /api/dialpad/webhook/call` +- Generic normalized inbound path: `POST /api/contact-center/voice/inbound` +- DialPad built-in path: `POST /api/dialpad/webhook/call` + +The provider never pushes state directly to the browser. It always comes into Orchard first. + +The generic provider and built-in DialPad webhook endpoints reject request bodies larger than 1 MiB with HTTP 413. The ceiling is measured against the bytes that arrive rather than the length the caller declares, so a chunked delivery that declares no length is refused at the same point, and it is refused as it arrives rather than after the whole body has been held in memory. The ingress permit is taken before the body is buffered, so the number of bodies a tenant holds at once is bounded by the concurrency limit; a caller that sends slowly is stopped by the server's minimum request body data rate rather than by the endpoint. Each tenant also enforces a shared concurrency limit and a separate authenticated token-bucket rate per canonical provider; rejected deliveries return HTTP 429 and include `Retry-After` when the rate limiter can calculate one. Unauthenticated deliveries do not consume the authenticated provider budget. Authenticated deliveries must contain a provider-signed UTC event timestamp within the configured maximum age and future clock-skew window. Generic normalized events use `OccurredUtc`; DialPad JWT payloads use `event_timestamp` in epoch milliseconds. Missing, malformed, non-UTC, stale, or excessively future timestamps are rejected with HTTP 400 before state-changing processing. + +After authentication and validation, Orchard normalizes the payload and commits it to a tenant-local YesSql provider webhook inbox before returning HTTP 2xx. The canonical provider name plus provider delivery id forms the replay boundary under a distributed acceptance lock, so a retry resolves to the existing durable message instead of creating a second one. Provider delivery ids longer than 256 characters are rejected before persistence. Processing starts only after that commit and never uses the caller's disconnect token. Successful processing removes the inbox record; transient failures retain it for exponential-backoff retry, one poison message does not block later due messages, and the message is dead-lettered after ten failed attempts. If an optional provider feature is temporarily disabled, its persisted messages remain pending without consuming the retry budget and resume when the handler is available again. The immediate persisted dispatch preserves normal call-state latency, while the tenant background task recovers deliveries interrupted by process failure or deployment restart. + +Single-node development can use Orchard's local lock implementation. Every supported multi-node production deployment must enable and configure `OrchardCore.Redis.Lock`; a Redis SignalR backplane alone does not make `IDistributedLock` cross-node. + +Configure tenant-local limits in shell configuration: + +```json +{ + "CrestApps_ContactCenter": { + "WebhookIngress": { + "ConcurrencyPermitLimit": 8, + "RatePermitLimit": 120, + "RatePeriodSeconds": 60, + "MaximumDeliveryAgeSeconds": 900, + "MaximumFutureSkewSeconds": 120 + } + } +} +``` + +`ConcurrencyPermitLimit` bounds all provider webhook requests buffering or processing on one tenant and application node. `RatePermitLimit` is applied independently to each authenticated canonical provider during `RatePeriodSeconds`. `MaximumDeliveryAgeSeconds` rejects authenticated deliveries older than the accepted replay window, and `MaximumFutureSkewSeconds` permits only bounded provider clock skew. Concurrency, rate, period, and delivery age must be greater than zero; future skew may be zero. In a multi-node deployment, each node enforces its own limits, so the external gateway should also enforce the deployment-wide provider contract. + +### 2. Contact Center creates the CRM work item and interaction + +`VoiceContactCenterCallRouter` takes the inbound event and: + +1. acquires a distributed lock scoped to provider name + provider call id +2. checks for an existing interaction using the same provider-scoped identity +3. resolves the dialed number (`ToAddress`) to the configured phone channel endpoint +4. resolves the matching subject flow and optional CRM contact +5. resolves the entry-point plan and validates its effective target queue +6. creates the `OmnichannelActivity` +7. creates the linked `Interaction` + +At this stage: + +- the **Activity** is the CRM work item +- the **Interaction** is the communication attempt record +- the provider call id is stored on the interaction so later provider truth can find it again + +Contact attribution is explicit and auditable. No contact match persists `Unresolved`; one loadable match persists `Resolved`; multiple matches persist `Ambiguous` with a deduplicated, deterministic candidate list and no assigned contact. Ambiguity does not block queueing or an agent offer because those operations are contact-independent. The source-neutral disposition service rejects an ambiguous activity, so alternate completion callers cannot bypass resolution. Before completing it, an agent with resource-scoped completion permission must select one of the persisted candidates on the completion form. The activity records the selected contact, resolving user, and UTC resolution time before disposition processing. Unresolved, ambiguous, and legacy inbound activities without explicit resolution cannot run contact-bound Subject Actions. + +Closed or unroutable entry points retain one provider-correlated activity and interaction for idempotency and audit, but never leave routable CRM work. A closed voicemail decision completes the activity; a closed rejection cancels it; and a missing or disabled effective queue fails it. The activity assignment is released and its terminal timestamp is recorded immediately. In the same tenant transaction, Contact Center registers exactly one durable voicemail or rejection command and schedules dispatch only after commit. The interaction remains `Ringing` with no end timestamp while provider truth is pending, then the fenced provider-command state machine ends it after confirmation or records a definitive failure or unknown outcome without returning the activity to routing. + +The stable reason code is returned in `ReasonCode`, stored as `TerminalReasonCode` on the activity, included in the provider-command request metadata, and copied to the interaction's `routing_terminal_reason` metadata. These paths create no queue item, reservation, or agent offer, and an entry-point decision never silently falls back to a generic queue. + +The activity status records the Contact Center routing decision, while the interaction and durable command metadata record whether the provider mechanically completed, failed, or could not prove the voicemail or rejection action. + +### 3. The activity is queued + +If the entry point is open and queueing is allowed, Contact Center enqueues the activity into the resolved `ActivityQueue`. + +The queue still owns routing. The provider does not decide which Orchard agent gets the work. + +### 4. Assignment selects the next agent + +`ActivityAssignmentService` serializes assignment with a **per-queue distributed lock** so multiple nodes or concurrent background tasks cannot assign the same queue item twice. + +Inside that lock, it: + +1. confirms the queue is enabled and open +2. selects the highest-priority waiting queue item +3. evaluates currently available agents through the routing pipeline +4. creates a pending reservation for the winning agent + +This is the single-writer boundary for queue assignment. + +### 5. The ringing offer is projected to the agent + +`VoiceContactCenterCallRouter.OfferNextAsync()` turns the pending reservation into a ringing offer: + +1. loads the reserved agent and linked interaction +2. refreshes the interaction from **provider truth** before offering it +3. if the provider confirms the call no longer exists, removes it from the queue and releases the reservation and agent (via `ProviderVoiceOfferSynchronizationService.ReconcileEndedOfferAsync`), then moves on to the next queued call instead of offering a dead call +4. moves a still-live interaction to `Ringing` +5. builds a telephony `TelephonyCall` +6. dispatches it through `IIncomingCallDispatcher` + +Because a queued call is validated against provider truth at offer time, a "zombie" interaction whose provider channel has already disappeared can never be offered to an agent. This prevents the agent from being reserved for a call they can neither answer nor hang up, which would otherwise leave them stuck and unable to receive new inbound calls. + +The Telephony module then: + +- sends the incoming call to the agent's soft-phone SignalR connections +- persists the telephony interaction history used by the **Recent** tab and reconnect restore + +### 6. The agent accepts through one authoritative server command + +The workspace and the soft-phone incoming modal both call the same authoritative accept endpoint: + +- `POST /Admin/contact-center/voice/offer/accept` + +`ContactCenterCallCommandService.AcceptInboundOfferAsync()` then: + +1. validates that the reservation is still pending and still belongs to the current agent +2. refreshes the interaction from **provider truth** before accepting +3. rejects the accept immediately if the provider already ended the call +4. accepts the reservation +5. connects media if the provider uses a server-side ACD model +6. if the media connect or answer fails, re-checks provider truth: when the provider confirms the call is gone, the offer is reconciled (removed from the queue and the agent released via `ReconcileEndedOfferAsync`) instead of leaving the accepted reservation stuck; only a still-live call is re-offered +7. leaves device-native providers in `Ringing` until the provider later reports `Connected` +8. creates or updates the `CallSession` +9. publishes Contact Center events such as `OfferAccepted` + +This is why the UI does not get to decide that the call is connected just because the user clicked **Accept**. + +### 7. Provider truth finishes the state transition + +After the provider actually changes call state, the provider webhook or provider call-state lookup drives the next authoritative transition: + +- `Ringing` → `Connected` +- `Connected` → `OnHold` +- `OnHold` → `Connected` +- `Connected` → `Ended` +- and so on + +`ProviderVoiceEventService` projects those transitions into: + +- the durable `Interaction` +- the durable `CallSession` +- Contact Center domain events +- the soft phone through server-side projections + +If a persisted interaction references a provider name that is no longer registered, restart and healing reconciliation retry the provider call id through the tenant's current default provider. A confirmed missing call is terminalized and its queue/agent state is released; a call the provider still reports as active remains assigned and is never requeued merely because the agent resets queue membership. + +When the browser receives a terminal event for a different call id than the call currently displayed, it immediately asks the server for the provider-authoritative active call. This preserves a genuinely newer call while clearing a stale browser call that no longer exists. + +## Outbound routing flow + +### 1. A dialer profile starts a cycle + +`DialerService` runs automated outbound work only for automated modes such as **Power** or **Progressive**. It first confirms that a Contact Center voice provider can route outbound calls for the dialer profile. + +### 2. The dialer strategy reserves work + +The selected `IDialerStrategy` picks how many attempts to launch for the cycle. Each attempt reserves: + +1. the next eligible queue item/activity +2. an available agent + +The reserved activity is still the CRM work item; the reservation only gives the dialer a temporary right to place the attempt. + +### 3. Compliance is checked before every attempt + +`DialerAttemptService` runs `IDialerEligibilityService` before dialing. + +That check enforces rules such as: + +- destination exists +- maximum attempts has not been reached +- retry cool-down has elapsed +- do-not-call and national registry suppression +- configured calling window + +If the attempt is suppressed: + +- the reservation is canceled +- the activity status is updated when appropriate +- a `DialSuppressed` event is published for auditability + +### 4. Contact Center creates the outbound interaction + +If the attempt is eligible, Contact Center creates a new outbound `Interaction` before the provider dial occurs. + +This means the routing/orchestration record exists before live provider events begin to arrive. + +### 5. The provider dials + +`VoiceContactCenterCallRouter.RouteOutboundAsync()` resolves the configured `IContactCenterVoiceProvider`, verifies that it advertises dialer dialing and implements `IContactCenterVoiceCallControlProvider`, and then calls the executable dial contract. + +For the current built-in DialPad provider: + +- Contact Center owns the outbound attempt +- DialPad executes the actual dial through the Telephony provider +- the provider returns a provider call id + +If the provider does not return a call id, the attempt is treated as a failure because Contact Center cannot reconcile a call it cannot identify later. + +### 6. Reservation acceptance and ringing state are persisted + +If the provider dial succeeds: + +1. the reservation is accepted +2. the interaction moves to `Ringing` +3. the provider call id is saved on the interaction +4. the activity moves into `Dialing` + +If the reservation can no longer be accepted by the time the provider succeeds, Contact Center fails the attempt and tears down the temporary state rather than letting the call continue without valid routing ownership. + +### 7. Provider truth drives answer, bridge, and completion + +The next transitions come from provider truth: + +- provider reports answer/connected +- Contact Center updates the interaction and `CallSession` +- for server-side ACD providers, Contact Center can bridge the live call to the agent +- provider reports terminal state +- Contact Center ends the interaction/session and moves the agent into the normal completion path + +## How Contact Center stays synchronized with the server + +Synchronization is built around **normalized provider truth** plus **reconciliation**. + +### Normalized provider events + +Providers translate their native events into `ProviderVoiceEvent`. + +That contract carries the authoritative server-side facts Contact Center cares about, including: + +- provider name and provider call id +- normalized call state +- addresses +- mute state +- recording state +- conference state +- idempotency key + +`ProviderVoiceEventService` ingests those events idempotently and updates the durable interaction/session projection. Provider name and call id are queried together first so identical call ids from two providers cannot collide. If an interaction was stamped with a provider identity that is no longer registered, the service can fall back to the provider call id, canonicalize the stored provider identity to the live event source, and continue terminal projection instead of silently losing the event. The fallback is rejected when the stored provider is still active, preserving provider-scoped ownership when two backends can produce the same call id. The service rejects stale events, never permits a nonterminal event to reopen a terminal call id, and gives every semantic event derived from one provider delivery its own idempotency key while keeping the base key on `CallSessionUpdated` for replay detection. + +When an answered inbound call reaches a terminal provider state, Contact Center moves the agent into `WrapUp` and marks the assigned queue item `Completed`. The accepted reservation and CRM activity remain as audit and after-call-work records until the normal disposition/completion flow releases the agent to the requested ready state. Pre-answer terminal calls still follow the abandon path, which removes the queue item, cancels the reservation, releases the CRM assignment, and restores the agent immediately. + +### Durable event delivery + +Every Contact Center domain event is persisted together with a pending outbox message before handler fan-out begins. Successful handlers are checkpointed individually, so a retry after a partial failure runs only the handlers that did not complete. Pending messages survive tenant/application restarts, retry with exponential backoff, and dead-letter after the configured attempt limit instead of silently disappearing between event persistence and real-time/workflow projection. + +### Provider call-state lookup + +When a provider implements `ITelephonyCallStateProvider`, Contact Center can actively ask: + +> "What is the current truth for provider call `` right now?" + +That lookup is used for: + +1. **pre-accept validation** so an ended call cannot still be accepted +2. **scheduled fresh-scope reconciliation** after a restart +3. **periodic safety reconciliation** in case a live event was missed or delayed + +### Ended-offer reconciliation + +If provider truth says a ringing call ended before it was actually answered, `ProviderVoiceOfferSynchronizationService` clears the stale routing state: + +- queue item +- **every** non-terminal (pending or accepted) reservation bound to the activity, not only the one referenced by the queue item +- agent active reservation/presence +- activity assignment metadata + +That prevents abandoned or already-ended calls from being re-offered as ghost work. Reject/re-offer cycles can accumulate more than one accepted reservation for the same activity, so the reconciler cancels **all** of them and clears the agent's active-reservation pointer whenever it referenced one of the cancelled reservations. This also runs for calls that were already answered: the wrap-up cascade still owns the agent's presence transition, but any lingering reservation pointer is cleared so the agent is never blocked from receiving the next offer. + +Ended-offer reconciliation only runs when a real non-terminal → terminal transition is observed. When a reconciliation sweep discovers a call that already disappeared on the provider **before any call session was ever recorded**, `ProviderVoiceEventService` seeds the newly created session with the interaction's pre-event (non-terminal) state rather than the incoming terminal state. This preserves the non-terminal → terminal transition so the `CallEnded` event is still published and the ended-offer cleanup runs; without the seed the session would be created already-terminal and the cleanup would silently never fire, leaving the interaction stuck in the queue. + +### Soft-phone projection stays server-driven + +The soft phone sends intents such as: + +- accept +- decline +- hold +- resume +- mute +- hang up + +But it does not become the system of record for live call state. + +The durable truth is: + +1. provider event or provider lookup +2. Contact Center interaction and call-session update +3. Telephony/Contact Center server projection +4. UI refresh from the resulting server state + +## What happens during an application or tenant restart + +The current design assumes that a short restart can happen during active traffic and must not permanently desynchronize routing. + +### Fresh-scope restart reconciliation + +Tenant activation performs no provider or database reconciliation inline with Orchard's shell construction. The feature registers `ProviderCallStateReconciliationBackgroundTask`, which runs the reconciliation contract in a normal fresh tenant scope within the next minute; provider listeners such as Asterisk also trigger an immediate provider-scoped pass after they reconnect. + +For each active interaction, Contact Center: + +1. resolves the telephony provider +2. asks for the current provider call state +3. rebuilds a normalized provider event from that lookup +4. re-ingests it through the same `ProviderVoiceEventService` pipeline + +If the provider says the call no longer exists, Contact Center treats it as terminal and clears stale local state. + +### Periodic reconciliation + +`ProviderCallStateReconciliationBackgroundTask` runs every minute as the restart and missed-event safety net. + +This catches cases where: + +- an app restart happened between two provider events +- a provider event was delayed +- a live stream or webhook delivery was missed + +Bulk reconciliation is serialized by a distributed lock. A provider live-stream reconnect requests a provider-scoped pass, so reconnecting one Asterisk endpoint does not repeatedly query unrelated providers or overlap another full reconciliation sweep. + +The Asterisk live event listener is hardened so a single bad event can never silence the stream. Each received event is dispatched in isolation: a malformed payload, an unroutable event, or a transient tenant-scope failure while the shell is reloading is logged and skipped instead of tearing down the WebSocket and forcing a reconnect storm. The listener also subscribes to **all** application events (`subscribeAll`), so every channel state change reaches provider-truth ingest rather than only the events for channels the app happens to own. In a supported Redis-locked multi-node deployment, more than one node may hold an Asterisk socket during startup or rolling deployment; ingestion serializes the canonical provider-call stream, commits before releasing the lock, and suppresses the duplicate against the committed provider event. Any event genuinely missed during a reconnect is still repaired by the periodic sweep above. + +### Re-offer and reconnect recovery + +When an agent becomes available again or reconnects, Contact Center can re-check waiting voice work and offer it again. Before it does, the healer/reconciliation path clears impossible leftovers so stale reservations do not block future offers. + +The healer never requeues a **provider-backed ringing** interaction on its own. Requeuing one would either yank a genuinely live ringing call away from the agent or resurrect a dead call in an endless offer loop, so a provider-backed ringing interaction is always left under provider control and released only when provider truth confirms it ended. Only non-provider-backed ringing work (which has no authoritative source to consult) is requeued locally. + +Manual presence changes are also self-healing. If an agent is parked in an on-call presence state (Reserved/Busy/WrapUp or still holding a reservation) and asks to return to a ready state, `AgentPresenceManagerService` first reconciles the agent against provider truth. A call that no longer exists on the provider is released so the requested change applies immediately; a genuinely live provider-backed call is preserved and the change is deferred as before. This stops an agent from being stuck as **Busy** and unable to go **Available** after a call that already disappeared on the provider. + +## Why the current implementation is resilient + +The current voice flow stays consistent because it combines these protections: + +1. **Per-queue, per-agent, and per-reservation distributed locks** prevent double assignment and accept/expiry races. +2. **Provider-scoped inbound locks and lookups** prevent duplicate work and cross-provider call-id collisions. +3. **Reservations** make offers explicit and auditable. +4. **Provider call ids** let Contact Center correlate server truth back to local interactions. +5. **Tenant-scoped provider-call locking plus monotonic lifecycle and sequence guards** prevents concurrent, stale, timestampless, mixed-order, or duplicate deliveries from corrupting state. +6. **Durable per-handler outbox delivery** prevents events from disappearing across handler failures or restarts. +7. **Pre-accept provider refresh** stops agents from accepting already-ended calls. +8. **Ended-offer reconciliation** clears stale queue and agent state immediately. +9. **Tenant-startup, reconnect, and periodic reconciliation** repair drift after restarts or missed events. +10. **Server-driven soft-phone projection** keeps the browser as a mirror instead of a source of truth. + +## Current limitations and important notes + +:::danger Not an emergency-calling service +Contact Center is **not** an emergency (E911/112/999) calling service and must never be relied on for life-safety communication. It performs no location determination, no routing to a Public Safety Answering Point, and no registered-address provisioning; no emergency-service origination code path exists anywhere in the platform. + +The emergency-number denial that does exist is **partial** and operators must not treat it as complete. `ExternalDestinationPolicy` rejects emergency and premium destinations, but it is only consulted on the **Contact Center server-side paths** — outbound first-dial (`DialProviderCommandTypeExecutor`) and external-transfer resolution (`TransferDestinationResolver`). The **Telephony soft-phone keypad and transfer field pass the dialed digits straight to the provider unfiltered** (`AsteriskTelephonyProviderBase.DialAsync` validates only that a destination is present), so an agent using the soft phone can dial a raw `911`. The policy's `IsEmergencyNumber` check is also a suffix match over the digit string that only covers `911`, `112`, and `999` — it does not cover other jurisdictions' emergency codes (for example `000`, `110`, `119`, `100`) — and a bare three-digit short code is in practice refused earlier by the E.164 form and minimum-length gate rather than by the emergency check. + +Because of this, operators **must** block emergency (and any disallowed) codes at the telephony layer — in the Asterisk dialplan and/or the SIP trunk configuration — and must provide emergency-calling access to agents and end users by other, independent means, communicating this limitation to their users. +::: + +- `InboundVoiceEvent.ToAddress` must be present for generic inbound routing because the router needs the dialed service address to resolve the entry point or queue. +- If multiple enabled queues have no explicit inbound mapping, the generic fallback queue resolution intentionally does not guess between them. +- DialPad currently uses the **agent-device-native** delivery model. Contact Center does not bridge media for it; the provider rings the agent's registered device and later tells Contact Center what really happened. +- Voice capabilities are metadata only. Dialing and agent connection require `IContactCenterVoiceCallControlProvider`; provider-owned queue placement, transfer, conference, recording, and monitoring each have a separate executable contract. A capability flag without its matching contract is rejected. +- Contact Center–orchestrated external transfers resolve only from a tenant-scoped approved-destination catalog. Callers pass an opaque catalog entry identifier (never a raw phone number); the resolver requires the external-transfer permission, denies missing or disabled entries, and re-validates the stored E.164 address with emergency and premium ranges always rejected. Operators curate the catalog in the **External transfer destinations** section of the **Settings → Contact Center** screen, and because it is stored as Orchard Core site settings it is isolated per tenant. **Important:** this catalog governs only the orchestrated transfer path (`TransferDestinationResolver`), which is not yet wired to an agent-facing surface in this release, so the catalog does not currently constrain any transfer an agent can actually initiate. The agent-facing **Telephony soft-phone transfer field passes its destination to the provider unfiltered** and does not resolve through the catalog, so it is the only live transfer path today and the emergency/premium denial above does not apply to it (see the emergency-calling warning). +- Bidirectional media is exposed only by a separately registered `IContactCenterVoiceMediaProvider` whose technical name matches a registered base voice provider. Asterisk registers that contract only with its ARI External Media feature; DialPad does not register one because its public integration does not expose equivalent raw live-media injection. +- DialPad webhook subscriptions are currently created and monitored in the DialPad administration portal; Orchard validates deliveries but does not automatically register or health-check the provider subscription. +- Asterisk and other server-side ACD providers can use server-driven answer/bridge flows instead. +- Reconciliation currently repairs **known local provider-backed interactions**. It does not yet bootstrap a completely unknown live provider call that never got a local interaction before the restart window. + +## How the call state machine tolerates the way providers really deliver events + +Every provider event that carries a call state reaches Contact Center through `ProviderVoiceEventService`. That service is the single place where a provider report becomes a change to the call session and the interaction, and where the resulting domain events are published, so it is the component that has to absorb the difference between the lifecycle a provider *had* and the way that provider *delivered* it. + +Those two things do not match in production. Provider nodes do not share a clock, so a hangup can be stamped fractionally behind the state change that preceded it. Providers do not sequence every event type, so the same stream can mix sequenced and unsequenced deliveries. Retries and at-least-once webhook semantics mean any delivery can arrive twice, or the whole stream can be replayed. Network paths reorder. A provider can also report a call terminated more than once and disagree with itself about how it ended. + +The state machine handles this with four rules: + +- **Lifecycle rank, not raw ordering.** States are ranked (planned; dialing and ringing; connected and on hold; ending; terminal). A delivery that would move the session backwards down that ranking is ignored, so a late `ringing` cannot undo a `connected`. +- **Terminal absorption.** Once a session is terminal it stops accepting further state reports, so a second, competing terminal report cannot rewrite how the call ended. +- **Terminal deliveries bypass the staleness guards.** Sequence-number and timestamp staleness checks are deliberately *not* applied to a terminal state. Discarding a hangup because it looked stale is far worse than accepting one late: the session would stay live forever, `CallEnded` would never publish, wrap-up would never start, and the agent and their reservation would never be released. +- **Terminated calls carry no live-only state.** Flags that only make sense while a call is up, such as mute, are cleared on termination and cannot be re-applied by a later delivery. + +`CallStateMachinePropertyTests` enforces all of this as properties rather than examples. It generates a real call lifecycle, then deliberately corrupts its delivery with duplicates, whole-stream replays, arbitrary reordering, clock skew wider than the spacing between events, mostly-unsequenced streams, late backward regressions stamped with the freshest timestamp and highest sequence, and competing terminal reports; it then drives the production service and the production event publisher over 400 seeded sequences per property and requires convergence to a single terminal outcome with no rank regression, no resurrection, no stranded call, no duplicated lifecycle event, and no live-only flag left set. A companion property asserts that the generator is still producing each hard case, so the suite cannot quietly become vacuous. + +## One call-state vocabulary and provider-neutral hangup causes + +Contact Center reasons about a call with a twelve-state vocabulary; the soft phone renders it with a seven-state one. Translating between them is lossy in one direction and ambiguous in the other, so it is written in exactly one place, `ContactCenterCallStateProjection`, and a build gate fails if a second translation appears anywhere in the product. + +The narrowing direction is a declared, lossy projection: four distinct terminal outcomes collapse onto the soft phone's `Disconnected`, and two more onto `Failed`. The widening direction cannot be recovered from the soft-phone state alone, which is why every provider event carries a **hangup cause**. + +`HangupCause` is provider-neutral and has eight meanings plus an explicit `Unknown`: + +| Cause | Meaning | +| --- | --- | +| `NormalClearing` | The call was answered and then released normally. | +| `Busy` | The remote party was busy. | +| `NoAnswer` | The call alerted but was never answered. | +| `Rejected` | The remote party or network explicitly rejected the call. | +| `Congestion` | No circuit was available, or the network was congested. Normally retryable. | +| `Failed` | A permanent failure such as an unallocated number or invalid format. Not retryable. | +| `Canceled` | The originating side abandoned the call before it was answered. | +| `AnsweringMachine` | A machine, voicemail greeting, or fax tone answered instead of a person. | +| `Unknown` | The provider ended the call without reporting any cause. Recorded rather than presented as a normal clearing. | + +Asterisk reports the release reason as a Q.850 cause code on its hangup events, which is normalized into that vocabulary; when only the standard cause text is present it is used instead. Answer detection takes precedence over the release cause, because a call released normally after a machine picked up is still a machine answer. DialPad publishes no release cause of its own, so its cause is derived from the call-state token it already reports, plus its answer classification. + +Q.850 has no distinct cause for an abandoned call — a caller who hangs up while the far end is still alerting releases the channel with the same normal cause as a completed conversation. That distinction therefore belongs to the state machine, which is the only component that knows whether the call was ever answered: a normal release with no answer is recorded as `Canceled`, and a cause that reached it as `Canceled` on an answered call is corrected back to `NormalClearing`. + +**No call may end without a cause.** A provider that reports a terminal state without one has the cause derived from the state itself, and the property suite asserts over every generated adversarial delivery sequence that a terminated session always carries both a cause and an end time. A cause that is never written at the source cannot be reconstructed later, which is what previously made outbound compliance reporting and abandon analytics impossible to compute. + +## Three writers, one telephony interaction + +The soft phone's interaction record is written from three independent directions, and none of them coordinates with the others: + +- **Real-time provider events** arrive as the call progresses and move the interaction through ringing, answered, and hangup. +- **The reconciliation pass** periodically asks the provider for authoritative call state and repairs anything a dropped event left behind. +- **Inbound dispatch** refreshes the record when a call is offered to an agent. + +Each of these used to read the interaction, mutate the copy it was holding, and save it. Whichever one committed last won, and everything the others had written in the meantime was discarded. The damaging case is not a lost cosmetic field: the reconciliation pass carries a provider snapshot that was already seconds old by the time it was applied, so it could reinstate a live call over a hangup that a real-time event had committed while the pass was running. The call then looked active to the agent until the next pass happened to catch it. + +Every write to a telephony interaction is now guarded by an optimistic-concurrency check, so a writer holding a version that has already been replaced fails instead of overwriting the winner. Read-modify-write callers do not perform their own read anymore; they hand the store a mutation, and the store opens a dedicated session, reads the current version, applies the mutation, and commits. If another writer commits first, the store re-reads and reapplies the mutation against the version that actually won, up to a bounded number of attempts. + +The mutation is also allowed to decline after seeing the fresh version. That is what makes the guards correct rather than merely retried: the real-time dispatcher re-evaluates "is this interaction already terminal?" against the version that won the race, so an event that arrives just behind a hangup declines instead of resurrecting the call. + +## Related guides + +- [Contact Center overview](index.md) +- [Agents, queues, and dialer](agents-queues-dialer.md) +- [Agent desktop and supervisor dashboard](agent-desktop.md) +- [Telephony soft phone](../telephony/index.md) +- [Custom telephony and Contact Center providers](../telephony/custom-providers.md) diff --git a/src/CrestApps.Docs/docs/getting-started.md b/src/CrestApps.Docs/docs/getting-started.md index 0577f7d38..af7fdcbd0 100644 --- a/src/CrestApps.Docs/docs/getting-started.md +++ b/src/CrestApps.Docs/docs/getting-started.md @@ -49,10 +49,12 @@ git clone https://github.com/CrestApps/CrestApps.OrchardCore.git cd CrestApps.OrchardCore npm install npm run rebuild -dotnet build .\CrestApps.OrchardCore.slnx -c Release /p:NuGetAudit=false -dotnet test .\tests\CrestApps.OrchardCore.Tests\CrestApps.OrchardCore.Tests.csproj -c Release /p:NuGetAudit=false +dotnet build .\CrestApps.OrchardCore.slnx -c Release +dotnet test .\tests\CrestApps.OrchardCore.Tests\CrestApps.OrchardCore.Tests.csproj -c Release ``` +> Dependency vulnerability auditing is enabled for every build and a published advisory fails it. If the build stops on an `NU1901`-`NU1904` error, pin the patched version in `Directory.Packages.props`; see [Supply chain security](supply-chain). Do not disable the audit. + > The .NET build depends on Orchard Core preview packages. If Cloudsmith is unreachable, asset builds still work but the .NET restore/build will not. > > The asset pipeline waits for each generated CSS and JavaScript stream to finish before Gulp completes the task, which keeps `npm run rebuild` reliable on current Node.js and Gulp releases. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 4db1e0d9a..e235a55ff 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -15,7 +15,7 @@ Provides way to manage Omnichannel Contacts. The module ships as two features. `CrestApps.OrchardCore.Omnichannel.Activities` is the headless half: contact, subject, campaign, and activity catalogs, their stores and managers, the content parts and indexes, the migrations, the permissions, and the subject-disposition endpoint. `CrestApps.OrchardCore.Omnichannel.Managements` adds the CRM administration experience on top of it - the screens, display drivers, and admin menus described below - and enabling it brings the headless feature with it. -The split exists so that a headless consumer of the activity model can depend on the work-item data without dragging an administration experience into a tenant that serves no user interface. +The split exists so that a headless consumer of the activity model, such as the [Contact Center](../contact-center/index.md), can depend on the work-item data without dragging an administration experience into a tenant that serves no user interface. ## Overview @@ -121,7 +121,7 @@ The loader runs as a background process to avoid overloading the system and to a The **Load Inventory** list is ordered by creation date with the newest inventory loads first, so a load you just created appears at the top. The list is paged and supports the standard admin bulk-selection controls (the header checkbox selects every row on the page). -Dialer profile selection is an optional integration supplied through the Omnichannel-owned `IActivityDialerContributor` contract. Omnichannel Management remains independently activatable when no dialer contributor is enabled; in that configuration, dialer profile choices are unavailable and non-dialer inventory management continues to work normally. +Dialer profile selection is an optional integration supplied through the Omnichannel-owned `IActivityDialerContributor` contract. Omnichannel Management remains independently activatable when Contact Center Dialer is disabled; in that configuration, dialer profile choices are unavailable and non-dialer inventory management continues to work normally. ## Getting started (recommended order) @@ -385,7 +385,7 @@ The page also includes a **Page size** selector so managers can review more than | **Change Subject** | Change the subject content type for all selected activities. | | **Clear Assignment** | Remove the current assignee and clear reservation state so the activity can be re-routed or dialed again. | | **Change Source** | Change the activity source and optionally clear assignment and reservation state. This is useful when reclassifying inventory between manual, automatic, and dialer-style workflows. | -| **Change Dialer Profile** | When a dialer contributor feature is available, update the activity campaign and dialer source to match a selected dialer profile. This can also clear assignment and reservation state so the dialer can pick the activity up again. | +| **Change Dialer Profile** | When the Contact Center dialer feature is available, update the activity campaign and dialer source to match a selected dialer profile. This can also clear assignment and reservation state so the dialer can pick the activity up again. | Use **Change Source** and **Clear Assignment** together when you need to convert assigned manual work back into dialer-ready inventory. Use **Change Dialer Profile** when you want to move selected outbound inventory to a different dialer campaign path without recreating the activities. @@ -410,3 +410,11 @@ On import, entries are matched by their identifier: an entry that already exists When a plan carries several of these steps, order them so that referenced entities import first: dispositions and channel endpoints, then campaign groups, then campaigns, and finally subject actions. Subject flow configuration is stored on the `OmnichannelSubjectPart` content-type part settings, so it travels with the content type definition through the standard **Content Definition** deployment step rather than a dedicated omnichannel step. + +## Data at rest and privacy + +The omnichannel/CRM layer stores customer communication content and contact addresses as **plaintext** in the tenant SQL database. `OmnichannelMessage.Content` (the message body), `OmnichannelMessage.CustomerAddress`, and `OmnichannelMessage.ServiceAddress` are persisted unencrypted in the YesSql document, and the two addresses are additionally projected — still in plaintext — into the `OmnichannelMessageIndex` table so they can be queried. No application-level encryption is applied to this data. + +This is a deliberate contrast with telephony **recording media**, which the media-execution layer encrypts at rest through the data protection provider. That asymmetry matters operationally: encrypting the recording bytes does not encrypt the message bodies or the phone numbers/addresses that the CRM stores alongside them. Protecting this content at rest is therefore a **deployment responsibility** — enable database- or disk-level encryption (for example, transparent data encryption) and restrict access to the database and its backups accordingly. Treat message content and contact addresses as personal data. + +There is currently **no automated per-contact subject erasure** (right-to-be-forgotten) across the CRM. The activity **Purge** action marks an activity as `Purged` and removes it from the work queue, but it does **not** delete the underlying message content, the customer/service addresses, or the contact record — that data remains in the database and its index. Comprehensive per-contact erasure across omnichannel activities, messages, and contacts is a known limitation and a general-availability blocker; until it ships, satisfy erasure requests through direct, audited database operations against the tenant store. diff --git a/src/CrestApps.Docs/docs/supply-chain.md b/src/CrestApps.Docs/docs/supply-chain.md new file mode 100644 index 000000000..a62e47372 --- /dev/null +++ b/src/CrestApps.Docs/docs/supply-chain.md @@ -0,0 +1,76 @@ +--- +title: Supply Chain Security +--- + +# Supply chain security + +Every dependency this repository resolves is treated as code we ship. The controls below run in CI on every pull request and on every push to `main` and `release/**`, in the `Supply Chain` workflow (`.github/workflows/supply_chain.yml`). + +## Dependency vulnerability auditing + +`NuGetAudit` is enabled for every build in `Directory.Build.props`, with `NuGetAuditMode` set to `all` and `NuGetAuditLevel` set to `low`, and the audit diagnostics `NU1901`-`NU1904` are promoted to errors. A published advisory against any package - direct or transitive, at any severity - fails the build. + +This is deliberately strict. Previously every workflow passed `-p:NuGetAudit=false`, which meant advisories against transitive packages were never reported by any build. + +When an advisory appears, pin the patched version in `Directory.Packages.props` rather than lowering the audit settings. Central transitive pinning is enabled, so a `PackageVersion` entry raises the resolved version everywhere without adding a direct dependency: + +```xml + + +``` + +Record the advisory identifier next to the pin so the entry can be removed once the dependency resolves to a patched version on its own. + +The `vulnerability-audit` job additionally reports the whole graph with `dotnet list package --vulnerable --include-transitive` and retains the report as an artifact, so a reviewer can see what is resolved rather than only what is broken. + +## Secret scanning + +The `secret-scan` job runs [gitleaks](https://github.com/gitleaks/gitleaks) across the full commit history, not just the working tree, and fails on any finding. The open source binary is used directly and pinned to an exact release, because the published action requires a paid licence for organisation repositories. + +Configuration lives in `.gitleaks.toml`, which extends the bundled rule set so new detectors arrive with upgrades. Allowlist entries must stay as narrow as possible - prefer an exact literal over a path, and a path over a rule, and never allowlist a rule wholesale. + +## Third-party license inventory + +The `license-inventory` job builds an inventory of every resolved package and its license, and fails when a package declares none. + +The inventory is generated by `.github/scripts/license-inventory.py` from data the build already produces: `dotnet list package --include-transitive --format json` supplies the resolved graph, and each license is read from the package's own `.nuspec` in the NuGet cache. That keeps it reproducible and verifiable offline once packages are restored, rather than depending on a third-party scanner. + +A small number of packages ship no license metadata at all. Each is listed in `REVIEWED_LICENSES` in that script together with the license found by reading the upstream repository the package itself names, so the inventory stays complete without weakening the gate. + +## Software bill of materials + +The `sbom` job generates an SPDX SBOM with [`microsoft/sbom-tool`](https://github.com/microsoft/sbom-tool) and retains it as a build artifact for 90 days. + +## Container image scanning + +The `container-scan` job scans the container images used by the single-node Contact Center profile with [Trivy](https://github.com/aquasecurity/trivy-action). It reports `HIGH` and `CRITICAL` vulnerabilities that have a fix available, and it reports secrets baked into image layers regardless of whether a fix exists. Every image is scanned, and every result is uploaded as SARIF so it appears in the repository's code scanning alerts. + +The gate blocks only on images this repository builds, because those are the only findings it can actually remediate. Pulled upstream images (`redis` and `coturn`) are scanned and reported but cannot fail the build. A CVE in an upstream image is fixed by its maintainer, not here, and for `coturn` every tag checked (`4.6.3`, `4.7.0`, and `alpine`) still reports `HIGH` or `CRITICAL` findings, so blocking on it would leave the pipeline permanently red without improving security. Exposure stays visible in code scanning, and the pinned tags are raised when upstream ships a cleaner one. + +The Asterisk image is built from `src/Startup/CrestApps.Aspire.AppHost/Asterisk/Dockerfile` and scanned as built, so it is the one image that can fail the job. + +That image generates its self-signed WebRTC certificate in its entrypoint rather than in a `RUN` layer. Generating it at build time would bake the private key into the published bytes, which Trivy correctly reports as a leaked secret, and would give every container pulled from the image the same key. Creating it at container start keeps the key inside the running container and gives each container its own. + +Base images are pinned by digest so a scan describes the exact bytes that will run. Update the tag comment and the digest together: + +```dockerfile +# andrius/asterisk:22.10.1_debian-trixie +FROM andrius/asterisk@sha256:4cfb208f877b45e88115b35140b1edba06e1374e9bce4b6f5e55a84e60254b92 +``` + +Pinning by digest means the base never receives the Debian security updates published after it was built, so a fixable `HIGH` or `CRITICAL` in an OS package it ships would fail the gate even though the fix is already in Debian. The Dockerfile therefore applies the available security patches with `apt-get upgrade` at build time. This keeps the reproducible pinned base while bringing its OS packages current, and the Asterisk binary is compiled from source in the base so the upgrade never touches it. + +## Dependency updates + +Dependabot version update pull requests are enabled weekly for NuGet, npm, and GitHub Actions, grouped so routine minor and patch bumps arrive as a single review. Orchard Core packages and the bundled themes remain pinned and are never auto-updated. + +Keeping updates flowing is part of the audit strategy rather than separate from it: because a published advisory is a hard build failure, a dependency left to drift eventually stops the pipeline outright. + +## Hermetic builds + +The GitHub Copilot SDK downloads a platform-specific CLI tarball from `registry.npmjs.org` during `BeforeBuild`, which made the whole solution unbuildable without public npm egress. That download is opt-in: `Directory.Build.props` defaults `CopilotSkipCliDownload` to `true`. + +To build the Copilot chat orchestrator with its CLI, set one of: + +- `-p:CopilotSkipCliDownload=false` - downloads the CLI from the configured npm registry. +- `-p:CopilotCliBinaryPath=` - uses a vendored binary and skips the download entirely. diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md new file mode 100644 index 000000000..186d799de --- /dev/null +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -0,0 +1,333 @@ +--- +sidebar_label: Asterisk +sidebar_position: 2 +title: Asterisk Telephony Provider +description: Integrate Asterisk as a telephony provider for the Orchard Core soft phone. +--- + +| | | +| --- | --- | +| **Feature Name** | Asterisk | +| **Feature ID** | `CrestApps.OrchardCore.Asterisk` | + +The **Asterisk** module integrates the [Asterisk](https://www.asterisk.org/) platform as a provider for the [Telephony](./) soft phone. It uses the **Asterisk REST Interface (ARI)** over HTTP basic authentication and performs call control server-side, so the browser never needs direct access to Asterisk credentials. Here, **provider** means the Asterisk backend adapter that the soft phone talks to, not a separate telecom billing provider. + +## Two Asterisk providers + +When the **Asterisk** feature is enabled, the telephony settings can expose up to two selectable providers: + +| Provider | How it is configured | When it appears | +| --- | --- | --- | +| **Asterisk** | Site settings under **Settings → Communication → Telephony → Asterisk** | When an administrator enables it for the tenant | +| **Default Asterisk** | Shell configuration (`appsettings.json`, user secrets, or environment variables) | Automatically, when the required configuration exists | + +This mirrors the Orchard Core default-provider pattern used by modules such as SMTP: the tenant-specific provider is managed in the UI, while the configuration-backed provider is automatically available across tenants whenever the host supplies its configuration. + +## Capabilities + +The Asterisk provider currently advertises support for: + +- **Dial** +- **Hang up** +- **Answer / reject inbound calls** +- **Send to voicemail** when a voicemail context and extension template are configured +- **Hold / resume** +- **Mute / unmute** +- **Blind transfer** when the endpoint template resolves to a `Local/...@context` dialplan route +- **Merge** +- **Send DTMF digits** +- **Directory lookup** from ARI endpoints + +Voicemail routing still depends on your dialplan design, but the provider can now expose the soft-phone +voicemail button when you configure a voicemail dialplan target for the integration. + +:::note +The Contact Center voice provider advertises the **server-side ACD** delivery model, but connecting a +parked call to a browser-media agent requires a .NET-side ARI originate/bridge because WebRTC PJSIP +endpoints are provisioned just in time per browser session and are not addressable from a static +Asterisk queue or dialplan. That ARI originate/bridge is scheduled for a later wave, so the connect-to-agent +operation currently **fails closed** (it returns a clear `agent_bridge_unavailable` failure) rather than +reporting a false success for a call whose media was never bridged. +::: + +For Contact Center orchestration, the Asterisk voice provider also advertises **call recording** (ARI bridge recording of the tenant-owned conversation) and **supervisor monitoring** — **monitor** (listen only), **whisper** (heard by the agent only), and **barge** (heard by all parties). A monitoring engagement originates the supervisor's live browser softphone and, for monitor and whisper, bridges it to a snoop of the agent channel; barge adds the supervisor directly into the conversation bridge. It also advertises **call transfer** for a **blind transfer to another agent**: the customer stays on the canonical conversation bridge (so recording continuity is preserved), the destination agent's live browser softphone is originated as a new leg and added to that same bridge once it answers, and the previous agent leg is then hung up. The handoff is crash-safe: the destination leg is persisted up front as a durable, non-owning **joining** claim on a deterministic channel id, so a concurrent duplicate transfer to the same destination is idempotent (it never re-rings the destination), while a retry that finds a stale, already-terminating claim fails closed with a confirmed, retryable error instead of reporting a completed transfer that has no live leg. Any death of the destination leg before the handoff commits tears down nothing and leaves the customer with the current agent; if a destination originate fails ambiguously and its compensating hangup cannot be confirmed, the joining claim is retained as a recovery record for the reconciler rather than being deleted. Ownership then transfers with a single atomic swap that promotes the destination leg and retires the previous leg in one transaction — committed only while the previous agent is still the live owner, so a second concurrent transfer can never create a second owner — and the conversation bridge is owned by exactly one agent at every instant, never dropped, never left unowned. If the destination agent does not answer, the original call is left fully intact. It also advertises **attended (consultative) transfer** as three explicit phases so the initiating agent can speak privately with the destination agent before committing the handoff. **Begin consult** places the customer on hold (music on hold, so the customer can never overhear the consult) and only then rings the destination agent's live browser softphone into the same canonical bridge as a non-owning **participating** leg — holding fails closed, so the destination is never rung while the customer could hear, and a destination that never answers leaves the original call fully intact. **Complete consult** resumes the customer and, in a single version-checked transaction, atomically promotes the destination participant to the `Connected` owner while retiring the initiating agent leg, then hangs up the initiating agent — so ownership passes with exactly one owner at every instant; if the consult leg is already gone the completion fails closed and the customer safely keeps the initiating agent. **Cancel consult** drops the non-owning destination leg and resumes the customer with the initiating agent, whose ownership never changed. Transfers to a queue, external number, or entry point are not yet executable and return a clear, confirmed failure rather than a false success. It also advertises **conference** for adding another agent as a **multi-party participant** on the canonical conversation bridge. The extra agent's live browser softphone is originated as a new leg and joined to the same bridge the customer is already on, so recording and monitoring stay continuous. Multi-party ownership is modeled without an N-writer teardown race: exactly one agent leg is the bridge's `Connected` owner (the sole party allowed to destroy the bridge and release the caller), while every additional agent is a non-owning **participating** leg whose own hangup tears down only its own channel — never the shared bridge, the caller, or another agent. When the `Connected` owner departs while participants remain, ownership is handed off by **atomically** promoting one remaining participating leg to `Connected` and retiring the departing owner in a single version-checked transaction (the same linearization used by the transfer swap), so the bridge always has exactly one destroyer and two concurrent owner departures can never create a second owner or strand the caller; only when the last participant is gone is the bridge destroyed and the caller released. These capabilities resolve the conversation through the tenant-owned channel binding and fail closed on a call the current tenant does not own, and they remain gated in the support matrix until the single-node audio and two-tenant proofs land. + +## Tenant-configured Asterisk settings + +Configure the tenant-specific **Asterisk** provider on the **Asterisk** tab under **Settings → Communication → Telephony**. The Telephony settings UI creates that tab from the site-settings display driver, and the Asterisk editor itself only renders the provider fields inside the tab. You need the `Manage telephony settings` permission. + +| Setting | Description | +| --- | --- | +| **Enable Asterisk provider** | Turns the tenant-configured provider on, makes it selectable as the default provider, and reveals the rest of the tenant-specific Asterisk fields in the settings UI. | +| **ARI base URL** | The base ARI endpoint, for example `http://localhost:8088/ari/`. If you omit `/ari`, it is added automatically. | +| **ARI user name** | The Asterisk ARI user name used for HTTP basic authentication. | +| **ARI password** | The ARI password. Stored encrypted with the data protection provider. | +| **Stasis application name** | Required. The ARI application name that receives originated channels. It must match the `Stasis()` application your dialplan hands calls to. This value is not defaulted for you: if it is left blank the provider fails closed (no inbound listener starts and outbound origination is unavailable) rather than silently sharing a well-known name. When several tenants share one Asterisk server, give each tenant a **unique** application name so a tenant never receives another tenant's Stasis events. | +| **Endpoint template** | Optional. Use `{number}` to convert the dialed destination into an Asterisk endpoint, for example `PJSIP/{number}@phones` or `Local/{number}@default`. The admin hint now renders that token literally, so the settings screen remains stable while showing the exact placeholder to enter. When empty, the dialed destination is sent to Asterisk as-is. | +| **Outbound caller id** | Optional caller identifier presented on outbound calls. | +| **Dial timeout (seconds)** | How long Asterisk keeps trying to originate the call before timing out. | +| **Voicemail context** | Optional. The dialplan context Orchard continues a ringing call into when the agent chooses **Send to voicemail**. | +| **Voicemail extension template** | Optional. Resolves the dialplan extension used for voicemail routing. It can reference provider-neutral call metadata such as `{voicemailRecipientUserName}`, `{voicemailRecipientUserId}`, `{calledAddress}`, or `{queueName}`. | +| **Voicemail priority** | Optional. The dialplan priority to start at when the provider continues the call to voicemail. | +| **SIP WebSocket URL** | Required for browser audio. The secure `wss://` endpoint exposed by Asterisk `chan_pjsip` for browser SIP user agents. | +| **SIP domain** | Required for browser audio. Used to compose tenant/session-scoped browser SIP addresses of record. | +| **ICE server URLs** | Optional STUN/TURN URLs sent to the browser. TURN URLs use coturn REST credentials when a shared secret is configured. | +| **TURN shared secret** | Optional coturn REST shared secret. Stored encrypted and used to issue time-limited TURN credentials. | +| **ICE transport policy** | Browser ICE transport policy. Use `all` for normal direct ICE or `relay` for forced-TURN validation. | +| **Browser audio codecs** | Comma- or newline-delimited codec preference list, typically `opus,g722,ulaw`. | +| **PJSIP credential lifetime (minutes)** | Short-lived browser SIP credential lifetime. Credentials are tenant-, session-, and expiry-bound. | +| **PJSIP contact expiration (seconds)** | Asterisk registration contact expiration. Revocation also explicitly removes contacts and terminates existing dialogs; credential expiry alone is not treated as a dialog teardown. | +| **PJSIP Realtime provider** | Required for browser audio. ADO.NET provider invariant name used to write Asterisk Realtime endpoint/auth/AOR rows. | +| **PJSIP Realtime connection string** | Required for browser audio. Stored encrypted in tenant settings. The database must be the same source configured by Asterisk `sorcery.conf`/`extconfig.conf`. | +| **PJSIP Realtime table prefix** | Optional prefix for `ps_endpoints`, `ps_auths`, and `ps_aors`. Because the prefix is concatenated into SQL table names, it must be a strict SQL identifier — only letters, digits, and underscores, optionally qualified with a single `schema.` component. Invalid prefixes are rejected when you save the settings, and the Realtime store refuses to run a query with an invalid stored prefix as defense-in-depth. | + +When you enable **Asterisk** and no default telephony provider is set yet, **Asterisk** becomes the default automatically. When you disable **Asterisk** while it is the default, the default provider is cleared and the soft phone is disabled until another provider is selected. + +## Configuration-backed Default Asterisk provider + +The **Default Asterisk** provider is not managed through site settings. Instead, the host configures it through shell configuration. When all required values are present, the provider appears automatically in the **Default telephony provider** selector for every tenant where the module is enabled. + +### Required configuration + +Use the `OrchardCore:CrestApps:Asterisk:Default` section: + +```json +{ + "OrchardCore": { + "CrestApps": { + "Asterisk": { + "Default": { + "BaseUrl": "http://localhost:8088/ari/", + "UserName": "", + "Password": "", + "ApplicationName": "crestapps-telephony", + "EndpointTemplate": "Local/{number}@default", + "TimeoutSeconds": 30, + "VoicemailContext": "crestapps-voicemail", + "VoicemailExtensionTemplate": "{voicemailRecipientUserName}", + "VoicemailPriority": 1 + } + } + } + } +} +``` + +Equivalent environment variables use the standard double-underscore path, for example: + +```text +OrchardCore__CrestApps__Asterisk__Default__BaseUrl=http://localhost:8088/ari/ +OrchardCore__CrestApps__Asterisk__Default__UserName= +OrchardCore__CrestApps__Asterisk__Default__Password= +OrchardCore__CrestApps__Asterisk__Default__ApplicationName=crestapps-telephony +OrchardCore__CrestApps__Asterisk__Default__EndpointTemplate=Local/{number}@default +OrchardCore__CrestApps__Asterisk__Default__TimeoutSeconds=30 +OrchardCore__CrestApps__Asterisk__Default__VoicemailContext=crestapps-voicemail +OrchardCore__CrestApps__Asterisk__Default__VoicemailExtensionTemplate={voicemailRecipientUserName} +OrchardCore__CrestApps__Asterisk__Default__VoicemailPriority=1 +``` + +The provider becomes available only when `BaseUrl`, `UserName`, `Password`, and `ApplicationName` are all configured. + +## Browser WebRTC media + +The Asterisk adapter uses `chan_pjsip` WebRTC and a browser SIP user agent. Agent media flows directly between the browser and Asterisk over WSS, DTLS-SRTP, and ICE; Orchard/.NET does not relay agent RTP. The existing ARI External Media RTP/UDP adapter remains a separate development foundation for server-side media taps and AI scenarios. + +Browser audio is advertised only when the tenant or default provider has executable WebRTC settings: a `wss://` SIP WebSocket URL, SIP domain, codec list, positive PJSIP credential lifetime, positive contact expiration, and PJSIP Realtime provider/connection settings. Missing settings fail closed by making browser audio unavailable instead of crashing host startup. + +The credential lifecycle uses PJSIP Realtime rather than static pre-provisioning. ARI cannot create endpoint, auth, or AOR objects, and static pre-provisioning cannot bind every browser registration to a tenant, session, and expiry. Orchard issues short-lived PJSIP credentials through a scoped issuer, persists a durable per-tenant credential **lease** in the tenant's own YesSql store as the single source of truth for ownership and expiry, materializes endpoint/auth/AOR rows through the Realtime store seam, rotates by revoking the prior session credential, and cleans up expired or revoked registrations. The reference templates live under `src/Startup/CrestApps.Aspire.AppHost/Asterisk/pjsip-webrtc-realtime.conf.template` and `src/Startup/CrestApps.Aspire.AppHost/Coturn/turnserver-webrtc.conf.template`. + +Each credential is bound to an authoritative, server-owned media session. The issuer derives ownership from the authenticated user, generates the session id itself, and never trusts a caller-supplied identifier (such as an interaction id) to authorize issuance — an interaction id may still travel as non-authoritative metadata. Every issue, rotate, revoke, and cleanup state transition runs under a tenant-qualified `IDistributedLock`. Ownership, expiry, the per-user cap, and revocation are all resolved from the durable lease store, which is inherently isolated to the current tenant because each query runs through the tenant's own YesSql session — there is no `LIKE` prefix scan over the shared Realtime tables, so one tenant can never observe or delete another tenant's rows. Issuance writes the durable lease **first**, then the Realtime SIP row, then the cache, so a Realtime row can never exist without a lease the current tenant owns; if the Realtime write fails, the lease is marked revoked and cleanup reclaims any partial row by exact authorization user. The distributed cache is only a read-through performance cache: a cache miss is reconciled against the durable lease (expiry is read from the lease and is never inferred from a cache miss). Cleanup queries only the current tenant's expired or revoked leases and deletes each corresponding Realtime row by its exact authorization user. Authorization-user identifiers additionally incorporate a fixed-width stable hash of the raw tenant name so tenants that share one Realtime database and whose sanitized names would otherwise collide (for example `acme`, `acme-east`, and `Acme`) receive distinct identifier namespaces. Each authenticated user is capped at a small number of concurrent live browser credentials; issuing beyond the cap revokes the oldest session first so the newest browser session wins. Signing out of the soft phone (or terminating the agent session) revokes all of the user's live credentials immediately instead of waiting for natural expiry. + +## How call control works + +The provider uses ARI endpoints such as: + +- `POST /channels` to originate a call +- `DELETE /channels/{id}` to hang up a call +- `POST /channels/{id}/answer` to answer an inbound Stasis-managed channel +- `POST` / `DELETE /channels/{id}/hold` to hold and resume +- `POST` / `DELETE /channels/{id}/mute?direction=both` to mute and unmute +- `POST /channels/{id}/continue` to blind-transfer a Stasis-managed Local channel back into the dialplan +- `POST /channels/{id}/variable` plus `POST /channels/{id}/continue` to push provider-neutral metadata into the channel and route it to the configured voicemail dialplan target +- `POST /channels/{id}/dtmf` to send digits +- `POST /bridges` plus `POST /bridges/{id}/addChannel` to merge all selected calls; the provider clears each prior hold marker, stamps the owned bridge id on every channel, and deletes the mixing bridge after the last tracked participant hangs up +- `GET /endpoints` to list transfer destinations in the soft-phone directory + +Because all requests are issued server-side, the ARI password never reaches the browser. + +## Asterisk Web development-only host + +`src\Startup\CrestApps.OrchardCore.Asterisk.Web` is a local development harness, not a production dashboard. It refuses to start unless `ASPNETCORE_ENVIRONMENT=Development`, and the development launch profile binds to loopback addresses. To use the standalone host, provide its ARI credentials through user secrets or environment variables instead of committing them to `appsettings.json`: + +```bash +dotnet user-secrets set "AsteriskWeb:AsteriskUserName" "" +dotnet user-secrets set "AsteriskWeb:AsteriskPassword" "" +``` + +The equivalent environment variables are `AsteriskWeb__AsteriskUserName` and `AsteriskWeb__AsteriskPassword`. Override the local listener addresses only when needed with `ASPNETCORE_URLS`, for example `https://127.0.0.1:59496;http://127.0.0.1:59497`. Do not expose this host or its anonymous diagnostics, call-origination, hangup, and bridge-deletion endpoints to an untrusted network. + +## Bidirectional RTP media + +The Asterisk package can run as a Telephony provider without Contact Center. Install the Contact Center module package before enabling `CrestApps.OrchardCore.Asterisk.ContactCenterVoice` or `CrestApps.OrchardCore.Asterisk.ContactCenterMedia`. + +`CrestApps.OrchardCore.ContactCenter.Voice.Media` and `CrestApps.OrchardCore.Asterisk.ContactCenterMedia` are dependency-only development foundations and are not included in either approved GA-Core profile. They remain hidden from direct selection in the Orchard Features UI until the transport certification tracked for R9 is complete. The executable media feature registers `AsteriskContactCenterVoiceMediaProvider`; the base Asterisk voice provider does not advertise media through a capability flag. The adapter uses ARI External Media over RTP/UDP with G.711 mu-law, 8 kHz, mono audio. + +Opening a media session: + +1. binds an Orchard UDP socket +2. finds the bridge containing the provider call or creates a mixing bridge +3. creates an ARI `/channels/externalMedia` channel using RTP/UDP and `ulaw` +4. adds the external-media channel to the call bridge +5. reads `UNICASTRTP_LOCAL_ADDRESS` and `UNICASTRTP_LOCAL_PORT` from Asterisk for outbound RTP +6. exposes incoming and outgoing frames through `IContactCenterVoiceMediaSession` + +The session request must include `AsteriskConstants.ExternalMediaHostMetadataKey` (`externalHost`), containing the host or IP address Asterisk can reach for the Orchard RTP socket. This is often different from the address Orchard binds locally when containers, NAT, or reverse proxies are involved. + +Optional metadata: + +| Key | Description | +| --- | --- | +| `bindAddress` | Local IP address on which Orchard binds the UDP socket. Defaults to all local interfaces. | +| `bindPort` | Local UDP port. Defaults to an operating-system-assigned ephemeral port. Production deployments should select and allow an explicit UDP range. | + +The current adapter accepts and emits RTP payload type `0` (G.711 mu-law). It parses RTP header extensions, contributing-source entries, and padding before exposing the audio payload. Stopping the media session removes the external-media channel without hanging up the customer call. A bridge created exclusively for the media session is also removed. + +The automated test suite exercises the ARI bridge and External Media lifecycle with a scripted HTTP transport and exchanges real RTP datagrams over loopback UDP sockets. It validates sender filtering, malformed-packet rejection, mu-law payload enforcement, sequence/timestamp continuity, and cleanup behavior. This is development-foundation evidence only. Production support requires a documented secure RTP network boundary plus loss, reordering, jitter, capacity, failover, and node-affinity certification; that work is deferred to R9. + +Any non-production lab must isolate the unencrypted RTP/UDP path on a trusted private network, restrict ingress to the expected Asterisk endpoint and explicit UDP port range, and prevent public routing to the Orchard media socket. If Orchard is scaled across nodes, the media session must remain pinned to the node that owns the UDP socket unless a dedicated media relay is introduced. These requirements describe the current risk boundary; they do not certify the adapter for production. + +## Real-time call state and recovery + +The module keeps a tenant-scoped ARI WebSocket listener for every configured Asterisk provider. Long-running listeners create an explicit scope through `IShellHost` and the tenant's `ShellSettings` for every reconciliation and event dispatch; they do not depend on an ambient request scope that disappears after tenant activation. Each listener is supervised independently, reconnects with exponential jitter after failure, and runs immediate provider-scoped reconciliation for both Contact Center interactions and plain Telephony interactions after connecting. A failed endpoint therefore does not stop healthy provider listeners, and reconnect recovery does not trigger overlapping full-provider sweeps. + +ARI events are normalized into provider-authoritative call states and projected through Orchard to connected soft-phone clients without continuous browser polling. Command acknowledgements do not update or re-query the browser call state; the corresponding ARI event drives the transition. A hangup request that receives ARI `404 Channel not found` is treated as idempotent success because Asterisk has already reached the requested disconnected state. Provider lookup also verifies that the channel still exists after reading hold, mute, and conference variables, preventing a channel destroyed during the multi-request lookup from being reported as connected. A two-channel mixing bridge is treated as a conference because the agent is the controlling participant even when the provider bridge contains only the two remote call channels. Supported dashboard states include **Offered**, **Offering**, **Ringing**, **Connected**, **In conference**, and **On hold**, with hold and mute badges where Asterisk exposes those facts. Bridge-leave events only project a lifecycle state when the channel snapshot is authoritative, while hold/unhold, mute-variable, state-change, hangup, and Stasis lifecycle events update the projection in real time. Once an interaction is terminal, later `ChannelHangupRequest`, `StasisEnd`, or `ChannelDestroyed` events are ignored so one physical hangup cannot republish the terminal transition multiple times. + +Periodic and provider-reconnect reconciliation query the ARI channel directly. The provider reads the `CRESTAPPS_STATE_ONHOLD` and `CRESTAPPS_STATE_MUTED` channel variables so an `Up` channel is not incorrectly collapsed to a plain connected state after a restart. Unknown ARI channel states fail the lookup instead of being guessed as connected, leaving the prior projection intact until authoritative state is available. An ARI `404` is authoritative evidence that the channel no longer exists: the reconciler removes the orphaned in-progress Telephony record and sends a disconnected state to the soft phone, preventing a restart or page reload from restoring a call that Asterisk has already ended. + +An accepted ARI originate response begins in **Connecting** even for the bundled Local-channel endpoint. The soft phone does not assume that accepting the Dial command means the call is connected; it waits for an ARI event or authoritative channel lookup to report the actual state. + +## Voicemail routing + +When an agent clicks **Send to voicemail**, Orchard now sends a provider-neutral metadata bag along +with the call action. For Contact Center offers that bag includes values such as: + +- `voicemailRecipientUserId` +- `voicemailRecipientUserName` +- `voicemailRecipientDisplayName` +- `calledAddress` +- `callerAddress` +- `queueId` +- `queueName` + +The Asterisk provider copies those values into channel variables with a `CRESTAPPS_METADATA_` +prefix and then continues the call into the configured voicemail dialplan target. For example, +`voicemailRecipientUserName = mike` becomes the channel variable +`CRESTAPPS_METADATA_VOICEMAILRECIPIENTUSERNAME`. + +That lets your dialplan decide how to route voicemail without introducing Asterisk-specific +properties into the shared telephony contracts. A common pattern is to make the extension template +match the intended mailbox key: + +```text +VoicemailContext = crestapps-voicemail +VoicemailExtensionTemplate = {voicemailRecipientUserName} +VoicemailPriority = 1 +``` + +Then configure Asterisk to map the extension or the channel variables to the actual mailbox, with a +fallback when the user-specific mailbox does not exist. + +## Aspire local development + +`src\Startup\CrestApps.Aspire.AppHost` now provisions an **Asterisk** container for local development using the `andrius/asterisk:latest` image, mounts minimal `http.conf`, `ari.conf`, and `extensions.conf` files, and injects the **Default Asterisk** environment variables into the Orchard Core web project automatically. Repository projects use `TargetFramework` for the default single `net10.0` target while preserving `TargetFrameworks` for multi-target overrides, so Aspire can launch Orchard Core, Asterisk Web, and the sample clients without `dotnet run` stopping for an ambiguous framework selection. + +This makes the configuration-backed provider available immediately for local tenants as soon as: + +1. The **Asterisk** module is enabled. +2. The tenant selects **Default Asterisk** as its default telephony provider. + +The Aspire host supplies its local-only ARI connection values at runtime. The standalone Asterisk Web sample intentionally keeps ARI credentials out of committed configuration; use user secrets or environment variables as described above. Visiting `http://localhost:8088/` returns **Not Found** by design because the container exposes the ARI HTTP service, not a browser landing page. The `/ari/` endpoint can be used to verify that the local ARI service is reachable. + +The Asterisk container generates its self-signed WebRTC certificate in its entrypoint rather than in a `RUN` layer, so no private key is baked into the image. The key is created on first start under `/etc/asterisk/keys` and is unique per container. + +### Coturn dashboard URLs are suppressed on purpose + +The Coturn container registers its TURN endpoints with `WithUrls(context => context.Urls.Clear())`. This is a workaround for a defect in Aspire `13.4.6`, not a configuration preference, and removing it makes the app host fail to start on most runs. + +Aspire builds each resource's dashboard URL snapshot in `ResourceSnapshotBuilder.GetUrls`, which resolves a URL to its DCP endpoint with `SingleOrDefault`. Coturn publishes the same port over both TCP and UDP, so that lookup matches more than one endpoint and throws. The exception escapes into the DCP resource watch tasks and terminates them, and because those watchers drive orchestration for every resource, the failure is not limited to Coturn: when it triggers, no container and no project starts. The symptom is `Watch task over Kubernetes Service resources terminated unexpectedly` followed by `Sequence contains more than one matching element` in the app host log. The failure is timing dependent, so an occasional clean run does not mean it is fixed. + +Clearing the generated URLs skips that lookup entirely. The endpoints themselves are still published and proxied, so TURN over TCP and UDP on `3478` and `5349` continues to work; the only thing lost is the clickable Coturn link in the dashboard. Remove the call once Aspire resolves the underlying defect. + +The default Aspire endpoint template uses `Local/{number}@default`, which loops the originated call back into the bundled demo dialplan. Numeric and E.164 destinations beginning with `+` are supported. The dialplan answers, plays a short generated tone sequence, and enters `Echo()` so the simulation does not depend on sound files that are absent from the container image. That local development path still **originates through the configured Stasis application**, so the same live channel remains under ARI control for hold, resume, mute, merge, inbound answer/reject, and Local-route blind transfer while the simulated media stays inside Asterisk. + +### Two-party dashboard simulation + +The **Asterisk Dashboard** now includes a **Two-party call simulation** form. It originates both selected endpoints into the dashboard's Stasis application, waits until both channels are under ARI control, creates a `mixing` bridge, and adds both channels to that bridge. Partial failures remove any channels or bridge that were already created. + +The bundled defaults are: + +| Party | Endpoint | Behavior | +| --- | --- | --- | +| A | `Local/2001@crestapps-simulation` | Answers and emits a repeating 440 Hz synthetic tone pattern. | +| B | `Local/2002@crestapps-simulation` | Answers and emits a repeating 659 Hz synthetic tone pattern. | + +These endpoints create a real Asterisk media bridge and remain active for up to five minutes or until disconnected from the dashboard. Disconnect the channels to end either simulated party, and disconnect the bridge when the simulation is finished so the ARI bridge is removed. The live dashboard shows four Local channel legs, the two logical calls, and the shared mixing bridge. To test with two actual people or devices, replace the defaults with endpoints configured by your PBX, such as `PJSIP/1001` and `PJSIP/1002`. Both endpoints must answer before the bridge can be completed. + +This simulation validates Asterisk channel origination, Stasis control, media bridging, live ARI events, and dashboard diagnostics. It does not run the automated AI conversation pipeline or create a Contact Center activity; use the separate **Inbound Simulator** for Contact Center routing and activity creation. + +For inbound routing tests, use the **Asterisk Web** startup sample (`src\Startup\CrestApps.OrchardCore.Asterisk.Web`). It signs in to Orchard, originates one or more Asterisk channels directly into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards the normalized `InboundVoiceEvent` requests to `POST /api/contact-center/voice/inbound` using the live Asterisk channel ids. The WebSocket reader queues events to concurrent dispatch workers, so one slow Orchard ingress request does not block later calls in a burst, and each forward has a bounded timeout. If the sample listener misses the matching event, the simulator briefly reconciles the originated channel through ARI and forwards it only when the authoritative channel snapshot confirms the configured Stasis application and exact simulation key. This prevents a transient listener gap from turning a successfully originated inbound call into a false HTTP 504 result. The sign-in check also recognizes tenant-prefixed login redirects and fails explicitly instead of continuing with an unauthenticated client. The sample exposes two pages: **Asterisk Dashboard** for live ARI telemetry and two-party bridge testing, and **Inbound Simulator** for Contact Center burst testing. The dashboard uses a dedicated `crestapps-dashboard` ARI application so it does not compete with the CMS `crestapps-telephony` event listener. It treats the Asterisk event stream as the primary update path: channel, bridge, state, dialplan, and variable events request an immediate snapshot, the server coalesces only a short event burst, reads independent ARI diagnostics endpoints concurrently, enriches active channels concurrently, and pushes the snapshot to connected browsers over SignalR. Dashboard ARI HTTP requests close their connections after each response to avoid stale pooled sockets after container restarts. The sample serves the SignalR client from the application instead of depending on an external CDN. While SignalR is connected, browser polling is stopped; a 15-second reconciliation poll starts only while SignalR is unavailable or reconnecting, then stops again after reconnection. Call and bridge count badges update from every snapshot, and the initial page snapshot uses the same web JSON naming policy as live hub messages. The dashboard groups raw local channel legs into logical calls so one Local call is easier to read, shows inferred call direction, surfaces provider-tracked hold and mute state, estimates party counts from bridge membership, and adds a disconnect action so you can simulate caller hangup from the PBX side. Its two-party form can use the bundled synthetic Local endpoints or real PBX endpoints and reports the created bridge and channel ids. The inbound simulator distinguishes calls that were immediately **Offered**, are **Waiting in queue**, or were **Not queued**, so `routed: false` is no longer presented as a rejection when the durable queue accepted the call. Live notifications now sit beside the raw ARI payload drill-down so the active call and bridge tables have more room. In the simulator, configured defaults populate the initial form, the configured provider identity is authoritative and read-only so ingress records match the live ARI listener, **To address** controls which Contact Center entry point or queue mapping the inbound call targets, and **Caller number seed** only changes the generated caller identities. The sample and Aspire host use the root Orchard URL and **Default Asterisk** provider by default; set **Orchard base URL** to the tenant URL, such as `https://localhost:5001/blog1`, when testing a named tenant. + +The bundled local configuration is intended for development and connectivity testing. The Asterisk Web host is development-only and must not be deployed as a production component. Production telephony deployments should supply their own ARI credentials, dialplan, endpoints, and media/network configuration through protected configuration. + +## Verifying local Asterisk activity + +The bundled image does not expose a separate web dashboard for live calls. For local development, the easiest inspection points are the ARI endpoints and the container logs. + +### Quick ARI checks + +With the default Aspire credentials, these endpoints are useful: + +- `GET http://localhost:8088/ari/asterisk/info` confirms that ARI is reachable and authenticated. +- `GET http://localhost:8088/ari/channels` lists the active channels that Asterisk currently knows about. +- `GET http://localhost:8088/ari/bridges` lists any active bridges, including merged calls. + +If the soft phone dials successfully but `channels` stays empty while the call is active, the originate request is not reaching or being accepted by Asterisk. If the call appears in `channels` but a later action such as hold fails, inspect the Orchard application logs and the Asterisk container logs together to see the ARI response body and the PBX-side reason. + +The Telephony SignalR hub now logs the start and completion of each soft-phone action with the authenticated user id, SignalR connection id, the provider call id, and any Contact Center correlation metadata that travelled with the call reference. When an Asterisk call-control action fails after a queued inbound offer is accepted, those hub entries make it easier to confirm whether Orchard is still acting on the original offered channel id or on the latest provider-side call identity. + +The Asterisk module also registers a Contact Center voice-provider adapter. It returns the actual tenant or configuration-backed provider name used for the call, allowing outbound interactions and later ARI lifecycle events to correlate on the same provider identity. + +The Asterisk development dashboard also logs the refresh source, refresh-lock wait, ARI snapshot duration, SignalR broadcast duration, and resulting channel/bridge/logical-call counts. Compare the timestamp of the incoming ARI event with these entries to distinguish delayed provider event delivery from slow snapshot acquisition, lock contention, or SignalR broadcast delay. The standalone sample builds its own local SignalR browser asset through `Assets.json`; if `/js/signalr.min.js` is missing, the dashboard cannot receive event pushes and intentionally falls back to its slower reconciliation poll. + +### What to expect from the bundled local path + +The local `Local/{number}@default` endpoint remains useful for keeping the media loop inside Asterisk during development, and the provider now originates those calls directly into the configured Stasis application so ARI events, dashboard telemetry, and the forwarded Contact Center ingress event all describe the same provider call id. Because the same Local loopback call leg stays inside Stasis, the Orchard soft phone can now expose advanced ARI-backed controls there instead of hiding them. + +## Provider protocol contract tests + +The Asterisk integration is pinned to the protocol Asterisk actually publishes, not to a hand-written stub. The Asterisk project ships its REST Interface as machine-readable Swagger declarations for every release, and the declarations for the release this repository pins its container image to are vendored verbatim under `tests/CrestApps.OrchardCore.Tests/Telephony/Cassettes/Asterisk/{version}`. + +That directory contains four things: + +- `spec-events.json`, `spec-channels.json`, `spec-bridges.json`, and `spec-recordings.json` are the upstream declarations, copied byte for byte. +- `manifest.json` records the provenance of those copies: the upstream repository, the tag they were taken from, the path and URL of each file, the SHA-256 of each file, and the container image tag and digest the single-node profile runs. +- `contract.json` binds the realtime voice event mapper to those declarations. For each event the mapper interprets it lists the property paths the mapper depends on, and it separately lists the compatibility fallbacks the mapper only tolerates, together with the reason each one is tolerated. +- `events/` and `rest/` hold the recorded payloads and REST responses that are replayed through the production code. + +Four suites enforce this: + +- `AsteriskAriContractProvenanceTests` recomputes every recorded hash, requires the vendored release to match the release parsed out of `src/Startup/CrestApps.Aspire.AppHost/Asterisk/Dockerfile`, and requires exactly one vendored release directory to exist. An Asterisk version bump therefore breaks the build until the declarations are refreshed. +- `AsteriskAriEventContractTests` scans `AsteriskRealtimeVoiceEventMapper` for the event types it special-cases and requires `contract.json` and the recorded events to cover exactly that set, requires every declared property path to resolve against the vendored declarations, requires every recorded payload to contain only fields those declarations permit, requires each tolerated fallback to stay absent from the corresponding model, and replays every recorded event through the production mapper. +- `AsteriskAriRestContractTests` constructs the real `AsteriskAriClient` over the recorded responses, exercises every method `IAsteriskAriClient` publishes, and requires every request the client issues to match an operation the vendored declarations declare, with only query parameters that release accepts. +- `DialPadWebhookContractTests` covers the other provider; see [DialPad](dialpad.md). + +### Refreshing the vendored declarations + +1. Change the pinned image in `src/Startup/CrestApps.Aspire.AppHost/Asterisk/Dockerfile` and record the new digest. +2. Download `rest-api/api-docs/events.json`, `channels.json`, `bridges.json`, and `recordings.json` from the Asterisk repository at the tag matching the new release. +3. Rename the version directory to the new release and replace the four `spec-*.json` files. +4. Update `manifest.json` with the new tag, digest, URLs, and SHA-256 values. +5. Run the Asterisk contract suites. Any property the mapper depends on that the new release no longer declares, and any request the client issues that the new release no longer accepts, fails immediately and must be resolved before the bump lands. + +These gates are not theoretical. They found two real defects. The mapper read channel variables from four locations, none of which was `Channel.channelvars`, the only location a conforming Asterisk release populates, so the interaction correlation identifier could never be recovered from a channel object. Recording duration was read from `StoredRecording`, which Asterisk declares with only a name and a format, so every completed recording reported no duration; the client now reads the live recording, where the duration is declared, before stopping it. diff --git a/src/CrestApps.Docs/docs/telephony/custom-providers.md b/src/CrestApps.Docs/docs/telephony/custom-providers.md new file mode 100644 index 000000000..ae43a49b2 --- /dev/null +++ b/src/CrestApps.Docs/docs/telephony/custom-providers.md @@ -0,0 +1,343 @@ +--- +sidebar_label: Custom Providers +sidebar_position: 4 +title: Custom Telephony and Contact Center Providers +description: How to add a custom telephony provider, Contact Center voice provider, and real-time event ingress path for CrestApps Orchard Core. +--- + +# Custom Telephony and Contact Center Providers + +Use this guide when you want to add another PBX or telephony backend to CrestApps.OrchardCore. + +## Architecture at a glance + +There are five separate seams, and a provider may implement any combination supported by its backend: + +| Seam | Interface | Responsibility | +| --- | --- | --- | +| Provider identity | `ITelephonyProvider` | Display name and advertised `TelephonyCapabilities`. It declares no call operations | +| Soft-phone call control | `ITelephonyCallControlProvider` | Dial and hang up | +| Inbound call handling | `ITelephonyInboundCallProvider` | Answer and reject a ringing inbound call | +| Hold and mute | `ITelephonyHoldProvider`, `ITelephonyMuteProvider` | Hold/resume and mute/unmute | +| Transfer | `ITelephonyTransferProvider`, `ITelephonyAttendedTransferProvider` | Blind transfer, and attended transfer where the destination is consulted first | +| Conference and DTMF | `ITelephonyConferenceProvider`, `ITelephonyDtmfProvider` | Merge calls and send digits | +| Voicemail | `ITelephonyVoicemailProvider` | Send a ringing call to voicemail | +| Soft-phone credentials | `ITelephonySoftPhoneCredentialsProvider` | Issue the client credentials the browser soft phone registers with | +| Agent audio delivery | `ITelephonyAudioProvider` | Advertise browser and/or external-device audio, expose the configured mode, and name an executable browser media adapter | +| Live call-state lookup | `ITelephonyCallStateProvider` | Query the provider's current server truth for a specific call so Contact Center can revalidate offers and reconcile restarts | +| Contact Center identity | `IContactCenterVoiceProvider` | Stable provider identity, display name, delivery model, and capability metadata | +| Contact Center call control | `IContactCenterVoiceCallControlProvider` | Dialer dialing and server-side agent bridging | +| Contact Center queue ownership | `IContactCenterVoiceQueueAssignmentProvider` | Provider-side agent assignment and queue placement | +| Contact Center transfer and conference | `IContactCenterVoiceTransferProvider`, `IContactCenterVoiceConferenceProvider` | Live-call transfer and conference execution | +| Contact Center recording and monitoring | `IContactCenterVoiceRecordingProvider`, `IContactCenterVoiceMonitoringProvider` | Recording control and supervisor monitor, whisper, or barge execution | +| Bidirectional live media | `IContactCenterVoiceMediaProvider` | Receive caller audio and inject application-generated audio into an existing provider call | +| Provider event ingestion | `IProviderVoiceEventSink` | Submit normalized provider call-state events without referencing Contact Center persistence models | +| Inbound provider routing | `IInboundVoiceEventSink` | Route a normalized inbound call into Contact Center work | +| Provider reconciliation | `IProviderCallStateReconciler` | Reconcile active Contact Center calls against authoritative provider state | +| Durable webhook ingress | `IProviderWebhookInbox`, `IProviderWebhookInboxHandler`, `IProviderWebhookIngressLimiter` | Commit authenticated deliveries, dispatch provider-owned payload handlers, and enforce ingress limits | +| Provider event ingress | Provider-owned webhook endpoint + `IProviderWebhookInboxHandler`, or a provider-specific stream listener | Authenticate provider deliveries at the provider's own endpoint, commit them to the durable inbox, and convert them into normalized `ProviderVoiceEvent` instances | + +The soft phone stays provider-agnostic because **providers never push UI updates directly to the browser**. Every provider must translate its native events into the internal Contact Center voice-event pipeline first. + +```text +Provider webhook / stream / callback + | + v +Provider endpoint or stream listener (+ durable inbox) + | + v +ProviderVoiceEvent + | + v +IProviderVoiceEventService + | + v +CallSession + Interaction + Contact Center events + | + v +TelephonyHub / soft phone projection +``` + +## 1. Implement the soft-phone provider + +To appear as a telephony provider in **Settings → Communication → Telephony**, implement `ITelephonyProvider`. + +`ITelephonyProvider` declares only the provider's display name and its advertised `TelephonyCapabilities`. Every call operation lives on a separate capability contract, so you implement exactly the operations your backend really supports and nothing else. A provider that can only place and end calls is complete after two methods: + +```csharp +public sealed class MyTelephonyProvider : ITelephonyProvider, ITelephonyCallControlProvider +{ + public LocalizedString Name => S["My PBX"]; + + public TelephonyCapabilities Capabilities => TelephonyCapabilities.Dial | TelephonyCapabilities.Hangup; + + public Task DialAsync(DialRequest request, CancellationToken cancellationToken = default) { /* ... */ } + + public Task HangupAsync(CallReference call, CancellationToken cancellationToken = default) { /* ... */ } +} +``` + +At minimum, your provider should: + +1. Return a stable technical name and display name +2. Advertise accurate `TelephonyCapabilities` +3. Implement the capability contract behind every capability it advertises +4. Return provider-neutral `TelephonyCall` results +5. Register the provider through the Telephony provider options configuration pattern used by the built-in modules + +Use `TelephonyCall.Metadata` only for contextual data that should travel with the call without polluting the shared contract with provider-specific fields. + +### Capabilities and contracts are checked together + +`TelephonyCapabilityContracts` records the contract each capability flag requires, and the shared telephony service refuses an operation unless the resolved provider **both** advertises the flag **and** implements the contract. Advertising a capability you did not implement fails closed, and implementing a contract you did not advertise fails closed too, so neither half alone can make an operation reachable. + +| Capability | Required contract | +| --- | --- | +| `Dial`, `Hangup` | `ITelephonyCallControlProvider` | +| `ReceiveCalls` | `ITelephonyInboundCallProvider` | +| `Hold`, `Resume` | `ITelephonyHoldProvider` | +| `Mute` | `ITelephonyMuteProvider` | +| `Transfer` | `ITelephonyTransferProvider` | +| `AttendedTransfer` | `ITelephonyAttendedTransferProvider` | +| `Merge` | `ITelephonyConferenceProvider` | +| `SendDigits` | `ITelephonyDtmfProvider` | +| `Voicemail` | `ITelephonyVoicemailProvider` | +| `Directory` | `ITelephonyDirectoryProvider` | + +Blind and attended transfer are separate capabilities. A backend that can release a call to a destination but cannot consult that destination first advertises `Transfer` only; a warm transfer request then fails closed instead of reaching a method that would have to refuse it. + +### Declare the agent audio path + +Implement `ITelephonyAudioProvider` when the provider has a known, executable agent audio path: + +- Advertise `Browser` only when a provider browser SDK or signaling adapter can actually send microphone audio and play live remote audio. +- Advertise `ExternalDevice` when audio stays on a hard phone, desktop/mobile provider client, or another provider-owned endpoint. +- Advertise both only when the provider supports both deployments. Add the choice to that provider's settings UI and return it from `ConfiguredAudioMode`. +- Return a stable `BrowserMediaAdapterName` only for browser audio. The shared resolver fails closed when browser capability has no adapter name. + +The provider's browser script registers `window.telephonySoftPhone.mediaAdapters[adapterName]`. The factory receives provider credentials, the acquired microphone stream, the remote audio element, a remote-stream setter, and the shared error callback. It may return `handleCallState(call)` and `dispose()` methods. Call state remains provider-authoritative through the server event pipeline; the adapter is the media executor, not the orchestration authority. + +## 2. Implement the Contact Center voice provider when the backend can do more than keypad calling + +If the provider participates in Contact Center voice orchestration, implement `IContactCenterVoiceProvider` for identity and capability metadata. Add only the executable interfaces backed by real provider operations: + +- `IContactCenterVoiceCallControlProvider` for dialer calls and server-side agent connection +- `IContactCenterVoiceQueueAssignmentProvider` for provider-owned assignment and queue placement +- `IContactCenterVoiceTransferProvider` for live-call transfer +- `IContactCenterVoiceConferenceProvider` for conference creation or participant addition +- `IContactCenterVoiceRecordingProvider` for start, stop, pause, and resume recording +- `IContactCenterVoiceMonitoringProvider` for monitor, whisper, and barge +- `IContactCenterVoiceMediaProvider` for bidirectional live media + +Capability flags are discovery metadata, not executable behavior. Advertise an executable call-control, transfer, conference, recording, or monitoring capability only when the corresponding interface is implemented. Contact Center also checks the executable contract before routing or staging provider work, so a flag without an implementation fails closed. Live media is discovered only from `IContactCenterVoiceMediaProvider` registrations and has no capability flag. + +## 3. Implement bidirectional media only when the provider exposes live audio + +Providers that can attach an external media stream to an active call may implement `IContactCenterVoiceMediaProvider`. + +Provider modules should reference `CrestApps.OrchardCore.ContactCenter.Abstractions` only. Do not reference the Contact Center Core or module assemblies to ingest events, route inbound calls, reconcile provider state, or participate in the durable webhook inbox; use the stable provider-facing contracts above. + +Installing a provider package does not implicitly install the Contact Center module. Hosts that enable a provider's Contact Center adapter must also install the Contact Center module package; the adapter feature's manifest dependency then enables the required Contact Center Voice feature for that tenant. + +`IContactCenterVoiceMediaProviderResolver` returns a media provider only when: + +1. a base voice provider with the same technical name is registered +2. the media feature registers an `IContactCenterVoiceMediaProvider` + +The executable media registration is authoritative so independently enabled Orchard features remain safe. Enabling the base provider adapter alone cannot expose media, while enabling the declared media feature adds the contract without requiring the base singleton to advertise services owned by another feature. + +An opened `IContactCenterVoiceMediaSession` exposes: + +- the provider call and media-session identifiers +- negotiated incoming and outgoing audio formats +- an asynchronous stream of ordered incoming audio frames +- an outgoing audio-frame writer +- an explicit stop operation that detaches media without ending the underlying call + +The initial shared format vocabulary supports linear PCM, G.711 mu-law, and G.711 A-law, including sample rate, channel count, and preferred frame duration. Provider implementations remain responsible for their native transport, framing, codec negotiation, jitter handling, and bridge attachment. + +Do not register `IContactCenterVoiceMediaProvider` for event-only integrations, recording downloads, post-call transcripts, or providers that can receive audio but cannot inject audio into the same live call. + +## 4. Normalize provider events into `ProviderVoiceEvent` + +This is the most important real-time seam. + +Every provider-specific callback or stream event should be translated into `ProviderVoiceEvent` and passed to `INormalizedVoiceEventIngestor.IngestAsync()`. Do not call a specific consumer such as `IProviderVoiceEventService` or `IProviderVoiceEventSink` directly: the ingestor is the single entry point that canonicalizes your provider identity, takes the ingestion lease for the call stream exactly once, and then hands the same delivery to every consumer. + +The normalized event supports: + +- `State` for lifecycle changes such as dialing, ringing, connected, held, transferred, ended, failed +- `IsMuted` for mute/unmute changes +- `RecordingState` and `RecordingReference` for recording lifecycle +- `IsConference` and `ParticipantCount` for multi-party/conference updates +- `Metadata` for provider-specific troubleshooting context + +`ProviderVoiceEvent` is immutable. Build one with an object initializer and, if you need a variant of an event you already hold, derive it with a `with` expression rather than assigning to it. The type is both a contract you implement against and something ingestion adjusts — it canonicalizes the provider identity and scopes the idempotency key by it — and while the type was mutable those adjustments were applied to the caller's instance, so ingestion had to defend itself with a hand-written copy. That copy was one more thing to keep complete, and it was not: it dropped the provider's hangup cause, and because a session infers a cause when none is supplied, every call reported the inferred cause instead of the real one, with nothing to say the real one had been lost. A `with` expression copies every member by construction, so that class of loss cannot recur. `Metadata` is snapshotted when you assign it, so keeping your own reference to the dictionary you supplied and writing to it afterwards does not change the event you handed over. The snapshot keeps the comparer your dictionary was built with, and it keeps it through derivation too: assigning `Metadata` from another event carries that event's comparer rather than resetting to ordinal comparison, so a case-insensitive metadata set stays case-insensitive however many times the event is derived. A source that cannot report a comparer — an implementation the platform does not recognize — is keyed ordinally, which is the only honest choice when the source will not say how it compares its own keys. + +### One ingest, many projections + +A normalized delivery is consumed by every registered `INormalizedVoiceEventHandler`, not by the first one that recognizes it. Two ship in the box: + +- `TelephonyCallHistoryVoiceEventHandler` writes the `TelephonyInteraction` call-history record and pushes the soft-phone state change. It runs whether or not Contact Center is installed. +- `ContactCenterVoiceProjection` drives the durable `CallSession` and `Interaction`, emits detailed internal events such as `CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `RecordingStarted`, `RecordingPaused`, `RecordingResumed`, `RecordingStopped`, and `CallConferenceChanged`, and projects the authoritative state back to the soft phone. It is registered by the Contact Center Voice feature. + +Handlers are peers. A handler must never suppress the delivery for the others, because each one is an independent view of the same call and a suppressed delivery silently desynchronizes it. Return `false` from `HandleAsync` when your handler had nothing to project — that is a report, not a veto — and let the ingestor continue. + +If you add your own handler, note what the ingress already did for you: the provider name on the event is canonical, the call stream is already serialized by the ingestion lease, and asking for that lease again from inside your handler is satisfied re-entrantly rather than taken twice. Do not open your own distributed lock on the same call, and do not create a second de-duplication record for a delivery another consumer already recorded. + +Only internal call-control orchestration may take an event *out* of the stream before ingestion — for example answering and parking a first-seen inbound channel, or releasing a leg the module itself originated. That is a provider-private concern and belongs in your own module, never in a normalized-event handler. + +When the provider also implements `ITelephonyCallStateProvider`, Contact Center can use that same server truth to: + +1. revalidate a ringing offer immediately before accept +2. reconcile persisted active interactions when the tenant activates after a restart +3. run a periodic safety reconciliation in case a live provider event was delayed or missed + +## 5. Choose the provider transport model + +Providers usually fall into one of these transport models: + +### Webhook model + +The provider sends HTTP callbacks to Orchard. Map a **provider-owned endpoint** when: + +- the provider signs webhook requests +- the payload can be authenticated per request +- Orchard only needs to accept inbound HTTP events + +Typical flow: + +1. the provider's own minimal-API endpoint receives the webhook and validates the signature +2. the endpoint commits the raw delivery to the durable `IProviderWebhookInbox` under an idempotency key +3. the provider's `IProviderWebhookInboxHandler` deserializes the payload and normalizes one or more `ProviderVoiceEvent` records +4. `IProviderVoiceEventService` ingests them + +The durable inbox (commit-then-dispatch) makes ingress idempotent and recoverable, so a retried delivery is de-duplicated and an event is never lost to an inline dispatch race. The shipping DialPad provider follows exactly this shape (`api/dialpad/webhook/call` → inbox → `DialPadWebhookInboxHandler`). + +### Live stream model + +The provider exposes a long-lived WebSocket, SSE, or similar server-side event stream. + +Use a provider-specific **tenant-scoped shell component** when: + +- Orchard must keep a connection open to the provider +- the provider pushes state changes over a socket instead of posting webhooks +- event delivery needs reconnect, backoff, and tenant-aware configuration + +Do **not** push those raw provider events directly to the browser. The stream listener should still normalize everything into `ProviderVoiceEvent` and route it through `IProviderVoiceEventService`. In Orchard Core, that listener should follow the shell lifecycle instead of an app-wide hosted service: start it from a tenant-scoped `ModularTenantEvents` component, reconnect per tenant configuration, and resolve scoped services through `ShellScope.UsingChildScopeAsync(...)` while handling each event so persistence and hub projection run inside a fresh shell scope. + +### Hybrid model + +Some providers use both: + +- webhooks for durable lifecycle events +- WebSocket/SSE for faster live state + +That is fine. Both paths should normalize into the same internal `ProviderVoiceEvent` contract. + +## Transport and firewall checklist + +When documenting or deploying a provider, be explicit about which protocols the environment must allow: + +| Scenario | Protocol(s) to allow | Notes | +| --- | --- | --- | +| Browser soft phone ↔ Orchard | `https`, `wss` | Required for the Telephony/Contact Center SignalR experience. Keep HTTPS fallback traffic available too because SignalR may use SSE or long polling when WebSockets are blocked. | +| Provider webhook → Orchard | `https` | Recommended for all production webhook ingress, including DialPad-style signed callbacks. | +| Orchard → provider REST API | `https` | Used for call control, authentication, and call-state lookup when the provider exposes HTTP APIs. | +| Orchard → provider live socket | `wss` | Preferred for production provider event streams. | +| Orchard → provider live socket (dev/lab only) | `ws` | Acceptable only in trusted non-production environments or when TLS terminates before the provider connection. | +| Orchard → Asterisk ARI control API | `http` or `https` | Depends on the Asterisk deployment. Prefer HTTPS whenever ARI is exposed across networks you do not fully trust. | +| Orchard → Asterisk ARI events | `ws` or `wss` | Required for the tenant-scoped ARI listener to receive live channel changes. Prefer WSS in production. | + +If a proxy, ingress controller, or firewall is involved, make sure it allows: + +1. **WebSocket upgrade headers** for browser SignalR and provider live-stream connections. +2. **Long-lived outbound sockets** from Orchard to provider event streams such as Asterisk ARI. +3. **Inbound HTTPS webhook posts** from providers such as DialPad. +4. **Outbound HTTPS API calls** for provider lookup and control endpoints. +5. **Explicit outbound egress rules** on locked-down hosts. If Orchard runs in an environment where outbound traffic is restricted by default, you must allow the app to open outbound `https`, `ws`, or `wss` connections to the provider endpoints it depends on. + +In other words, yes: the docs now distinguish **inbound to Orchard**, **outbound from Orchard**, and **bidirectional browser traffic**, because providers do not all use the same direction: + +- **DialPad webhook delivery** is primarily **inbound to Orchard** +- **DialPad REST lookup/control** is **outbound from Orchard** +- **Asterisk ARI control** is **outbound from Orchard** +- **Asterisk ARI real-time events** are also **outbound from Orchard** because Orchard opens the `ws`/`wss` connection to Asterisk +- **Browser soft-phone SignalR** is **bidirectional** + +## 6. Keep the soft phone authoritative from server truth + +The browser should send **intents** such as dial, hold, resume, mute, hang up, or accept offer. + +The browser should **not** be treated as the source of truth for the live call state. + +Instead: + +1. provider executes the action +2. provider sends webhook or stream event +3. Orchard normalizes that event +4. Contact Center updates the call session and interaction +5. Telephony hub pushes the resulting state back to the soft phone + +This keeps hard phones, provider-native devices, and the browser soft phone synchronized from the same server-side truth. + +## 7. Keep provider-private metadata inside your own module + +Provider operation results carry a metadata dictionary. It is tempting to declare every key you populate in the shared Contact Center contracts so it looks like part of the platform, but a key that only your module writes and that no provider-neutral code reads is not a shared contract — it is your implementation detail wearing the platform's name. Published there, it silently obligates every future provider to populate a concept that may not exist in its backend at all. + +The rule the codebase enforces is: + +- **Shared contracts** hold keys the platform itself supplies or consumes. `ContactCenterConstants.TransferMetadata.AgentUserId`, `ConferenceMetadata.AgentUserId`, and `AttendedTransferMetadata.AgentUserId` are shared because the platform passes them *into* every provider. `RecordingMetadata.ProviderRecordingId` and `RecordingMetadata.RecordingUrl` are shared because neutral recording and governance code reads them back out. +- **Provider-private keys** live in your own module. The Asterisk adapter keeps its channel, snoop, and bridge identifiers in `AsteriskVoiceResultMetadata`, an `internal` class inside `CrestApps.OrchardCore.Asterisk`. Nothing outside that module can reference them, which is exactly right: nothing outside that module knows what they mean. + +Provider-neutral projects must also avoid vendor vocabulary in the names they declare. Use platform words for platform concepts — a call session's joined media is `MediaTopologyId`, not a vendor's word for its own grouping primitive. Prose in comments and docs may still name a provider as a concrete example; it is the declared identifiers and metadata key literals that must stay neutral. `ProviderNeutralContractArchitectureTests` fails the build when either rule is broken. + +## 8. Registration checklist + +For a new provider module, the usual registration checklist is: + +1. Register the telephony provider implementation and settings UI +2. Implement `ITelephonyAudioProvider` and a browser media adapter only for executable agent audio modes +3. Register `IContactCenterVoiceProvider` for Contact Center identity and capability metadata, then implement only the executable operation interfaces the backend supports +4. Register `IContactCenterVoiceMediaProvider` only in the feature that supplies bidirectional live audio +5. Register webhook endpoints or the tenant-safe live-stream listener +6. Implement `ITelephonyCallStateProvider` when the backend can query the current state of a call by id +7. Normalize every provider event into `ProviderVoiceEvent` +8. Ensure the provider's current-state lookup and live-event mapping agree on lifecycle semantics so reconciliation never "undoes" provider truth +9. Add targeted tests for: + - state mapping + - idempotency + - inbound routing + - capability-to-executable-contract parity and media resolver matching + - media-session cancellation and cleanup when live media is supported + - live state updates such as hold, resume, mute, unmute, recording, and multi-party changes + - call-state lookup and restart reconciliation +10. Update the docs and changelog with the supported capabilities and ingress model + +## Current built-in examples + +| Provider | Transport into Orchard | Notes | +| --- | --- | --- | +| DialPad | Signed webhook + per-call REST lookup | Converts call-event webhooks into `ProviderVoiceEvent`, routes new inbound calls, and supports current-state reconciliation by call id. Telephony audio is currently external-device/provider-client only; it does not advertise embedded browser audio or bidirectional Contact Center media. | +| Asterisk | ARI HTTP control + per-call ARI lookup + tenant-scoped ARI event stream + External Media RTP/UDP | Handles call control, call-state lookup, live normalized events, and server-side bidirectional G.711 mu-law media sessions attached to call bridges. Telephony audio is currently external-device only; the External Media adapter is not browser WebRTC. | + +## Related interfaces + +- `ITelephonyProvider` +- `ITelephonyCallStateProvider` +- `IContactCenterVoiceProvider` +- `IContactCenterVoiceCallControlProvider` +- `IContactCenterVoiceQueueAssignmentProvider` +- `IContactCenterVoiceTransferProvider` +- `IContactCenterVoiceConferenceProvider` +- `IContactCenterVoiceRecordingProvider` +- `IContactCenterVoiceMonitoringProvider` +- `IContactCenterVoiceMediaProvider` +- `IContactCenterVoiceMediaProviderResolver` +- `IContactCenterVoiceMediaSession` +- `IProviderWebhookInboxHandler` +- `IProviderVoiceEventService` +- `IIncomingCallContextProvider` +- `IIncomingCallDispatcher` + +Use those seams together and the next provider can plug in without changing the soft phone itself. diff --git a/src/CrestApps.Docs/docs/telephony/dialpad.md b/src/CrestApps.Docs/docs/telephony/dialpad.md index f2bad99bb..c08f88697 100644 --- a/src/CrestApps.Docs/docs/telephony/dialpad.md +++ b/src/CrestApps.Docs/docs/telephony/dialpad.md @@ -17,9 +17,9 @@ DialPad SDK or token. ## Dependencies -Enabling **DialPad** automatically enables the **Telephony** feature it depends on. The DialPad -module depends only on `CrestApps.OrchardCore.Telephony.Abstractions`, keeping it decoupled from the -soft phone and the hub. +Enabling **DialPad** automatically enables the **Telephony** feature it depends on. The DialPad module compiles only against the Telephony and Contact Center abstraction packages, keeping it decoupled from their implementation assemblies, the soft phone, and the hub. + +The base DialPad feature does not require Contact Center. Install the Contact Center module package before enabling `CrestApps.OrchardCore.DialPad.ContactCenterVoice`; its manifest dependency then enables Contact Center Voice for that tenant. ## Configuration @@ -40,6 +40,7 @@ connects their own DialPad account. | **OAuth scopes** | Optional. The space-separated OAuth scopes requested during authorization. The `offline_access` scope is always added automatically so access tokens can be refreshed. | | **Outbound caller id** | The phone number presented to recipients on outbound calls. Include a country code, for example `+1`. | | **User id** | The DialPad user id that places outbound calls when **API key** authentication is selected. | +| **Webhook signing secret** | Required when DialPad Contact Center Voice is enabled. The secret DialPad uses to sign inbound call-event webhooks (HS256 JWT). Stored encrypted with the data protection provider. Used to validate webhooks posted to `/api/dialpad/webhook/call` for the Contact Center inbound flow. | DialPad API calls use the environment's fixed REST endpoint (`https://dialpad.com/api/v2/` for production or `https://sandbox.dialpad.com/api/v2/` for sandbox), so there is no tenant-level API base URL field to configure. @@ -95,9 +96,7 @@ provider follows DialPad's documented requirements: ## Capabilities -The DialPad provider advertises support for dialing, hang up, hold, resume, mute, transfer, merge, -sending DTMF digits, and receiving inbound calls. The soft phone UI uses these capabilities to decide -which controls to display. +The DialPad provider advertises support for dialing, hang up, hold, resume, mute, transfer, merge, sending DTMF digits, receiving inbound calls, and provider-directory lookup. The soft phone UI uses these capabilities to decide which controls to display. Multi-party conference requests are executed as sequential DialPad merge operations that merge every additional selected call into the primary call. Transfer directory lookup calls DialPad's paginated company-users endpoint, displays the user's name, and prefers the internal extension before falling back to the assigned phone number. ## How call control works @@ -107,6 +106,20 @@ the DialPad REST API on the server. For example, a dial request issues an authen `call/{id}/{action}` endpoints. Because all control happens server-side, the API key never reaches the browser. +## Contact Center integration + +Enable the **DialPad Contact Center Voice** feature to use DialPad as the phone provider for the +Contact Center. It implements the Contact Center voice provider boundary over DialPad, advertises the +`AgentDeviceNative` delivery model (DialPad rings the agent's own soft phone), and supports outbound +dialing and call transfer. + +- **Outbound / dialer** — the Contact Center dialer and manual dialing route outbound calls through the + Voice Contact Center Call Router to DialPad, which places the call and rings the agent's DialPad soft + phone. +- **Inbound** — configure a DialPad webhook to `POST` call events to `/api/dialpad/webhook/call`. The webhook is authenticated by the **Webhook signing secret** configured on the DialPad settings screen (DialPad signs the payload as an HS256 JWT). New inbound calls create a CRM activity and a voice interaction, are queued through the matching entry point, and are offered to an available agent; later events (answered, held, muted, recording/conference changes, ended) update the interaction and call session. Missing signing secrets are rejected, and a configured secret that cannot be decrypted returns a service-unavailable response instead of downgrading to unsigned acceptance. Webhook request bodies are limited to 1 MiB, oversized deliveries return HTTP 413, and accepted state-changing processing is not canceled when the sending client disconnects. + +Create the call-event webhook subscription in the DialPad administration portal and point it at the tenant's public HTTPS URL. Orchard validates and processes deliveries but does not currently create or health-check the DialPad subscription automatically, so operators should monitor subscription status and delivery failures in DialPad. + ## Registering the provider in code The provider is registered by the module's startup with a named HTTP client that uses the standard @@ -138,3 +151,11 @@ The `DialPadProviderOptionsConfigurations` implementation contributes the DialPa the tenant settings enable it. The named HTTP client is resolved by the provider for REST API and OAuth token calls, so transient DialPad failures go through the configured retry, timeout, circuit-breaker, and attempt-limiter policies. + +## Webhook contract tests + +DialPad does not publish a machine-readable schema, so its contract cannot be bound to a vendored specification the way [Asterisk](asterisk.md) is. It is bound to recorded deliveries instead, and the manifest at `tests/CrestApps.OrchardCore.Tests/Telephony/Cassettes/DialPad/manifest.json` declares that weaker guarantee explicitly rather than implying a protocol proof it cannot make. + +The rigor comes from coverage floors that are derived from the production code rather than restated by hand. `DialPadWebhookContractTests` scans the normalizer's own token switches and requires `states.json` to name exactly the call-state, recording-state, and answer-classification tokens the production code interprets, so a newly interpreted token fails the build until a recorded expectation is added for it. Tokens the normalizer deliberately ignores are recorded as such and asserted to stay ignored. + +Each recorded scenario is then replayed through the whole ingress path rather than through the normalizer alone: the signed JWT webhook endpoint, the production deserializer, and the production normalizer. A delivery signed with the wrong secret is rejected, and every payload field the recordings use must bind to the property names the production model declares, so renaming a serialized field breaks the build. diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index 127901ea4..fc2a192ba 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -9,56 +9,93 @@ description: Provider-agnostic soft phone, SignalR hub, and telephony provider m | --- | --- | | **Feature Name** | Telephony | | **Feature ID** | `CrestApps.OrchardCore.Telephony` | +| **Administration feature ID** | `CrestApps.OrchardCore.Telephony.Admin` | -The **Telephony** module adds a provider-agnostic soft phone to Orchard Core. It exposes a SignalR -hub that receives call-control requests from the browser and routes them to whichever telephony -provider is configured for the tenant. The UI never talks to a provider directly, so the same soft -phone works with any provider that implements the telephony abstractions (for example -[DialPad](dialpad)). +The **Telephony** module adds a provider-agnostic soft phone to Orchard Core. It exposes a SignalR hub that receives call-control requests from the browser and routes them to whichever telephony provider is configured for the tenant. The UI never talks to a provider directly, so the same soft phone works with any provider that implements the telephony abstractions (for example [DialPad](dialpad)). + +In this module, **provider** means the configured **telephony backend adapter** (for example Asterisk, +DialPad, or another PBX/carrier API integration), not the user's phone company in the business or +billing sense. + +`CrestApps.OrchardCore.Telephony` registers services only. The telephony settings screen and its administration menu entry live in `CrestApps.OrchardCore.Telephony.Admin`, so a headless deployment - or a Contact Center tenant that only needs telephony services - can enable telephony without activating an administration surface. Enable `CrestApps.OrchardCore.Telephony.Admin` to configure the provider from the dashboard. ## Architecture The feature is split into three layers so that providers stay decoupled from the UI and the hub: ```text -Browser soft phone (soft-phone.js) - │ SignalR (invoke Dial/Hangup/Hold/...) - ▼ -TelephonyHub ──► ITelephonyService ──► ITelephonyProviderResolver ──► ITelephonyProvider - (DialPad, ...) +Browser command ──SignalR──► TelephonyHub ──► ITelephonyService ──► ITelephonyProvider + │ +Provider event stream/webhook ──► server projection/reconciliation ────────┘ + │ + └──SignalR CallStateChanged──► Browser state ``` -- **`CrestApps.OrchardCore.Telephony.Abstractions`** contains the provider-agnostic contracts: - `ITelephonyProvider`, `ITelephonyService`, `ITelephonyProviderResolver`, `ITelephonyClient`, - `ITelephonyAuthenticationProvider`, `ITelephonyAuthenticationService`, `ITelephonyUserTokenStore`, - `ITelephonyInteractionStore`, the request/response and interaction models, - `TelephonyProviderOptions`, `TelephonySettings`, and `TelephonyPermissions`. - A provider module depends only on this package. +- **`CrestApps.OrchardCore.Telephony.Abstractions`** contains the provider-agnostic contracts: `ITelephonyProvider`, the per-capability operation contracts (`ITelephonyCallControlProvider`, `ITelephonyInboundCallProvider`, `ITelephonyHoldProvider`, `ITelephonyMuteProvider`, `ITelephonyTransferProvider`, `ITelephonyAttendedTransferProvider`, `ITelephonyConferenceProvider`, `ITelephonyDtmfProvider`, `ITelephonyVoicemailProvider`, `ITelephonySoftPhoneCredentialsProvider`) and their `TelephonyCapabilityContracts` map, `ITelephonyCallStateProvider`, `ITelephonyService`, `ITelephonyProviderResolver`, `ITelephonyClient`, `ITelephonyAuthenticationProvider`, `ITelephonyAuthenticationService`, `ITelephonyUserTokenStore`, `ITelephonyInteractionStore`, `ITelephonyInteractionSynchronizationService`, the request/response and interaction models, `TelephonyProviderOptions`, `TelephonySettings`, and `TelephonyPermissions`. A provider module depends only on this package. - **`CrestApps.OrchardCore.Telephony`** contains the `TelephonyHub`, the default service and resolver implementations, the site settings, and the soft phone widget. -- A **provider module** (such as DialPad) implements `ITelephonyProvider` and registers itself as a +- A **provider module** (such as DialPad or Asterisk) implements `ITelephonyProvider` and registers itself as a selectable provider. +If you are building another provider, see [Custom Telephony and Contact Center Providers](custom-providers.md). + ## The provider contract -A telephony provider implements `ITelephonyProvider`. The interface covers the common soft phone -operations: +A telephony provider implements `ITelephonyProvider`, which declares only the provider's display name and +the operations it advertises through the `Capabilities` property (a `TelephonyCapabilities` flags value). +Every call operation lives on its own capability contract, so a provider implements exactly the operations +its backend really supports: -| Operation | Method | -| --- | --- | -| Dial | `DialAsync` | -| Hang up | `HangupAsync` | -| Hold (pause) | `HoldAsync` | -| Resume | `ResumeAsync` | -| Mute / Unmute | `MuteAsync` / `UnmuteAsync` | -| Transfer | `TransferAsync` | -| Merge calls | `MergeAsync` | -| Send DTMF digits | `SendDigitsAsync` | -| Answer / Reject inbound | `AnswerAsync` / `RejectAsync` | -| Client bootstrap | `GetClientCredentialsAsync` | - -Each provider also advertises the operations it supports through the `Capabilities` property (a -`TelephonyCapabilities` flags value). The soft phone UI uses these flags to show or hide controls. +| Operation | Capability | Contract and method | +| --- | --- | --- | +| Dial | `Dial` | `ITelephonyCallControlProvider.DialAsync` | +| Hang up | `Hangup` | `ITelephonyCallControlProvider.HangupAsync` | +| Hold (pause) | `Hold` | `ITelephonyHoldProvider.HoldAsync` | +| Resume | `Resume` | `ITelephonyHoldProvider.ResumeAsync` | +| Mute / Unmute | `Mute` | `ITelephonyMuteProvider.MuteAsync` / `UnmuteAsync` | +| Blind transfer | `Transfer` | `ITelephonyTransferProvider.TransferAsync` | +| Attended (warm) transfer | `AttendedTransfer` | `ITelephonyAttendedTransferProvider.StartAttendedTransferAsync` | +| Merge calls | `Merge` | `ITelephonyConferenceProvider.MergeAsync` | +| Send DTMF digits | `SendDigits` | `ITelephonyDtmfProvider.SendDigitsAsync` | +| Answer / Reject inbound | `ReceiveCalls` | `ITelephonyInboundCallProvider.AnswerAsync` / `RejectAsync` | +| Send to voicemail | `Voicemail` | `ITelephonyVoicemailProvider.SendToVoicemailAsync` | +| Directory lookup | `Directory` | `ITelephonyDirectoryProvider.GetDirectoryAsync` | +| Client bootstrap | — | `ITelephonySoftPhoneCredentialsProvider.GetClientCredentialsAsync` | + +The soft phone UI uses the advertised flags to show or hide controls, and `DefaultTelephonyService` repeats +the check server-side before calling the provider so a hidden or forged client command fails closed. It also +requires the provider to implement the capability's declared contract, recorded once in +`TelephonyCapabilityContracts`, so advertising a capability without implementing it and implementing a +contract without advertising it both fail closed instead of dispatching. + +Live agent audio is advertised separately through the optional `ITelephonyAudioProvider` contract. `TelephonyAudioCapabilities` distinguishes browser audio from an external device or provider-owned application, and `TelephonyAudioModeResolver` applies these rules: + +- A browser-only provider automatically uses browser audio. +- An external-device-only provider automatically leaves microphone and speaker handling outside Orchard. +- A provider that supports both must expose a provider setting and return the administrator-selected `ConfiguredAudioMode`. +- Browser audio fails closed unless the provider also names an executable browser media adapter. + +The current built-in DialPad and Asterisk Telephony providers explicitly advertise `ExternalDevice`. DialPad call control currently relies on its REST integration and provider-owned clients, while the Asterisk provider currently controls calls through ARI. Asterisk External Media is a server-side Contact Center media seam and is not an embedded browser WebRTC endpoint. Neither provider currently advertises embedded browser audio. + +Call operations can also carry an optional provider-neutral metadata bag through `CallReference` and +`TelephonyCall`. This keeps the shared contracts clean while still letting integrations exchange +routing hints or contextual data for scenarios such as voicemail routing. + +## Provider event normalization + +Modern soft-phone behavior depends on more than keypad actions. A command response only acknowledges that the provider accepted or rejected a request; it does not change the browser's call state. While a command is awaiting that acknowledgement, the widget disables its call controls and ignores duplicate submissions so repeated clicks cannot originate or mutate the same call multiple times. Provider integrations should normalize live provider events into the Contact Center voice-event pipeline so the server becomes the source of truth for: + +- call lifecycle transitions +- hold and resume +- mute and unmute +- recording lifecycle +- conference and participant-count changes + +The normalized provider contract is `ProviderVoiceEvent`. Providers that can emit richer details should populate those fields and let Contact Center project the resulting state back to the soft phone instead of trying to update the browser directly. + +When a provider also implements `ITelephonyCallStateProvider`, Telephony and Contact Center can query the provider's current server truth for an individual call during reconnect, page restoration, authoritative offer accept, tenant-startup recovery, provider-listener reconnect, and periodic reconciliation. If a persisted in-progress Telephony interaction no longer exists at the provider, Telephony deletes the orphaned active record and sends a disconnected state to connected clients. It performs the same cleanup when the interaction's provider is no longer registered or enabled, because the stale call can no longer be controlled and must not block the agent after a provider configuration change. A transient lookup failure does not silently delete the record because the provider did not authoritatively confirm that the call is gone. + +The built-in Asterisk provider now also keeps a tenant-scoped ARI event-stream listener open inside the Orchard shell lifecycle. Server-side Asterisk hangups, hold changes, and other mapped channel events are normalized on the server, written back to persisted telephony history, and pushed through the Telephony hub so a plain soft-phone call does not stay stuck on a stale client-side state after the PBX changes it externally. ## SignalR hub @@ -71,10 +108,7 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro } ``` -Every hub method runs in its own Orchard Core shell scope and is authorized against the -`Use the telephony soft phone` permission. The hub returns a `TelephonyResult` to the caller and -pushes `CallStateChanged`, `IncomingCall`, and `ReceiveError` events to the connected client through -the strongly-typed `ITelephonyClient` interface. +Every hub method runs in its own Orchard Core shell scope and is authorized against the `Use the telephony soft phone` permission. Authorized connections join a user destination qualified by the immutable Orchard shell name, and every server-side incoming-call or call-state projection uses that tenant-qualified destination. This remains required when a multi-node deployment uses a shared SignalR backplane because Orchard user identifiers are not globally unique across tenants. Call-control methods return a `TelephonyResult` that acknowledges the command but do not optimistically push its returned call as authoritative state. The hub exposes `GetActiveCall` for compatibility and `GetActiveCalls` for provider-authoritative multi-call restoration and recovery, while provider event projections push `CallStateChanged`, `IncomingCall` (with its contextual cards), and `ReceiveError` events through the strongly typed `ITelephonyClient` interface. It also exposes `Answer`, `Reject`, and `Voicemail` operations for a ringing inbound call. ## Site settings @@ -97,48 +131,128 @@ Enable the **Telephony Soft Phone** feature (`CrestApps.OrchardCore.Telephony.SoftPhone`) to inject a floating soft phone into the site. Configure where it appears on the **Soft Phone** tab of the telephony settings: -- **Show the soft phone on the admin dashboard** displays the widget on admin pages and is enabled by default. +- **Show the soft phone on the admin dashboard** displays the widget on admin pages. This is enabled + by default. - **Show the soft phone on the front end** displays the widget on the website. - **Recent calls to display** controls how many interactions appear on the **Recent** tab (default `30`, maximum `200`). - **Accent color** controls the widget's button and control colors. +- **Recent calls count** controls how many calls the **Recent** tab loads. The default is `30`, and administrators can select a value from `1` through `200`. You can enable the soft phone on the admin, the front end, or both. The widget is rendered when its surface is enabled and the current user has the `Use the telephony soft phone` permission, so the soft phone only appears for authorized users. +Modules can contribute display-driver tabs and views to the widget by registering a +`DisplayDriver`. Contact Center uses this extension point to add a **Work** tab for +agent queue/campaign sign-in, sign-out, and presence controls when the current user can sign in to +Contact Center work. + ### Moving and persisting the widget -The soft phone is draggable by its header. Its position and open/closed state are saved to the -browser's `localStorage`, so the widget reappears exactly where you left it after a page reload — and -it is restored before the first paint, so there is no flash or jump as the page loads. You can drag -the widget anywhere on the screen, including all the way to the right edge and on top of other -widgets such as the AI chat widget. By **default**, when the AI chat widget is also present, the soft -phone automatically offsets itself so the two widgets sit side by side instead of overlapping. +The soft phone is draggable by its header. Its position, open/closed state, and the selected footer tab are saved to the browser's `localStorage`, so the widget reappears exactly where you left it after a page reload — and it is restored before the first paint, so there is no flash or jump as the page loads. You can drag the widget anywhere on the screen, including all the way to the right edge and on top of other widgets such as the AI chat widget. By **default**, when the AI chat widget is also present, the soft phone automatically offsets itself so the two widgets sit side by side instead of overlapping. ### Status and call controls -The widget reflects the live connection status reported by the hub and only enables the dial pad and -call controls when the provider is **available, connected, and authenticated**: +The widget reflects the live connection status reported by the hub and only enables the dial pad and call controls when the provider is **available, connected, and authenticated**: + +- When no provider is enabled, the widget shows a compact warning at the top of the panel that explains how to fix the setup: enable at least one provider and set the default phone provider in site settings. The warning is shown only after the hub resolves the real provider status, so a configured tenant does not flash a false **No provider is configured** warning during page load. The keypad and call buttons stay hidden, and the warning does not stretch to fill the widget. +- When the provider requires a per-user connection, the widget shows the **Connect to provider** button (see [Authenticating users with a provider](#authenticating-users-with-a-provider)). +- During an active call the main floating toggle keeps its normal accent color and phone icon; hang-up remains on the keypad itself. The widget exposes hold/resume and transfer when the provider supports them, and only exposes mute/unmute after the selected call has reached the **Connected** state. End-user error messages stay provider-neutral even when the active provider logs provider-specific details on the server. + +### Browser audio adapters + +When the active provider resolves to `TelephonyAudioMode.Browser`, the soft phone lazily requests microphone permission immediately before the first dial or answer action. It obtains provider bootstrap credentials through the authorized Telephony hub, initializes the provider's registered browser media adapter, supplies the local microphone stream, and provides a remote `