From 8a57944c1d978280f905b156bca6eb4eb7114752 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 28 Jun 2026 20:37:07 -0700 Subject: [PATCH 01/56] Add Contact Center module --- .github/contact-center/PLAN.md | 1379 +++++++++++++++++ .github/copilot-instructions.md | 14 + CrestApps.OrchardCore.slnx | 3 + .../ContactCenterConstants.cs | 131 ++ ...hardCore.ContactCenter.Abstractions.csproj | 20 + .../Models/InteractionChannel.cs | 27 + .../Models/InteractionDirection.cs | 17 + .../Models/InteractionParticipantRole.cs | 32 + .../Models/InteractionPriority.cs | 32 + .../Models/InteractionStatus.cs | 47 + .../ContactCenterPermissions.cs | 24 + ...Apps.OrchardCore.ContactCenter.Core.csproj | 30 + .../Indexes/InteractionEventIndex.cs | 49 + .../Indexes/InteractionIndex.cs | 75 + .../Models/Interaction.cs | 147 ++ .../Models/InteractionCallLeg.cs | 42 + .../Models/InteractionEvent.cs | 103 ++ .../Models/InteractionParticipant.cs | 39 + .../Models/InteractionQueueHistoryEntry.cs | 27 + .../Models/InteractionTransferHistoryEntry.cs | 37 + .../DefaultContactCenterEventPublisher.cs | 97 ++ .../Services/IContactCenterEventHandler.cs | 17 + .../Services/IContactCenterEventPublisher.cs | 17 + .../Services/IInteractionEventStore.cs | 32 + .../Services/IInteractionManager.cs | 65 + .../Services/IInteractionStore.cs | 59 + .../Services/InteractionEventStore.cs | 57 + .../Services/InteractionManager.cs | 103 ++ .../Services/InteractionStore.cs | 92 ++ .../Indexes/OmnichannelActivityBatchIndex.cs | 5 + .../Indexes/OmnichannelActivityIndex.cs | 35 + .../Models/ActivityAssignmentStatus.cs | 37 + .../Models/ActivityBatchSourceOptions.cs | 76 + .../Models/ActivityDispositionRequest.cs | 37 + .../Models/ActivityDispositionResult.cs | 50 + .../Models/ActivityDispositionSource.cs | 33 + .../Models/ActivityKind.cs | 32 + .../Models/ActivitySources.cs | 57 + .../Models/OmnichannelActivity.cs | 48 + .../Models/OmnichannelActivityBatch.cs | 6 + .../Services/IActivityDispositionService.cs | 19 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 13 + .../docs/contact-center/index.md | 110 ++ .../docs/omnichannel/management.md | 13 +- src/CrestApps.Docs/sidebars.js | 7 + ...CrestApps.OrchardCore.ContactCenter.csproj | 34 + .../Indexes/InteractionEventIndexProvider.cs | 37 + .../Indexes/InteractionIndexProvider.cs | 42 + .../Manifest.cs | 23 + .../InteractionEventIndexMigrations.cs | 46 + .../Migrations/InteractionIndexMigrations.cs | 56 + .../ContactCenterPermissionProvider.cs | 33 + .../Startup.cs | 38 + .../Controllers/ActivityBatchesController.cs | 113 +- .../OmnichannelActivityBatchDisplayDriver.cs | 36 +- .../OmnichannelActivityBatchIndexProvider.cs | 1 + .../OmnichannelActivityIndexProvider.cs | 7 + ...OmnichannelActivityBatchIndexMigrations.cs | 18 +- .../OmnichannelActivityIndexMigrations.cs | 50 +- .../Startup.cs | 17 + .../ListOmnichannelActivityBatchViewModel.cs | 15 + .../OmnichannelActivityBatchViewModel.cs | 17 + .../Views/ActivityBatches/Index.cshtml | 38 +- ...ivityBatch.DefaultMeta.SummaryAdmin.cshtml | 4 + ...OmnichannelActivityBatchFields.Edit.cshtml | 77 +- ...OmnichannelActivityBatchSource.Link.cshtml | 37 + ...stApps.OrchardCore.Cms.Core.Targets.csproj | 1 + .../CrestApps.OrchardCore.Tests.csproj | 2 + ...DefaultContactCenterEventPublisherTests.cs | 159 ++ .../ContactCenter/InteractionEntityTests.cs | 32 + .../ContactCenter/InteractionEventTests.cs | 65 + .../ContactCenter/InteractionManagerTests.cs | 80 + 72 files changed, 4416 insertions(+), 54 deletions(-) create mode 100644 .github/contact-center/PLAN.md create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions.csproj create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionChannel.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionDirection.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionParticipantRole.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionPriority.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionStatus.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionEventIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionCallLeg.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionParticipant.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionQueueHistoryEntry.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionTransferHistoryEntry.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventHandler.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventPublisher.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityAssignmentStatus.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionRequest.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionResult.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionSource.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityKind.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivitySources.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityDispositionService.cs create mode 100644 src/CrestApps.Docs/docs/contact-center/index.md create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionEventIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionEventIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/ListOmnichannelActivityBatchViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchSource.Link.cshtml create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEntityTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEventTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md new file mode 100644 index 000000000..e901648f7 --- /dev/null +++ b/.github/contact-center/PLAN.md @@ -0,0 +1,1379 @@ +# Contact Center Module Architecture and Implementation Plan + +> **Status:** Active. This is the durable, repository-tracked design and progress document for the Contact Center module set. It is referenced from `.github/copilot-instructions.md` so every AI session reviews it before doing Contact Center work. +> +> **How to use this document:** +> +> - Read the **Progress status** section (bottom) first to see what is done and what is next. +> - Treat the **Phased delivery plan** as the source of truth for scope and ordering. Start at the lowest incomplete phase. +> - Keep the **Progress status** section current after each meaningful change (what shipped, what is in progress, decisions made). +> - Never write competitor product names in code, comments, public docs, or identifiers. Adopt only the industry-standard concepts and terminology captured in the **Standard contact center terminology and metrics** section. +> - 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. + +## Problem statement + +Design an enterprise-grade Contact Center orchestration layer for the existing Orchard Core communications platform. The Contact Center must extend Omnichannel Management instead of introducing a separate work model, sit between CRM and Telephony, own routing and communication orchestration, and allow agents and supervisors to operate directly inside the CRM UI without depending on an external contact center system. + +The design is intentionally domain- and architecture-focused. It does not include code or low-level implementation details. + +## Current codebase baseline + +### Existing Telephony boundary + +- `src\Abstractions\CrestApps.OrchardCore.Telephony.Abstractions` defines provider-agnostic soft-phone abstractions such as provider, service, client callbacks, call requests, call references, capabilities, calls, call state, direction, and persisted telephony interactions. +- `src\Modules\CrestApps.OrchardCore.Telephony` registers the Telephony feature, the soft-phone feature, the SignalR hub, provider resolver, call-control service, authentication services, settings, permissions, and persisted call history. +- `TelephonyHub` exposes user-initiated call-control operations and pushes call state changes to the current soft-phone client through SignalR. +- `ITelephonyService` delegates dial, answer, reject, hangup, hold, resume, mute, transfer, merge, send digits, credentials, and provider capabilities to the configured provider. +- `TelephonyInteraction` stores provider-independent call history for the soft phone, but this is currently a call-centric history model, not a business interaction orchestration model. +- `src\Modules\CrestApps.OrchardCore.DialPad` is one provider module that implements the Telephony provider boundary. + +Design implication: Telephony must remain the media and provider execution layer. Contact Center must not own media, provider authentication, or provider-specific call execution. + +### Existing Omnichannel and CRM boundary + +- `src\Modules\CrestApps.OrchardCore.Omnichannel` is the base omnichannel layer and configures the shared `Omnichannel` YesSql collection. +- `src\Core\CrestApps.OrchardCore.Omnichannel.Core` owns shared CRM communication models: + - contacts via `OmnichannelContactPart` + - activities via `OmnichannelActivity` + - activity batches via `OmnichannelActivityBatch` + - campaigns via `OmnichannelCampaign` + - dispositions via `OmnichannelDisposition` + - channel endpoints via `OmnichannelChannelEndpoint` + - subject flow settings via `SubjectFlowSettings` + - subject actions via `SubjectAction` + - messages and inbound omnichannel events +- `OmnichannelActivity` is the CRM task/work item and remains the universal unit of work for Contact Center. It stores channel, endpoint, manual or automated interaction type, AI session, contact, campaign, schedule, assignee, completion, disposition, subject content type, subject payload, urgency, and status. +- Current activity statuses are task-oriented and narrow: not started, awaiting agent response, awaiting customer answer, completed, and purged. +- `src\Modules\CrestApps.OrchardCore.Omnichannel.Managements` provides Interaction Center CRM screens for contacts, subject flows, campaigns, dispositions, channel endpoints, activities, batches, bulk activity management, and subject actions. +- Subject-driven behavior currently lives in Subject Flows and Subject Actions. The changelog states that the OrchardCore.Workflows dependency and previous workflow activities/events were removed from Omnichannel Management. +- `AutomatedActivitiesProcessorBackgroundTask` processes scheduled automated activities every five minutes and dispatches them to channel-specific `IOmnichannelProcessor` implementations. +- `src\Modules\CrestApps.OrchardCore.Omnichannel.Sms` provides automated SMS activity processing and inbound SMS event handling, including AI chat session integration and disposition/action completion. + +Design implication: CRM remains the system of record for contacts, activities, campaigns, subjects, dispositions, subject actions, and the CRM timeline. Contact Center must not introduce a second work-item/task model. It extends activities with assignment/reservation and classification metadata, uses activities as queue and dialer inventory, records one or more `Interaction` history records for each activity, and routes all disposition changes through the CRM activity-disposition path. + +Required CRM alignment: + +- Contacts are customer records only. Dialers and queues never operate directly on contacts. +- Subjects are workflow/disposition definitions only. They provide scripts, subject actions, rules, and automation behavior for activities. +- Activities are the only business work items. They may be created with `AssignedToId = null` so dialers and routing can dynamically reserve and assign ownership later. +- Activities need Contact Center assignment metadata (for example assignment status, reservation id, reserved-by actor, reservation timestamps, and reservation expiry) so multiple dialer or routing instances cannot claim the same work. +- Activities need classification metadata such as kind (call, email, SMS, meeting, task) and source (manual, preview dial, power dial, progressive dial, predictive dial, callback, inbound, workflow, API). Workflows must ignore source and react only to the activity and final disposition. +- Activity batches select an activity source before showing the editor. Manual batches require user assignment while loading; dialer batches hide user selection and load unassigned activities with an available assignment status so the dialer can reserve and assign them later. +- Dispositions belong to activities, not interactions. Provider, agent, AI, and workflow outcomes must converge through a single activity disposition service before subject actions/workflows run. + +### Existing real-time boundary + +- `src\Modules\CrestApps.OrchardCore.SignalR` provides the shared SignalR feature and hub registration pattern. +- Telephony already uses SignalR for soft-phone call-control requests and current-user call state updates. + +Design implication: Contact Center should add its own real-time event stream for agent desktop, supervisor dashboard, and queue monitors, and it should consume or normalize Telephony events instead of overloading TelephonyHub with routing responsibilities. + +### Current gaps to solve + +- No communication-history Interaction domain object linked to CRM activities across voice sessions, future channels, routing, and analytics. +- CRM activities need nullable ownership, assignment/reservation metadata, and source/kind classification so dialers can work unassigned inventory safely. +- No queue model, queue membership, agent reservation, or routing decision model. +- No real-time agent presence or capacity model. +- No inbound routing orchestration. +- No outbound dialer orchestration beyond manual CRM activities and automated SMS processing. +- No contact-center-level call session mapping between provider calls and business interactions. +- No supervisor dashboard or live queue metric projection. +- No durable domain event stream/outbox for contact center orchestration. +- Existing OrchardCore.Workflows integration for Omnichannel Management has been removed; Contact Center needs an explicit workflow strategy. +- Existing CRM Activity statuses and manual/automated interaction types are too limited for contact center lifecycle management. + +## Design principles + +1. Activity first for work: `OmnichannelActivity` is the universal CRM work item for queues, dialers, wrap-up, dispositions, and workflows. +2. Interaction first for communication history: every voice call, chat, SMS, email, or future channel attempt is an Interaction linked to an Activity; an Interaction never owns workflow or disposition. +3. Source-driven activity loading: Activity batches are the common activity-loading surface and support source-specific UI/behavior for manual, dialer, callback, inbound, API, and future sources through Orchard display drivers and source options. +4. Contact Center owns orchestration: routing, queues, reservations, presence, dialer pacing, wrap-up, lifecycle, and operational metrics. +5. Telephony owns media execution: providers dial, answer, transfer, hang up, hold, resume, conference, and report provider call state. +6. Domain events connect components: components publish and subscribe to events instead of directly invoking each other’s internal workflows. +7. Feature-gated modularity: capabilities should be split into Orchard Core features so tenants can enable only the contact center capabilities they need. +8. Tenant isolation by default: all state, events, real-time groups, settings, permissions, and analytics projections are tenant-scoped. +9. Channel-agnostic core: voice is the MVP channel, but the same interaction, routing, queue, SLA, presence, and wrap-up concepts must support chat, SMS, email, and AI agents later. +10. Industry-standard naming: use generic contact center terms in code and public docs. Do not name implementation artifacts after competing products. +11. Durable resumability: keep a persistent project plan/progress document in the repository and reference it from `.github\copilot-instructions.md` so future AI sessions review it before changing Contact Center code. + +## Proposed module and feature breakdown + +### Shared abstractions and core services + +1. `CrestApps.OrchardCore.ContactCenter.Abstractions` + - Shared contracts and domain vocabulary for interactions, events, channel adapters, routing strategies, dialer strategies, real-time notifications, permissions, and feature constants. + - Depends only on stable abstractions needed by providers and optional channel adapters. + +2. `CrestApps.OrchardCore.ContactCenter.Core` + - Domain models, stores, managers, event dispatcher contracts, projections, and reusable policies that are not themselves Orchard modules. + - Uses tenant-local persistence patterns consistent with Omnichannel Core. + +### Orchard modules and features + +1. `CrestApps.OrchardCore.ContactCenter` + - Base Contact Center module and dependency root. + - Core feature: interaction management, event log, tenant settings, baseline permissions, and admin navigation. + +2. `CrestApps.OrchardCore.ContactCenter.Queues` + - Queues, queue membership, queue priorities, overflow rules, SLA settings, queue metrics, and queue monitor surfaces. + +3. `CrestApps.OrchardCore.ContactCenter.Routing` + - Routing engine, routing policies, routing strategies, reservation engine, business hours, skills, sticky-agent logic, and routing decision audit. + +4. `CrestApps.OrchardCore.ContactCenter.Agents` + - Agent profiles, presence, capacity, skill profiles, queue membership, agent reservations, and agent desktop state. + +5. `CrestApps.OrchardCore.ContactCenter.Voice` + - Voice channel adapter that integrates Contact Center with the Telephony module. + - Owns call session mapping and Contact Center voice lifecycle projection. + - Depends on Telephony but does not replace Telephony. + +6. `CrestApps.OrchardCore.ContactCenter.Dialer` + - Outbound campaign dialing modes: manual, preview, power, progressive, and later predictive. + - Pacing, retry, agent reservation before dialing, callback scheduling, DNC/compliance checks, and activity/campaign integration. + +7. `CrestApps.OrchardCore.ContactCenter.WrapUp` + - Wrap-up timers, required disposition rules, disposition validation, post-interaction completion, and CRM activity updates. + +8. `CrestApps.OrchardCore.ContactCenter.Supervision` + - Supervisor live monitoring, agent monitoring, queue controls, coaching/assist metadata, SLA alerts, and operational command permissions. + - Live call-control primitives: silent monitor, whisper coaching, barge-in, and take-over, expressed as orchestration intents that Telephony/providers execute. + +9. `CrestApps.OrchardCore.ContactCenter.EntryPoints` + - Inbound entry points, DID/number-to-entry-point mapping, IVR/self-service decision flows, business-hours/holiday gating, queue selection, announcements, and screen-pop context. + - Reuses subject flows and the optional Workflows bridge for decision logic instead of hardcoding IVR trees. + +10. `CrestApps.OrchardCore.ContactCenter.Recording` + - Recording orchestration: start/stop/pause/resume intents, consent capture, recording metadata, retention/disposal policy, and access auditing. + - Stores recording metadata and references only; media capture and storage stay with Telephony/providers or a configured media store. + +11. `CrestApps.OrchardCore.ContactCenter.Compliance` + - Outbound calling windows, abandonment-rate caps, safe-harbor/abandon messaging, caller-ID/local-presence policy, list scrubbing/recycling, consent tracking, and suppression auditing. + - Reuses existing DNC registry and contact communication preferences rather than duplicating them. + +12. `CrestApps.OrchardCore.ContactCenter.Analytics` + - Metric projections, historical reporting, SLA snapshots, campaign performance, agent performance, queue performance, adherence, and export-ready reporting data. + +13. `CrestApps.OrchardCore.ContactCenter.Quality` + - Optional quality management: evaluation forms/scorecards, recording review, calibration, and coaching records. Advanced phase. + +14. `CrestApps.OrchardCore.ContactCenter.Workflows` + - Optional OrchardCore.Workflows bridge for tenants that want workflow activities/events in addition to Subject Flows and Subject Actions. + - Should be feature-gated because current Omnichannel Management intentionally removed its direct Workflows dependency. + +15. `CrestApps.OrchardCore.ContactCenter.AI` + - Optional AI assist, virtual agent, summarization, disposition suggestions, quality insights, and future AI routing recommendations. + +16. `CrestApps.OrchardCore.ContactCenter.Deployment` + - Recipes and deployment steps for queues, routing policies, skills, agent profiles, dialer profiles, entry points, recording/compliance policies, supervisor dashboards, and tenant defaults. + +## Domain architecture overview + +```text +CRM / Omnichannel Management +Contacts, Activities, Campaigns, Subjects, Dispositions, Subject Actions + | + | business context, activity lifecycle updates + v +Contact Center Orchestration +Activity queues/reservations, routing, presence, dialer, wrap-up, interaction history, metrics + | + | call-control intents, call/session events + v +Telephony +Soft phone, provider resolver, provider call execution, provider call state + | + v +Telephony Providers +Dial, answer, transfer, hold, resume, conference, hangup, provider webhooks +``` + +The CRM layer answers “who is the customer, what activity is the work item, what subject defines the workflow, what disposition was selected, and what subject action or automation should run.” + +The Contact Center layer answers “which activity should be worked next, which queue owns it, which agent should reserve it, when should a dial occur, what communication history exists for the activity, and what real-time event should the UI see.” + +The Telephony layer answers “how does the configured provider execute this call action and what is the provider’s current call state.” + +## Component design + +### 1. Interaction Management + +| Area | Design | +| --- | --- | +| Purpose | Own durable communication history for CRM activities without becoming a second work-item model. | +| Responsibilities | Create interactions from inbound events, outbound dialer attempts, manual agent actions, callbacks, transfers, and future channels; link each interaction to exactly one CRM activity; maintain provider identifiers, participants, call legs, queue history, transfer history, timestamps, recording/transcript references, correlation ids, and technical metadata; expose activity communication history to agent and supervisor UX. | +| Data owned | Interaction, interaction participant, provider session references, communication status, call legs, queue history, transfer history, start/answer/end timestamps, recording and transcript references, technical metadata, correlation ids, tenant id, and audit metadata. | +| Events consumed | ActivityScheduled, ActivityReserved, ActivityDialingStarted, ActivityWorkStarted, InboundChannelEventReceived, DialerAttemptRequested, CallStarted, CallAnswered, CallEnded, TransferRequested, ChannelSessionEnded. | +| Events emitted | InteractionCreated, InteractionLinkedToActivity, InteractionStarted, InteractionUpdated, InteractionTransferred, InteractionEnded, InteractionFailed. | +| Interactions | Reads the CRM activity id as the work anchor; receives Telephony call session projections; notifies Real-Time UX, Analytics, Recording, Quality, and the CRM timeline. It does not own disposition, workflow, campaign, subject, priority, or business rules. | +| Why it exists | One activity can have many communication attempts (busy, no answer, connected). Interaction provides the durable communication history for those attempts while CRM Activity remains the business work item. | + +### 2. Call Session Management + +| Area | Design | +| --- | --- | +| Purpose | Maintain Contact Center’s voice-channel projection for active and historical calls without owning media execution. | +| Responsibilities | Map provider call identifiers to interactions; normalize call states; track voice session lifecycle; handle hold, resume, transfer, consult transfer, blind transfer, conference, and disconnect events; correlate provider sessions with CRM/contact center state; preserve provider metadata for troubleshooting. | +| Data owned | Call session, provider session id, provider name, interaction id, direction, from/to addresses, current normalized state, hold state, conference membership, transfer chain, start/answer/end timestamps, call duration, talk duration, hold duration, queue wait duration, and provider metadata. | +| Events consumed | CallDialRequested, TelephonyCallStateChanged, IncomingCallReceived, CallAnswered, CallRejected, CallHeld, CallResumed, CallTransferRequested, CallTransferred, CallMerged, CallEnded, ProviderCallFailed. | +| Events emitted | CallSessionCreated, CallStarted, CallRinging, CallAnswered, CallHeld, CallResumed, CallTransferStarted, CallTransferred, ConferenceStarted, CallEnded, CallSessionClosed. | +| Interactions | Receives call-control results and provider call events from Telephony; updates Interaction Management; informs Wrap-Up when voice work ends; feeds Analytics and Real-Time UX. | +| Why it exists | Telephony call state is provider/media truth, but Contact Center needs a business-oriented call session projection tied to interactions, queues, agents, and CRM activities. | + +Call state lifecycle: + +```text +Planned -> Dialing -> Ringing -> Connected -> OnHold -> Connected -> Ending -> Ended + \ \ \ \ \ + \ \ \ \ Failed + \ \ \ Transferred + \ NoAnswer Rejected + Canceled +``` + +### 3. Queue Management + +| Area | Design | +| --- | --- | +| Purpose | Hold and prioritize work waiting for agents across inbound, outbound, callback, and future channels. | +| Responsibilities | Define queues; enqueue/dequeue interactions; maintain priorities; enforce queue eligibility; track wait time and SLA; apply overflow and escalation rules; manage queue memberships; expose live queue metrics; support reservation locks. | +| Data owned | Queue, queue type, queue membership, queue priority rules, overflow rules, SLA thresholds, queue item, queue item status, queue item age, reservation lock, and queue metric projection. | +| Events consumed | InteractionQueued, AgentPresenceChanged, AgentCapacityChanged, RoutingDecisionFailed, ReservationExpired, InteractionRequeued, QueueOverflowTriggered, BusinessHoursChanged. | +| Events emitted | QueueItemAdded, QueueItemUpdated, QueueItemReserved, QueueItemDequeued, QueueItemOverflowed, QueueSlaWarningRaised, QueueSlaBreached, QueueMetricsUpdated. | +| Interactions | Receives interactions from Interaction Management; provides eligible work to Routing Engine; uses Agent & Presence for available capacity; pushes live metrics to Real-Time UX and Supervisor layers. | +| Why it exists | Queues are the operational inventory of the contact center. They decouple work arrival from assignment and make SLA, priority, and overflow behavior explicit. | + +Queue types: + +- Static queues: explicitly configured work queues. +- Dynamic queues: rule-based queues resolved from subject, contact, campaign, channel, priority, geography, language, or custom metadata. +- Campaign-based queues: outbound work grouped by CRM campaign and dialer profile. +- Callback queues: scheduled customer callback work with due windows and customer preference constraints. + +Agent reservation model: + +- Routing creates a short-lived reservation before assignment is finalized. +- A reserved agent is temporarily removed from matching capacity. +- Reservation can be accepted, rejected, expired, canceled, or converted to assignment. +- Reservation events are durable and visible in supervisor/audit views. + +### 4. Routing Engine + +| Area | Design | +| --- | --- | +| Purpose | Make auditable, policy-driven decisions about which agent, queue, workflow branch, or overflow path should handle an interaction. | +| Responsibilities | Evaluate routing policies; match skills and queue membership; calculate priority; enforce business hours; apply sticky-agent preference; choose routing strategy; reserve an agent; publish decision results; explain routing outcomes for audit and supervisor troubleshooting. | +| Data owned | Routing policy, routing rule, skill requirement, strategy settings, routing decision, decision reason, routing attempt, route score, escalation path, and routing audit trail. | +| Events consumed | InteractionQueued, RoutingRequested, QueueItemAdded, AgentPresenceChanged, AgentCapacityChanged, ReservationExpired, BusinessHoursChanged, WorkflowRoutingDecisionReturned. | +| Events emitted | RoutingStarted, RoutingDecisionMade, RoutingDecisionFailed, AgentReserved, InteractionRouted, InteractionRequeued, OverflowRequested. | +| Interactions | Reads queues, agent presence, skills, capacity, CRM activity metadata, subject flow settings, and business hours; calls Workflow Integration only through a feature boundary; writes decisions back to Interaction Management and Queue Management. | +| Why it exists | Routing logic must be centralized and auditable. It cannot live in Telephony, CRM screens, or provider-specific code. | + +Routing methods: + +- Skills-based: match required skills and proficiency levels. +- Priority: rank by urgency, campaign priority, SLA age, customer tier, callback due time, and manual overrides. +- Sticky agent: prefer the last successful agent, account owner, or assigned CRM user when available and allowed. +- Round robin: fair distribution inside a queue or skill pool. +- Least busy: prefer agents with the lowest active capacity usage. +- Longest idle: prefer the available agent idle for the longest time. +- Business hours: route, defer, callback, overflow, or play after-hours behavior based on queue calendars. +- Workflow-based: optional workflows can return route target, priority, required skills, IVR-like branch, or callback intent. + +Decision execution: + +```text +InteractionQueued + -> RoutingStarted + -> Candidate queues and agents resolved + -> Rules, skills, priority, business hours, and workflow decisions evaluated + -> Best candidate selected + -> Agent reservation created + -> Reservation accepted or expired + -> InteractionAssigned or InteractionRequeued +``` + +### 5. Agent & Presence Management + +| Area | Design | +| --- | --- | +| Purpose | Track agent availability, capacity, queue membership, skills, and real-time state. | +| Responsibilities | Maintain agent profile; publish presence changes; calculate channel capacity; track current interactions; enforce wrap-up and break states; provide availability to routing; drive agent desktop state. | +| Data owned | Agent profile, queue membership, skill profile, presence state, capacity profile, active capacity usage, current reservation, current interactions, last activity timestamp, supervisor/team assignment. | +| Events consumed | AgentSignedIn, AgentSignedOut, PresenceSetRequested, InteractionAssigned, InteractionAccepted, InteractionStarted, CallAnswered, CallEnded, WrapUpStarted, WrapUpCompleted, ReservationExpired, BreakStarted, BreakEnded. | +| Events emitted | AgentPresenceChanged, AgentCapacityChanged, AgentReserved, AgentReleased, AgentStateChanged, AgentSkillUpdated, AgentQueueMembershipChanged. | +| Interactions | Provides candidate availability to Routing Engine; receives session events from Interaction and Call Session Management; pushes state to Real-Time UX and Supervisor layer. | +| Why it exists | Contact centers depend on accurate live agent state. Routing, dialer pacing, supervisor monitoring, and agent UX all require a single tenant-scoped presence model. | + +Presence states: + +- Offline +- Available +- Busy +- Reserved +- Ringing +- On interaction +- On hold +- Wrap-up +- Break +- Training +- Meeting +- Away +- Do not disturb +- After-hours unavailable + +Capacity model: + +- Voice normally consumes full voice capacity. +- Future channels can use weighted capacity, such as multiple chats or SMS threads. +- Capacity is per channel and per agent profile. +- Routing must reserve capacity before offering work. + +### 6. Campaign Dialer + +| Area | Design | +| --- | --- | +| Purpose | Orchestrate outbound work from CRM activities while respecting agent capacity, pacing, retries, compliance, and customer preferences. | +| Responsibilities | Select eligible unassigned or available activities; reserve activities and agents before dialing when required; assign owner only after reservation/acceptance or answer-prediction rules allow it; choose dialing mode; enforce pacing; apply retry rules; schedule callbacks; update CRM activity assignment status; create interaction communication-history records for each attempt; request Telephony dial actions through the voice adapter. | +| Data owned | Dialer profile, dialing mode, dialer run, dialer attempt, pacing settings, retry policy, callback policy, compliance checks, suppression results, campaign queue state, and dialer metrics. | +| Events consumed | DialerRunStarted, AgentCapacityChanged, QueueMetricsUpdated, ActivityEligibleForDialing, DialerAttemptCompleted, CallAnswered, CallEnded, DispositionSelected, CallbackScheduled. | +| Events emitted | DialerAttemptScheduled, AgentReservedForDial, DialerAttemptStarted, OutboundDialRequested, DialerAttemptConnected, DialerAttemptFailed, DialerAttemptNoAnswer, DialerAttemptCompleted, DialerRunPaused, DialerRunCompleted. | +| Interactions | Reads CRM activities first, then resolves contact, subject flow, endpoint, DNC/compliance flags, and time zone from that activity context; creates outbound interaction history records per attempt; reserves agents through Routing; asks Telephony through Contact Center Voice to dial. Dialers never operate directly on contacts. | +| Why it exists | Outbound dialing is orchestration-heavy and should not be embedded in CRM activity screens or Telephony providers. | + +Dialing modes: + +- Manual: agent chooses and places the call from CRM UI. +- Preview: agent reviews contact/activity first, then accepts or skips before dialing. +- Power: system reserves agents and dials a controlled number of calls per available agent. +- Progressive: system automatically dials when an agent becomes available, one call per reserved agent. +- Predictive: future advanced mode that forecasts answer rates and agent availability to dial ahead safely. + +MVP dialer modes: + +- Manual and preview first. +- Power dialer second. +- Progressive after stable routing and reservations. +- Predictive only after reliable historical metrics and abandonment controls exist. + +### 7. Disposition & Wrap-Up Management + +| Area | Design | +| --- | --- | +| Purpose | Govern post-communication activity completion, required outcomes, timers, notes, CRM activity updates, and follow-up automation. | +| Responsibilities | Start wrap-up when interaction work ends; enforce required activity disposition rules by queue/subject/campaign; track wrap-up duration; save notes and outcome; update CRM activity through `IActivityDispositionService`; trigger subject actions and optional workflows; release agent capacity when wrap-up completes or times out. | +| Data owned | Wrap-up session, wrap-up start/end, required disposition policy, disposition source, notes, completion state, timer policy, auto-close policy, and validation results. | +| Events consumed | CallEnded, ChannelSessionEnded, InteractionWorkCompleted, DispositionSelected, WrapUpTimerExpired, AgentSubmittedWrapUp. | +| Events emitted | WrapUpStarted, DispositionRequired, DispositionSelected, WrapUpCompleted, ActivityCompleted, SubjectActionsRequested, PostInteractionWorkflowRequested, AgentReleased. | +| Interactions | Updates CRM Activity and Subject data; executes existing Subject Actions; optionally emits OrchardCore workflow events; informs Agent Presence and Analytics. It never dispositions an Interaction. | +| Why it exists | Contact center work is not complete when the call ends. Wrap-up ensures business outcomes are captured consistently through the Activity and agents are released at the correct time. | + +Activity disposition service: + +- `IActivityDispositionService` is the only path for agent, provider, AI, workflow, dialer, and system outcomes to modify activity disposition or completion state. +- It validates the disposition against the activity's Subject and configured Subject Actions. +- It updates `OmnichannelActivity`, records audit metadata, publishes Contact Center/CRM domain events, and triggers existing Subject Actions or optional Workflow bridge behavior. +- Telephony, provider adapters, soft-phone UI, and dialers must not update `OmnichannelActivity.DispositionId`, completion fields, or workflow outcomes directly. +- Workflow execution remains source-agnostic: workflows see the final Activity + Disposition, not whether the outcome came from an agent, provider, AI, or system process. + +### 8. Event-Driven Architecture + +| Area | Design | +| --- | --- | +| Purpose | Decouple Contact Center components while preserving an auditable lifecycle history. | +| Responsibilities | Define domain events; publish events after state transitions; maintain tenant-local event log; support durable outbox processing; create read models/projections; stream selected events to SignalR; provide correlation and replay for debugging. | +| Data owned | Domain event envelope, event payload, event version, aggregate id, interaction id, tenant id, correlation id, causation id, actor, source component, timestamp, dispatch status, and projection checkpoints. | +| Events consumed | All Contact Center domain events and selected Telephony/Omnichannel events. | +| Events emitted | ProjectionUpdated, RealTimeNotificationRequested, EventDispatchFailed, EventDispatchRetried. | +| Interactions | Used by every component as the communication mechanism; avoids direct coupling between routing, queues, presence, dialer, wrap-up, analytics, and UX. | +| Why it exists | Contact center lifecycle is multi-step, asynchronous, and auditable. Events make that lifecycle reliable and observable. | + +Core domain events: + +- InteractionCreated +- InteractionLinkedToActivity +- InteractionQueued +- InteractionRouted +- InteractionAssigned +- InteractionAccepted +- InteractionRejected +- InteractionRequeued +- InteractionStarted +- InteractionTransferred +- InteractionCompleted +- InteractionAbandoned +- QueueItemAdded +- QueueItemReserved +- QueueItemDequeued +- QueueMetricsUpdated +- RoutingStarted +- RoutingDecisionMade +- RoutingDecisionFailed +- AgentPresenceChanged +- AgentCapacityChanged +- AgentReserved +- AgentReleased +- CallSessionCreated +- CallStarted +- CallRinging +- CallAnswered +- CallHeld +- CallResumed +- CallTransferRequested +- CallTransferred +- ConferenceStarted +- CallEnded +- DialerRunStarted +- DialerAttemptScheduled +- DialerAttemptStarted +- DialerAttemptCompleted +- CallbackScheduled +- WrapUpStarted +- DispositionRequired +- DispositionSelected +- WrapUpCompleted +- ActivityCompleted +- WorkflowRequested +- WorkflowCompleted +- SlaWarningRaised +- SlaBreached + +Event envelope requirements: + +- Tenant id +- Event id +- Event type +- Schema version +- Aggregate type and id +- Interaction id when available +- Correlation id +- Causation id +- Actor user id or system actor +- Source component +- UTC timestamp +- Idempotency key for external/provider-originated events + +### 9. Provider Abstraction Boundary + +| Area | Design | +| --- | --- | +| Purpose | Preserve a clean separation between Contact Center business orchestration and Telephony provider/media execution. | +| Responsibilities | Define which layer owns routing, call actions, call state, provider metadata, and business interaction state. | +| Data owned | Boundary contracts, channel adapter mappings, provider call references, and normalized channel session metadata. | +| Events consumed | RoutingDecisionMade, OutboundDialRequested, AgentAcceptedInteraction, TelephonyCallStateChanged, ProviderWebhookReceived. | +| Events emitted | CallControlRequested, CallControlAccepted, CallControlFailed, ChannelSessionEventReceived, ProviderEventNormalized. | +| Interactions | Contact Center Voice talks to Telephony through provider-agnostic Telephony services. Telephony providers never read Contact Center queues or routing policies. | +| Why it exists | This prevents business logic from leaking into providers and prevents Telephony from becoming an orchestration engine. | + +Ownership rules: + +| Responsibility | Owner | +| --- | --- | +| Routing decisions | Contact Center | +| Queue selection | Contact Center | +| Agent reservation | Contact Center | +| Agent presence/capacity | Contact Center | +| CRM activity lifecycle | CRM plus Contact Center orchestration | +| Call-control request intent | Contact Center or agent UI | +| Provider call execution | Telephony | +| Provider authentication | Telephony | +| Provider/media state truth | Telephony/provider | +| Business interaction state truth | Contact Center | +| Call session business projection | Contact Center Voice | +| Persistent call history currently used by soft phone | Telephony | +| Enterprise interaction history and analytics | Contact Center | + +### 10. Workflow Integration + +| Area | Design | +| --- | --- | +| Purpose | Allow tenant-specific business logic to participate in routing and lifecycle automation without hardcoding customer behavior into Contact Center services. | +| Responsibilities | Integrate with current Subject Flows and Subject Actions first; add optional OrchardCore.Workflows bridge for advanced tenants; support pre-call routing decisions, IVR-like branching, post-call automation, callback scheduling, and disposition-driven activity creation. | +| Data owned | Workflow trigger definitions, workflow invocation records, workflow outputs, routing workflow results, callback workflow results, and failure/audit records. | +| Events consumed | InteractionCreated, InboundInteractionReceived, RoutingStarted, CallEnded, WrapUpCompleted, DispositionSelected, CallbackRequested. | +| Events emitted | WorkflowRequested, WorkflowCompleted, WorkflowFailed, RoutingAttributesUpdated, CallbackScheduled, SubjectActionExecutionRequested. | +| Interactions | Reads and updates CRM context; returns routing attributes to Routing Engine; triggers Subject Actions through the existing Omnichannel model; emits workflow events for optional workflows. | +| Why it exists | Enterprise contact center behavior varies by tenant, queue, subject, campaign, compliance rules, and customer lifecycle. Workflow integration keeps orchestration extensible. | + +Workflow strategy: + +- MVP: use existing Subject Flows and Subject Actions as the primary CRM workflow mechanism. +- Near-term: add Contact Center lifecycle events that can execute Subject Actions at wrap-up and activity completion. +- Future optional feature: add an OrchardCore.Workflows bridge that depends on Workflows only when enabled. +- Do not reintroduce Workflows as a hard dependency of Omnichannel Management. + +Workflow use cases: + +- Pre-call routing: enrich interaction with priority, skills, queue, sticky agent, or suppression reason. +- IVR-like decisions: classify inbound voice intent, language, department, or callback preference before routing. +- Post-call automation: create follow-up activity, update contact fields, notify internal users, schedule callback, or mark DNC preference. +- Callback scheduling: convert missed/abandoned/ineligible work into scheduled callback queue items. + +### 11. Real-Time UX Model + +| Area | Design | +| --- | --- | +| Purpose | Deliver low-latency operational state to agents, supervisors, and queue monitors. | +| Responsibilities | Stream domain event projections to SignalR groups; isolate tenant/user/queue/team streams; provide current snapshots on reconnect; support event ordering and idempotent UI updates. | +| Data owned | Real-time subscription model, client group mapping, last-seen event cursor, UI projection payloads, and reconnect snapshot metadata. | +| Events consumed | InteractionUpdated, QueueMetricsUpdated, AgentPresenceChanged, AgentReserved, CallSessionUpdated, WrapUpStarted, WrapUpCompleted, SlaBreached, DialerMetricsUpdated. | +| Events emitted | AgentDesktopUpdated, SupervisorDashboardUpdated, QueueMonitorUpdated, ToastNotificationRequested, ClientSnapshotAvailable. | +| Interactions | Receives projection events from the event dispatcher; pushes to CRM agent desktop, supervisor dashboards, and queue monitor widgets; does not own domain state. | +| Why it exists | Agent and supervisor UX must reflect current contact center state without polling and without coupling screens to domain services. | + +Real-time streams: + +- Agent stream: current reservation, active interaction, call session state, wrap-up timer, next activity, disposition requirements, errors. +- Supervisor stream: agent states, active interactions, queue depths, SLA warnings, dialer state, campaign progress. +- Queue monitor stream: queue depth, oldest item age, average wait, answer rate, abandoned count, staffed agents, available agents. +- Interaction stream: full lifecycle updates for an interaction detail screen. + +SignalR grouping model: + +- Per tenant shell boundary. +- Per user for agent desktop. +- Per queue for queue monitors. +- Per supervisor team for supervisor dashboards. +- Per interaction for detail views. + +UI extensibility requirements: + +- Use Orchard Core Display Management only: shapes, display drivers, placement, templates, and shape alternates. +- CRUD screens for queues, routing policies, dialer profiles, agent profiles, entry points, and other Contact Center settings should follow the AI Profile UI pattern: controller loads a catalog entry through a manager, builds list rows with `IDisplayManager.BuildDisplayAsync(..., "SummaryAdmin")`, builds editors with `BuildEditorAsync`/`UpdateEditorAsync`, and leaves sections extensible through display drivers. +- Agent desktop and supervisor surfaces should be shape composition points, not custom rendering frameworks. Telephony can inject call controls, Contact Center can inject presence/reservation/wrap-up shapes, providers can inject provider-specific settings, and supervision/analytics modules can inject live queue metrics through placement. +- Contact Center should extend existing Omnichannel activity shapes instead of replacing them. Activity list/detail/complete screens should expose display-driver groups and placement zones for reservation state, next-work actions, interaction history, dialer controls, wrap-up, and supervisor decorations. +- New entity models that need extensibility should inherit from Orchard-compatible `Entity` infrastructure (for catalog entries this is provided through `CatalogItem`) so modules can use `entity.Put(...)` and `entity.TryGet(...)` metadata the same way AI profiles and Orchard users do. + +### 12. Supervisor & Analytics Layer + +| Area | Design | +| --- | --- | +| Purpose | Provide live operational oversight and historical performance reporting. | +| Responsibilities | Build live dashboards; aggregate metrics; alert on SLA risk; expose agent monitoring; track campaign performance; provide drill-downs into interactions, queues, and outcomes. | +| Data owned | Live queue metrics, agent state metrics, SLA snapshots, call metrics, interaction metrics, campaign metrics, dialer metrics, supervisor audit records, and historical metric projections. | +| Events consumed | InteractionCreated, InteractionQueued, InteractionAssigned, InteractionCompleted, CallAnswered, CallEnded, AgentPresenceChanged, WrapUpCompleted, DispositionSelected, QueueMetricsUpdated, DialerAttemptCompleted, SlaBreached. | +| Events emitted | SupervisorAlertRaised, MetricProjectionUpdated, SlaTrendUpdated, CampaignPerformanceUpdated. | +| Interactions | Reads event log/projections; pushes real-time updates through SignalR; supports export/reporting surfaces and future BI integration. | +| Why it exists | Supervisors need operational control and accountability. Analytics also feeds future dialer pacing, staffing, routing optimization, and AI insights. | + +Monitoring capabilities: + +- Live queue depth, oldest wait, average wait, service level, abandon rate, answer rate. +- Agent live state, active interaction, idle time, handle time, wrap-up time, occupancy, capacity utilization. +- SLA warning and breach tracking by queue, campaign, priority, and subject. +- Call metrics: talk time, hold time, queue time, ring time, transfer count, conference count, completion outcome. +- Campaign performance: attempts, connects, no answers, retries, callbacks, conversion outcomes, disposition mix. +- Dialer safety: pacing, abandonment, no-agent events, retry exhaustion, compliance suppression. + +### 13. Security & Multi-Tenancy + +| Area | Design | +| --- | --- | +| Purpose | Ensure tenant isolation, role-based access, queue-level authorization, and auditability. | +| Responsibilities | Scope data by tenant; define permissions; protect real-time groups; enforce queue membership and supervisor permissions; audit sensitive actions; isolate provider metadata; validate channel actions. | +| Data owned | Permission definitions, role stereotypes, queue access policies, supervisor scope, audit event records, and security projection metadata. | +| Events consumed | UserRoleChanged, QueueMembershipChanged, AgentProfileUpdated, SupervisorActionRequested, InteractionAccessRequested, ProviderActionRequested. | +| Events emitted | AccessDenied, AuditRecorded, SupervisorActionAudited, SecurityPolicyChanged. | +| Interactions | Works with Orchard Core roles, users, authorization, tenants, content item access, Contact Center queues, and CRM activity permissions. | +| Why it exists | Contact center data contains customer information, agent performance data, provider metadata, and operational controls. Security must be designed into every boundary. | + +Permission model: + +- Contact Center Agent: use agent desktop, update own presence, accept/reject own reservations, complete assigned interactions. +- Contact Center Supervisor: monitor assigned queues/teams, view live dashboards, assist/reassign/override within scope. +- Queue Manager: manage queues, queue membership, skills, priorities, overflow, and SLA settings. +- Dialer Manager: manage dialer profiles, start/pause campaigns, view dialer metrics, configure retry/pacing. +- Contact Center Administrator: manage all Contact Center settings and permissions. +- Auditor/Analyst: view historical interaction and metric reports without operational controls. + +Tenant isolation: + +- Tenant-local stores and indexes. +- Tenant-local event log and projections. +- Tenant-scoped SignalR hub routing. +- Tenant-scoped settings, features, recipes, and permissions. +- No cross-tenant queue, event, provider, or interaction access. + +### 14. Inbound Entry Points, IVR & Self-Service + +| Area | Design | +| --- | --- | +| Purpose | Define how inbound contacts enter the contact center, get qualified, and reach the right queue or self-service path before an agent is involved. | +| Responsibilities | Map provider numbers/DIDs to entry points; run business-hours and holiday gating; run IVR-like decision flows; collect caller intent, language, and identifiers; resolve the contact for screen pop; select the target queue, skills, and priority; offer callback or voicemail when appropriate. | +| Data owned | Entry point, number/DID mapping, IVR/self-service flow definition, prompt/announcement metadata, collected caller attributes, entry-point routing result, and self-service outcome. | +| Events consumed | InboundChannelEventReceived, ProviderInboundCallReceived, BusinessHoursChanged, WorkflowRoutingDecisionReturned, SelfServiceCompleted. | +| Events emitted | EntryPointMatched, SelfServiceStarted, SelfServiceCompleted, CallerIdentified, ScreenPopRequested, InteractionQualified, InteractionQueued, CallbackOffered, VoicemailRequested. | +| Interactions | Receives normalized inbound events from Contact Center Voice; resolves CRM contact; optionally calls subject flows or the Workflows bridge for IVR-like branching; hands the qualified interaction to Queue Management and Routing. | +| Why it exists | Inbound contact centers need a configurable front door that performs qualification, business-hours/holiday handling, screen pop, and self-service before consuming agent capacity. | + +Inbound entry behaviors: + +- Number/DID to entry-point mapping per tenant. +- Business-hours and holiday calendars with open, closed, and special-day behavior. +- IVR-like menu, intent capture, language selection, and authentication hooks. +- Screen pop: resolve the contact and surface the 360 view to the agent on answer. +- Self-service deflection, callback offer, and voicemail fallback. + +### 15. Call Recording & Compliance Recording + +| Area | Design | +| --- | --- | +| Purpose | Orchestrate recording lifecycle and recording governance without owning media capture. | +| Responsibilities | Emit start/stop/pause/resume recording intents; capture consent state; enforce pause/resume around sensitive data (for example payment capture); track recording metadata and references; apply retention and disposal policy; audit recording access and playback. | +| Data owned | Recording session, recording reference/location pointer, consent state, recording state (recording, paused, stopped), retention policy result, and recording access audit. | +| Events consumed | CallStarted, CallAnswered, SensitiveDataEntryStarted, SensitiveDataEntryEnded, CallEnded, ConsentCaptured, RetentionPolicyChanged. | +| Events emitted | RecordingStarted, RecordingPaused, RecordingResumed, RecordingStopped, RecordingStored, RecordingRetentionScheduled, RecordingPurged, RecordingAccessAudited. | +| Interactions | Sends recording intents through Contact Center Voice to Telephony/providers; links recording metadata to the interaction and call session; feeds Quality and Analytics; enforces retention with Data Governance. | +| Why it exists | Recording, consent, pause/resume for sensitive data, retention, and access auditing are core enterprise contact center requirements that must be orchestrated and audited even though media stays in the provider/media layer. | + +### 16. Live Monitoring & Supervisor Call Control + +| Area | Design | +| --- | --- | +| Purpose | Let supervisors observe and intervene on live interactions within their scope. | +| Responsibilities | Provide silent monitor (listen only), whisper (coach the agent only), barge-in (join the call), and take-over (replace the agent); enforce supervisor scope and permissions; audit every monitoring action; surface live coaching context. | +| Data owned | Monitor session, monitor mode, supervisor/agent/interaction references, monitor start/end, and monitoring audit record. | +| Events consumed | SupervisorMonitorRequested, SupervisorWhisperRequested, SupervisorBargeRequested, SupervisorTakeOverRequested, CallEnded, AgentStateChanged. | +| Events emitted | MonitorSessionStarted, WhisperStarted, BargeStarted, TakeOverStarted, MonitorSessionEnded, SupervisorActionAudited. | +| Interactions | Expresses monitor/whisper/barge/take-over as orchestration intents that Telephony/providers execute when the provider supports them; updates Real-Time UX and audit. | +| Why it exists | Live monitoring and intervention are standard supervisor capabilities for coaching, quality, and escalation, and they must be permissioned and fully audited. | + +Provider capability awareness: + +- Monitoring primitives depend on provider capabilities, similar to the existing Telephony capability flags. +- When a provider cannot support a primitive, the UI must hide or disable it instead of failing silently. + +### 17. Outbound Compliance & List Management + +| Area | Design | +| --- | --- | +| Purpose | Keep outbound dialing within legal, contractual, and customer-preference constraints. | +| Responsibilities | Enforce allowed calling windows by contact time zone and region; cap abandonment rate for power/predictive modes; play safe-harbor/abandon messaging when no agent connects; manage caller-ID and local-presence policy; scrub against DNC and communication preferences; manage retry limits, cool-down, and list recycling; record suppression reasons. | +| Data owned | Compliance policy, calling-window rules, abandonment thresholds, caller-ID/local-presence policy, suppression result, list recycling state, and compliance audit record. | +| Events consumed | ActivityEligibleForDialing, DialerAttemptCompleted, CallAbandoned, RetentionPolicyChanged, CommunicationPreferenceChanged. | +| Events emitted | DialBlockedByCompliance, CallingWindowViolationPrevented, AbandonmentThresholdReached, SafeHarborMessagePlayed, SuppressionRecorded, ListRecycled. | +| Interactions | Gates the Campaign Dialer before dialing; reuses DNC registry and contact communication preferences; feeds compliance metrics to Analytics. | +| Why it exists | Power and predictive dialing are unsafe without enforced calling windows, abandonment caps, and suppression auditing. Compliance must be a first-class gate, not an afterthought. | + +### 18. Quality Management (advanced) + +| Area | Design | +| --- | --- | +| Purpose | Evaluate and improve interaction quality. | +| Responsibilities | Define evaluation forms/scorecards; sample and assign interactions for review; link recordings, transcripts, and disposition; capture scores, calibration, and coaching; feed performance and training. | +| Data owned | Evaluation form, evaluation assignment, evaluation result, calibration session, and coaching record. | +| Events consumed | InteractionCompleted, RecordingStored, WrapUpCompleted, DispositionSelected. | +| Events emitted | EvaluationAssigned, EvaluationCompleted, CoachingRecorded, CalibrationCompleted. | +| Interactions | Reads recordings/transcripts/dispositions; feeds Analytics and agent performance; integrates with optional AI quality scoring. | +| Why it exists | Quality management is a standard enterprise contact center pillar and a natural consumer of recordings, transcripts, and dispositions once the core platform is stable. | + +## Conceptual data model + +The data model below is conceptual only. + +| Entity | Purpose | Key relationships | +| --- | --- | --- | +| OmnichannelActivity | Universal CRM work item | Links to Contact, Campaign, Subject, Disposition, assignment/reservation metadata, ActivityKind, ActivitySource, and zero or more Interactions | +| Interaction | Communication-history record for one activity attempt | Links to OmnichannelActivity, provider session/call id, queue history, transfer history, call legs, recording/transcript references | +| InteractionParticipant | Customer, agent, supervisor, AI agent, external party | Belongs to Interaction | +| InteractionEvent | Durable domain event history | References Interaction and event envelope | +| ChannelSession | Generic per-channel session projection | Voice session now; chat/SMS/email later | +| CallSession | Voice-specific channel session | References Interaction, Telephony provider call id, agent, queue | +| Queue | Work container | Owns queue items, membership, SLA, routing policy | +| QueueItem | Enqueued activity waiting for assignment | References OmnichannelActivity and Queue | +| QueueMembership | Agent-to-queue relationship | References Queue and AgentProfile | +| AgentProfile | Contact center agent configuration | References Orchard user, skills, capacity, teams | +| AgentPresence | Live agent state | References AgentProfile and active reservations/activities/interactions | +| AgentSkill | Agent skill and proficiency | References AgentProfile and Skill | +| Skill | Routeable capability | Referenced by queues, policies, agents | +| AgentReservation | Temporary assignment lock | References AgentProfile, OmnichannelActivity, QueueItem | +| RoutingPolicy | Rules and strategy for routing | References Queue, Skills, BusinessHours, Workflow hooks | +| RoutingDecision | Auditable result of routing | References OmnichannelActivity, Queue, Agent, score, reason | +| DialerProfile | Outbound dialing configuration | References Campaign, Queue, pacing and retry policy | +| DialerRun | Execution instance for outbound campaign work | References DialerProfile and campaign/activity set | +| DialerAttempt | Single outbound attempt | References DialerRun, Interaction, Activity, CallSession | +| WrapUpSession | Post-work completion period | References OmnichannelActivity, latest Interaction, Agent, Disposition | +| ActivityDispositionRequest | Source-neutral disposition command | References OmnichannelActivity, Disposition, source, actor, notes | +| MetricSnapshot | Aggregated operational analytics | References queue, agent, campaign, time bucket | +| EntryPoint | Inbound front door configuration | References number/DID mapping, IVR flow, business hours, target queues | +| NumberMapping | Provider number/DID to entry point | References EntryPoint and channel endpoint | +| IvrFlowDefinition | Self-service/IVR decision flow | Referenced by EntryPoint; may delegate to subject flows or workflows | +| BusinessHoursCalendar | Open/closed and holiday schedule | Referenced by queues, entry points, routing | +| CallbackRequest | Scheduled or queued callback | References Interaction, Contact, Queue, due window | +| RecordingSession | Recording lifecycle and reference | References Interaction, CallSession, consent, retention | +| MonitorSession | Supervisor live monitoring action | References Supervisor, Agent, Interaction, monitor mode | +| AgentStateReason | Reason code for presence/not-ready/break | References AgentProfile and presence state | +| CompliancePolicy | Outbound calling constraints | References calling windows, abandonment caps, caller-ID policy | +| RetentionPolicy | Data retention/disposal rules | References recordings, events, interactions, PII fields | +| EvaluationForm | Quality scorecard definition | Referenced by evaluation assignments and results | +| AuditRecord | Security and operational audit | References actor, action, target, tenant, correlation id | + +Relationship overview: + +```text +Contact ContentItem + -> OmnichannelActivity + -> QueueItem / AgentReservation / WrapUpSession + -> Activity Disposition -> Subject Actions / Workflow bridge + -> Interaction* + -> CallSession / Provider session + -> QueueHistory / TransferHistory / CallLegs + -> Recording / Transcript reference + -> InteractionEvent* +``` + +## Interaction lifecycle diagram + +```text +Created + -> LinkedToActivity + -> Queued + -> Routing + -> Reserved + -> Assigned + -> Offered + -> Accepted + -> Active + -> WorkCompleted + -> WrapUp + -> Completed + +Alternative paths: +Queued -> Overflowed -> Queued +Queued -> Abandoned +Reserved -> ReservationExpired -> Queued +Offered -> Rejected -> Queued +Active -> Transferred -> Routing +Active -> Failed -> WrapUp or Completed +WorkCompleted -> CallbackScheduled -> Queued +``` + +## Inbound call routing sequence + +```text +1. Telephony/provider receives inbound voice event. +2. Contact Center Voice normalizes the provider event into an inbound channel session event. +3. Interaction Management creates or finds the Interaction. +4. CRM context is resolved: contact, campaign, subject, previous owner, communication preferences. +5. Pre-routing workflow or subject-flow rules enrich priority, skills, queue, language, and callback options. +6. Queue Management enqueues the interaction. +7. Routing Engine evaluates business hours, queue rules, skills, priority, sticky agent, capacity, and strategy. +8. Agent & Presence creates a reservation for the selected agent. +9. Real-Time UX offers the interaction to the agent desktop. +10. Agent accepts, and Contact Center asks Telephony to answer/connect/bridge according to the provider capability. +11. Call Session Management tracks voice lifecycle events. +12. When the call ends, Wrap-Up starts. +13. Agent selects disposition and completes required fields. +14. CRM activity is updated and Subject Actions or optional workflows run. +15. Analytics projections and supervisor dashboards update. +``` + +## Outbound dialing sequence + +```text +1. CRM campaign/activity batch produces eligible phone activities. +2. Campaign Dialer selects eligible activities using schedule, timezone, DNC/compliance, retry, and priority rules. +3. Dialer mode determines whether an agent previews first or the system reserves an agent before dialing. +4. Routing Engine reserves an eligible agent and capacity. +5. Interaction Management creates the outbound Interaction and links it to the CRM Activity. +6. Contact Center Voice requests Telephony to dial using the configured channel endpoint/caller id. +7. Telephony executes the provider dial action and returns provider call state. +8. Call Session Management maps provider call id to Interaction. +9. Agent desktop receives real-time dial/call state updates. +10. Outcome is classified: connected, no answer, busy, failed, canceled, voicemail, callback, or completed. +11. Wrap-Up enforces disposition and notes when agent-handled. +12. CRM Activity, retry policy, callback schedule, and campaign metrics update. +13. Agent capacity is released or the next dialer reservation begins. +``` + +## Event flow architecture + +```text +Domain command + -> Domain service validates and changes aggregate state + -> Domain event appended to tenant event log + -> Outbox dispatches event to handlers + -> Handlers update projections and trigger next orchestration step + -> Real-time notifier streams projection event to SignalR groups + -> Analytics projector updates live and historical metrics +``` + +Rules: + +- Components do not mutate each other’s owned data directly. +- Every cross-component transition is represented by an event. +- Every event is tenant-scoped, versioned, correlated, and idempotent. +- External/provider events are normalized before they enter the Contact Center event stream. +- Real-time notifications are projections of domain events, not the source of truth. +- Analytics are projections and can be rebuilt from event history. + +## Standard contact center terminology and metrics + +Use these industry-standard terms for type, member, event, metric, and permission naming. Do not name any code artifact, comment, or public doc after a competing product; use only the generic vocabulary below. + +Core domain vocabulary: + +- Interaction: any customer engagement across any channel. +- Channel session: a per-channel session (voice call, chat, SMS thread, email) attached to an interaction. +- Queue: a container of interactions waiting for handling. +- Reservation (offer): a short-lived hold that offers an interaction to an agent before assignment. +- Presence (agent state): the agent's current availability state. +- Capacity: how much concurrent work an agent can handle, per channel. +- Disposition (wrap code / outcome code): the business outcome selected at completion. +- Wrap-up (after-call work, ACW): post-interaction completion time. +- Entry point: the inbound front door that qualifies and routes a contact. +- Skill: a routeable capability with optional proficiency. + +Operational metrics (use these names): + +- Service Level (SL): percent of interactions answered within a target threshold (for example, 80/20). +- Average Speed of Answer (ASA): average queue wait before answer. +- Average Handle Time (AHT): talk time plus hold time plus wrap-up time. +- Average Talk Time and Average Hold Time. +- Abandon Rate: percent of queued interactions abandoned before answer. +- Occupancy: percent of logged-in time spent handling interactions. +- Adherence and Shrinkage: schedule adherence and non-productive time (workforce metrics). +- First Contact Resolution (FCR): resolved on the first interaction. +- Answer Rate, Connect Rate, and Right-Party-Contact (RPC) rate (outbound). +- Abandonment Rate cap (outbound dialing safety threshold). +- Queue depth, oldest-in-queue age, and estimated wait time (EWT). + +Agent states (canonical set): + +- Offline, Available, Reserved, Ringing, On interaction, On hold, Wrap-up, Not ready (with reason code), Break (with reason code), Training, Meeting, Away, Do not disturb, After-hours unavailable. + +Transfer and conference taxonomy: + +- Blind (cold) transfer, Consultative (warm) transfer, Transfer to agent, Transfer to queue, Transfer to external number, Transfer to IVR/entry point. +- Conference: add party, drop party, supervisor-initiated conference (barge). + +## MVP scope + +The MVP should prove that agents can operate voice contact center work fully inside CRM while preserving the Telephony boundary. + +MVP includes: + +1. Extended `OmnichannelActivity` as the universal work item with nullable owner, activity kind/source, assignment status, and reservation metadata. +2. Durable Interaction entity as communication history linked to OmnichannelActivity, with provider ids, timestamps, queue/transfer/call-leg history, recording/transcript references, and technical metadata. +3. Contact Center event log and baseline projections. +4. Static queues over activities with priority, SLA thresholds, and queue metrics. +5. Agent profiles, queue membership, presence, and single-voice-session capacity. +6. Activity and agent reservation model. +7. Basic routing: queue membership, available agents, priority, longest idle, round robin, business hours, and sticky assigned user preference. +8. Voice channel adapter over existing Telephony abstractions. +9. Call session mapping from Telephony call id to Interaction. +10. Manual and preview outbound dialing that selects Activities, not Contacts. +11. Power dialing after reservations are stable. +12. Wrap-up timer, required activity disposition, notes, CRM activity completion, and Subject Action execution through `IActivityDispositionService`. +13. Agent desktop CRM integration for next work, current activity, interaction history, call controls, and wrap-up. +14. Supervisor live queue and agent monitor. +15. Tenant-scoped permissions and audit trail. +16. Documentation and persistent project plan reference in `.github\copilot-instructions.md`. +17. A basic inbound entry point: number/DID to queue mapping, business-hours/holiday gating, and screen pop of the resolved contact on answer. +18. Canonical agent state set with not-ready and break reason codes. + +MVP excludes (deferred to later phases, not dropped): + +- Call recording, pause/resume, and media storage (Phase 9). +- Live monitoring, whisper, barge, and take-over (Phase 9). +- Full IVR/self-service designer; MVP ships only basic entry-point gating (Phase 8). +- Outbound compliance hardening: calling-window enforcement, abandonment caps, safe-harbor messaging, AMD, local presence (Phase 10). +- Progressive and predictive dialing (Phase 13). +- Quality management, evaluation forms, and speech/AI quality scoring (Phase 13). +- Chat/SMS/email agent routing and AI virtual-agent handoff (Phase 13). +- Workforce management, forecasting, and adherence scheduling. +- External BI connectors and data warehouse export beyond built-in reports. +- Multi-region active-active contact center scaling (single-region scale-out is Phase 12). + +## Phased delivery plan + +### Phase 0: Project governance and durable planning + +Goals: + +- Create a repository-tracked Contact Center design and progress document. +- Add a reference to that document in `.github\copilot-instructions.md`. +- Establish naming policy that avoids competitor names in code and public docs. +- Document current boundaries: CRM owns business work data, Contact Center owns orchestration, Telephony owns media execution. +- Create initial docs page under `src\CrestApps.Docs\docs\contact-center`. + +Deliverables: + +- Durable project plan/progress document. +- Copilot instruction pointer. +- Contact Center docs landing page. +- Initial feature/module map. + +### Phase 1: Domain foundation + +Goals: + +- Introduce Contact Center abstractions and core domain vocabulary. +- Establish `OmnichannelActivity` as the universal work item and Interaction as communication history. +- Define event envelope, event log, and event projection strategy. +- Extend existing Omnichannel activities with nullable ownership, classification, assignment, and reservation metadata. +- Define baseline permissions and tenant settings. + +Deliverables: + +- Contact Center base feature. +- Conceptual migrations/indexes for interaction history and event history. +- Communication-history projection. +- Activity extension model for assignment/reservation/classification. +- Initial `IActivityDispositionService` contract for source-neutral activity disposition. +- Baseline docs and changelog updates. +- Unit tests for lifecycle rules and event envelopes. + +### Phase 2: Agent, presence, queue, and reservation foundation + +Goals: + +- Add agent profiles, queue membership, skills, presence, and capacity. +- Add static queues, queue priorities, SLA thresholds, and queue item lifecycle. +- Add reservation model and expiration behavior. +- Add real-time presence and queue updates. + +Deliverables: + +- Agent management feature. +- Queue management feature. +- Reservation lifecycle. +- Queue metric projections. +- Agent and supervisor permissions. +- Tests for presence transitions, reservation conflicts, queue ordering, and tenant isolation. + +### Phase 3: Routing MVP + +Goals: + +- Add routing policies and strategy pipeline. +- Support longest idle, round robin, priority, sticky assigned user, business hours, and skills. +- Produce auditable routing decisions. +- Requeue or overflow when no agent is available. + +Deliverables: + +- Routing engine feature. +- Routing decision records. +- Routing audit view. +- Queue overflow model. +- Tests for routing strategies, tie breaking, business hours, and reservation failures. + +### Phase 4: Voice integration with Telephony + +Goals: + +- Add Contact Center Voice feature that depends on Telephony. +- Map provider call/session identifiers to Contact Center interactions. +- Normalize call session events into Contact Center domain events. +- Define a provider inbound-event normalization boundary so inbound calls and provider webhooks enter Contact Center reliably. +- Keep Telephony as the execution and provider state boundary. +- Surface current interaction and call state in CRM UI. + +Deliverables: + +- Voice channel adapter. +- Call session model. +- Inbound voice event ingress and normalization strategy. +- Outbound call-control integration. +- Transfer and conference taxonomy (blind, consultative, to agent/queue/external). +- Real-time voice session projection. +- Tests for call mapping, state transitions, transfer, hold/resume, conference, and end-call wrap-up start. + +### Phase 5: Outbound dialer MVP + +Goals: + +- Use CRM campaigns, activities, contacts, subject flows, endpoints, and dispositions as dialer inputs. +- Add manual and preview dialing. +- Add power dialing after reservations are stable. +- Add retry rules, callback scheduling, timezone checks, DNC/communication-preference suppression, and pacing safeguards. + +Deliverables: + +- Dialer profiles. +- Dialer run and attempt projections. +- Preview dialing agent UX. +- Power dialing service. +- Callback request model and callback queue. +- Campaign and activity updates. +- Tests for eligibility, suppression, retries, pacing, reservation-before-dial, and callback scheduling. + +Note: full outbound compliance (calling-window enforcement, abandonment caps, safe-harbor messaging, answering-machine detection, and caller-ID/local presence) is hardened in Phase 10. Phase 5 only enforces the existing DNC and communication-preference suppression and basic timezone checks. + +### Phase 6: Wrap-up and disposition lifecycle + +Goals: + +- Start wrap-up after interaction work ends. +- Enforce required disposition rules by queue, subject, and campaign. +- Track wrap-up time, notes, selected disposition, and completion. +- Execute existing Subject Actions and optionally emit workflow events. +- Release agent capacity after wrap-up completion or timeout. + +Deliverables: + +- Wrap-up feature. +- Required disposition policies. +- Activity completion integration. +- Subject Action integration. +- Timer and timeout behavior. +- Tests for required dispositions, wrap-up release, activity update, and subject action execution. + +### Phase 7: Agent desktop and supervisor real-time UX + +Goals: + +- Add CRM-integrated agent desktop surfaces for available work, active interaction, call controls, customer context, subject form, and wrap-up. +- Add screen pop of the resolved contact 360 view on interaction answer. +- Add canonical agent states with not-ready and break reason codes. +- Add supervisor live dashboard for queues, agents, SLA, active interactions, and dialer state. +- Add queue monitor (wallboard) view with estimated wait time. + +Deliverables: + +- Agent desktop feature. +- Screen pop integration. +- Agent state and reason-code model. +- Supervisor feature. +- Queue monitor feature. +- SignalR stream contracts and reconnect snapshots. +- UI docs and permissions. +- Playwright coverage for core agent and supervisor flows. + +### Phase 8: Inbound entry points, IVR and self-service + +Goals: + +- Add inbound entry points and number/DID-to-entry-point mapping. +- Add business-hours and holiday calendars with open, closed, and special-day behavior. +- Add IVR-like decision flows (menu, intent, language, authentication hooks) that can delegate to subject flows or the Workflows bridge. +- Add self-service deflection, callback offer, and voicemail fallback. +- Promote the basic MVP entry point into a configurable front door. + +Deliverables: + +- Entry point and number-mapping model. +- Business-hours/holiday calendar model. +- IVR/self-service flow definition and runtime. +- Callback offer and voicemail fallback. +- Tests for entry-point matching, business-hours/holiday gating, self-service outcomes, and queue selection. + +### Phase 9: Recording and live monitoring + +Goals: + +- Add recording orchestration: start/stop/pause/resume intents, consent capture, and recording metadata. +- Add pause/resume around sensitive data entry (for example payment capture). +- Add live monitoring: silent monitor, whisper, barge-in, and take-over, gated by provider capabilities and supervisor scope. +- Audit all recording access and monitoring actions. + +Deliverables: + +- Recording session model and recording intents. +- Consent and recording-state tracking. +- Monitor session model and supervisor call-control intents. +- Provider-capability gating for recording and monitoring. +- Recording and monitoring audit records. +- Tests for recording lifecycle, pause/resume, monitor modes, scope enforcement, and audit. + +### Phase 10: Outbound compliance hardening + +Goals: + +- Enforce allowed calling windows by contact time zone and region. +- Cap abandonment rate for power and predictive dialing and play safe-harbor/abandon messaging. +- Add answering-machine detection handling and outcome classification. +- Add caller-ID and local-presence policy. +- Add list scrubbing, retry cool-down, and list recycling with suppression auditing. + +Deliverables: + +- Compliance policy model and calling-window rules. +- Abandonment-rate caps and safe-harbor messaging. +- Answering-machine detection outcome handling. +- Caller-ID/local-presence policy. +- List recycling and suppression auditing. +- Tests for calling-window enforcement, abandonment caps, AMD outcomes, caller-ID selection, and suppression auditing. + +### Phase 11: Optional Workflow bridge + +Goals: + +- Add feature-gated OrchardCore.Workflows integration without reintroducing Workflows as a hard dependency of Omnichannel Management. +- Emit workflow events for routing, IVR-like decisions, wrap-up completion, callback scheduling, and SLA breach. +- Allow workflows to return route attributes, priority, skills, callback time, or suppression result. + +Deliverables: + +- Contact Center Workflows feature. +- Workflow events and tasks. +- Workflow result model. +- Failure and timeout behavior. +- Tests for workflow result handling and fallback routing. + +### Phase 12: Analytics and operations + +Goals: + +- Build historical metric projections. +- Add reports for queue, agent, interaction, call, wrap-up, campaign, and dialer performance. +- Add SLA trend analysis and operational alerts. +- Add export surfaces. + +Deliverables: + +- Analytics feature. +- Metric snapshots. +- Supervisor reporting. +- Campaign performance reports. +- SLA alerts. +- Tests for projection correctness and rebuild behavior. + +### Phase 13: Scale-out, resilience and data governance + +Goals: + +- Validate multi-node operation: SignalR backplane, distributed reservation locking, and single-writer guarantees for queue/presence transitions. +- Add provider/media resilience and failover behavior and stale-reservation/stale-session cleanup. +- Add data retention and disposal for recordings, events, and interaction history. +- Add PII handling, redaction, and right-to-erasure support aligned with existing platform conventions. + +Deliverables: + +- Scale-out validation and backplane configuration guidance. +- Distributed locking and reconnection/cleanup behavior. +- Retention and disposal jobs for recordings, events, and interactions. +- PII redaction and erasure support. +- Load/soak test guidance and tests for cleanup, retention, and failover. + +### Phase 14: Advanced capabilities + +Goals: + +- Add progressive dialing. +- Add predictive dialing only after enough safe metrics and abandonment controls exist. +- Add future non-voice channel routing. +- Add AI assist, summarization, next-best-action, sentiment, disposition suggestion, and virtual agent handoff. +- Add advanced supervisor controls and quality workflows. + +Deliverables: + +- Progressive dialer. +- Predictive dialer safety design. +- Chat/SMS/email routing adapters. +- AI assistance feature. +- Advanced analytics and quality insights. + +## Cross-cutting architecture requirements + +### Persistence + +- Contact Center state should follow existing tenant-local Orchard/YesSql patterns. +- Interaction event history should be durable and replayable for projections. +- Operational live state should be reconstructable from durable events and current snapshots. +- Analytics projections should be rebuildable. + +### Idempotency + +- Provider events, dialer attempts, reservations, workflow results, and real-time reconnect snapshots must be idempotent. +- Events should carry idempotency keys when sourced from external systems. + +### Concurrency + +- Agent reservations are the concurrency boundary for assignment. +- Dialer attempts must not dial without valid capacity/reservation when a mode requires agent availability. +- Queue dequeue and reservation conversion must be atomic at the domain level. + +### Observability + +- Every routing decision should explain the selected queue, agent, strategy, score, and skipped candidates. +- Every dialer attempt should explain eligibility, suppression, pacing, and final outcome. +- Every workflow or subject action execution should be auditable. +- Every provider event should retain enough sanitized metadata for troubleshooting without exposing secrets. + +### Compliance + +- Reuse existing contact communication preferences and DNC integration. +- Respect contact time zone for outbound calls. +- Provide suppression reasons for audit. +- Keep sensitive provider and customer data out of logs. + +### Documentation + +- Every phase that changes behavior must update `src\CrestApps.Docs`. +- The changelog file matching `VersionPrefix` must be updated. +- The persistent Contact Center project plan/progress document must be updated as work progresses. + +### Scale-out and high availability + +- Contact Center is real-time and stateful, so it must work across multiple application nodes. +- SignalR requires a backplane (for example Redis) for multi-node real-time delivery; reconnect snapshots must rebuild client state. +- Reservation, queue dequeue, and presence transitions need distributed locking or a single-writer strategy so two nodes cannot assign the same work. +- Live operational state must be reconstructable from durable events plus current snapshots after a node restart. + +### Resilience and failover + +- Provider/media outages must degrade gracefully: stop dialing, hold queued work, and surface status instead of losing interactions. +- Stale reservations, orphaned call sessions, and disconnected agents must be detected and cleaned up automatically. +- Inbound provider events must be idempotent and survive retries and out-of-order delivery. +- Dialer pacing must back off automatically when provider errors or abandonment thresholds rise. + +### Data retention and privacy + +- Recordings, transcripts, events, and interaction history must honor configurable retention and disposal policies. +- PII must be redactable, and right-to-erasure must be supported in line with existing platform conventions. +- Provider metadata and customer data must be kept out of logs, and recording/playback access must be audited. + +### Testing and validation strategy + +- Unit tests for lifecycle rules, routing strategies, reservation concurrency, dialer eligibility/suppression, compliance windows, and wrap-up enforcement. +- Integration tests for end-to-end inbound and outbound flows against the in-memory/stub Telephony provider used by existing Telephony tests. +- Playwright tests for agent desktop and supervisor flows, following the existing `CrestApps.OrchardCore.Telephony.PlaywrightTests` pattern. +- Projection rebuild tests proving analytics and live state can be reconstructed from the event log. +- Load/soak tests for queue throughput, reservation contention, and real-time fan-out before enabling power/predictive dialing. +- Follow repository validation: `npm run rebuild` for assets, `dotnet build` with warnings-as-errors, and targeted `dotnet test`. + +### Migration strategy + +- Breaking changes are acceptable, so `OmnichannelActivity` is expanded rather than replaced. +- Chosen direction: `OmnichannelActivity` remains the universal work item. `Interaction` links to an activity and stores communication history only; it does not wrap, replace, or disposition the activity. +- Provide data migrations that add activity classification and assignment/reservation columns, then backfill sensible defaults for existing activities. +- Keep the existing automated SMS activity processing working, or migrate it to create interaction history deliberately, not accidentally. +- The existing Telephony `TelephonyInteraction` history stays for the soft phone; Contact Center adds its own interaction history rather than repurposing it. + +## Extensibility strategy + +### Future channels + +Voice is only the first channel. The domain model should support: + +- Chat sessions. +- SMS threads. +- Email conversations. +- AI agent sessions. +- Co-browsing or screen-share metadata. +- Future custom channels. + +The extension point is a channel adapter that can: + +- Create or attach a channel session to an interaction. +- Normalize channel events into Contact Center domain events. +- Execute allowed channel actions through the owning channel/provider module. +- Provide channel-specific capacity rules. +- Provide channel-specific SLA and wrap-up behavior. + +### AI agents + +AI should be modeled as a participant or assistant, not as a replacement for the Interaction: + +- AI pre-routing classification. +- AI agent self-service before human routing. +- AI summarization after interaction. +- AI disposition and next-action suggestions. +- AI quality and compliance signals. +- Human takeover and AI-to-agent handoff. + +### Routing strategies + +Routing strategies should be plug-in based: + +- Built-in strategies: priority, longest idle, least busy, round robin, sticky agent, business hours, skills. +- Optional strategies: workflow-directed, AI-assisted, account-owner, geographic, language, customer tier. +- Custom strategies should return explainable scores and reasons. + +### Dialer strategies + +Dialer modes should be strategy-based: + +- Manual. +- Preview. +- Power. +- Progressive. +- Predictive. + +All dialer strategies must share compliance checks, retry policies, callbacks, pacing safeguards, reservations, and event output. + +## Known design risks and decisions to validate + +1. Current Omnichannel Management removed OrchardCore.Workflows. The plan keeps Subject Flows and Subject Actions as the default workflow model and adds Workflows as an optional Contact Center feature. +2. Existing `OmnichannelActivity` statuses are not rich enough for Contact Center. Breaking changes are acceptable, so the activity lifecycle can be expanded or bridged with Interaction state. +3. Telephony currently records soft-phone call history and pushes current-user call state. Contact Center needs a separate business interaction history and may need Telephony/provider event ingress improvements for inbound routing. +4. The Telephony abstraction may need a provider event normalization boundary so inbound calls and provider webhooks can enter Contact Center reliably. +5. Contact Center real-time events should not reuse TelephonyHub for routing or supervisor data. A separate Contact Center real-time stream is needed. +6. Dialer pacing and predictive dialing require reliable historical metrics before advanced modes are safe. +7. Queue and presence accuracy depend on robust disconnect/reconnect handling and stale reservation cleanup. +8. A persistent repo-tracked plan is needed because the session plan file is not enough for long-running multi-session work. +9. Recording, pause/resume, and live monitoring (whisper/barge/take-over) depend on provider capabilities; capability flags and graceful degradation are required, mirroring the existing Telephony capability model. +10. Outbound power and predictive dialing are legally sensitive; calling windows, abandonment-rate caps, and safe-harbor messaging must gate dialing before those modes are enabled. +11. Real-time, stateful operation requires a SignalR backplane and distributed reservation locking for multi-node deployments; single-node assumptions must not leak into the design. +12. Recording and PII introduce consent, retention, and right-to-erasure obligations that must be designed in from the recording phase, not added later. +13. Inbound routing depends on reliable provider inbound events/webhooks; the Telephony provider boundary may need a normalization/ingress improvement to support entry points and screen pop. + +## Project todos + +1. Establish durable project planning and instructions references. +2. Create Contact Center documentation landing page and architecture overview. +3. Extend `OmnichannelActivity` as the universal work item and introduce Interaction as communication history. +4. Add domain event log and projection model. +5. Integrate Contact Center orchestration with existing Omnichannel activities, contacts, campaigns, subjects, dispositions, and subject actions. +6. Add agent profile, presence, capacity, queue membership, and skills concepts. +7. Add queue management, queue item lifecycle, priorities, SLA thresholds, overflow, and metrics. +8. Add reservation-based routing with initial routing strategies. +9. Add Contact Center Voice adapter and call session mapping over Telephony. +10. Add inbound voice routing flow. +11. Add outbound manual and preview dialing. +12. Add power dialer and campaign pacing safeguards. +13. Add wrap-up and required disposition enforcement. +14. Add real-time agent desktop, supervisor dashboard, and queue monitor streams. +15. Add security, roles, queue permissions, and audit logging. +16. Add inbound entry points, business-hours/holiday calendars, IVR/self-service, screen pop, and callback/voicemail fallback. +17. Add recording orchestration and live monitoring (silent monitor, whisper, barge, take-over) with consent and audit. +18. Add outbound compliance hardening: calling windows, abandonment caps, safe-harbor messaging, AMD, caller-ID/local presence, and list recycling. +19. Add optional OrchardCore.Workflows bridge. +20. Add analytics, reports, and metric projections. +21. Add scale-out/high-availability validation, resilience/failover, and data retention/privacy. +22. Add quality management, future channel adapters, and AI assistance after the voice MVP is stable. + +## Progress status + +Keep this section current. Use the checklist below to track phase-level progress; add dated notes under "Change log" for meaningful decisions. + +### Phase checklist + +- [x] **Phase 0 — Project governance and durable planning** + - [x] Durable repo-tracked plan at `.github/contact-center/PLAN.md` + - [x] Pointer added to `.github/copilot-instructions.md` + - [x] Public docs landing page under `src/CrestApps.Docs/docs/contact-center` + - [x] Module/feature map confirmed against the solution and target bundle +- [x] **Phase 1 — Domain foundation** (CRM activity extension, interaction history, event log, base module) + - [x] `CrestApps.OrchardCore.ContactCenter.Abstractions` (constants, channel/direction/status/priority/role enums, event vocabulary) + - [x] `OmnichannelActivity` extended with activity kind/source, assignment status, and reservation metadata so CRM activities remain the universal work item + - [x] Activity Batch UI changed to source-first creation with Manual and Dialer sources; Dialer batches load unassigned activities for later reservation + - [x] `IActivityDispositionService` contract added as the source-neutral path for activity dispositions + - [x] `CrestApps.OrchardCore.ContactCenter.Core` (Interaction + InteractionParticipant + InteractionEvent models, indexes, stores, `IInteractionManager`, event publisher, permissions) + - [x] `CrestApps.OrchardCore.ContactCenter` base module (Startup, index providers, migrations, permission provider) + - [x] Registered in `.slnx` and the `Cms.Core.Targets` bundle + - [x] 13 unit tests (event envelope, event publisher dispatch/idempotency/resilience, interaction manager lifecycle, entity metadata extensibility) + - [x] Docs landing page + `v2.0.0` changelog entry +- [ ] Phase 2 — Agent, presence, queue, and reservation foundation +- [ ] Phase 3 — Routing MVP +- [ ] Phase 4 — Voice integration with Telephony +- [ ] Phase 5 — Outbound dialer MVP +- [ ] Phase 6 — Wrap-up and disposition lifecycle +- [ ] Phase 7 — Agent desktop and supervisor real-time UX +- [ ] Phase 8 — Inbound entry points, IVR and self-service +- [ ] Phase 9 — Recording and live monitoring +- [ ] Phase 10 — Outbound compliance hardening +- [ ] Phase 11 — Optional Workflow bridge +- [ ] Phase 12 — Analytics and operations +- [ ] Phase 13 — Scale-out, resilience and data governance +- [ ] Phase 14 — Advanced capabilities + +### Change log + +- Codebase analysis completed for Telephony, Omnichannel Core, Omnichannel Management, SMS automation, SignalR docs, target bundle, solution structure, docs, and tests. +- Plan reviewed and expanded to add inbound entry points/IVR, call recording, live monitoring (silent monitor/whisper/barge/take-over), outbound compliance hardening, quality management, a standard terminology/metrics glossary, scale-out/high-availability, data retention/privacy, a testing strategy, and a migration strategy. Phases renumbered to 0-14. +- Phase 0 started: promoted the session plan to this durable repo-tracked document, added the copilot-instructions pointer, and created the public Contact Center docs landing page. +- Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). +- Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9c1ee38a2..6df9a8301 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -10,6 +10,20 @@ 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 being built between the CRM (Omnichannel) and Telephony modules. Before doing any Contact Center work (anything under `src/**/CrestApps.OrchardCore.ContactCenter*`), **ALWAYS read the durable plan first**: + +- **Plan & progress:** [`.github/contact-center/PLAN.md`](contact-center/PLAN.md) + +That document is the source of truth for the Contact Center architecture, phased scope, MVP definition, conceptual data model, event catalog, and current progress. Always read its **Progress status** section to see what is done and what is next, start at the lowest incomplete phase, and update that section after each meaningful change. + +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 the generic, industry-standard terminology defined in the plan's terminology/metrics section. +- Group related capabilities into separate, feature-gated Orchard modules/features (the plan's module breakdown), the way commercial platforms separate licensed capabilities. + ## Working Effectively ### Prerequisites and Environment Setup diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index 56ff7b0ef..036de7232 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -17,6 +17,7 @@ + @@ -27,6 +28,7 @@ + @@ -61,6 +63,7 @@ + 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..57384189c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -0,0 +1,131 @@ +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; + + /// + /// Identifies a system actor for events that are not originated by an interactive user. + /// + public const string SystemActor = "system"; + + /// + /// 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"; + } + + /// + /// 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 wrap-up and disposition component. + /// + public const string WrapUp = "WrapUp"; + } + + /// + /// 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 the communication session for an interaction ends. + /// + public const string InteractionEnded = "InteractionEnded"; + + /// + /// Raised when an interaction fails. + /// + public const string InteractionFailed = "InteractionFailed"; + } +} 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..c64be4b68 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions.csproj @@ -0,0 +1,20 @@ + + + + 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/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/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs new file mode 100644 index 000000000..144c9a929 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs @@ -0,0 +1,24 @@ +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]); +} 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..bf977ce20 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj @@ -0,0 +1,30 @@ + + + + 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/Indexes/InteractionEventIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionEventIndex.cs new file mode 100644 index 000000000..c4068cd76 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionEventIndex.cs @@ -0,0 +1,49 @@ +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 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..bc154a28b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/InteractionIndex.cs @@ -0,0 +1,75 @@ +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; } +} 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..8935928f1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs @@ -0,0 +1,147 @@ +using CrestApps.Core; +using CrestApps.OrchardCore.ContactCenter.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 : Entity, IModifiedUtcAwareModel +{ + /// + /// Gets or sets the stable identifier of the interaction. + /// + public string ItemId { 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 relative to the contact center. + /// + public InteractionDirection Direction { get; set; } + + /// + /// Gets or sets the communication-session status of the interaction. + /// + public InteractionStatus Status { get; 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 transcript reference when a transcript is available for the interaction. + /// + public string TranscriptReference { 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 provider call legs that were associated with the interaction. + /// + public IList CallLegs { 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 participants involved in the interaction. + /// + public IList Participants { get; set; } = []; +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionCallLeg.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionCallLeg.cs new file mode 100644 index 000000000..96831fc7d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionCallLeg.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents a provider call leg recorded as part of an interaction's communication history. +/// +public sealed class InteractionCallLeg +{ + /// + /// Gets or sets the provider leg identifier. + /// + public string ProviderLegId { get; set; } + + /// + /// Gets or sets the source address for the leg. + /// + public string FromAddress { get; set; } + + /// + /// Gets or sets the destination address for the leg. + /// + public string ToAddress { get; set; } + + /// + /// Gets or sets the UTC time the leg started. + /// + 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 status of the leg. + /// + public string Status { get; set; } +} 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..5ca2d268e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +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 : Entity +{ + /// + /// Gets or sets the stable identifier of the event. + /// + public string ItemId { 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. 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/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..08ba3c3fc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionTransferHistoryEntry.cs @@ -0,0 +1,37 @@ +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, such as agent, queue, external, or entry point. + /// + 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 transfer result. + /// + public string Result { get; set; } +} 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..9b732d09c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs @@ -0,0 +1,97 @@ +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 then dispatched to every registered handler. +/// Handler failures are logged and do not prevent the event from being recorded or other handlers from running. +/// +public sealed class DefaultContactCenterEventPublisher : IContactCenterEventPublisher +{ + private readonly IInteractionEventStore _eventStore; + private readonly IEnumerable _handlers; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The durable interaction event store. + /// The registered event handlers. + /// The clock used to stamp events. + /// The logger instance. + public DefaultContactCenterEventPublisher( + IInteractionEventStore eventStore, + IEnumerable handlers, + IClock clock, + ILogger logger) + { + _eventStore = eventStore; + _handlers = handlers; + _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); + } + + return; + } + + await _eventStore.CreateAsync(interactionEvent, cancellationToken); + + foreach (var handler in _handlers) + { + try + { + await handler.HandleAsync(interactionEvent, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "An error occurred while handling the Contact Center event '{EventType}' for interaction '{InteractionId}' in handler '{Handler}'.", + interactionEvent.EventType, + interactionEvent.InteractionId, + handler.GetType().FullName); + } + } + } +} 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..68c8c535a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEventHandler.cs @@ -0,0 +1,17 @@ +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 +{ + /// + /// 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/IInteractionEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs new file mode 100644 index 000000000..94d78788e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs @@ -0,0 +1,32 @@ +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 +{ + /// + /// Creates the specified interaction event. + /// + /// The interaction event to create. + /// The token to monitor for cancellation requests. + ValueTask CreateAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default); + + /// + /// 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); +} 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..d8efcca5d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs @@ -0,0 +1,65 @@ +using CrestApps.Core.Models; +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 +{ + /// + /// Creates a new interaction instance with default values. + /// + /// The new interaction. + ValueTask NewAsync(); + + /// + /// Creates the specified interaction. + /// + /// The interaction to create. + /// The token to monitor for cancellation requests. + ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default); + + /// + /// Updates the specified interaction. + /// + /// The interaction to update. + /// The token to monitor for cancellation requests. + ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default); + + /// + /// Finds the interaction with the specified identifier. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default); + + /// + /// 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); + + /// + /// 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); +} 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..d870e3696 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs @@ -0,0 +1,59 @@ +using CrestApps.Core.Models; +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 +{ + /// + /// Creates the specified interaction. + /// + /// The interaction to create. + /// The token to monitor for cancellation requests. + ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default); + + /// + /// Updates the specified interaction. + /// + /// The interaction to update. + /// The token to monitor for cancellation requests. + ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default); + + /// + /// Finds the interaction with the specified identifier. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// The matching interaction, or when none is found. + ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default); + + /// + /// 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); + + /// + /// 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); +} 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..1b6e877f9 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs @@ -0,0 +1,57 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class InteractionEventStore : IInteractionEventStore +{ + private readonly ISession _session; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public InteractionEventStore(ISession session) + { + _session = session; + } + + /// + public async ValueTask CreateAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + await _session.SaveAsync(interactionEvent, ContactCenterConstants.CollectionName); + } + + /// + 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 events.ToArray(); + } + + /// + 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; + } +} 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..d1d1b2e5c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs @@ -0,0 +1,103 @@ +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class InteractionManager : IInteractionManager +{ + private readonly IInteractionStore _store; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The underlying interaction store. + /// The clock used to stamp audit times. + public InteractionManager( + IInteractionStore store, + IClock clock) + { + _store = store; + _clock = clock; + } + + /// + public ValueTask NewAsync() + { + return ValueTask.FromResult(new Interaction + { + ItemId = IdGenerator.GenerateId(), + CorrelationId = IdGenerator.GenerateId(), + CreatedUtc = _clock.UtcNow, + }); + } + + /// + public async ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + EnsureIdentity(interaction); + + if (interaction.CreatedUtc == default) + { + interaction.CreatedUtc = _clock.UtcNow; + } + + await _store.CreateAsync(interaction, cancellationToken); + } + + /// + public async ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + EnsureIdentity(interaction); + interaction.ModifiedUtc = _clock.UtcNow; + + await _store.UpdateAsync(interaction, cancellationToken); + } + + /// + public ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default) + { + return _store.FindByIdAsync(id, cancellationToken); + } + + /// + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) + { + return await _store.FindByActivityIdAsync(activityItemId, cancellationToken); + } + + /// + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) + { + return await _store.FindByCorrelationIdAsync(correlationId, cancellationToken); + } + + /// + public async Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default) + { + return await _store.PageByStatusAsync(page, pageSize, status, cancellationToken); + } + + private static void EnsureIdentity(Interaction interaction) + { + if (string.IsNullOrEmpty(interaction.ItemId)) + { + interaction.ItemId = IdGenerator.GenerateId(); + } + + if (string.IsNullOrEmpty(interaction.CorrelationId)) + { + interaction.CorrelationId = IdGenerator.GenerateId(); + } + } +} 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..bb0b613b6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs @@ -0,0 +1,92 @@ +using CrestApps.Core.Models; +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; + +/// +/// Provides a YesSql-based implementation of . +/// +public sealed class InteractionStore : IInteractionStore +{ + private readonly ISession _session; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + public InteractionStore(ISession session) + { + _session = session; + } + + /// + public async ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + await _session.SaveAsync(interaction, ContactCenterConstants.CollectionName); + } + + /// + public async ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + await _session.SaveAsync(interaction, ContactCenterConstants.CollectionName); + } + + /// + public async ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(id); + + return await _session.Query( + index => index.ItemId == id, + collection: ContactCenterConstants.CollectionName) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + 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> 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(), + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityBatchIndex.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityBatchIndex.cs index 691d25ff6..a8f34ec44 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityBatchIndex.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityBatchIndex.cs @@ -13,6 +13,11 @@ public sealed class OmnichannelActivityBatchIndex : CatalogItemIndex /// public string DisplayText { get; set; } + /// + /// Gets or sets the activity source used when loading activities from this batch. + /// + public string Source { get; set; } + /// /// Gets or sets the status. /// diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityIndex.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityIndex.cs index 7c505989b..6a197c6fd 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityIndex.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelActivityIndex.cs @@ -13,6 +13,16 @@ public sealed class OmnichannelActivityIndex : CatalogItemIndex /// public long DocumentId { get; set; } + /// + /// Gets or sets the kind of work the activity represents. + /// + public ActivityKind Kind { get; set; } + + /// + /// Gets or sets the source that created or is driving the activity. + /// + public string Source { get; set; } + /// /// Gets or sets the interaction type. /// @@ -73,6 +83,31 @@ public sealed class OmnichannelActivityIndex : CatalogItemIndex /// public DateTime? AssignedToUtc { get; set; } + /// + /// Gets or sets the assignment lifecycle status. + /// + public ActivityAssignmentStatus AssignmentStatus { get; set; } + + /// + /// Gets or sets the active reservation identifier. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the user or system actor that reserved the activity. + /// + public string ReservedById { get; set; } + + /// + /// Gets or sets the UTC time the activity was reserved. + /// + public DateTime? ReservedUtc { get; set; } + + /// + /// Gets or sets the UTC time the activity reservation expires. + /// + public DateTime? ReservationExpiresUtc { get; set; } + /// /// Gets or sets the created by id. /// diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityAssignmentStatus.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityAssignmentStatus.cs new file mode 100644 index 000000000..1f29b368b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityAssignmentStatus.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Identifies whether an omnichannel activity is available for assignment or currently owned by an agent, queue, or dialer. +/// +public enum ActivityAssignmentStatus +{ + /// + /// The activity has no owner and can be considered by routing or dialer components. + /// + Unassigned, + + /// + /// The activity is available for routing or dialing. + /// + Available, + + /// + /// The activity is temporarily reserved by a routing or dialer component. + /// + Reserved, + + /// + /// The activity is assigned to an agent or workflow owner. + /// + Assigned, + + /// + /// The activity is actively being worked. + /// + InProgress, + + /// + /// The activity assignment has been released. + /// + Released, +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs new file mode 100644 index 000000000..6302ad56c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Configures the available activity batch sources. +/// +public sealed class ActivityBatchSourceOptions +{ + private readonly Dictionary _sources = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the registered activity batch sources. + /// + public IReadOnlyDictionary Sources + => _sources; + + /// + /// Adds or updates an activity batch source. + /// + /// The unique source identifier. + /// An optional configuration action. + public void AddSource(string source, Action configure = null) + { + ArgumentException.ThrowIfNullOrEmpty(source); + + if (!_sources.TryGetValue(source, out var entry)) + { + entry = new ActivityBatchSourceEntry(source); + } + + configure?.Invoke(entry); + + entry.DisplayName ??= new LocalizedString(source, source); + entry.Description ??= new LocalizedString(source, source); + + _sources[source] = entry; + } +} + +/// +/// Represents a registered activity batch source entry. +/// +public sealed class ActivityBatchSourceEntry +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique source identifier. + public ActivityBatchSourceEntry(string source) + { + ArgumentException.ThrowIfNullOrEmpty(source); + + Source = source; + } + + /// + /// Gets the unique source identifier. + /// + public string Source { get; } + + /// + /// Gets or sets the display name shown in the UI. + /// + public LocalizedString DisplayName { get; set; } + + /// + /// Gets or sets the description shown in the source selection dialog. + /// + public LocalizedString Description { get; set; } + + /// + /// Gets or sets a value indicating whether batches from this source require user assignment while loading. + /// + public bool RequiresUserAssignment { get; set; } = true; +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionRequest.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionRequest.cs new file mode 100644 index 000000000..b02175c05 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionRequest.cs @@ -0,0 +1,37 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Represents a request to disposition an omnichannel activity. +/// +public sealed class ActivityDispositionRequest +{ + /// + /// Gets or sets the activity to disposition. + /// + public OmnichannelActivity Activity { get; set; } + + /// + /// Gets or sets the selected disposition identifier. + /// + public string DispositionId { get; set; } + + /// + /// Gets or sets the source that produced the disposition. + /// + public ActivityDispositionSource Source { get; set; } = ActivityDispositionSource.Agent; + + /// + /// Gets or sets optional notes to append or store with the activity disposition. + /// + public string Notes { get; set; } + + /// + /// Gets or sets the actor identifier applying the disposition. + /// + public string ActorId { get; set; } + + /// + /// Gets or sets the actor display name applying the disposition. + /// + public string ActorDisplayName { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionResult.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionResult.cs new file mode 100644 index 000000000..4c038dae6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionResult.cs @@ -0,0 +1,50 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Represents the result of applying a disposition to an omnichannel activity. +/// +public sealed class ActivityDispositionResult +{ + /// + /// Gets or sets a value indicating whether the disposition was applied successfully. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the dispositioned activity. + /// + public OmnichannelActivity Activity { get; set; } + + /// + /// Gets or sets the error message when the disposition could not be applied. + /// + public string ErrorMessage { get; set; } + + /// + /// Creates a successful result for the specified activity. + /// + /// The dispositioned activity. + /// The successful disposition result. + public static ActivityDispositionResult Success(OmnichannelActivity activity) + { + return new ActivityDispositionResult + { + Succeeded = true, + Activity = activity, + }; + } + + /// + /// Creates a failed result with the specified error message. + /// + /// The error message. + /// The failed disposition result. + public static ActivityDispositionResult Failure(string errorMessage) + { + return new ActivityDispositionResult + { + Succeeded = false, + ErrorMessage = errorMessage, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionSource.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionSource.cs new file mode 100644 index 000000000..22c138e11 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityDispositionSource.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Identifies the source that supplied an activity disposition. +/// Workflows receive the final activity disposition without needing to know this value. +/// +public enum ActivityDispositionSource +{ + /// + /// The disposition was selected by an agent. + /// + Agent, + + /// + /// The disposition was produced by a provider event. + /// + Provider, + + /// + /// The disposition was produced by AI analysis. + /// + AI, + + /// + /// The disposition was produced by workflow automation. + /// + Workflow, + + /// + /// The disposition was produced by a system process. + /// + System, +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityKind.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityKind.cs new file mode 100644 index 000000000..93cdd0068 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityKind.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Identifies the kind of work represented by an omnichannel activity. +/// +public enum ActivityKind +{ + /// + /// A general task activity. + /// + Task, + + /// + /// A phone-call activity. + /// + Call, + + /// + /// An email activity. + /// + Email, + + /// + /// An SMS activity. + /// + Sms, + + /// + /// A meeting activity. + /// + Meeting, +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivitySources.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivitySources.cs new file mode 100644 index 000000000..4a39a5b3c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivitySources.cs @@ -0,0 +1,57 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Contains well-known activity source identifiers. +/// +public static class ActivitySources +{ + /// + /// The activity was created or selected manually. + /// + public const string Manual = "Manual"; + + /// + /// The activity was loaded for a dialer and is assigned dynamically later. + /// + public const string Dialer = "Dialer"; + + /// + /// The activity is driven by preview dialing. + /// + public const string PreviewDial = "PreviewDial"; + + /// + /// The activity is driven by power dialing. + /// + public const string PowerDial = "PowerDial"; + + /// + /// The activity is driven by progressive dialing. + /// + public const string ProgressiveDial = "ProgressiveDial"; + + /// + /// The activity is driven by predictive dialing. + /// + public const string PredictiveDial = "PredictiveDial"; + + /// + /// The activity is a callback request. + /// + public const string Callback = "Callback"; + + /// + /// The activity originated from an inbound customer contact. + /// + public const string Inbound = "Inbound"; + + /// + /// The activity was created by workflow automation. + /// + public const string Workflow = "Workflow"; + + /// + /// The activity was created by an API integration. + /// + public const string Api = "Api"; +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs index c523787bc..a7a53e9b2 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs @@ -13,6 +13,17 @@ public sealed class OmnichannelActivity : CatalogItem /// public long Id { get; set; } + /// + /// Gets or sets the kind of work the activity represents. + /// + public ActivityKind Kind { get; set; } = ActivityKind.Task; + + /// + /// Gets or sets the source that created or is currently driving the activity. + /// Workflow processing must not depend on this value. + /// + public string Source { get; set; } = ActivitySources.Manual; + /// /// 'SMS', 'Chat', 'Email', etc. /// @@ -83,6 +94,36 @@ public sealed class OmnichannelActivity : CatalogItem /// public DateTime? AssignedToUtc { get; set; } + /// + /// Gets or sets the assignment lifecycle status used by queues, dialers, and reservations. + /// + public ActivityAssignmentStatus AssignmentStatus { get; set; } + + /// + /// Gets or sets the active reservation identifier when a queue or dialer has reserved the activity. + /// + public string ReservationId { get; set; } + + /// + /// Gets or sets the user or system actor that reserved the activity. + /// + public string ReservedById { get; set; } + + /// + /// Gets or sets the display name of the user or system actor that reserved the activity. + /// + 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 activity reservation expires. + /// + public DateTime? ReservationExpiresUtc { get; set; } + /// /// Gets or sets the created by id. /// @@ -162,6 +203,13 @@ public enum ActivityStatus AwaitingAgentResponse, AwaitingCustomerAnswer, Completed, + Pending, + Scheduled, + Reserved, + Dialing, + InProgress, + Failed, + Cancelled, Purged, } diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs index 28fed4c65..8055196fb 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs @@ -19,6 +19,11 @@ public sealed class OmnichannelActivityBatch : CatalogItem, IDisplayTextAwareMod /// public string CampaignId { get; set; } + /// + /// Gets or sets the activity source used when loading activities from this batch. + /// + public string Source { get; set; } = ActivitySources.Manual; + /// /// Gets or sets the subject content type. /// @@ -156,6 +161,7 @@ public OmnichannelActivityBatch Clone() ItemId = ItemId, DisplayText = DisplayText, CampaignId = CampaignId, + Source = Source, SubjectContentType = SubjectContentType, ContactContentType = ContactContentType, UserIds = UserIds?.ToArray(), diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityDispositionService.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityDispositionService.cs new file mode 100644 index 000000000..a5dd455ed --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityDispositionService.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Omnichannel.Core.Services; + +/// +/// Defines the centralized service for applying dispositions to omnichannel activities. +/// Components that produce outcomes must use this service instead of modifying activities directly. +/// +public interface IActivityDispositionService +{ + /// + /// Applies the specified disposition to an activity, validates it against the activity subject, + /// records audit information, triggers the configured subject flow, and publishes domain events. + /// + /// The disposition request. + /// The token to monitor for cancellation requests. + /// The disposition result. + Task ApplyAsync(ActivityDispositionRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index bc13031ef..13a1f8da2 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -161,6 +161,17 @@ 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. +- 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). + ### Content Access Control #### Role-based content restrictions @@ -249,6 +260,8 @@ At a high level, the platform changes are: - Automated subject flows now select a chat AI profile instead of configuring provider, connection, deployment, prompt, and tuning settings inline. - Subject pickers only show subjects whose flows are fully configured, and activities and activity batches now resolve `CampaignId`, channel, interaction type, and channel endpoint from the selected subject flow. - 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 batches now use a source-selection modal before creation. Manual batches keep the existing selected-user assignment flow, while dialer batches hide the user selector and load activities as unassigned, available dialer work for later reservation. +- Activity batch sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. - 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. - Admin editors in Omnichannel Management now use the current `ocat-*` admin layout classes instead of older generic Bootstrap field markup. diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md new file mode 100644 index 000000000..9e624cf4a --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -0,0 +1,110 @@ +--- +sidebar_label: Contact Center +sidebar_position: 0 +title: Contact Center +description: Provider-agnostic contact center orchestration for Orchard Core - interactions, queues, routing, presence, and outbound dialing on top of the Telephony and Omnichannel modules. +--- + +| | | +| --- | --- | +| **Feature Name** | Contact Center | +| **Feature ID** | `CrestApps.OrchardCore.ContactCenter` | + +The **Contact Center** module set turns the CRM into a full contact center that agents and +supervisors operate without leaving Orchard Core. It extends the [Omnichannel](../omnichannel/index.md) +CRM instead of introducing a second work model, and it sits between the CRM and the +[Telephony](../telephony/index.md) soft phone: the CRM owns business work data, the Contact Center +owns orchestration, and Telephony owns media execution. + +The CRM **Activity** remains the universal unit of work. Activities can be created before an owner +exists, then later reserved and assigned by a dialer, queue, or agent workflow. An **Interaction** is +communication history for a single attempt on that activity - for example a busy call attempt, a +no-answer attempt, or a connected call - and it never owns workflow or disposition. + +## Layer boundaries + +```text +Omnichannel CRM Contacts, activities, campaigns, subjects, dispositions, subject actions + │ universal work item, business context, disposition, workflow + ▼ +Contact Center Activity queues/reservations, routing, presence, dialer, wrap-up, interactions, metrics + │ call-control intents, assignment changes, normalized call/session events + ▼ +Telephony Soft phone, provider resolver, provider call execution and call state + │ + ▼ +Telephony providers Dial, answer, transfer, hold, resume, conference, hangup, provider webhooks +``` + +- **CRM (Omnichannel)** answers *who the customer is, which Activity is the work item, which Subject + defines workflow, and which Disposition was selected*. +- **Contact Center** answers *which activity happens next, which queue owns it, which agent should + reserve it, when a dial occurs, what communication history exists for the activity, and what + real-time event the UI should see*. +- **Telephony** answers *how the configured provider executes a call action and what the provider's + current call state is*. + +The Contact Center never handles telephony media and never directly bypasses the CRM disposition +path. It orchestrates behavior, records communication history, and projects operational state. + +## CRM activity extensions + +Contact Center extends `OmnichannelActivity` with metadata needed by queues and dialers: + +- Nullable ownership so preview, power, progressive, and predictive dialing can create activities + before an agent is selected. +- Activity kind and extensible source metadata so the same Activity model can represent calls, SMS, + email, meetings, tasks, callbacks, inbound work, workflow-created work, API-created work, and + dialer inventory. +- Assignment and reservation metadata so multiple dialer or routing instances do not claim the same + record concurrently. +- Activity batches can load either user-assigned manual work or unassigned dialer work. The batch + creation dialog selects a source first, then display drivers render source-specific UI. + +Dispositions are applied to Activities, not Interactions. Agent, provider, AI, workflow, and system +outcomes converge through the activity disposition service before Subject Actions or workflow +automation runs. + +## Capabilities + +The Contact Center is delivered as a set of feature-gated modules so tenants enable only the +capabilities they need, similar to how commercial platforms separate licensed capabilities: + +- **Interaction management** - communication history for activity attempts. +- **Queues** - inbound, outbound, callback, and dynamic activity queues with priority, SLA, and overflow. +- **Routing** - skills-based, priority, sticky-agent, round robin, longest-idle, and business-hours + routing with auditable routing decisions. +- **Agents and presence** - agent profiles, real-time presence and reason codes, skills, capacity, + and queue membership. +- **Voice** - a voice channel adapter over the Telephony module that maps provider calls to + interactions. +- **Dialer** - outbound manual, preview, power, and progressive dialing driven by CRM activities. +- **Wrap-up** - wrap-up timers, required activity dispositions, CRM activity completion, and + post-communication automation. +- **Supervision** - live queue and agent monitoring with supervisor call-control intents. +- **Analytics** - queue, agent, and campaign metrics and historical reporting. + +Inbound entry points and IVR, call recording, outbound compliance, quality management, an optional +workflow bridge, and AI assistance are additional capabilities on the roadmap. + +## Real-time experience + +The Contact Center publishes its own real-time event stream over SignalR for agent desktops, +supervisor dashboards, and queue monitors. It does not reuse the Telephony soft-phone hub for +routing, queue, or supervisor data; voice call state continues to flow through Telephony and is +projected into the interaction. + +## UI extensibility + +All Contact Center UI is built with Orchard Core display management: shapes, display drivers, +placement, templates, and shape alternates. CRUD screens should follow the AI Profile UI pattern, +where 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 is under active, phased development. The first milestone is a voice MVP that +proves agents can run inbound and outbound voice work entirely inside the CRM while preserving the +Telephony boundary. This documentation will expand as each capability ships. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 476930b08..e7a93d3ae 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -200,14 +200,17 @@ Subjects without any actions show a **Missing flow** badge in the Subject Flows ### 8) Create and Load an Activity Batch 1. Go to `Interaction Center` → `Activity Batches`. -2. Create a new batch: +2. Click **Add Activity Batch** and choose a source: + - **Manual** loads activities assigned to the selected users immediately. + - **Dialer** loads unassigned activities that a dialer reserves and assigns later. +3. Create the new batch: - Select contact type - Select subject type - - Assign users - - Optionally set lead created range filters -3. Click `Load`. + - Assign users when the selected source requires assignment + - Optionally set lead created range, phone number, time zone, and last activity filters +4. Click `Load`. -The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the campaign, interaction type, channel, and channel endpoint. +The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the campaign, interaction type, channel, and channel endpoint. Manual batches assign each created activity to a selected user. Dialer batches leave activities unassigned with assignment status `Available` so dialers can reserve and assign them safely later. ### 9) Complete Activities diff --git a/src/CrestApps.Docs/sidebars.js b/src/CrestApps.Docs/sidebars.js index f12f71caf..1118e5117 100644 --- a/src/CrestApps.Docs/sidebars.js +++ b/src/CrestApps.Docs/sidebars.js @@ -102,6 +102,13 @@ const sidebars = { 'telephony/dialpad', ], }, + { + type: 'category', + label: 'Contact Center', + items: [ + 'contact-center/index', + ], + }, { type: 'category', label: 'Standard Modules', diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj new file mode 100644 index 000000000..3470bcd12 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -0,0 +1,34 @@ + + + + true + CrestApps OrchardCore Contact Center + + $(CrestAppsDescription) + + The base Contact Center module. Provides the interaction lifecycle, the durable domain event + log, baseline permissions, and admin navigation for the CrestApps OrchardCore Contact Center. + + $(PackageTags) ContactCenter Interactions + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionEventIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionEventIndexProvider.cs new file mode 100644 index 000000000..d4105deba --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionEventIndexProvider.cs @@ -0,0 +1,37 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class InteractionEventIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public InteractionEventIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(interactionEvent => new InteractionEventIndex + { + ItemId = interactionEvent.ItemId, + InteractionId = interactionEvent.InteractionId, + EventType = interactionEvent.EventType, + AggregateType = interactionEvent.AggregateType, + AggregateId = interactionEvent.AggregateId, + CorrelationId = interactionEvent.CorrelationId, + IdempotencyKey = interactionEvent.IdempotencyKey, + OccurredUtc = interactionEvent.OccurredUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionIndexProvider.cs new file mode 100644 index 000000000..306bc24bc --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/InteractionIndexProvider.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class InteractionIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public InteractionIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(interaction => new InteractionIndex + { + ItemId = interaction.ItemId, + Channel = interaction.Channel, + Direction = interaction.Direction, + Status = interaction.Status, + ActivityItemId = interaction.ActivityItemId, + ProviderName = interaction.ProviderName, + ProviderInteractionId = interaction.ProviderInteractionId, + ProviderLegId = interaction.ProviderLegId, + QueueId = interaction.QueueId, + AgentId = interaction.AgentId, + CorrelationId = interaction.CorrelationId, + CreatedUtc = interaction.CreatedUtc, + EndedUtc = interaction.EndedUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs new file mode 100644 index 000000000..6cfa33ad1 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -0,0 +1,23 @@ +using CrestApps.OrchardCore; +using CrestApps.OrchardCore.ContactCenter; +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Contact Center", + Author = CrestAppsManifestConstants.Author, + Website = CrestAppsManifestConstants.Website, + Version = CrestAppsManifestConstants.Version, + Description = "Provides the contact center orchestration layer that turns the CRM into a full contact center.", + Category = "Communications" +)] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.Area, + Name = "Contact Center", + Description = "Provides the interaction lifecycle, the durable domain event log, baseline permissions, and admin navigation.", + Category = "Communications", + Dependencies = + [ + "OrchardCore.Users", + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionEventIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionEventIndexMigrations.cs new file mode 100644 index 000000000..e61283943 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionEventIndexMigrations.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class InteractionEventIndexMigrations : DataMigration +{ + /// + /// Creates the interaction event index table and its supporting indexes. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("InteractionId", column => column.WithLength(26)) + .Column("EventType", column => column.WithLength(128)) + .Column("AggregateType", column => column.WithLength(128)) + .Column("AggregateId", column => column.WithLength(26)) + .Column("CorrelationId", column => column.WithLength(26)) + .Column("IdempotencyKey", column => column.WithLength(128)) + .Column("OccurredUtc", column => column.NotNull()), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_InteractionEventIndex_Interaction", + "InteractionId", + "OccurredUtc", + "EventType"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_InteractionEventIndex_Idempotency", + "IdempotencyKey"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionIndexMigrations.cs new file mode 100644 index 000000000..1f23847d6 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/InteractionIndexMigrations.cs @@ -0,0 +1,56 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class InteractionIndexMigrations : DataMigration +{ + /// + /// Creates the interaction index table and its supporting indexes. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Channel", column => column.WithLength(50)) + .Column("Direction", column => column.WithLength(50)) + .Column("Status", column => column.WithLength(50)) + .Column("ActivityItemId", column => column.WithLength(26)) + .Column("ProviderName", column => column.WithLength(128)) + .Column("ProviderInteractionId", column => column.WithLength(128)) + .Column("ProviderLegId", column => column.WithLength(128)) + .Column("QueueId", column => column.WithLength(26)) + .Column("AgentId", column => column.WithLength(26)) + .Column("CorrelationId", column => column.WithLength(26)) + .Column("CreatedUtc", column => column.NotNull()) + .Column("EndedUtc"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_InteractionIndex_DocumentId", + "DocumentId", + "ItemId", + "Status", + "QueueId", + "AgentId"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_InteractionIndex_Lookup", + "ActivityItemId", + "ProviderInteractionId", + "ProviderLegId", + "CorrelationId"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs new file mode 100644 index 000000000..579dd739a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using OrchardCore; +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Provides the baseline permissions exposed by the Contact Center feature. +/// +internal sealed class ContactCenterPermissionProvider : IPermissionProvider +{ + private readonly IEnumerable _allPermissions = + [ + ContactCenterPermissions.ManageContactCenter, + ContactCenterPermissions.ManageInteractions, + ContactCenterPermissions.ViewInteractions, + ]; + + /// + public IEnumerable GetDefaultStereotypes() + => + [ + new PermissionStereotype + { + Name = OrchardCoreConstants.Roles.Administrator, + Permissions = _allPermissions, + }, + ]; + + /// + public Task> GetPermissionsAsync() + => Task.FromResult(_allPermissions); +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs new file mode 100644 index 000000000..eb537db48 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -0,0 +1,38 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Indexes; +using CrestApps.OrchardCore.ContactCenter.Migrations; +using CrestApps.OrchardCore.ContactCenter.Services; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Data; +using OrchardCore.Data.Migration; +using OrchardCore.Modules; +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Registers the services and configuration for the base Contact Center feature. +/// +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.Configure(options => options.Collections.Add(ContactCenterConstants.CollectionName)); + + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + services + .AddIndexProvider() + .AddDataMigration(); + + services + .AddIndexProvider() + .AddDataMigration(); + + services.AddPermissionProvider(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs index 10f05e2c0..e23cee1e3 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs @@ -6,6 +6,7 @@ using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels; using Dapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -52,6 +53,7 @@ public sealed class ActivityBatchesController : Controller private readonly IClock _clock; private readonly INotifier _notifier; private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; + private readonly ActivityBatchSourceOptions _activityBatchSourceOptions; internal readonly IHtmlLocalizer H; internal readonly IStringLocalizer S; @@ -66,6 +68,7 @@ public sealed class ActivityBatchesController : Controller /// The clock. /// The notifier. /// The subject flow settings service. + /// The configured activity batch sources. /// The html localizer. /// The string localizer. public ActivityBatchesController( @@ -76,6 +79,7 @@ public ActivityBatchesController( IClock clock, INotifier notifier, ISubjectFlowSettingsService subjectFlowSettingsService, + IOptions activityBatchSourceOptions, IHtmlLocalizer htmlLocalizer, IStringLocalizer stringLocalizer) { @@ -86,6 +90,7 @@ public ActivityBatchesController( _clock = clock; _notifier = notifier; _subjectFlowSettingsService = subjectFlowSettingsService; + _activityBatchSourceOptions = activityBatchSourceOptions.Value; H = htmlLocalizer; S = stringLocalizer; } @@ -124,11 +129,13 @@ public async Task Index( routeData.Values.TryAdd(_optionsSearch, options.Search); } - var viewModel = new ListCatalogEntryViewModel> + var viewModel = new ListOmnichannelActivityBatchViewModel { Models = [], Options = options, Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + Sources = _activityBatchSourceOptions.Sources.Values + .OrderBy(source => source.DisplayName.Value), }; foreach (var model in result.Entries) @@ -169,19 +176,27 @@ public async Task IndexFilterPost(ListCatalogEntryViewModel model) /// /// Creates a new . /// - [Admin("omnichannel/activity/batches/create", "OmnichannelActivityBatchesCreate")] - public async Task Create() + [Admin("omnichannel/activity/batches/create/{source?}", "OmnichannelActivityBatchesCreate")] + public async Task Create(string source = ActivitySources.Manual) { if (!await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ManageActivityBatches)) { return Forbid(); } + if (!TryGetActivityBatchSource(source, out var sourceEntry)) + { + await _notifier.ErrorAsync(H["Unable to find an activity batch source with the name '{0}'.", source]); + + return RedirectToAction(nameof(Index)); + } + var model = await _manager.NewAsync(); + model.Source = sourceEntry.Source; var viewModel = new EditCatalogEntryViewModel { - DisplayName = S["Activity Batch"], + DisplayName = sourceEntry.DisplayName, Editor = await _batchDisplayDriver.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), }; @@ -193,19 +208,27 @@ public async Task Create() /// [HttpPost] [ActionName(nameof(Create))] - [Admin("omnichannel/activity/batches/create", "OmnichannelActivityBatchesCreate")] - public async Task CreatePost() + [Admin("omnichannel/activity/batches/create/{source?}", "OmnichannelActivityBatchesCreate")] + public async Task CreatePost(string source = ActivitySources.Manual) { if (!await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ManageActivityBatches)) { return Forbid(); } + if (!TryGetActivityBatchSource(source, out var sourceEntry)) + { + await _notifier.ErrorAsync(H["Unable to find an activity batch source with the name '{0}'.", source]); + + return RedirectToAction(nameof(Index)); + } + var model = await _manager.NewAsync(); + model.Source = sourceEntry.Source; var viewModel = new EditCatalogEntryViewModel { - DisplayName = S["New Activity Batch"], + DisplayName = sourceEntry.DisplayName, Editor = await _batchDisplayDriver.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), }; @@ -413,9 +436,24 @@ await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync("load-activity-batch", Use await using var readonlySession = session.Store.CreateSession(withTracking: false); - var users = (await readonlySession.Query(x => x.IsEnabled && x.UserId.IsIn(batch.UserIds)).ListAsync()).ToArray(); + var sourceOptions = scope.ServiceProvider.GetRequiredService>().Value; - if (users.Length == 0) + if (!TryGetActivityBatchSource(batch.Source, sourceOptions, out var sourceEntry)) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await catalog.UpdateAsync(batch); + + logger.LogError("No valid activity batch source was found for the batch with ID '{BatchId}' and source '{Source}'.", batch.ItemId, batch.Source); + return; + } + + var requiresUserAssignment = sourceEntry.RequiresUserAssignment; + var users = requiresUserAssignment + ? (await readonlySession.Query(x => x.IsEnabled && x.UserId.IsIn(batch.UserIds)).ListAsync()).ToArray() + : []; + + if (requiresUserAssignment && users.Length == 0) { batch.Status = OmnichannelActivityBatchStatus.New; @@ -664,10 +702,14 @@ SELECT MAX(a2.{completedCol}) return; } - var user = users[activityCounter++ % users.Length]; + var user = requiresUserAssignment + ? users[activityCounter++ % users.Length] + : null; var activity = await activityManager.NewAsync(); + activity.Kind = GetActivityKind(flowSettings.Channel); + activity.Source = sourceEntry.Source; activity.InteractionType = flowSettings.InteractionType; activity.Channel = flowSettings.Channel; activity.ContactContentItemId = contact.ContentItemId; @@ -677,15 +719,26 @@ SELECT MAX(a2.{completedCol}) activity.ChannelEndpointId = flowSettings.ChannelEndpointId; activity.CampaignId = flowSettings.CampaignId; activity.ScheduledUtc = scheduledUtc; - activity.AssignedToId = user.UserId; - activity.AssignedToUsername = user.UserName; - activity.AssignedToUtc = now; + if (user is not null) + { + activity.AssignedToId = user.UserId; + activity.AssignedToUsername = user.UserName; + activity.AssignedToUtc = now; + activity.AssignmentStatus = ActivityAssignmentStatus.Assigned; + } + else + { + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + } + activity.Instructions = batch.Instructions; activity.CreatedUtc = now; activity.CreatedById = loaderId; activity.CreatedByUsername = loaderUserName; activity.UrgencyLevel = batch.UrgencyLevel; - activity.Status = ActivityStatus.NotStated; + activity.Status = user is null + ? ActivityStatus.Scheduled + : ActivityStatus.NotStated; batch.TotalLoaded++; @@ -765,4 +818,36 @@ public async Task IndexPost(CatalogEntryOptions options, IEnumerab return RedirectToAction(nameof(Index)); } + + private bool TryGetActivityBatchSource(string source, out ActivityBatchSourceEntry sourceEntry) + => TryGetActivityBatchSource(source, _activityBatchSourceOptions, out sourceEntry); + + private static bool TryGetActivityBatchSource(string source, ActivityBatchSourceOptions options, out ActivityBatchSourceEntry sourceEntry) + { + var normalizedSource = string.IsNullOrWhiteSpace(source) + ? ActivitySources.Manual + : source.Trim(); + + return options.Sources.TryGetValue(normalizedSource, out sourceEntry); + } + + private static ActivityKind GetActivityKind(string channel) + { + if (string.Equals(channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase)) + { + return ActivityKind.Call; + } + + if (string.Equals(channel, OmnichannelConstants.Channels.Sms, StringComparison.OrdinalIgnoreCase)) + { + return ActivityKind.Sms; + } + + if (string.Equals(channel, OmnichannelConstants.Channels.Email, StringComparison.OrdinalIgnoreCase)) + { + return ActivityKind.Email; + } + + return ActivityKind.Task; + } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs index d7a2e0c41..bf6104643 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs @@ -6,6 +6,7 @@ using CrestApps.OrchardCore.Users; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; using OrchardCore.ContentManagement.Metadata; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; @@ -27,6 +28,7 @@ internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriver _dispositionsCatalog; private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; + private readonly ActivityBatchSourceOptions _activityBatchSourceOptions; internal readonly IStringLocalizer S; @@ -40,6 +42,7 @@ internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriverThe YesSql session. /// The dispositions catalog. /// The subject flow settings service. + /// The configured activity batch sources. /// The string localizer. public OmnichannelActivityBatchDisplayDriver( IDisplayNameProvider displayNameProvider, @@ -49,6 +52,7 @@ public OmnichannelActivityBatchDisplayDriver( ISession session, INamedCatalog dispositionsCatalog, ISubjectFlowSettingsService subjectFlowSettingsService, + IOptions activityBatchSourceOptions, IStringLocalizer stringLocalizer) { _displayNameProvider = displayNameProvider; @@ -58,6 +62,7 @@ public OmnichannelActivityBatchDisplayDriver( _session = session; _dispositionsCatalog = dispositionsCatalog; _subjectFlowSettingsService = subjectFlowSettingsService; + _activityBatchSourceOptions = activityBatchSourceOptions.Value; S = stringLocalizer; } @@ -83,6 +88,9 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC return Initialize("OmnichannelActivityBatchFields_Edit", async model => { model.DisplayText = batch.DisplayText; + model.Source = string.IsNullOrEmpty(batch.Source) ? ActivitySources.Manual : batch.Source; + model.SourceDisplayName = GetSourceEntry(model.Source)?.DisplayName.Value ?? model.Source; + model.RequiresUserAssignment = GetSourceEntry(model.Source)?.RequiresUserAssignment ?? true; model.ScheduleAt = context.IsNew ? (await _localClock.GetLocalNowAsync()).DateTime : batch.ScheduleAt; model.SubjectContentType = batch.SubjectContentType; model.ContactContentType = batch.ContactContentType; @@ -119,7 +127,7 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC } } - if (batch.UserIds is { Length: > 0 }) + if (model.RequiresUserAssignment && batch.UserIds is { Length: > 0 }) { var users = (await _session.Query(x => x.UserId.IsIn(batch.UserIds)).ListAsync()) .OrderBy(user => Array.FindIndex(batch.UserIds, itemId => string.Equals(itemId, user.UserId, StringComparison.OrdinalIgnoreCase))); @@ -181,8 +189,18 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC public override async Task UpdateAsync(OmnichannelActivityBatch batch, UpdateEditorContext context) { var model = new OmnichannelActivityBatchViewModel(); + model.Source = string.IsNullOrEmpty(batch.Source) ? ActivitySources.Manual : batch.Source; await context.Updater.TryUpdateModelAsync(model, Prefix); + model.Source = string.IsNullOrEmpty(model.Source) ? batch.Source : model.Source; + model.Source = string.IsNullOrEmpty(model.Source) ? ActivitySources.Manual : model.Source; + + var sourceEntry = GetSourceEntry(model.Source); + + if (sourceEntry is null) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Source), S["The selected activity source is invalid."]); + } if (string.IsNullOrEmpty(model.DisplayText)) { @@ -203,7 +221,7 @@ public override async Task UpdateAsync(OmnichannelActivityBatch context.Updater.ModelState.AddModelError(Prefix, nameof(model.ContactContentType), S["Contact is required."]); } - if (model.UserIds is null || model.UserIds.Length == 0) + if ((sourceEntry?.RequiresUserAssignment ?? true) && (model.UserIds is null || model.UserIds.Length == 0)) { context.Updater.ModelState.AddModelError(Prefix, nameof(model.UserIds), S["At least one user is required."]); } @@ -219,12 +237,13 @@ public override async Task UpdateAsync(OmnichannelActivityBatch } batch.DisplayText = model.DisplayText?.Trim(); + batch.Source = model.Source?.Trim(); batch.SubjectContentType = model.SubjectContentType; batch.ContactContentType = model.ContactContentType; batch.Instructions = model.Instructions?.Trim(); batch.UrgencyLevel = model.UrgencyLevel; - batch.UserIds = model.UserIds ?? []; + batch.UserIds = sourceEntry?.RequiresUserAssignment == true ? model.UserIds ?? [] : []; batch.IncludeDoNoCalls = model.IncludeDoNoCalls; batch.IncludeDoNoSms = model.IncludeDoNoSms; batch.IncludeDoNoEmail = model.IncludeDoNoEmail; @@ -246,4 +265,15 @@ public override async Task UpdateAsync(OmnichannelActivityBatch return Edit(batch, context); } + + private ActivityBatchSourceEntry GetSourceEntry(string source) + { + var normalizedSource = string.IsNullOrWhiteSpace(source) + ? ActivitySources.Manual + : source.Trim(); + + _activityBatchSourceOptions.Sources.TryGetValue(normalizedSource, out var entry); + + return entry; + } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityBatchIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityBatchIndexProvider.cs index 4f0c06413..169098e07 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityBatchIndexProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityBatchIndexProvider.cs @@ -25,6 +25,7 @@ public override void Describe(DescribeContext context) DisplayText = !string.IsNullOrEmpty(batch.DisplayText) ? batch.DisplayText.Substring(0, Math.Min(255, batch.DisplayText.Length)) : null, + Source = batch.Source, Status = batch.Status, }); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityIndexProvider.cs index 6ec199afc..df9cd7630 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityIndexProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelActivityIndexProvider.cs @@ -25,6 +25,8 @@ public override void Describe(DescribeContext context) .Map(activity => new OmnichannelActivityIndex() { ItemId = activity.ItemId, + Kind = activity.Kind, + Source = activity.Source, Channel = activity.Channel, ChannelEndpointId = activity.ChannelEndpointId, SubjectContentType = activity.SubjectContentType, @@ -36,6 +38,11 @@ public override void Describe(DescribeContext context) Attempts = activity.Attempts, AssignedToId = activity.AssignedToId, AssignedToUtc = activity.AssignedToUtc, + AssignmentStatus = activity.AssignmentStatus, + ReservationId = activity.ReservationId, + ReservedById = activity.ReservedById, + ReservedUtc = activity.ReservedUtc, + ReservationExpiresUtc = activity.ReservationExpiresUtc, CreatedById = activity.CreatedById, CompletedUtc = activity.CompletedUtc, InteractionType = activity.InteractionType, diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityBatchIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityBatchIndexMigrations.cs index e3494ca40..28e06b0ab 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityBatchIndexMigrations.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityBatchIndexMigrations.cs @@ -15,6 +15,7 @@ public async Task CreateAsync() await SchemaBuilder.CreateMapIndexTableAsync(table => table .Column("ItemId", column => column.WithLength(26)) .Column("DisplayText", column => column.WithLength(255)) + .Column("Source", column => column.WithLength(50)) .Column("Status", column => column.WithLength(20)), collection: OmnichannelConstants.CollectionName ); @@ -29,6 +30,21 @@ await SchemaBuilder.AlterIndexTableAsync(table => collection: OmnichannelConstants.CollectionName ); - return 1; + return 2; + } + + /// + /// Adds the activity batch source column. + /// + /// The migration version number. + public async Task UpdateFrom1Async() + { + await SchemaBuilder.AlterIndexTableAsync(table => + { + table.AddColumn("Source", column => column.WithLength(50)); + }, + collection: OmnichannelConstants.CollectionName); + + return 2; } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs index 428028aba..24173bc8d 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs @@ -14,6 +14,8 @@ public async Task CreateAsync() { await SchemaBuilder.CreateMapIndexTableAsync(table => table .Column("ItemId", column => column.WithLength(26)) + .Column("Kind", column => column.WithLength(50)) + .Column("Source", column => column.WithLength(50)) .Column("Channel", column => column.WithLength(50)) .Column("ChannelEndpointId", column => column.WithLength(26)) .Column("PreferredDestination", column => column.WithLength(255)) @@ -27,6 +29,11 @@ await SchemaBuilder.CreateMapIndexTableAsync(table => .Column("Attempts", column => column.NotNull()) .Column("AssignedToId", column => column.WithLength(26)) .Column("AssignedToUtc") + .Column("AssignmentStatus", column => column.WithLength(50)) + .Column("ReservationId", column => column.WithLength(26)) + .Column("ReservedById", column => column.WithLength(26)) + .Column("ReservedUtc") + .Column("ReservationExpiresUtc") .Column("CreatedById", column => column.WithLength(26)) .Column("DispositionId", column => column.WithLength(26)) .Column("CreatedUtc", column => column.NotNull()) @@ -53,6 +60,7 @@ await SchemaBuilder.AlterIndexTableAsync(table => tabl "DocumentId", "AssignedToId", "Status", + "AssignmentStatus", "InteractionType", "ScheduledUtc"), collection: OmnichannelConstants.CollectionName @@ -68,6 +76,46 @@ await SchemaBuilder.AlterIndexTableAsync(table => tabl collection: OmnichannelConstants.CollectionName ); - return 1; + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_OmnichannelActivity_Assignment", + "AssignmentStatus", + "ReservationId", + "ReservedById", + "ScheduledUtc", + "DocumentId"), + collection: OmnichannelConstants.CollectionName + ); + + return 2; + } + + /// + /// Adds Contact Center assignment and classification columns to the activity index. + /// + /// The migration version number. + public async Task UpdateFrom1Async() + { + await SchemaBuilder.AlterIndexTableAsync(table => + { + table.AddColumn("Kind", column => column.WithLength(50)); + table.AddColumn("Source", column => column.WithLength(50)); + table.AddColumn("AssignmentStatus", column => column.WithLength(50)); + table.AddColumn("ReservationId", column => column.WithLength(26)); + table.AddColumn("ReservedById", column => column.WithLength(26)); + table.AddColumn("ReservedUtc"); + table.AddColumn("ReservationExpiresUtc"); + }, + collection: OmnichannelConstants.CollectionName); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_OmnichannelActivity_Assignment", + "AssignmentStatus", + "ReservationId", + "ReservedById", + "ScheduledUtc", + "DocumentId"), + collection: OmnichannelConstants.CollectionName); + + return 2; } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index c66773975..69c99efe0 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -137,6 +137,23 @@ public override void ConfigureServices(IServiceCollection services) }); }); + services.Configure(options => + { + options.AddSource(ActivitySources.Manual, entry => + { + entry.DisplayName = S["Manual"]; + entry.Description = S["Loads activities assigned to selected users for manual agent work."]; + entry.RequiresUserAssignment = true; + }); + + options.AddSource(ActivitySources.Dialer, entry => + { + entry.DisplayName = S["Dialer"]; + entry.Description = S["Loads unassigned activities that dialers reserve and assign later."]; + entry.RequiresUserAssignment = false; + }); + }); + services.AddPermissionProvider(); services.AddNavigationProvider(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/ListOmnichannelActivityBatchViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/ListOmnichannelActivityBatchViewModel.cs new file mode 100644 index 000000000..a54bd6757 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/ListOmnichannelActivityBatchViewModel.cs @@ -0,0 +1,15 @@ +using CrestApps.OrchardCore.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.ViewModels; + +/// +/// Represents the list view model for omnichannel activity batches. +/// +public sealed class ListOmnichannelActivityBatchViewModel : ListCatalogEntryViewModel> +{ + /// + /// Gets or sets the available activity batch sources. + /// + public IEnumerable Sources { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs index 16501286d..583a4fe99 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs @@ -15,6 +15,23 @@ public class OmnichannelActivityBatchViewModel /// public string DisplayText { get; set; } + /// + /// Gets or sets the activity source. + /// + public string Source { get; set; } + + /// + /// Gets or sets the activity source display name. + /// + [BindNever] + public string SourceDisplayName { get; set; } + + /// + /// Gets or sets a value indicating whether the selected source requires user assignment while loading. + /// + [BindNever] + public bool RequiresUserAssignment { get; set; } + /// /// Gets or sets the schedule at. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml index 5938e3bcc..28f5cbf0a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml @@ -1,10 +1,11 @@ @using CrestApps.Core.Models @using CrestApps.OrchardCore.Core.Models @using CrestApps.OrchardCore.Omnichannel.Core.Models +@using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels @using Microsoft.AspNetCore.Mvc.Localization @using OrchardCore.DisplayManagement -@model ListCatalogEntryViewModel> +@model ListOmnichannelActivityBatchViewModel

@RenderTitleSegments(T["Activity Batches"])

@@ -24,7 +25,7 @@
- @T["Add Activity Batch"] +
@@ -97,3 +98,36 @@ + + + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityBatch.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityBatch.DefaultMeta.SummaryAdmin.cshtml index 869c22965..b39437956 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityBatch.DefaultMeta.SummaryAdmin.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityBatch.DefaultMeta.SummaryAdmin.cshtml @@ -34,6 +34,10 @@ @status + + @(Model.Value.Source ?? ActivitySources.Manual) + + @if (Model.Value.Status == OmnichannelActivityBatchStatus.Loaded) { diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml index 77aecf511..13f1b9129 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml @@ -33,6 +33,24 @@ @T["Batch settings"]
+ + +
+ +
+ + + @if (Model.RequiresUserAssignment) + { + @T["Activities from this source are assigned to selected users when the batch is loaded."] + } + else + { + @T["Activities from this source stay unassigned until a dialer or routing component reserves them."] + } +
+
+
@@ -65,35 +83,38 @@
-
- -
- @await Component.InvokeAsync("ItemSelector", new - { - id = $"{Html.IdFor(m => m.UserIds)}_Selector", - inputName = Html.NameFor(m => m.UserIds), - endpoint = Url.RouteUrl("CrestApps.Users.Search", new { valueType = "userId" }), - initialItemsJson = JsonSerializer.Serialize(Model.SelectedUsers.Select(x => new { value = x.Value, text = x.Text, selected = true })), - multiple = true, - enableSearch = true, - enableSelectAll = true, - enableDeselectAll = true, - buttonText = T["Browse users"].Value, - searchPlaceholder = T["Search users"].Value, - emptyResultsText = T["No users found."].Value, - noSelectionText = T["No users selected."].Value, - loadingText = T["Loading users..."].Value, - loadErrorText = T["Unable to load users."].Value, - resultsTextFormat = T["Found {0} user(s)."].Value, - selectedItemsLabel = T["Selected users"].Value, - availableItemsLabel = T["Available users"].Value, - selectAllText = T["Select all"].Value, - deselectAllText = T["Deselect all"].Value, - searchButtonText = T["Search"].Value, - }) - + @if (Model.RequiresUserAssignment) + { +
+ +
+ @await Component.InvokeAsync("ItemSelector", new + { + id = $"{Html.IdFor(m => m.UserIds)}_Selector", + inputName = Html.NameFor(m => m.UserIds), + endpoint = Url.RouteUrl("CrestApps.Users.Search", new { valueType = "userId" }), + initialItemsJson = JsonSerializer.Serialize(Model.SelectedUsers.Select(x => new { value = x.Value, text = x.Text, selected = true })), + multiple = true, + enableSearch = true, + enableSelectAll = true, + enableDeselectAll = true, + buttonText = T["Browse users"].Value, + searchPlaceholder = T["Search users"].Value, + emptyResultsText = T["No users found."].Value, + noSelectionText = T["No users selected."].Value, + loadingText = T["Loading users..."].Value, + loadErrorText = T["Unable to load users."].Value, + resultsTextFormat = T["Found {0} user(s)."].Value, + selectedItemsLabel = T["Selected users"].Value, + availableItemsLabel = T["Available users"].Value, + selectAllText = T["Select all"].Value, + deselectAllText = T["Deselect all"].Value, + searchButtonText = T["Search"].Value, + }) + +
-
+ }
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchSource.Link.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchSource.Link.cshtml new file mode 100644 index 000000000..f523ad5d8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchSource.Link.cshtml @@ -0,0 +1,37 @@ +@using CrestApps.OrchardCore.Omnichannel.Core.Models +@using Microsoft.Extensions.Options + +@inject IOptions ActivityBatchSourceOptions + +@{ + string source = Model.Source; + + if (!ActivityBatchSourceOptions.Value.Sources.TryGetValue(source, out var entry)) + { + return; + } +} + +
+
+
+

@entry.DisplayName

+

@entry.Description

+ + @if (entry.RequiresUserAssignment) + { + @T["Assigns users when loaded"] + } + else + { + @T["Assigned later"] + } +
+ + +
+
diff --git a/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj b/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj index be995479b..f12635d9e 100644 --- a/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj +++ b/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj @@ -56,6 +56,7 @@ + diff --git a/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj b/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj index 790d99629..574aa30c7 100644 --- a/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj +++ b/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj @@ -25,6 +25,7 @@ + @@ -51,6 +52,7 @@ + diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs new file mode 100644 index 000000000..313f05234 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs @@ -0,0 +1,159 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class DefaultContactCenterEventPublisherTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task PublishAsync_PersistsTheEvent_AndDispatchesToHandlers() + { + // Arrange + var store = CreateStore(); + var handler = new Mock(); + handler + .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var publisher = CreatePublisher(store.Object, [handler.Object]); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionCreated, + InteractionId = "interaction-1", + }; + + // Act + await publisher.PublishAsync(interactionEvent, CancellationToken.None); + + // Assert + store.Verify(s => s.CreateAsync(interactionEvent, It.IsAny()), Times.Once); + handler.Verify(h => h.HandleAsync(interactionEvent, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PublishAsync_WhenOccurredUtcIsDefault_StampsFromClock() + { + // Arrange + var store = CreateStore(); + var publisher = CreatePublisher(store.Object, []); + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionCreated, + }; + + // Act + await publisher.PublishAsync(interactionEvent, CancellationToken.None); + + // Assert + Assert.Equal(_now, interactionEvent.OccurredUtc); + } + + [Fact] + public async Task PublishAsync_WhenActorIdIsEmpty_SetsTheSystemActor() + { + // Arrange + var store = CreateStore(); + var publisher = CreatePublisher(store.Object, []); + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionCreated, + }; + + // Act + await publisher.PublishAsync(interactionEvent, CancellationToken.None); + + // Assert + Assert.Equal(ContactCenterConstants.SystemActor, interactionEvent.ActorId); + } + + [Fact] + public async Task PublishAsync_WhenIdempotencyKeyAlreadyExists_SkipsPersistAndDispatch() + { + // Arrange + var store = CreateStore(); + store + .Setup(s => s.ExistsByIdempotencyKeyAsync("dup-key", It.IsAny())) + .ReturnsAsync(true); + + var handler = new Mock(); + var publisher = CreatePublisher(store.Object, [handler.Object]); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionCreated, + IdempotencyKey = "dup-key", + }; + + // Act + await publisher.PublishAsync(interactionEvent, CancellationToken.None); + + // Assert + store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + handler.Verify(h => h.HandleAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task PublishAsync_WhenAHandlerThrows_DoesNotThrow_AndStillRunsOtherHandlers() + { + // Arrange + var store = CreateStore(); + + var faultyHandler = new Mock(); + faultyHandler + .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("boom")); + + var healthyHandler = new Mock(); + healthyHandler + .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + + var publisher = CreatePublisher(store.Object, [faultyHandler.Object, healthyHandler.Object]); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.InteractionCreated, + }; + + // Act + await publisher.PublishAsync(interactionEvent, CancellationToken.None); + + // Assert + store.Verify(s => s.CreateAsync(interactionEvent, It.IsAny()), Times.Once); + healthyHandler.Verify(h => h.HandleAsync(interactionEvent, It.IsAny()), Times.Once); + } + + private static Mock CreateStore() + { + var store = new Mock(); + store + .Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + store + .Setup(s => s.ExistsByIdempotencyKeyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + return store; + } + + private static DefaultContactCenterEventPublisher CreatePublisher( + IInteractionEventStore store, + IEnumerable handlers) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new DefaultContactCenterEventPublisher( + store, + handlers, + clock.Object, + NullLogger.Instance); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEntityTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEntityTests.cs new file mode 100644 index 000000000..bba35ab70 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEntityTests.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using OrchardCore.Entities; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class InteractionEntityTests +{ + [Fact] + public void Interaction_SupportsEntityMetadata() + { + // Arrange + var interaction = new Interaction(); + var metadata = new TestInteractionMetadata + { + Value = "provider-specific", + }; + + // Act + interaction.Put(metadata); + var found = interaction.TryGet(out var result); + + // Assert + Assert.True(found); + Assert.NotNull(result); + Assert.Equal("provider-specific", result.Value); + } + + private sealed class TestInteractionMetadata + { + public string Value { get; set; } + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEventTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEventTests.cs new file mode 100644 index 000000000..c7e314eef --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionEventTests.cs @@ -0,0 +1,65 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class InteractionEventTests +{ + [Fact] + public void SetData_ThenGetData_RoundTripsThePayload() + { + // Arrange + var interactionEvent = new InteractionEvent(); + var payload = new Dictionary + { + ["queueId"] = "queue-1", + ["agentId"] = "agent-7", + }; + + // Act + interactionEvent.SetData(payload); + var result = interactionEvent.GetData>(); + + // Assert + Assert.NotNull(result); + Assert.Equal("queue-1", result["queueId"]); + Assert.Equal("agent-7", result["agentId"]); + } + + [Fact] + public void GetData_WhenNoPayload_ReturnsDefault() + { + // Arrange + var interactionEvent = new InteractionEvent(); + + // Act + var result = interactionEvent.GetData>(); + + // Assert + Assert.Null(result); + } + + [Fact] + public void SetData_WithNull_ClearsThePayload() + { + // Arrange + var interactionEvent = new InteractionEvent(); + interactionEvent.SetData(new Dictionary { ["queueId"] = "queue-1" }); + + // Act + interactionEvent.SetData>(null); + + // Assert + Assert.Null(interactionEvent.Data); + } + + [Fact] + public void SchemaVersion_DefaultsToCurrent() + { + // Arrange & Act + var interactionEvent = new InteractionEvent(); + + // Assert + Assert.Equal(ContactCenterConstants.CurrentEventSchemaVersion, interactionEvent.SchemaVersion); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs new file mode 100644 index 000000000..82c6ac7a5 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs @@ -0,0 +1,80 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class InteractionManagerTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task NewAsync_SetsItemIdCorrelationIdAndCreatedUtc() + { + // Arrange + var manager = CreateManager(); + + // Act + var interaction = await manager.NewAsync(); + + // Assert + Assert.False(string.IsNullOrEmpty(interaction.ItemId)); + Assert.False(string.IsNullOrEmpty(interaction.CorrelationId)); + Assert.Equal(_now, interaction.CreatedUtc); + } + + [Fact] + public async Task CreateAsync_WhenIdentityIsMissing_GeneratesIdentityAndCreatedUtc() + { + // Arrange + var store = new Mock(); + store + .Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + var manager = CreateManager(store.Object); + var interaction = new Interaction(); + + // Act + await manager.CreateAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.False(string.IsNullOrEmpty(interaction.ItemId)); + Assert.False(string.IsNullOrEmpty(interaction.CorrelationId)); + Assert.Equal(_now, interaction.CreatedUtc); + store.Verify(s => s.CreateAsync(interaction, It.IsAny()), Times.Once); + } + + [Fact] + public async Task UpdateAsync_SetsModifiedUtc() + { + // Arrange + var store = new Mock(); + store + .Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) + .Returns(ValueTask.CompletedTask); + + var manager = CreateManager(store.Object); + var interaction = new Interaction + { + ItemId = "interaction-1", + CorrelationId = "correlation-1", + }; + + // Act + await manager.UpdateAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(_now, interaction.ModifiedUtc); + store.Verify(s => s.UpdateAsync(interaction, It.IsAny()), Times.Once); + } + + private static InteractionManager CreateManager(IInteractionStore store = null) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new InteractionManager(store ?? Mock.Of(), clock.Object); + } +} From a3876524b729ac05ecc1076e17a5ecf6dab4d398 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sun, 28 Jun 2026 23:55:56 -0700 Subject: [PATCH 02/56] Address feedback --- .github/contact-center/PLAN.md | 48 ++++++++--- .../IContactCenterVoiceProvider.cs | 44 ++++++++++ .../ContactCenterCallAssignmentRequest.cs | 37 ++++++++ .../Models/ContactCenterDialRequest.cs | 47 ++++++++++ .../Models/ContactCenterQueueCallRequest.cs | 37 ++++++++ .../ContactCenterVoiceProviderCapabilities.cs | 38 +++++++++ .../ContactCenterVoiceProviderResult.cs | 32 +++++++ ...Apps.OrchardCore.ContactCenter.Core.csproj | 1 + .../Models/Interaction.cs | 13 ++- .../Models/InteractionEvent.cs | 13 ++- .../Services/IInteractionEventStore.cs | 10 +-- .../Services/IInteractionManager.cs | 31 +------ .../Services/IInteractionStore.cs | 25 +----- .../Services/InteractionEventStore.cs | 20 ++--- .../Services/InteractionManager.cs | 85 ++++++------------- .../Services/InteractionStore.cs | 41 ++------- .../Models/OmnichannelActivityBatch.cs | 2 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 1 + .../docs/contact-center/index.md | 26 ++++++ .../Handlers/InteractionHandler.cs | 60 +++++++++++++ .../Manifest.cs | 4 +- .../ContactCenterPermissionProvider.cs | 2 +- .../Startup.cs | 6 +- .../Controllers/ActivityBatchesController.cs | 19 +++-- .../ContactCenter/InteractionHandlerTests.cs | 68 +++++++++++++++ .../ContactCenter/InteractionManagerTests.cs | 80 ----------------- 26 files changed, 514 insertions(+), 276 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterCallAssignmentRequest.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterDialRequest.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterQueueCallRequest.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderResult.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/InteractionHandler.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionHandlerTests.cs delete mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index e901648f7..522d01b8e 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -60,6 +60,8 @@ Required CRM alignment: - Activities need classification metadata such as kind (call, email, SMS, meeting, task) and source (manual, preview dial, power dial, progressive dial, predictive dial, callback, inbound, workflow, API). Workflows must ignore source and react only to the activity and final disposition. - Activity batches select an activity source before showing the editor. Manual batches require user assignment while loading; dialer batches hide user selection and load unassigned activities with an available assignment status so the dialer can reserve and assign them later. - Dispositions belong to activities, not interactions. Provider, agent, AI, and workflow outcomes must converge through a single activity disposition service before subject actions/workflows run. +- Contact Center must provide the agent workspace where agents receive work. Agents sign in to queues and/or campaign assignments, set presence, accept or reject offers, handle the active activity, use injected Telephony controls, review interaction history, and complete wrap-up from the CRM UI. +- PBX/voice providers that can do more than soft-phone call control may implement Contact Center voice-provider abstractions for dialer dialing, call assignment, provider-side queues, queue events, and PBX presence synchronization. ### Existing real-time boundary @@ -76,7 +78,9 @@ Design implication: Contact Center should add its own real-time event stream for - No real-time agent presence or capacity model. - No inbound routing orchestration. - No outbound dialer orchestration beyond manual CRM activities and automated SMS processing. +- No Contact Center agent workspace for queue/campaign sign-in, current offer handling, active activity, injected phone controls, and wrap-up. - No contact-center-level call session mapping between provider calls and business interactions. +- No Contact Center voice-provider abstraction that lets PBX providers participate in dialer dialing, call assignment, and provider-side queue behavior. - No supervisor dashboard or live queue metric projection. - No durable domain event stream/outbox for contact center orchestration. - Existing OrchardCore.Workflows integration for Omnichannel Management has been removed; Contact Center needs an explicit workflow strategy. @@ -127,6 +131,7 @@ Design implication: Contact Center should add its own real-time event stream for - Voice channel adapter that integrates Contact Center with the Telephony module. - Owns call session mapping and Contact Center voice lifecycle projection. - Depends on Telephony but does not replace Telephony. + - Exposes `IContactCenterVoiceProvider` extension points so PBX providers can implement dialer dialing, provider-side call assignment, provider queue placement, queue events, and PBX presence synchronization when supported. 6. `CrestApps.OrchardCore.ContactCenter.Dialer` - Outbound campaign dialing modes: manual, preview, power, progressive, and later predictive. @@ -139,32 +144,36 @@ Design implication: Contact Center should add its own real-time event stream for - Supervisor live monitoring, agent monitoring, queue controls, coaching/assist metadata, SLA alerts, and operational command permissions. - Live call-control primitives: silent monitor, whisper coaching, barge-in, and take-over, expressed as orchestration intents that Telephony/providers execute. -9. `CrestApps.OrchardCore.ContactCenter.EntryPoints` +9. `CrestApps.OrchardCore.ContactCenter.AgentWorkspace` + - Agent workspace UI where agents sign in/out of queues and campaigns, change presence, receive activity offers, accept or reject work, see the active activity, use injected Telephony call controls, review interaction history, and complete wrap-up. + - Uses shapes, display drivers, placement, and SignalR snapshots/events so modules can inject provider controls, campaign panels, compliance warnings, AI assist, and supervisor coaching without replacing the workspace. + +10. `CrestApps.OrchardCore.ContactCenter.EntryPoints` - Inbound entry points, DID/number-to-entry-point mapping, IVR/self-service decision flows, business-hours/holiday gating, queue selection, announcements, and screen-pop context. - Reuses subject flows and the optional Workflows bridge for decision logic instead of hardcoding IVR trees. -10. `CrestApps.OrchardCore.ContactCenter.Recording` +11. `CrestApps.OrchardCore.ContactCenter.Recording` - Recording orchestration: start/stop/pause/resume intents, consent capture, recording metadata, retention/disposal policy, and access auditing. - Stores recording metadata and references only; media capture and storage stay with Telephony/providers or a configured media store. -11. `CrestApps.OrchardCore.ContactCenter.Compliance` +12. `CrestApps.OrchardCore.ContactCenter.Compliance` - Outbound calling windows, abandonment-rate caps, safe-harbor/abandon messaging, caller-ID/local-presence policy, list scrubbing/recycling, consent tracking, and suppression auditing. - Reuses existing DNC registry and contact communication preferences rather than duplicating them. -12. `CrestApps.OrchardCore.ContactCenter.Analytics` +13. `CrestApps.OrchardCore.ContactCenter.Analytics` - Metric projections, historical reporting, SLA snapshots, campaign performance, agent performance, queue performance, adherence, and export-ready reporting data. -13. `CrestApps.OrchardCore.ContactCenter.Quality` +14. `CrestApps.OrchardCore.ContactCenter.Quality` - Optional quality management: evaluation forms/scorecards, recording review, calibration, and coaching records. Advanced phase. -14. `CrestApps.OrchardCore.ContactCenter.Workflows` +15. `CrestApps.OrchardCore.ContactCenter.Workflows` - Optional OrchardCore.Workflows bridge for tenants that want workflow activities/events in addition to Subject Flows and Subject Actions. - Should be feature-gated because current Omnichannel Management intentionally removed its direct Workflows dependency. -15. `CrestApps.OrchardCore.ContactCenter.AI` +16. `CrestApps.OrchardCore.ContactCenter.AI` - Optional AI assist, virtual agent, summarization, disposition suggestions, quality insights, and future AI routing recommendations. -16. `CrestApps.OrchardCore.ContactCenter.Deployment` +17. `CrestApps.OrchardCore.ContactCenter.Deployment` - Recipes and deployment steps for queues, routing policies, skills, agent profiles, dialer profiles, entry points, recording/compliance policies, supervisor dashboards, and tenant defaults. ## Domain architecture overview @@ -329,6 +338,14 @@ Capacity model: - Capacity is per channel and per agent profile. - Routing must reserve capacity before offering work. +Agent workspace: + +- Agents use the Contact Center Agent Workspace in the CRM admin experience to receive work. +- The workspace shows queue sign-in, campaign sign-in, presence/reason selection, current reservation/offer, active activity, customer/contact summary, interaction history, Telephony call controls, wrap-up, required disposition, and supervisor/AI assistance. +- Agents can opt into queues and campaigns they are permitted to handle. Managers configure queue membership, campaign assignment, dialing mode, priority, and capacity rules. +- Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all deliver activity offers through the same workspace offer model. +- The workspace is shape-driven: Telephony injects call controls, Contact Center injects reservation/wrap-up/presence, providers inject provider-specific call state, and optional modules inject AI assist, compliance, or supervisor coaching. + ### 6. Campaign Dialer | Area | Design | @@ -458,11 +475,11 @@ Event envelope requirements: | Area | Design | | --- | --- | | Purpose | Preserve a clean separation between Contact Center business orchestration and Telephony provider/media execution. | -| Responsibilities | Define which layer owns routing, call actions, call state, provider metadata, and business interaction state. | +| Responsibilities | Define which layer owns routing, call actions, call state, provider metadata, business activity state, and provider-side contact center capabilities. | | Data owned | Boundary contracts, channel adapter mappings, provider call references, and normalized channel session metadata. | | Events consumed | RoutingDecisionMade, OutboundDialRequested, AgentAcceptedInteraction, TelephonyCallStateChanged, ProviderWebhookReceived. | | Events emitted | CallControlRequested, CallControlAccepted, CallControlFailed, ChannelSessionEventReceived, ProviderEventNormalized. | -| Interactions | Contact Center Voice talks to Telephony through provider-agnostic Telephony services. Telephony providers never read Contact Center queues or routing policies. | +| Interactions | Contact Center Voice talks to Telephony through provider-agnostic Telephony services for soft-phone/media control. PBX providers that support provider-side dialer or queue behavior can also implement `IContactCenterVoiceProvider`; Contact Center still owns the routing/campaign/reservation decision and sends provider-neutral intents to the provider adapter. Telephony providers never read Contact Center queues or routing policies directly. | | Why it exists | This prevents business logic from leaking into providers and prevents Telephony from becoming an orchestration engine. | Ownership rules: @@ -476,13 +493,22 @@ Ownership rules: | CRM activity lifecycle | CRM plus Contact Center orchestration | | Call-control request intent | Contact Center or agent UI | | Provider call execution | Telephony | +| Provider dialer dial execution | PBX provider through `IContactCenterVoiceProvider` when supported, otherwise Telephony dial | +| Provider-side call assignment | PBX provider through `IContactCenterVoiceProvider`; Contact Center owns the assignment decision | +| Provider-side queue placement | PBX provider through `IContactCenterVoiceProvider`; Contact Center owns the queue decision | | Provider authentication | Telephony | | Provider/media state truth | Telephony/provider | -| Business interaction state truth | Contact Center | +| CRM activity/work state truth | CRM plus Contact Center orchestration | | Call session business projection | Contact Center Voice | | Persistent call history currently used by soft phone | Telephony | | Enterprise interaction history and analytics | Contact Center | +Provider extension contract: + +- `IContactCenterVoiceProvider` is optional and complements `ITelephonyProvider`; it does not replace soft-phone call control. +- Providers implement it when they can dial on behalf of a dialer, assign provider calls to agents, place/move calls in provider-side queues, publish queue events, or synchronize PBX presence. +- Contact Center remains the brain: it chooses the activity, queue, agent, campaign, dialer mode, and compliance gates, then asks the provider to execute a specific operation. + ### 10. Workflow Integration | Area | Design | 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..ee85d8aa2 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs @@ -0,0 +1,44 @@ +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Defines optional Contact Center voice operations a PBX or telephony provider can implement in addition to soft-phone call control. +/// +public interface IContactCenterVoiceProvider +{ + /// + /// Gets the localized, human-readable name of the provider. + /// + LocalizedString Name { get; } + + /// + /// Gets the provider capabilities supported for Contact Center orchestration. + /// + ContactCenterVoiceProviderCapabilities Capabilities { get; } + + /// + /// Places an outbound dialer call for a reserved activity and agent. + /// + /// The dial request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task DialAsync(ContactCenterDialRequest request, CancellationToken cancellationToken = default); + + /// + /// Assigns an existing provider call to an agent for a Contact Center activity. + /// + /// The call assignment request. + /// The token to monitor for cancellation requests. + /// The provider operation result. + Task AssignCallAsync(ContactCenterCallAssignmentRequest request, CancellationToken cancellationToken = default); + + /// + /// Places or moves a provider call into a provider-side queue when the provider supports queue ownership. + /// + /// The queue call 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/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/ContactCenterDialRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterDialRequest.cs new file mode 100644 index 000000000..90c1c3aeb --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterDialRequest.cs @@ -0,0 +1,47 @@ +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 reserved agent identifier, when the dialing mode requires an agent before dialing. + /// + public string AgentId { 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/ContactCenterVoiceProviderCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs new file mode 100644 index 000000000..e8d430e4c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs @@ -0,0 +1,38 @@ +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, +} 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..c2fa051ca --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderResult.cs @@ -0,0 +1,32 @@ +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 the provider call identifier returned by the provider. + /// + public string ProviderCallId { 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/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj index bf977ce20..7f4999f48 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs index 8935928f1..2469ce9d3 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs @@ -1,4 +1,6 @@ +using System.Text.Json.Nodes; using CrestApps.Core; +using CrestApps.Core.Models; using CrestApps.OrchardCore.ContactCenter.Models; using OrchardCore.Entities; @@ -8,12 +10,17 @@ 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 : Entity, IModifiedUtcAwareModel +public sealed class Interaction : CatalogItem, IEntity, IModifiedUtcAwareModel { /// - /// Gets or sets the stable identifier of the interaction. + /// Gets or sets extensible Orchard entity metadata for the interaction. /// - public string ItemId { get; set; } + public JsonObject EntityProperties { get; set; } = []; + + JsonObject IEntity.Properties + { + get => EntityProperties; + } /// /// Gets or sets the channel the interaction is conducted on. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs index 5ca2d268e..934eee2e9 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InteractionEvent.cs @@ -1,4 +1,6 @@ using System.Text.Json; +using System.Text.Json.Nodes; +using CrestApps.Core.Models; using OrchardCore.Entities; namespace CrestApps.OrchardCore.ContactCenter.Core.Models; @@ -7,12 +9,17 @@ 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 : Entity +public sealed class InteractionEvent : CatalogItem, IEntity { /// - /// Gets or sets the stable identifier of the event. + /// Gets or sets extensible Orchard entity metadata for the event. /// - public string ItemId { get; set; } + public JsonObject EntityProperties { get; set; } = []; + + JsonObject IEntity.Properties + { + get => EntityProperties; + } /// /// Gets or sets the identifier of the interaction the event belongs to. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs index 94d78788e..3fb3e00f8 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs @@ -1,3 +1,4 @@ +using CrestApps.Core.Services; using CrestApps.OrchardCore.ContactCenter.Core.Models; namespace CrestApps.OrchardCore.ContactCenter.Core.Services; @@ -5,15 +6,8 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Defines the persistence contract for the durable interaction event history. /// -public interface IInteractionEventStore +public interface IInteractionEventStore : ICatalog { - /// - /// Creates the specified interaction event. - /// - /// The interaction event to create. - /// The token to monitor for cancellation requests. - ValueTask CreateAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default); - /// /// Lists the events recorded for the specified interaction, oldest first. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs index d8efcca5d..e5f69c9b6 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs @@ -1,4 +1,5 @@ using CrestApps.Core.Models; +using CrestApps.Core.Services; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Models; @@ -7,36 +8,8 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Defines the management contract for interactions. /// -public interface IInteractionManager +public interface IInteractionManager : ICatalogManager { - /// - /// Creates a new interaction instance with default values. - /// - /// The new interaction. - ValueTask NewAsync(); - - /// - /// Creates the specified interaction. - /// - /// The interaction to create. - /// The token to monitor for cancellation requests. - ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default); - - /// - /// Updates the specified interaction. - /// - /// The interaction to update. - /// The token to monitor for cancellation requests. - ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default); - - /// - /// Finds the interaction with the specified identifier. - /// - /// The interaction identifier. - /// The token to monitor for cancellation requests. - /// The matching interaction, or when none is found. - ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default); - /// /// Finds the interaction linked to the specified CRM activity. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs index d870e3696..83ab4b821 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs @@ -1,4 +1,5 @@ using CrestApps.Core.Models; +using CrestApps.Core.Services; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Models; @@ -7,30 +8,8 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Defines the persistence contract for interactions. /// -public interface IInteractionStore +public interface IInteractionStore : ICatalog { - /// - /// Creates the specified interaction. - /// - /// The interaction to create. - /// The token to monitor for cancellation requests. - ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default); - - /// - /// Updates the specified interaction. - /// - /// The interaction to update. - /// The token to monitor for cancellation requests. - ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default); - - /// - /// Finds the interaction with the specified identifier. - /// - /// The interaction identifier. - /// The token to monitor for cancellation requests. - /// The matching interaction, or when none is found. - ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default); - /// /// Finds the interaction linked to the specified CRM activity. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs index 1b6e877f9..0a09177dd 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs @@ -1,5 +1,6 @@ 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; @@ -7,25 +8,16 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Provides a YesSql-based implementation of . /// -public sealed class InteractionEventStore : IInteractionEventStore +public sealed class InteractionEventStore : DocumentCatalog, IInteractionEventStore { - private readonly ISession _session; - /// /// Initializes a new instance of the class. /// /// The YesSql session. public InteractionEventStore(ISession session) + : base(session) { - _session = session; - } - - /// - public async ValueTask CreateAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(interactionEvent); - - await _session.SaveAsync(interactionEvent, ContactCenterConstants.CollectionName); + CollectionName = ContactCenterConstants.CollectionName; } /// @@ -33,7 +25,7 @@ public async Task> ListByInteractionAsync(string { ArgumentException.ThrowIfNullOrEmpty(interactionId); - var events = await _session.Query( + var events = await Session.Query( index => index.InteractionId == interactionId, collection: ContactCenterConstants.CollectionName) .OrderBy(index => index.OccurredUtc) @@ -47,7 +39,7 @@ public async Task ExistsByIdempotencyKeyAsync(string idempotencyKey, Cance { ArgumentException.ThrowIfNullOrEmpty(idempotencyKey); - var match = await _session.Query( + var match = await Session.Query( index => index.IdempotencyKey == idempotencyKey, collection: ContactCenterConstants.CollectionName) .FirstOrDefaultAsync(cancellationToken); diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs index d1d1b2e5c..288fcc1a3 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs @@ -1,103 +1,70 @@ using CrestApps.Core.Models; +using CrestApps.Core.Services; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Models; -using OrchardCore; -using OrchardCore.Modules; +using Microsoft.Extensions.Logging; namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// -/// Provides the default implementation of . +/// Provides the default implementation of that delegates storage +/// to and loads entries through catalog handlers. /// -public sealed class InteractionManager : IInteractionManager +public sealed class InteractionManager : CatalogManager, IInteractionManager { private readonly IInteractionStore _store; - private readonly IClock _clock; /// /// Initializes a new instance of the class. /// /// The underlying interaction store. - /// The clock used to stamp audit times. + /// The catalog entry handlers for interactions. + /// The logger instance. public InteractionManager( IInteractionStore store, - IClock clock) + IEnumerable> handlers, + ILogger> logger) + : base(store, handlers, logger) { _store = store; - _clock = clock; } /// - public ValueTask NewAsync() - { - return ValueTask.FromResult(new Interaction - { - ItemId = IdGenerator.GenerateId(), - CorrelationId = IdGenerator.GenerateId(), - CreatedUtc = _clock.UtcNow, - }); - } - - /// - public async ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default) + public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) { - ArgumentNullException.ThrowIfNull(interaction); - - EnsureIdentity(interaction); + var interaction = await _store.FindByActivityIdAsync(activityItemId, cancellationToken); - if (interaction.CreatedUtc == default) + if (interaction is not null) { - interaction.CreatedUtc = _clock.UtcNow; + await LoadAsync(interaction, cancellationToken); } - await _store.CreateAsync(interaction, cancellationToken); + return interaction; } /// - public async ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(interaction); - - EnsureIdentity(interaction); - interaction.ModifiedUtc = _clock.UtcNow; - - await _store.UpdateAsync(interaction, cancellationToken); - } - - /// - public ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default) + public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) { - return _store.FindByIdAsync(id, cancellationToken); - } + var interaction = await _store.FindByCorrelationIdAsync(correlationId, cancellationToken); - /// - public async Task FindByActivityIdAsync(string activityItemId, CancellationToken cancellationToken = default) - { - return await _store.FindByActivityIdAsync(activityItemId, cancellationToken); - } + if (interaction is not null) + { + await LoadAsync(interaction, cancellationToken); + } - /// - public async Task FindByCorrelationIdAsync(string correlationId, CancellationToken cancellationToken = default) - { - return await _store.FindByCorrelationIdAsync(correlationId, cancellationToken); + return interaction; } /// public async Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default) { - return await _store.PageByStatusAsync(page, pageSize, status, cancellationToken); - } + var result = await _store.PageByStatusAsync(page, pageSize, status, cancellationToken); - private static void EnsureIdentity(Interaction interaction) - { - if (string.IsNullOrEmpty(interaction.ItemId)) + foreach (var entry in result.Entries) { - interaction.ItemId = IdGenerator.GenerateId(); + await LoadAsync(entry, cancellationToken); } - if (string.IsNullOrEmpty(interaction.CorrelationId)) - { - interaction.CorrelationId = IdGenerator.GenerateId(); - } + return result; } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs index bb0b613b6..2b5e9bdc9 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs @@ -2,6 +2,7 @@ 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; @@ -9,44 +10,16 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Provides a YesSql-based implementation of . /// -public sealed class InteractionStore : IInteractionStore +public sealed class InteractionStore : DocumentCatalog, IInteractionStore { - private readonly ISession _session; - /// /// Initializes a new instance of the class. /// /// The YesSql session. public InteractionStore(ISession session) + : base(session) { - _session = session; - } - - /// - public async ValueTask CreateAsync(Interaction interaction, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(interaction); - - await _session.SaveAsync(interaction, ContactCenterConstants.CollectionName); - } - - /// - public async ValueTask UpdateAsync(Interaction interaction, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(interaction); - - await _session.SaveAsync(interaction, ContactCenterConstants.CollectionName); - } - - /// - public async ValueTask FindByIdAsync(string id, CancellationToken cancellationToken = default) - { - ArgumentException.ThrowIfNullOrEmpty(id); - - return await _session.Query( - index => index.ItemId == id, - collection: ContactCenterConstants.CollectionName) - .FirstOrDefaultAsync(cancellationToken); + CollectionName = ContactCenterConstants.CollectionName; } /// @@ -54,7 +27,7 @@ public async Task FindByActivityIdAsync(string activityItemId, Canc { ArgumentException.ThrowIfNullOrEmpty(activityItemId); - return await _session.Query( + return await Session.Query( index => index.ActivityItemId == activityItemId, collection: ContactCenterConstants.CollectionName) .OrderByDescending(index => index.CreatedUtc) @@ -66,7 +39,7 @@ public async Task FindByCorrelationIdAsync(string correlationId, Ca { ArgumentException.ThrowIfNullOrEmpty(correlationId); - return await _session.Query( + return await Session.Query( index => index.CorrelationId == correlationId, collection: ContactCenterConstants.CollectionName) .OrderByDescending(index => index.CreatedUtc) @@ -76,7 +49,7 @@ public async Task FindByCorrelationIdAsync(string correlationId, Ca /// public async Task> PageByStatusAsync(int page, int pageSize, InteractionStatus status, CancellationToken cancellationToken = default) { - var query = _session.Query( + var query = Session.Query( index => index.Status == status, collection: ContactCenterConstants.CollectionName) .OrderBy(index => index.CreatedUtc); diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs index 8055196fb..1bd5654a8 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs @@ -22,7 +22,7 @@ public sealed class OmnichannelActivityBatch : CatalogItem, IDisplayTextAwareMod /// /// Gets or sets the activity source used when loading activities from this batch. /// - public string Source { get; set; } = ActivitySources.Manual; + public string Source { get; set; } /// /// Gets or sets the subject content type. diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 13a1f8da2..22b2d9cb1 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -169,6 +169,7 @@ At a high level, the platform changes are: - 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 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). diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 9e624cf4a..6e66659e1 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -94,6 +94,32 @@ supervisor dashboards, and queue monitors. It does not reuse the Telephony soft- routing, queue, or supervisor data; voice call state continues to flow through Telephony and is projected into the interaction. +## Agent workspace + +Agents receive Contact Center work in the Contact Center Agent Workspace inside the CRM admin +experience. The workspace is the place where agents sign in to allowed queues and outbound +campaigns, set presence and reason codes, receive activity offers, accept or reject reservations, +work the active CRM activity, use injected Telephony call controls, review interaction history, and +complete wrap-up/disposition. + +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 workspace model. + +## 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: it selects the Activity, queue, agent, campaign, dialer mode, and +compliance gates, then sends provider-neutral intents to the provider adapter. + ## UI extensibility All Contact Center UI is built with Orchard Core display management: shapes, display drivers, diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/InteractionHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/InteractionHandler.cs new file mode 100644 index 000000000..a132ed984 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/InteractionHandler.cs @@ -0,0 +1,60 @@ +using System.Security.Claims; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.AspNetCore.Http; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +/// +/// Maintains audit timestamps, the correlation identifier, and creator metadata for interactions. +/// +internal sealed class InteractionHandler : CatalogEntryHandlerBase +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP context accessor used to resolve the current user. + /// The clock used to stamp audit times. + public InteractionHandler( + IHttpContextAccessor httpContextAccessor, + IClock clock) + { + _httpContextAccessor = httpContextAccessor; + _clock = clock; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + if (string.IsNullOrEmpty(context.Model.CorrelationId)) + { + context.Model.CorrelationId = IdGenerator.GenerateId(); + } + + var user = _httpContextAccessor.HttpContext?.User; + + if (user is not null) + { + context.Model.CreatedById = user.FindFirstValue(ClaimTypes.NameIdentifier); + context.Model.CreatedByUserName = user.Identity?.Name; + } + + return Task.CompletedTask; + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs index 6cfa33ad1..3d1ff6e7c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -8,14 +8,14 @@ Website = CrestAppsManifestConstants.Website, Version = CrestAppsManifestConstants.Version, Description = "Provides the contact center orchestration layer that turns the CRM into a full contact center.", - Category = "Communications" + Category = "Communication" )] [assembly: Feature( Id = ContactCenterConstants.Feature.Area, Name = "Contact Center", Description = "Provides the interaction lifecycle, the durable domain event log, baseline permissions, and admin navigation.", - Category = "Communications", + Category = "Communication", Dependencies = [ "OrchardCore.Users", diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs index 579dd739a..7a2382dbf 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs @@ -9,7 +9,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Services; /// internal sealed class ContactCenterPermissionProvider : IPermissionProvider { - private readonly IEnumerable _allPermissions = + private static readonly IEnumerable _allPermissions = [ ContactCenterPermissions.ManageContactCenter, ContactCenterPermissions.ManageInteractions, diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index eb537db48..6e75ddec8 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -1,4 +1,7 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Handlers; using CrestApps.OrchardCore.ContactCenter.Indexes; using CrestApps.OrchardCore.ContactCenter.Migrations; using CrestApps.OrchardCore.ContactCenter.Services; @@ -23,7 +26,8 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped, InteractionHandler>(); services .AddIndexProvider() diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs index e23cee1e3..a6f9211f0 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs @@ -176,8 +176,8 @@ public async Task IndexFilterPost(ListCatalogEntryViewModel model) /// /// Creates a new . /// - [Admin("omnichannel/activity/batches/create/{source?}", "OmnichannelActivityBatchesCreate")] - public async Task Create(string source = ActivitySources.Manual) + [Admin("omnichannel/activity/batches/create/{source}", "OmnichannelActivityBatchesCreate")] + public async Task Create(string source) { if (!await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ManageActivityBatches)) { @@ -208,8 +208,8 @@ public async Task Create(string source = ActivitySources.Manual) /// [HttpPost] [ActionName(nameof(Create))] - [Admin("omnichannel/activity/batches/create/{source?}", "OmnichannelActivityBatchesCreate")] - public async Task CreatePost(string source = ActivitySources.Manual) + [Admin("omnichannel/activity/batches/create/{source}", "OmnichannelActivityBatchesCreate")] + public async Task CreatePost(string source) { if (!await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ManageActivityBatches)) { @@ -824,9 +824,14 @@ private bool TryGetActivityBatchSource(string source, out ActivityBatchSourceEnt private static bool TryGetActivityBatchSource(string source, ActivityBatchSourceOptions options, out ActivityBatchSourceEntry sourceEntry) { - var normalizedSource = string.IsNullOrWhiteSpace(source) - ? ActivitySources.Manual - : source.Trim(); + if (string.IsNullOrWhiteSpace(source)) + { + sourceEntry = null; + + return false; + } + + var normalizedSource = source.Trim(); return options.Sources.TryGetValue(normalizedSource, out sourceEntry); } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionHandlerTests.cs new file mode 100644 index 000000000..fabeceb09 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionHandlerTests.cs @@ -0,0 +1,68 @@ +using System.Text.Json.Nodes; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Handlers; +using Microsoft.AspNetCore.Http; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class InteractionHandlerTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task InitializedAsync_SetsCreatedUtc_AndGeneratesCorrelationId() + { + // Arrange + var handler = CreateHandler(); + var interaction = new Interaction(); + + // Act + await handler.InitializedAsync(new InitializedContext(interaction), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(_now, interaction.CreatedUtc); + Assert.False(string.IsNullOrEmpty(interaction.CorrelationId)); + } + + [Fact] + public async Task InitializedAsync_WhenCorrelationIdIsProvided_DoesNotOverwriteIt() + { + // Arrange + var handler = CreateHandler(); + var interaction = new Interaction + { + CorrelationId = "correlation-1", + }; + + // Act + await handler.InitializedAsync(new InitializedContext(interaction), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("correlation-1", interaction.CorrelationId); + } + + [Fact] + public async Task UpdatingAsync_SetsModifiedUtc() + { + // Arrange + var handler = CreateHandler(); + var interaction = new Interaction(); + + // Act + await handler.UpdatingAsync(new UpdatingContext(interaction, new JsonObject()), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(_now, interaction.ModifiedUtc); + } + + private static InteractionHandler CreateHandler() + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new InteractionHandler(Mock.Of(), clock.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs deleted file mode 100644 index 82c6ac7a5..000000000 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InteractionManagerTests.cs +++ /dev/null @@ -1,80 +0,0 @@ -using CrestApps.OrchardCore.ContactCenter.Core.Models; -using CrestApps.OrchardCore.ContactCenter.Core.Services; -using Moq; -using OrchardCore.Modules; - -namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; - -public sealed class InteractionManagerTests -{ - private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); - - [Fact] - public async Task NewAsync_SetsItemIdCorrelationIdAndCreatedUtc() - { - // Arrange - var manager = CreateManager(); - - // Act - var interaction = await manager.NewAsync(); - - // Assert - Assert.False(string.IsNullOrEmpty(interaction.ItemId)); - Assert.False(string.IsNullOrEmpty(interaction.CorrelationId)); - Assert.Equal(_now, interaction.CreatedUtc); - } - - [Fact] - public async Task CreateAsync_WhenIdentityIsMissing_GeneratesIdentityAndCreatedUtc() - { - // Arrange - var store = new Mock(); - store - .Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - - var manager = CreateManager(store.Object); - var interaction = new Interaction(); - - // Act - await manager.CreateAsync(interaction, TestContext.Current.CancellationToken); - - // Assert - Assert.False(string.IsNullOrEmpty(interaction.ItemId)); - Assert.False(string.IsNullOrEmpty(interaction.CorrelationId)); - Assert.Equal(_now, interaction.CreatedUtc); - store.Verify(s => s.CreateAsync(interaction, It.IsAny()), Times.Once); - } - - [Fact] - public async Task UpdateAsync_SetsModifiedUtc() - { - // Arrange - var store = new Mock(); - store - .Setup(s => s.UpdateAsync(It.IsAny(), It.IsAny())) - .Returns(ValueTask.CompletedTask); - - var manager = CreateManager(store.Object); - var interaction = new Interaction - { - ItemId = "interaction-1", - CorrelationId = "correlation-1", - }; - - // Act - await manager.UpdateAsync(interaction, TestContext.Current.CancellationToken); - - // Assert - Assert.Equal(_now, interaction.ModifiedUtc); - store.Verify(s => s.UpdateAsync(interaction, It.IsAny()), Times.Once); - } - - private static InteractionManager CreateManager(IInteractionStore store = null) - { - var clock = new Mock(); - clock.SetupGet(c => c.UtcNow).Returns(_now); - - return new InteractionManager(store ?? Mock.Of(), clock.Object); - } -} From f90a59575066093a5499c98098ba4ed50990517f Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 09:01:48 -0700 Subject: [PATCH 03/56] Add Contact Center agents, queues, reservations, assignment, and dialer-agnostic dialing (#502) --- .github/contact-center/PLAN.md | 10 +- .../ContactCenterConstants.cs | 85 +++++++ .../IDialerProvider.cs | 43 ++++ .../IDialerProviderResolver.cs | 21 ++ .../Models/AgentPresenceStatus.cs | 37 +++ .../Models/DialerDialRequest.cs | 49 ++++ .../Models/DialerDialResult.cs | 62 +++++ .../Models/DialerMode.cs | 32 +++ .../Models/DialerProviderCapabilities.cs | 33 +++ .../Models/QueueItemStatus.cs | 32 +++ .../Models/ReservationStatus.cs | 32 +++ .../ContactCenterPermissions.cs | 20 ++ ...Apps.OrchardCore.ContactCenter.Core.csproj | 1 + .../Indexes/ActivityQueueIndex.cs | 24 ++ .../Indexes/ActivityReservationIndex.cs | 35 +++ .../Indexes/AgentProfileIndex.cs | 30 +++ .../Indexes/DialerProfileIndex.cs | 34 +++ .../Indexes/QueueItemIndex.cs | 45 ++++ .../Models/ActivityQueue.cs | 56 +++++ .../Models/ActivityReservation.cs | 51 ++++ .../Models/AgentProfile.cs | 81 ++++++ .../Models/DialerProfile.cs | 81 ++++++ .../Models/QueueItem.cs | 56 +++++ .../Services/ActivityAssignmentService.cs | 74 ++++++ .../Services/ActivityQueueManager.cs | 54 ++++ .../Services/ActivityQueueService.cs | 101 ++++++++ .../Services/ActivityQueueStore.cs | 44 ++++ .../Services/ActivityReservationManager.cs | 54 ++++ .../Services/ActivityReservationService.cs | 233 ++++++++++++++++++ .../Services/ActivityReservationStore.cs | 45 ++++ .../Services/AgentPresenceManagerService.cs | 126 ++++++++++ .../Services/AgentProfileManager.cs | 54 ++++ .../Services/AgentProfileStore.cs | 47 ++++ .../Services/DialerProfileManager.cs | 54 ++++ .../Services/DialerProfileStore.cs | 44 ++++ .../Services/DialerProviderResolver.cs | 35 +++ .../Services/DialerService.cs | 147 +++++++++++ .../Services/IActivityAssignmentService.cs | 25 ++ .../Services/IActivityQueueManager.cs | 25 ++ .../Services/IActivityQueueService.cs | 28 +++ .../Services/IActivityQueueStore.cs | 25 ++ .../Services/IActivityReservationManager.cs | 26 ++ .../Services/IActivityReservationService.cs | 42 ++++ .../Services/IActivityReservationStore.cs | 26 ++ .../Services/IAgentPresenceManager.cs | 38 +++ .../Services/IAgentProfileManager.cs | 26 ++ .../Services/IAgentProfileStore.cs | 26 ++ .../Services/IDialerProfileManager.cs | 25 ++ .../Services/IDialerProfileStore.cs | 25 ++ .../Services/IDialerService.cs | 18 ++ .../Services/IQueueItemManager.cs | 26 ++ .../Services/IQueueItemStore.cs | 26 ++ .../Services/QueueItemManager.cs | 54 ++++ .../Services/QueueItemStore.cs | 50 ++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 8 + .../contact-center/agents-queues-dialer.md | 77 ++++++ .../docs/contact-center/index.md | 4 +- .../DialerPacingBackgroundTask.cs | 40 +++ .../ReservationExpiryBackgroundTask.cs | 43 ++++ .../Controllers/AgentWorkspaceController.cs | 121 +++++++++ .../Controllers/DialerProfilesController.cs | 194 +++++++++++++++ .../Controllers/QueuesController.cs | 188 ++++++++++++++ ...CrestApps.OrchardCore.ContactCenter.csproj | 1 + .../Indexes/ActivityQueueIndexProvider.cs | 32 +++ .../ActivityReservationIndexProvider.cs | 34 +++ .../Indexes/AgentProfileIndexProvider.cs | 33 +++ .../Indexes/DialerProfileIndexProvider.cs | 34 +++ .../Indexes/QueueItemIndexProvider.cs | 36 +++ .../Manifest.cs | 34 +++ .../ActivityQueueIndexMigrations.cs | 32 +++ .../ActivityReservationIndexMigrations.cs | 34 +++ .../Migrations/AgentProfileIndexMigrations.cs | 33 +++ .../DialerProfileIndexMigrations.cs | 34 +++ .../Migrations/QueueItemIndexMigrations.cs | 36 +++ .../Services/ContactCenterAdminMenu.cs | 45 ++++ .../ContactCenterPermissionProvider.cs | 13 + .../Startup.cs | 103 ++++++++ .../ViewModels/AgentWorkspaceViewModel.cs | 30 +++ .../ViewModels/DialerProfileViewModel.cs | 61 +++++ .../ViewModels/QueueViewModel.cs | 46 ++++ .../Views/AgentWorkspace/Index.cshtml | 17 ++ .../Views/DialerProfiles/Create.cshtml | 15 ++ .../Views/DialerProfiles/Edit.cshtml | 16 ++ .../Views/DialerProfiles/Index.cshtml | 32 +++ .../Views/Queues/Create.cshtml | 12 + .../Views/Queues/Edit.cshtml | 13 + .../Views/Queues/Index.cshtml | 32 +++ .../Views/_ViewImports.cshtml | 7 + .../CrestApps.OrchardCore.DialPad.csproj | 2 + .../DialPadConstants.cs | 5 + .../DialerStartup.cs | 18 ++ .../CrestApps.OrchardCore.DialPad/Manifest.cs | 12 + .../Services/DialPadDialerProvider.cs | 83 +++++++ .../ActivityAssignmentServiceTests.cs | 97 ++++++++ .../ActivityReservationServiceTests.cs | 84 +++++++ .../AgentPresenceManagerServiceTests.cs | 52 ++++ 96 files changed, 4412 insertions(+), 4 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProvider.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProviderResolver.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialRequest.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialResult.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerMode.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerProviderCapabilities.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueItemStatus.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ReservationStatus.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityReservationIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/AgentProfileIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/DialerProfileIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/QueueItemIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityReservation.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProfileStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProviderResolver.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityAssignmentService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerProfileStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemStore.cs create mode 100644 src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/DialerPacingBackgroundTask.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityQueueIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityReservationIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentProfileIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/DialerProfileIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/QueueItemIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityQueueIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityReservationIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentProfileIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/DialerProfileIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/QueueItemIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 522d01b8e..88ecfad63 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1382,10 +1382,13 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Registered in `.slnx` and the `Cms.Core.Targets` bundle - [x] 13 unit tests (event envelope, event publisher dispatch/idempotency/resilience, interaction manager lifecycle, entity metadata extensibility) - [x] Docs landing page + `v2.0.0` changelog entry -- [ ] Phase 2 — Agent, presence, queue, and reservation foundation -- [ ] Phase 3 — Routing MVP +- [x] **Phase 2 — Agent, presence, queue, and reservation foundation** + - [x] `Agents` feature: AgentProfile (presence, capacity, skills, queue/campaign membership), store/manager/index, presence manager, Agent Workspace sign-in/out + - [x] `Queues` feature: ActivityQueue, QueueItem, ActivityReservation models/stores/managers/indexes; queue + reservation lifecycle (reserve/accept/reject/expire); reservation-expiry background task + - [x] Availability-based assignment service (longest-idle agent ↔ highest-priority item); agent/queue/dialer permissions; admin menu + CRUD UI; unit tests +- [~] Phase 3 — Routing MVP (availability + priority + longest-idle assignment shipped; skills/sticky/business-hours/audit pending) - [ ] Phase 4 — Voice integration with Telephony -- [ ] Phase 5 — Outbound dialer MVP +- [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, dialer-agnostic `IDialerProvider`/resolver, power/progressive pacing, dialer batch sources, DialPad.Dialer provider; retry/callback/suppression pending) - [ ] Phase 6 — Wrap-up and disposition lifecycle - [ ] Phase 7 — Agent desktop and supervisor real-time UX - [ ] Phase 8 — Inbound entry points, IVR and self-service @@ -1403,3 +1406,4 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 0 started: promoted the session plan to this durable repo-tracked document, added the copilot-instructions pointer, and created the public Contact Center docs landing page. - Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). - Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. +- Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, with power/progressive pacing and dialer batch sources (Phase 5 core). Added the `DialPad.Dialer` feature implementing `IDialerProvider` over the DialPad telephony provider. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index 57384189c..3d15454b9 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -29,6 +29,21 @@ public static class Feature /// The identifier of the base Contact Center feature. /// public const string Area = "CrestApps.OrchardCore.ContactCenter"; + + /// + /// The identifier of the agent, presence, and queue-membership feature. + /// + public const string Agents = "CrestApps.OrchardCore.ContactCenter.Agents"; + + /// + /// The identifier of the queue and reservation feature. + /// + public const string Queues = "CrestApps.OrchardCore.ContactCenter.Queues"; + + /// + /// The identifier of the outbound dialer feature. + /// + public const string Dialer = "CrestApps.OrchardCore.ContactCenter.Dialer"; } /// @@ -127,5 +142,75 @@ public static class Events /// Raised when an interaction fails. /// public const string InteractionFailed = "InteractionFailed"; + + /// + /// 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 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 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 a callback is scheduled. + /// + public const string CallbackScheduled = "CallbackScheduled"; } } diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProvider.cs new file mode 100644 index 000000000..3502e3c22 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProvider.cs @@ -0,0 +1,43 @@ +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Defines a dialer-agnostic provider that executes outbound calling on behalf of the Contact Center. +/// The Contact Center owns all assignment, queue, pacing, and compliance logic; the provider only places +/// and ends calls so any telephony platform can act as the calling engine. +/// +public interface IDialerProvider +{ + /// + /// Gets the stable technical name used to resolve the provider. + /// + string TechnicalName { get; } + + /// + /// Gets the localized, human-readable name of the provider. + /// + LocalizedString DisplayName { get; } + + /// + /// Gets the calling capabilities supported by the provider. + /// + DialerProviderCapabilities Capabilities { get; } + + /// + /// Places an outbound call for a reserved activity. + /// + /// The provider-agnostic dial request. + /// The token to monitor for cancellation requests. + /// The result of the dial operation. + Task PlaceCallAsync(DialerDialRequest request, CancellationToken cancellationToken = default); + + /// + /// Ends or cancels a provider call previously placed for an attempt. + /// + /// The provider call identifier returned by . + /// The token to monitor for cancellation requests. + /// The result of the end-call operation. + Task EndCallAsync(string providerCallId, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProviderResolver.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProviderResolver.cs new file mode 100644 index 000000000..e131cff22 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IDialerProviderResolver.cs @@ -0,0 +1,21 @@ +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Resolves the registered implementations so the Contact Center can +/// dial through any installed provider without depending on a specific telephony platform. +/// +public interface IDialerProviderResolver +{ + /// + /// Resolves the dialer provider with the specified technical name, or the only registered provider when no name is supplied. + /// + /// The provider technical name, or to resolve the default. + /// The matching provider, or when none is found. + IDialerProvider Get(string technicalName = null); + + /// + /// Gets every registered dialer provider. + /// + /// The registered dialer providers. + IEnumerable GetAll(); +} 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..e509d415a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs @@ -0,0 +1,37 @@ +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, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialRequest.cs new file mode 100644 index 000000000..bf72d7966 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialRequest.cs @@ -0,0 +1,49 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider-agnostic request to place an outbound call for a reserved activity. +/// The Contact Center owns the activity, queue, agent, campaign, and compliance decisions; the +/// dialer provider only executes the calling operation. +/// +public sealed class DialerDialRequest +{ + /// + /// 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 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 reserved agent identifier when the dialing mode requires an agent before dialing. + /// + public string AgentId { 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/DialerDialResult.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialResult.cs new file mode 100644 index 000000000..705b762e7 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerDialResult.cs @@ -0,0 +1,62 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents the result of a dialer provider call operation. +/// +public sealed class DialerDialResult +{ + /// + /// Gets or sets a value indicating whether the provider accepted the dial request. + /// + public bool Succeeded { get; set; } + + /// + /// Gets or sets the provider call identifier returned by the provider. + /// + public string ProviderCallId { get; set; } + + /// + /// Gets or sets the provider error code when the dial request failed. + /// + public string ErrorCode { get; set; } + + /// + /// Gets or sets the provider error message when the dial request failed. + /// + public string ErrorMessage { get; set; } + + /// + /// Gets or sets provider-specific result metadata. + /// + public IDictionary Metadata { get; set; } = new Dictionary(); + + /// + /// Creates a successful result for the specified provider call identifier. + /// + /// The provider call identifier. + /// A successful . + public static DialerDialResult Success(string providerCallId) + { + return new DialerDialResult + { + Succeeded = true, + ProviderCallId = providerCallId, + }; + } + + /// + /// Creates a failed result with the specified error details. + /// + /// The provider error code. + /// The provider error message. + /// A failed . + public static DialerDialResult Failure(string errorCode, string errorMessage) + { + return new DialerDialResult + { + Succeeded = false, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + }; + } +} 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/DialerProviderCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerProviderCapabilities.cs new file mode 100644 index 000000000..4bbafe81d --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/DialerProviderCapabilities.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Identifies the calling capabilities exposed by a dialer provider. +/// +[Flags] +public enum DialerProviderCapabilities +{ + /// + /// The provider exposes no calling capabilities. + /// + None = 0, + + /// + /// The provider can place an outbound call for a reserved activity. + /// + Outbound = 1 << 0, + + /// + /// The provider can present a caller identifier when placing a call. + /// + CallerId = 1 << 1, + + /// + /// The provider can detect an answering machine before connecting an agent. + /// + AnsweringMachineDetection = 1 << 2, + + /// + /// The provider can cancel an in-progress outbound attempt. + /// + Cancellation = 1 << 3, +} 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/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/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs index 144c9a929..5c9f61042 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs @@ -21,4 +21,24 @@ public static class ContactCenterPermissions /// 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 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"); } 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 index 7f4999f48..ff24b79e6 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/CrestApps.OrchardCore.ContactCenter.Core.csproj @@ -26,6 +26,7 @@ + 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..67230f751 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityQueueIndex.cs @@ -0,0 +1,24 @@ +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 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..75796084e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ActivityReservationIndex.cs @@ -0,0 +1,35 @@ +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 agent the activity is reserved for. + /// + public string AgentId { 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; } +} 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/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/QueueItemIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/QueueItemIndex.cs new file mode 100644 index 000000000..2e8dc3d0a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/QueueItemIndex.cs @@ -0,0 +1,45 @@ +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 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; } +} 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..eeba6d757 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs @@ -0,0 +1,56 @@ +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 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 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 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 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/ActivityReservation.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityReservation.cs new file mode 100644 index 000000000..f1fd96a46 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityReservation.cs @@ -0,0 +1,51 @@ +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. + /// + public ReservationStatus Status { get; set; } + + /// + /// 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/AgentProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs new file mode 100644 index 000000000..4fdde2148 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs @@ -0,0 +1,81 @@ +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 UTC time the presence state last changed. + /// + public DateTime? PresenceChangedUtc { 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 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/DialerProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs new file mode 100644 index 000000000..1d29455a8 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs @@ -0,0 +1,81 @@ +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 dialer 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 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 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/QueueItem.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs new file mode 100644 index 000000000..4fdd5fc0c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs @@ -0,0 +1,56 @@ +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. + /// + public QueueItemStatus Status { get; set; } + + /// + /// Gets or sets the active reservation identifier when the item is reserved. + /// + public string ReservationId { 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. + /// + 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/Services/ActivityAssignmentService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs new file mode 100644 index 000000000..da873346f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs @@ -0,0 +1,74 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +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). +/// +public sealed class ActivityAssignmentService : IActivityAssignmentService +{ + private readonly IQueueItemManager _queueItemManager; + private readonly IAgentProfileManager _agentManager; + private readonly IActivityQueueManager _queueManager; + private readonly IActivityReservationService _reservationService; + + /// + /// Initializes a new instance of the class. + /// + /// The queue item manager. + /// The agent profile manager. + /// The queue manager. + /// The reservation service. + public ActivityAssignmentService( + IQueueItemManager queueItemManager, + IAgentProfileManager agentManager, + IActivityQueueManager queueManager, + IActivityReservationService reservationService) + { + _queueItemManager = queueItemManager; + _agentManager = agentManager; + _queueManager = queueManager; + _reservationService = reservationService; + } + + /// + public async Task AssignNextAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + var waiting = await _queueItemManager.ListWaitingAsync(queueId, cancellationToken); + var topItem = waiting.FirstOrDefault(); + + if (topItem is null) + { + return null; + } + + var agents = await _agentManager.ListAvailableForQueueAsync(queueId, cancellationToken); + var agent = agents.OrderBy(a => a.PresenceChangedUtc ?? DateTime.MaxValue).FirstOrDefault(); + + if (agent is null) + { + return null; + } + + var queue = await _queueManager.FindByIdAsync(queueId, cancellationToken); + var timeout = queue?.ReservationTimeoutSeconds ?? 30; + + return await _reservationService.ReserveAsync(topItem, agent, timeout, cancellationToken); + } + + /// + public async Task AssignQueueAsync(string queueId, CancellationToken cancellationToken = default) + { + var count = 0; + + while (await AssignNextAsync(queueId, cancellationToken) is not null) + { + count++; + } + + return count; + } +} 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..aaf226c2d --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs @@ -0,0 +1,101 @@ +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; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityQueueService : IActivityQueueService +{ + private readonly IQueueItemManager _queueItemManager; + private readonly IActivityQueueManager _queueManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The queue item manager. + /// The queue manager. + /// The CRM activity manager. + /// The Contact Center event publisher. + /// The clock used to stamp queue times. + public ActivityQueueService( + IQueueItemManager queueItemManager, + IActivityQueueManager queueManager, + IOmnichannelActivityManager activityManager, + IContactCenterEventPublisher publisher, + IClock clock) + { + _queueItemManager = queueItemManager; + _queueManager = queueManager; + _activityManager = activityManager; + _publisher = publisher; + _clock = clock; + } + + /// + public async Task EnqueueAsync(string activityItemId, string queueId, InteractionPriority? priority, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(activityItemId); + ArgumentException.ThrowIfNullOrEmpty(queueId); + + 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 item = await _queueItemManager.NewAsync(cancellationToken: cancellationToken); + item.QueueId = queueId; + item.ActivityItemId = activityItemId; + item.Priority = priority ?? queue?.DefaultPriority ?? InteractionPriority.Normal; + item.Status = QueueItemStatus.Waiting; + item.EnqueuedUtc = _clock.UtcNow; + + await _queueItemManager.CreateAsync(item, cancellationToken: cancellationToken); + + var activity = await _activityManager.FindByIdAsync(activityItemId, cancellationToken); + + if (activity is not null) + { + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + } + + await _publisher.PublishAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemAdded, + AggregateType = nameof(QueueItem), + AggregateId = item.ItemId, + SourceComponent = ContactCenterConstants.Components.Queues, + }, cancellationToken); + + return item; + } + + /// + public async Task DequeueAsync(QueueItem queueItem, QueueItemStatus status, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queueItem); + + queueItem.Status = 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); + } +} 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..0848b3d7b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.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 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; + } +} 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..c027eed73 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -0,0 +1,233 @@ +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; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ActivityReservationService : IActivityReservationService +{ + private readonly IActivityReservationManager _reservationManager; + private readonly IQueueItemManager _queueItemManager; + private readonly IAgentProfileManager _agentManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The reservation manager. + /// The queue item manager. + /// The agent profile manager. + /// The CRM activity manager. + /// The Contact Center event publisher. + /// The clock used to stamp reservation times. + public ActivityReservationService( + IActivityReservationManager reservationManager, + IQueueItemManager queueItemManager, + IAgentProfileManager agentManager, + IOmnichannelActivityManager activityManager, + IContactCenterEventPublisher publisher, + IClock clock) + { + _reservationManager = reservationManager; + _queueItemManager = queueItemManager; + _agentManager = agentManager; + _activityManager = activityManager; + _publisher = publisher; + _clock = clock; + } + + /// + public async Task ReserveAsync(QueueItem queueItem, AgentProfile agent, int timeoutSeconds, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(queueItem); + ArgumentNullException.ThrowIfNull(agent); + + 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.Status = ReservationStatus.Pending; + reservation.CreatedUtc = now; + reservation.ExpiresUtc = now.AddSeconds(timeoutSeconds); + + await _reservationManager.CreateAsync(reservation, cancellationToken: cancellationToken); + + queueItem.Status = QueueItemStatus.Reserved; + queueItem.ReservationId = reservation.ItemId; + queueItem.AgentId = agent.ItemId; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + + agent.PresenceStatus = AgentPresenceStatus.Reserved; + agent.ActiveReservationId = reservation.ItemId; + agent.PresenceChangedUtc = now; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + + await UpdateActivityAsync(queueItem.ActivityItemId, activity => + { + activity.AssignmentStatus = ActivityAssignmentStatus.Reserved; + activity.ReservationId = reservation.ItemId; + activity.ReservedById = agent.UserId; + activity.ReservedByUsername = agent.UserName; + activity.ReservedUtc = now; + activity.ReservationExpiresUtc = reservation.ExpiresUtc; + }, cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.QueueItemReserved, reservation, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentReserved, reservation, cancellationToken); + + return reservation; + } + + /// + public async Task AcceptAsync(string reservationId, CancellationToken cancellationToken = default) + { + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || reservation.Status != ReservationStatus.Pending) + { + return null; + } + + reservation.Status = ReservationStatus.Accepted; + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + + var queueItem = await _queueItemManager.FindByIdAsync(reservation.QueueItemId, cancellationToken); + + if (queueItem is not null) + { + queueItem.Status = 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 UpdateActivityAsync(reservation.ActivityItemId, activity => + { + activity.AssignmentStatus = ActivityAssignmentStatus.Assigned; + activity.AssignedToId = agent?.UserId; + activity.AssignedToUsername = agent?.UserName; + activity.AssignedToUtc = _clock.UtcNow; + }, cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.QueueItemAssigned, reservation, cancellationToken); + + return reservation; + } + + /// + public async Task RejectAsync(string reservationId, CancellationToken cancellationToken = default) + { + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || reservation.Status != ReservationStatus.Pending) + { + return null; + } + + await ReleaseAsync(reservation, ReservationStatus.Rejected, cancellationToken); + + return reservation; + } + + /// + public async Task ExpireDueAsync(CancellationToken cancellationToken = default) + { + var expired = await _reservationManager.ListExpiredAsync(_clock.UtcNow, cancellationToken); + var count = 0; + + foreach (var reservation in expired) + { + await ReleaseAsync(reservation, ReservationStatus.Expired, cancellationToken); + count++; + } + + return count; + } + + private async Task ReleaseAsync(ActivityReservation reservation, ReservationStatus status, CancellationToken cancellationToken) + { + reservation.Status = status; + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + + var queueItem = await _queueItemManager.FindByIdAsync(reservation.QueueItemId, cancellationToken); + + if (queueItem is not null) + { + queueItem.Status = QueueItemStatus.Waiting; + queueItem.ReservationId = null; + queueItem.AgentId = null; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + if (agent is not null) + { + agent.PresenceStatus = AgentPresenceStatus.Available; + agent.ActiveReservationId = null; + agent.PresenceChangedUtc = _clock.UtcNow; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + } + + await UpdateActivityAsync(reservation.ActivityItemId, activity => + { + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + activity.ReservationId = null; + activity.ReservedById = null; + activity.ReservedByUsername = null; + activity.ReservedUtc = null; + activity.ReservationExpiresUtc = null; + }, cancellationToken); + + await PublishAsync(ContactCenterConstants.Events.AgentReleased, reservation, cancellationToken); + } + + private async Task UpdateActivityAsync(string activityItemId, Action mutate, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(activityItemId)) + { + return; + } + + var activity = await _activityManager.FindByIdAsync(activityItemId, cancellationToken); + + if (activity is null) + { + return; + } + + mutate(activity); + + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + } + + 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); + } +} 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..11f5d4d5b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs @@ -0,0 +1,45 @@ +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 +{ + /// + /// 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); + } +} 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..29767e16b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -0,0 +1,126 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using OrchardCore; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class AgentPresenceManagerService : IAgentPresenceManager +{ + private readonly IAgentProfileManager _agentManager; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager. + /// The Contact Center event publisher. + /// The clock used to stamp presence changes. + public AgentPresenceManagerService( + IAgentProfileManager agentManager, + IContactCenterEventPublisher publisher, + IClock clock) + { + _agentManager = agentManager; + _publisher = publisher; + _clock = clock; + } + + /// + public async Task SignInAsync(string userId, IEnumerable queueIds, IEnumerable campaignIds, 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; + } + + profile.QueueIds = queueIds?.Distinct().ToList() ?? []; + profile.CampaignIds = campaignIds?.Distinct().ToList() ?? []; + profile.PresenceStatus = AgentPresenceStatus.Available; + profile.PresenceChangedUtc = _clock.UtcNow; + + await SaveAsync(profile, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentSignedIn, profile, 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) + { + return null; + } + + profile.PresenceStatus = AgentPresenceStatus.Offline; + profile.PresenceChangedUtc = _clock.UtcNow; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentSignedOut, profile, cancellationToken); + + 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) + { + return null; + } + + profile.PresenceStatus = status; + profile.PresenceReason = reason; + profile.PresenceChangedUtc = _clock.UtcNow; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentPresenceChanged, profile, cancellationToken); + + return profile; + } + + 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 Task PublishAsync(string eventType, AgentProfile profile, CancellationToken cancellationToken) + { + return _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + AggregateType = nameof(AgentProfile), + AggregateId = profile.ItemId, + ActorId = profile.UserId, + SourceComponent = ContactCenterConstants.Components.Agents, + }, cancellationToken); + } +} 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..fe6ee4913 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileManager.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 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; + } +} 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..a41215290 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileStore.cs @@ -0,0 +1,47 @@ +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 +{ + /// + /// 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 available = await Session.Query( + index => index.PresenceStatus == AgentPresenceStatus.Available, + collection: ContactCenterConstants.CollectionName) + .ListAsync(cancellationToken); + + return available.Where(agent => agent.QueueIds.Contains(queueId)).ToArray(); + } +} 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/DialerProviderResolver.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProviderResolver.cs new file mode 100644 index 000000000..3dadcbc34 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerProviderResolver.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Resolves registered implementations by technical name. +/// +public sealed class DialerProviderResolver : IDialerProviderResolver +{ + private readonly IEnumerable _providers; + + /// + /// Initializes a new instance of the class. + /// + /// The registered dialer providers. + public DialerProviderResolver(IEnumerable providers) + { + _providers = providers; + } + + /// + public IDialerProvider 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/DialerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs new file mode 100644 index 000000000..d397aef05 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs @@ -0,0 +1,147 @@ +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.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . The Contact Center owns the +/// agent reservation, attempt limits, and compliance decisions; calling is delegated to a provider. +/// +public sealed class DialerService : IDialerService +{ + private readonly IActivityAssignmentService _assignmentService; + private readonly IInteractionManager _interactionManager; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IDialerProviderResolver _providerResolver; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The assignment service used to reserve agents and activities. + /// The interaction manager used to record attempts. + /// The CRM activity manager. + /// The dialer provider resolver. + /// The Contact Center event publisher. + /// The clock used to stamp attempts. + /// The logger instance. + public DialerService( + IActivityAssignmentService assignmentService, + IInteractionManager interactionManager, + IOmnichannelActivityManager activityManager, + IDialerProviderResolver providerResolver, + IContactCenterEventPublisher publisher, + IClock clock, + ILogger logger) + { + _assignmentService = assignmentService; + _interactionManager = interactionManager; + _activityManager = activityManager; + _providerResolver = providerResolver; + _publisher = publisher; + _clock = clock; + _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; + } + + var provider = _providerResolver.Get(profile.ProviderName); + + if (provider is null) + { + _logger.LogWarning("No dialer provider is registered for dialer profile '{Profile}'.", profile.Name); + + return 0; + } + + var started = 0; + + var reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); + + while (reservation is not null) + { + if (await TryDialAsync(profile, reservation, provider, cancellationToken)) + { + started++; + } + + reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); + } + + return started; + } + + private async Task TryDialAsync(DialerProfile profile, ActivityReservation reservation, IDialerProvider provider, CancellationToken cancellationToken) + { + var activity = await _activityManager.FindByIdAsync(reservation.ActivityItemId, cancellationToken); + + if (activity is null || activity.Attempts > profile.MaxAttempts) + { + return false; + } + + var interaction = await _interactionManager.NewAsync(cancellationToken: cancellationToken); + interaction.Channel = InteractionChannel.Voice; + interaction.Direction = InteractionDirection.Outbound; + interaction.Status = InteractionStatus.Created; + interaction.ActivityItemId = activity.ItemId; + interaction.QueueId = profile.QueueId; + interaction.AgentId = reservation.AgentId; + interaction.ProviderName = provider.TechnicalName; + interaction.CustomerAddress = activity.PreferredDestination; + await _interactionManager.CreateAsync(interaction, cancellationToken: cancellationToken); + + var result = await provider.PlaceCallAsync(new DialerDialRequest + { + ActivityId = activity.ItemId, + InteractionId = interaction.ItemId, + AgentId = reservation.AgentId, + QueueId = profile.QueueId, + CampaignId = profile.CampaignId, + Destination = activity.PreferredDestination, + CallerId = profile.CallerId, + }, cancellationToken); + + activity.Attempts++; + activity.Status = result.Succeeded ? ActivityStatus.Dialing : ActivityStatus.Failed; + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + + if (result.Succeeded) + { + interaction.Status = InteractionStatus.Ringing; + interaction.ProviderInteractionId = result.ProviderCallId; + interaction.StartedUtc = _clock.UtcNow; + await _interactionManager.UpdateAsync(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, + }, cancellationToken); + + return result.Succeeded; + } +} 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/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..e99e1db13 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityQueueService.cs @@ -0,0 +1,28 @@ +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); +} 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..ead11003a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs @@ -0,0 +1,26 @@ +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); +} 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..0538432e4 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs @@ -0,0 +1,42 @@ +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); + + /// + /// 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..f2d34dc74 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs @@ -0,0 +1,26 @@ +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); +} 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..c40196082 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs @@ -0,0 +1,38 @@ +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); + + /// + /// 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); +} 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..dabb9b84b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileManager.cs @@ -0,0 +1,26 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.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); +} 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..843c0f9ed --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentProfileStore.cs @@ -0,0 +1,26 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.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); +} 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..95552e768 --- /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 a dialer-agnostic provider 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/IQueueItemManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemManager.cs new file mode 100644 index 000000000..36b96d31f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemManager.cs @@ -0,0 +1,26 @@ +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); +} 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..2b94a4b67 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IQueueItemStore.cs @@ -0,0 +1,26 @@ +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); +} 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..d87cff23f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemManager.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 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; + } +} 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..0ccdf86a6 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/QueueItemStore.cs @@ -0,0 +1,50 @@ +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 QueueItemStore : DocumentCatalog, IQueueItemStore +{ + /// + /// 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); + } +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 22b2d9cb1..a89b70814 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -173,6 +173,14 @@ At a high level, the platform changes are: - 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 dialer-agnostic 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 an Agent Workspace. +- 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 activity batches. The dialer is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, so the Contact Center owns assignment, queue, pacing, and compliance while any platform places calls. +- Adds the `CrestApps.OrchardCore.DialPad.Dialer` feature, a DialPad implementation of the dialer-agnostic provider over the existing DialPad telephony provider. +- See [Agents, Queues & Dialer](../contact-center/agents-queues-dialer.md). + ### Content Access Control #### Role-based content restrictions diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md new file mode 100644 index 000000000..9395d508e --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -0,0 +1,77 @@ +--- +sidebar_label: Agents, Queues & Dialer +sidebar_position: 1 +title: Agents, Queues, and Dialer +description: Phase 2 and 5 of the Contact Center - agent presence, queues, reservations, availability-based assignment, and dialer-agnostic outbound dialing with the DialPad provider. +--- + +This phase adds the operational core of the Contact Center: agent presence, work queues, +reservations, availability-based assignment, and a dialer-agnostic outbound dialer. Each capability +is a separate, feature-gated module so tenants enable only what they need. + +## Features + +| Feature | Feature ID | Purpose | +| --- | --- | --- | +| Contact Center Agents | `CrestApps.OrchardCore.ContactCenter.Agents` | Agent profiles, presence, capacity, skills, and queue/campaign sign-in. | +| Contact Center Queues | `CrestApps.OrchardCore.ContactCenter.Queues` | Work queues, queue items, reservations, and availability-based assignment. | +| Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Dialer-agnostic outbound profiles, pacing, and dialer activity batches. | +| DialPad Dialer | `CrestApps.OrchardCore.DialPad.Dialer` | DialPad implementation of the dialer-agnostic provider. | + +## Agents and presence + +An **agent profile** links an Orchard user to Contact Center configuration: display name, capacity, +skills, queue membership, campaign membership, and live presence. Presence states are `Offline`, +`Available`, `Reserved`, `Busy`, `WrapUp`, and `Break`. + +Agents sign in from **Contact Center → Agent Workspace**, selecting the queues and campaigns they +want to receive work from. Signing in sets presence to `Available`; signing out sets it to `Offline`. +The `SignIntoQueues` permission grants self-service sign-in and presence changes. + +## Queues, reservations, and assignment + +A **queue** holds activities waiting for an agent, with a default priority, an SLA threshold, and a +reservation timeout. Activities enter a queue as **queue items**; the system pairs the highest +priority, oldest waiting item with the **longest-idle available agent** who is signed in to that +queue and creates a short-lived **reservation**. + +A reservation locks the activity for one agent and can be accepted, rejected, or expired. The CRM +activity moves through `Available → Reserved → Assigned`, mirrored on the queue item and agent +presence. Expired reservations return the item to the queue automatically. A background task expires +stale reservations and assigns waiting work every minute. + +## Dialer + +A **dialer profile** ties a campaign and queue to a dialing mode (`Manual`, `Preview`, `Power`, +`Progressive`, `Predictive`), a provider, calls-per-agent pacing, and attempt limits. Power and +progressive profiles run automatically each minute: the Contact Center reserves an available agent, +creates an outbound interaction, and asks the configured provider to place the call. Manual and +preview profiles wait for agent action. Dialer activity batches load **unassigned** inventory the +dialer reserves later. + +## Dialer-agnostic providers + +The dialer never talks to a telephony platform directly. It calls `IDialerProvider` and resolves the +configured provider through `IDialerProviderResolver`, so any platform can be the calling engine +while the Contact Center keeps all assignment, queue, pacing, and compliance logic. The +`DialPad.Dialer` feature implements `IDialerProvider` over the DialPad telephony provider; enable it +to dial through DialPad. + +## Enable via recipe + +```json +{ + "steps": [ + { + "name": "Feature", + "enable": [ + "CrestApps.OrchardCore.ContactCenter", + "CrestApps.OrchardCore.ContactCenter.Agents", + "CrestApps.OrchardCore.ContactCenter.Queues", + "CrestApps.OrchardCore.ContactCenter.Dialer", + "CrestApps.OrchardCore.DialPad.Dialer" + ] + } + ] +} +``` diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 6e66659e1..00fa0a1c4 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -133,4 +133,6 @@ decorations. The Contact Center is under active, phased development. The first milestone is a voice MVP that proves agents can run inbound and outbound voice work entirely inside the CRM while preserving the -Telephony boundary. This documentation will expand as each capability ships. +Telephony boundary. This documentation will expand as each capability ships. See +[Agents, Queues & Dialer](agents-queues-dialer.md) for the agent, queue, reservation, and dialer +features. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/DialerPacingBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/DialerPacingBackgroundTask.cs new file mode 100644 index 000000000..cb7375f93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/DialerPacingBackgroundTask.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Runs one pacing cycle for each enabled dialer profile so power and progressive campaigns dial automatically. +/// +[BackgroundTask( + Title = "Contact Center Dialer Pacing", + Schedule = "* * * * *", + Description = "Reserves agents and places outbound calls for enabled dialer profiles.", + LockTimeout = 5_000, + LockExpiration = 60_000)] +public sealed class DialerPacingBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var dialerManager = serviceProvider.GetRequiredService(); + var dialerService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + var profiles = await dialerManager.ListEnabledAsync(cancellationToken); + + foreach (var profile in profiles) + { + try + { + await dialerService.RunCycleAsync(profile, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while running dialer profile '{Profile}'.", profile.Name); + } + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs new file mode 100644 index 000000000..2dc4b6be1 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs @@ -0,0 +1,43 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Expires stale agent reservations and assigns waiting work to available agents across enabled queues. +/// +[BackgroundTask( + Title = "Contact Center Reservation and Assignment", + Schedule = "* * * * *", + Description = "Expires stale reservations and assigns queued activities to available agents.", + LockTimeout = 5_000, + LockExpiration = 60_000)] +public sealed class ReservationExpiryBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var reservationService = serviceProvider.GetRequiredService(); + var assignmentService = serviceProvider.GetRequiredService(); + var queueManager = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + await reservationService.ExpireDueAsync(cancellationToken); + + var queues = await queueManager.ListEnabledAsync(cancellationToken); + + foreach (var queue in queues) + { + try + { + await assignmentService.AssignQueueAsync(queue.ItemId, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while assigning work for queue '{QueueId}'.", queue.ItemId); + } + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs new file mode 100644 index 000000000..2bec6b7a9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs @@ -0,0 +1,121 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Admin; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides the agent workspace where agents sign in to queues and campaigns and change presence. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Agents)] +public sealed class AgentWorkspaceController : Controller +{ + private readonly IAgentPresenceManager _presenceManager; + private readonly IActivityQueueManager _queueManager; + private readonly IAuthorizationService _authorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The agent presence manager. + /// The queue manager. + /// The authorization service. + public AgentWorkspaceController( + IAgentPresenceManager presenceManager, + IActivityQueueManager queueManager, + IAuthorizationService authorizationService) + { + _presenceManager = presenceManager; + _queueManager = queueManager; + _authorizationService = authorizationService; + } + + /// + /// Displays the agent workspace. + /// + /// The workspace view. + [Admin("contact-center/workspace", "ContactCenterWorkspace")] + public async Task Index() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) + { + return Forbid(); + } + + var queues = await _queueManager.ListEnabledAsync(); + + return View(new AgentWorkspaceViewModel + { + AvailableQueues = [.. queues], + }); + } + + /// + /// Signs the agent in to the selected queues and campaigns. + /// + /// The queues to sign in to. + /// The comma-separated campaigns to sign in to. + /// A redirect to the workspace. + [HttpPost] + public async Task SignIn(IEnumerable selectedQueueIds, string campaignIds) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) + { + return Forbid(); + } + + var campaigns = string.IsNullOrEmpty(campaignIds) + ? [] + : campaignIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Signs the agent out. + /// + /// A redirect to the workspace. + [HttpPost] + public async Task SignOutAgent() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) + { + return Forbid(); + } + + await _presenceManager.SignOutAsync(GetUserId()); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Sets the agent presence state. + /// + /// The presence state. + /// The optional reason code. + /// A redirect to the workspace. + [HttpPost] + public async Task SetPresence(AgentPresenceStatus status, string reason) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) + { + return Forbid(); + } + + await _presenceManager.SetPresenceAsync(GetUserId(), status, reason); + + return RedirectToAction(nameof(Index)); + } + + private string GetUserId() + => User.FindFirstValue(ClaimTypes.NameIdentifier); +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs new file mode 100644 index 000000000..09556c22b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs @@ -0,0 +1,194 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Admin; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides administration of dialer profiles. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Dialer)] +public sealed class DialerProfilesController : Controller +{ + private readonly IDialerProfileManager _manager; + private readonly IAuthorizationService _authorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The dialer profile manager. + /// The authorization service. + public DialerProfilesController( + IDialerProfileManager manager, + IAuthorizationService authorizationService) + { + _manager = manager; + _authorizationService = authorizationService; + } + + /// + /// Lists the dialer profiles. + /// + /// The list view. + [Admin("contact-center/dialers", "ContactCenterDialersIndex")] + public async Task Index() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + var profiles = await _manager.GetAllAsync(); + + return View(profiles); + } + + /// + /// Displays the dialer profile create form. + /// + /// The create view. + [Admin("contact-center/dialers/create", "ContactCenterDialersCreate")] + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + return View(new DialerProfileViewModel()); + } + + /// + /// Persists a new dialer profile. + /// + /// The submitted profile. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Create))] + public async Task CreatePost(DialerProfileViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + if (!ModelState.IsValid) + { + return View(model); + } + + var profile = await _manager.NewAsync(); + Apply(profile, model); + await _manager.CreateAsync(profile); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Displays the dialer profile edit form. + /// + /// The profile identifier. + /// The edit view. + [Admin("contact-center/dialers/edit/{id}", "ContactCenterDialersEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + var profile = await _manager.FindByIdAsync(id); + + if (profile is null) + { + return NotFound(); + } + + return View(new DialerProfileViewModel + { + Id = profile.ItemId, + Name = profile.Name, + CampaignId = profile.CampaignId, + QueueId = profile.QueueId, + Mode = profile.Mode, + ProviderName = profile.ProviderName, + CallsPerAgent = profile.CallsPerAgent, + MaxAttempts = profile.MaxAttempts, + CallerId = profile.CallerId, + Enabled = profile.Enabled, + }); + } + + /// + /// Persists changes to a dialer profile. + /// + /// The submitted profile. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Edit))] + public async Task EditPost(DialerProfileViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + var profile = await _manager.FindByIdAsync(model.Id); + + if (profile is null) + { + return NotFound(); + } + + if (!ModelState.IsValid) + { + return View(model); + } + + Apply(profile, model); + await _manager.UpdateAsync(profile); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Deletes a dialer profile. + /// + /// The profile identifier. + /// A redirect to the list. + [HttpPost] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + var profile = await _manager.FindByIdAsync(id); + + if (profile is not null) + { + await _manager.DeleteAsync(profile); + } + + return RedirectToAction(nameof(Index)); + } + + private static void Apply(Core.Models.DialerProfile profile, DialerProfileViewModel model) + { + profile.Name = model.Name; + profile.CampaignId = model.CampaignId; + profile.QueueId = model.QueueId; + profile.Mode = model.Mode; + profile.ProviderName = model.ProviderName; + profile.CallsPerAgent = model.CallsPerAgent; + profile.MaxAttempts = model.MaxAttempts; + profile.CallerId = model.CallerId; + profile.Enabled = model.Enabled; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs new file mode 100644 index 000000000..62ba3c2ad --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs @@ -0,0 +1,188 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Admin; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides administration of Contact Center queues. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Queues)] +public sealed class QueuesController : Controller +{ + private readonly IActivityQueueManager _manager; + private readonly IAuthorizationService _authorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The queue manager. + /// The authorization service. + public QueuesController( + IActivityQueueManager manager, + IAuthorizationService authorizationService) + { + _manager = manager; + _authorizationService = authorizationService; + } + + /// + /// Lists the queues. + /// + /// The queues list view. + [Admin("contact-center/queues", "ContactCenterQueuesIndex")] + public async Task Index() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var queues = await _manager.GetAllAsync(); + + return View(queues); + } + + /// + /// Displays the queue create form. + /// + /// The create view. + [Admin("contact-center/queues/create", "ContactCenterQueuesCreate")] + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + return View(new QueueViewModel()); + } + + /// + /// Persists a new queue. + /// + /// The submitted queue. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Create))] + public async Task CreatePost(QueueViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + if (!ModelState.IsValid) + { + return View(model); + } + + var queue = await _manager.NewAsync(); + Apply(queue, model); + await _manager.CreateAsync(queue); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Displays the queue edit form. + /// + /// The queue identifier. + /// The edit view. + [Admin("contact-center/queues/edit/{id}", "ContactCenterQueuesEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var queue = await _manager.FindByIdAsync(id); + + if (queue is null) + { + return NotFound(); + } + + return View(new QueueViewModel + { + Id = queue.ItemId, + Name = queue.Name, + Description = queue.Description, + DefaultPriority = queue.DefaultPriority, + SlaThresholdSeconds = queue.SlaThresholdSeconds, + ReservationTimeoutSeconds = queue.ReservationTimeoutSeconds, + Enabled = queue.Enabled, + }); + } + + /// + /// Persists changes to a queue. + /// + /// The submitted queue. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Edit))] + public async Task EditPost(QueueViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var queue = await _manager.FindByIdAsync(model.Id); + + if (queue is null) + { + return NotFound(); + } + + if (!ModelState.IsValid) + { + return View(model); + } + + Apply(queue, model); + await _manager.UpdateAsync(queue); + + return RedirectToAction(nameof(Index)); + } + + /// + /// Deletes a queue. + /// + /// The queue identifier. + /// A redirect to the list. + [HttpPost] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var queue = await _manager.FindByIdAsync(id); + + if (queue is not null) + { + await _manager.DeleteAsync(queue); + } + + return RedirectToAction(nameof(Index)); + } + + private static void Apply(Core.Models.ActivityQueue queue, QueueViewModel model) + { + queue.Name = model.Name; + queue.Description = model.Description; + queue.DefaultPriority = model.DefaultPriority; + queue.SlaThresholdSeconds = model.SlaThresholdSeconds; + queue.ReservationTimeoutSeconds = model.ReservationTimeoutSeconds; + queue.Enabled = model.Enabled; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index 3470bcd12..cbdfcc9fc 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityQueueIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityQueueIndexProvider.cs new file mode 100644 index 000000000..c45a54700 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityQueueIndexProvider.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class ActivityQueueIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public ActivityQueueIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(queue => new ActivityQueueIndex + { + ItemId = queue.ItemId, + Name = queue.Name, + Enabled = queue.Enabled, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityReservationIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityReservationIndexProvider.cs new file mode 100644 index 000000000..e7a01969f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ActivityReservationIndexProvider.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class ActivityReservationIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public ActivityReservationIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(reservation => new ActivityReservationIndex + { + ItemId = reservation.ItemId, + ActivityItemId = reservation.ActivityItemId, + AgentId = reservation.AgentId, + Status = reservation.Status, + ExpiresUtc = reservation.ExpiresUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentProfileIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentProfileIndexProvider.cs new file mode 100644 index 000000000..f413e5e8e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentProfileIndexProvider.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class AgentProfileIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public AgentProfileIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(profile => new AgentProfileIndex + { + ItemId = profile.ItemId, + Name = profile.Name, + UserId = profile.UserId, + PresenceStatus = profile.PresenceStatus, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/DialerProfileIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/DialerProfileIndexProvider.cs new file mode 100644 index 000000000..1e764fe50 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/DialerProfileIndexProvider.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class DialerProfileIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public DialerProfileIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(profile => new DialerProfileIndex + { + ItemId = profile.ItemId, + Name = profile.Name, + CampaignId = profile.CampaignId, + QueueId = profile.QueueId, + Enabled = profile.Enabled, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/QueueItemIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/QueueItemIndexProvider.cs new file mode 100644 index 000000000..57571a756 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/QueueItemIndexProvider.cs @@ -0,0 +1,36 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class QueueItemIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public QueueItemIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(item => new QueueItemIndex + { + ItemId = item.ItemId, + QueueId = item.QueueId, + ActivityItemId = item.ActivityItemId, + Status = item.Status, + Priority = item.Priority, + AgentId = item.AgentId, + EnqueuedUtc = item.EnqueuedUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs index 3d1ff6e7c..f2f3a815a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -21,3 +21,37 @@ "OrchardCore.Users", ] )] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.Agents, + Name = "Contact Center Agents", + Description = "Adds agent profiles, presence, capacity, skills, and queue/campaign sign-in.", + Category = "Communication", + Dependencies = + [ + ContactCenterConstants.Feature.Area, + ] +)] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.Queues, + Name = "Contact Center Queues", + Description = "Adds work queues, queue items, reservations, and availability-based activity assignment.", + Category = "Communication", + Dependencies = + [ + ContactCenterConstants.Feature.Agents, + "CrestApps.OrchardCore.Omnichannel.Managements", + ] +)] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.Dialer, + Name = "Contact Center Dialer", + Description = "Adds dialer-agnostic outbound dialing profiles, pacing, and dialer activity batches over any installed dialer provider.", + Category = "Communication", + Dependencies = + [ + ContactCenterConstants.Feature.Queues, + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityQueueIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityQueueIndexMigrations.cs new file mode 100644 index 000000000..2c95497b2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityQueueIndexMigrations.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class ActivityQueueIndexMigrations : DataMigration +{ + /// + /// Creates the queue index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_ActivityQueueIndex_DocumentId", "DocumentId", "ItemId", "Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityReservationIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityReservationIndexMigrations.cs new file mode 100644 index 000000000..8c91a0bc9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ActivityReservationIndexMigrations.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class ActivityReservationIndexMigrations : DataMigration +{ + /// + /// Creates the reservation index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("ActivityItemId", column => column.WithLength(26)) + .Column("AgentId", column => column.WithLength(26)) + .Column("Status", column => column.WithLength(50)) + .Column("ExpiresUtc", column => column.NotNull()), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_ActivityReservationIndex_DocumentId", "DocumentId", "AgentId", "Status", "ExpiresUtc"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentProfileIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentProfileIndexMigrations.cs new file mode 100644 index 000000000..f9caa1890 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentProfileIndexMigrations.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class AgentProfileIndexMigrations : DataMigration +{ + /// + /// Creates the agent profile index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("UserId", column => column.WithLength(26)) + .Column("PresenceStatus", column => column.WithLength(50)), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_AgentProfileIndex_DocumentId", "DocumentId", "ItemId", "UserId", "PresenceStatus"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/DialerProfileIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/DialerProfileIndexMigrations.cs new file mode 100644 index 000000000..28dc55de4 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/DialerProfileIndexMigrations.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class DialerProfileIndexMigrations : DataMigration +{ + /// + /// Creates the dialer profile index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("CampaignId", column => column.WithLength(26)) + .Column("QueueId", column => column.WithLength(26)) + .Column("Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_DialerProfileIndex_DocumentId", "DocumentId", "CampaignId", "QueueId", "Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/QueueItemIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/QueueItemIndexMigrations.cs new file mode 100644 index 000000000..1cd958a43 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/QueueItemIndexMigrations.cs @@ -0,0 +1,36 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class QueueItemIndexMigrations : DataMigration +{ + /// + /// Creates the queue item index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("QueueId", column => column.WithLength(26)) + .Column("ActivityItemId", column => column.WithLength(26)) + .Column("Status", column => column.WithLength(50)) + .Column("Priority", column => column.WithLength(50)) + .Column("AgentId", column => column.WithLength(26)) + .Column("EnqueuedUtc", column => column.NotNull()), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_QueueItemIndex_DocumentId", "DocumentId", "QueueId", "Status", "ActivityItemId", "AgentId"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs new file mode 100644 index 000000000..ae2cb4505 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs @@ -0,0 +1,45 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Adds the Contact Center entries to the admin navigation. +/// +public sealed class ContactCenterAdminMenu : AdminNavigationProvider +{ + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ContactCenterAdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder + .Add(S["Contact Center"], "6", contactCenter => contactCenter + .AddClass("contact-center") + .Id("contactCenter") + .Add(S["Agent Workspace"], S["Agent Workspace"].PrefixPosition(), workspace => workspace + .Action("Index", "AgentWorkspace", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.SignIntoQueues) + .LocalNav()) + .Add(S["Queues"], S["Queues"].PrefixPosition(), queues => queues + .Action("Index", "Queues", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageQueues) + .LocalNav()) + .Add(S["Dialer Profiles"], S["Dialer Profiles"].PrefixPosition(), dialer => dialer + .Action("Index", "DialerProfiles", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageDialer) + .LocalNav())); + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs index 7a2382dbf..ac55032a7 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs @@ -14,6 +14,10 @@ internal sealed class ContactCenterPermissionProvider : IPermissionProvider ContactCenterPermissions.ManageContactCenter, ContactCenterPermissions.ManageInteractions, ContactCenterPermissions.ViewInteractions, + ContactCenterPermissions.ManageAgents, + ContactCenterPermissions.ManageQueues, + ContactCenterPermissions.ManageDialer, + ContactCenterPermissions.SignIntoQueues, ]; /// @@ -25,6 +29,15 @@ public IEnumerable GetDefaultStereotypes() Name = OrchardCoreConstants.Roles.Administrator, Permissions = _allPermissions, }, + new PermissionStereotype + { + Name = "Agent", + Permissions = + [ + ContactCenterPermissions.ViewInteractions, + ContactCenterPermissions.SignIntoQueues, + ], + }, ]; /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 6e75ddec8..1e5955144 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -1,14 +1,20 @@ using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.BackgroundTasks; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Handlers; using CrestApps.OrchardCore.ContactCenter.Indexes; using CrestApps.OrchardCore.ContactCenter.Migrations; using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Models; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; +using OrchardCore.BackgroundTasks; using OrchardCore.Data; using OrchardCore.Data.Migration; using OrchardCore.Modules; +using OrchardCore.Navigation; using OrchardCore.Security.Permissions; namespace CrestApps.OrchardCore.ContactCenter; @@ -38,5 +44,102 @@ public override void ConfigureServices(IServiceCollection services) .AddDataMigration(); services.AddPermissionProvider(); + services.AddNavigationProvider(); + } +} + +/// +/// Registers agent profiles, presence, capacity, skills, and queue/campaign sign-in. +/// +[Feature(ContactCenterConstants.Feature.Agents)] +public sealed class AgentsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped(); + + services + .AddIndexProvider() + .AddDataMigration(); + } +} + +/// +/// Registers queues, queue items, reservations, and availability-based assignment. +/// +[Feature(ContactCenterConstants.Feature.Queues)] +public sealed class QueuesStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + services + .AddIndexProvider() + .AddDataMigration() + .AddIndexProvider() + .AddDataMigration() + .AddIndexProvider() + .AddDataMigration(); + + services.AddSingleton(); + } +} + +/// +/// Registers dialer-agnostic outbound dialing profiles, pacing, and dialer activity batch sources. +/// +[Feature(ContactCenterConstants.Feature.Dialer)] +public sealed class DialerStartup : StartupBase +{ + private readonly IStringLocalizer S; + + public DialerStartup(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + services + .AddIndexProvider() + .AddDataMigration(); + + services.AddSingleton(); + + services.Configure(options => + { + options.AddSource(ActivitySources.PreviewDial, entry => + { + entry.DisplayName = S["Preview dial batch"]; + entry.Description = S["Loads unassigned activities the dialer offers to agents one at a time for review before dialing."]; + entry.RequiresUserAssignment = false; + }); + + options.AddSource(ActivitySources.PowerDial, entry => + { + entry.DisplayName = S["Power dial batch"]; + entry.Description = S["Loads unassigned activities the dialer dials automatically for available agents."]; + entry.RequiresUserAssignment = false; + }); + }); } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs new file mode 100644 index 000000000..83c9c46bf --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the agent workspace sign-in view model. +/// +public class AgentWorkspaceViewModel +{ + /// + /// Gets or sets the agent profile, when the agent has signed in before. + /// + public AgentProfile Profile { get; set; } + + /// + /// Gets or sets the queues the agent can join. + /// + public IList AvailableQueues { get; set; } = []; + + /// + /// Gets or sets the queues the agent has selected to sign in to. + /// + public IList SelectedQueueIds { get; set; } = []; + + /// + /// Gets or sets the campaign identifiers, comma separated, the agent is signed in to. + /// + public string CampaignIds { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs new file mode 100644 index 000000000..a967627ea --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs @@ -0,0 +1,61 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the edit view model for a dialer profile. +/// +public class DialerProfileViewModel +{ + /// + /// Gets or sets the dialer profile identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the unique dialer profile name. + /// + [Required] + public string Name { get; set; } + + /// + /// Gets or sets the campaign identifier. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the queue identifier. + /// + public string QueueId { get; set; } + + /// + /// Gets or sets the dialing mode. + /// + public DialerMode Mode { get; set; } = DialerMode.Preview; + + /// + /// Gets or sets the dialer provider technical name. + /// + public string ProviderName { get; set; } + + /// + /// Gets or sets the number of calls per available agent. + /// + public int CallsPerAgent { get; set; } = 1; + + /// + /// Gets or sets the maximum number of attempts per activity. + /// + public int MaxAttempts { get; set; } = 3; + + /// + /// Gets or sets the caller identifier. + /// + public string CallerId { get; set; } + + /// + /// Gets or sets a value indicating whether the dialer profile is enabled. + /// + public bool Enabled { get; set; } = true; +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs new file mode 100644 index 000000000..605e8e79e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the edit view model for an activity queue. +/// +public class QueueViewModel +{ + /// + /// Gets or sets the queue identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the unique queue name. + /// + [Required] + public string Name { get; set; } + + /// + /// Gets or sets the queue description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the default priority. + /// + public InteractionPriority DefaultPriority { get; set; } = InteractionPriority.Normal; + + /// + /// Gets or sets the SLA threshold in seconds. + /// + public int SlaThresholdSeconds { get; set; } = 120; + + /// + /// Gets or sets the reservation timeout in seconds. + /// + public int ReservationTimeoutSeconds { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether the queue is enabled. + /// + public bool Enabled { get; set; } = true; +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml new file mode 100644 index 000000000..cbd4be0e4 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml @@ -0,0 +1,17 @@ +@model AgentWorkspaceViewModel +

@T["Agent Workspace"]

+
+

@T["Select the queues you want to receive work from, then sign in."]

+ @foreach (var queue in Model.AvailableQueues) + { +
+ + +
+ } +
+ +
+
+ +
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml new file mode 100644 index 000000000..5e5bdbdbd --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml @@ -0,0 +1,15 @@ +@model DialerProfileViewModel +

@T["Add Dialer Profile"]

+
+
+
+
+
+
+
+
+
+
+ + @T["Cancel"] +
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml new file mode 100644 index 000000000..c3488ad13 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml @@ -0,0 +1,16 @@ +@model DialerProfileViewModel +

@T["Edit Dialer Profile"]

+
+ +
+
+
+
+
+
+
+
+
+ + @T["Cancel"] +
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml new file mode 100644 index 000000000..8194d6731 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml @@ -0,0 +1,32 @@ +@model IEnumerable +

@T["Dialer Profiles"]

+ + + + + + + + + + + + + @foreach (var profile in Model) + { + + + + + + + + } + +
@T["Name"]@T["Mode"]@T["Provider"]@T["Enabled"]
@profile.Name@profile.Mode@profile.ProviderName@(profile.Enabled ? T["Yes"] : T["No"]) +
+ +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml new file mode 100644 index 000000000..947ec7ed9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml @@ -0,0 +1,12 @@ +@model QueueViewModel +

@T["Add Queue"]

+
+
+
+
+
+
+
+ + @T["Cancel"] +
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml new file mode 100644 index 000000000..7e83b046c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml @@ -0,0 +1,13 @@ +@model QueueViewModel +

@T["Edit Queue"]

+
+ +
+
+
+
+
+
+ + @T["Cancel"] +
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml new file mode 100644 index 000000000..4af9cece3 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml @@ -0,0 +1,32 @@ +@model IEnumerable +

@T["Queues"]

+ + + + + + + + + + + + + @foreach (var queue in Model) + { + + + + + + + + } + +
@T["Name"]@T["Default Priority"]@T["SLA (s)"]@T["Enabled"]
@queue.Name@queue.DefaultPriority@queue.SlaThresholdSeconds@(queue.Enabled ? T["Yes"] : T["No"]) +
+ +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml new file mode 100644 index 000000000..b76412916 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml @@ -0,0 +1,7 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using CrestApps.OrchardCore.ContactCenter.ViewModels +@using CrestApps.OrchardCore.ContactCenter.Models +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer T diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj b/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj index 86edd9296..9c061dd81 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj +++ b/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj @@ -26,6 +26,7 @@ + @@ -35,6 +36,7 @@ while the DialPad code itself depends only on the provider-agnostic Telephony abstractions. --> + diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs index 12e66b442..d3b6cb26a 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs @@ -88,5 +88,10 @@ public static class Feature /// The identifier of the DialPad provider feature. /// public const string Area = "CrestApps.OrchardCore.DialPad"; + + /// + /// The identifier of the DialPad Contact Center dialer feature. + /// + public const string Dialer = "CrestApps.OrchardCore.DialPad.Dialer"; } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs new file mode 100644 index 000000000..af7c10153 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.DialPad.Services; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.DialPad; + +/// +/// Registers the DialPad implementation of the Contact Center dialer-agnostic provider. +/// +[Feature(DialPadConstants.Feature.Dialer)] +public sealed class DialerStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs index 89a432de5..c704ac946 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs @@ -22,3 +22,15 @@ TelephonyConstants.Feature.Area, ] )] + +[assembly: Feature( + Id = DialPadConstants.Feature.Dialer, + Name = "DialPad Dialer", + Description = "Implements the dialer-agnostic Contact Center dialer provider over DialPad so outbound campaigns dial through DialPad.", + Category = "Communications", + Dependencies = + [ + DialPadConstants.Feature.Area, + "CrestApps.OrchardCore.ContactCenter.Dialer", + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs new file mode 100644 index 000000000..124d48221 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs @@ -0,0 +1,83 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Implements the Contact Center dialer-agnostic over the DialPad telephony +/// provider so the Contact Center can place outbound campaign calls through DialPad while owning all +/// assignment, queue, pacing, and compliance logic. +/// +public sealed class DialPadDialerProvider : IDialerProvider +{ + private readonly ITelephonyProviderResolver _telephonyResolver; + + /// + /// Initializes a new instance of the class. + /// + /// The telephony provider resolver. + /// The string localizer. + public DialPadDialerProvider( + ITelephonyProviderResolver telephonyResolver, + IStringLocalizer stringLocalizer) + { + _telephonyResolver = telephonyResolver; + DisplayName = stringLocalizer["DialPad"]; + } + + /// + public string TechnicalName => DialPadConstants.ProviderTechnicalName; + + /// + public LocalizedString DisplayName { get; } + + /// + public DialerProviderCapabilities Capabilities => DialerProviderCapabilities.Outbound | DialerProviderCapabilities.CallerId | DialerProviderCapabilities.Cancellation; + + /// + public async Task PlaceCallAsync(DialerDialRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var provider = await _telephonyResolver.GetAsync(DialPadConstants.ProviderTechnicalName); + + if (provider is null) + { + return DialerDialResult.Failure("provider_unavailable", "The DialPad telephony provider is not configured."); + } + + var result = await provider.DialAsync(new DialRequest + { + To = request.Destination, + From = request.CallerId, + Metadata = request.Metadata, + }, cancellationToken); + + if (!result.Succeeded) + { + return DialerDialResult.Failure("dial_failed", result.Error); + } + + return DialerDialResult.Success(result.Call?.CallId); + } + + /// + public async Task EndCallAsync(string providerCallId, CancellationToken cancellationToken = default) + { + var provider = await _telephonyResolver.GetAsync(DialPadConstants.ProviderTechnicalName); + + if (provider is null) + { + return DialerDialResult.Failure("provider_unavailable", "The DialPad telephony provider is not configured."); + } + + var result = await provider.HangupAsync(new CallReference { CallId = providerCallId }, cancellationToken); + + return result.Succeeded + ? DialerDialResult.Success(providerCallId) + : DialerDialResult.Failure("hangup_failed", result.Error); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs new file mode 100644 index 000000000..b3aca65d9 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs @@ -0,0 +1,97 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ActivityAssignmentServiceTests +{ + [Fact] + public async Task AssignNextAsync_WhenNoWaitingItems_ReturnsNull() + { + // Arrange + var queueItemManager = new Mock(); + queueItemManager + .Setup(m => m.ListWaitingAsync("q1", It.IsAny())) + .ReturnsAsync([]); + + var service = CreateService(queueItemManager, new Mock(), new Mock()); + + // Act + var reservation = await service.AssignNextAsync("q1"); + + // Assert + Assert.Null(reservation); + } + + [Fact] + public async Task AssignNextAsync_WhenNoAvailableAgents_ReturnsNull() + { + // Arrange + var queueItemManager = new Mock(); + queueItemManager + .Setup(m => m.ListWaitingAsync("q1", It.IsAny())) + .ReturnsAsync([new QueueItem { ItemId = "i1", QueueId = "q1" }]); + + var agentManager = new Mock(); + agentManager + .Setup(m => m.ListAvailableForQueueAsync("q1", It.IsAny())) + .ReturnsAsync([]); + + var service = CreateService(queueItemManager, agentManager, new Mock()); + + // Act + var reservation = await service.AssignNextAsync("q1"); + + // Assert + Assert.Null(reservation); + } + + [Fact] + public async Task AssignNextAsync_ReservesTopItemForLongestIdleAgent() + { + // Arrange + var topItem = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var queueItemManager = new Mock(); + queueItemManager + .Setup(m => m.ListWaitingAsync("q1", It.IsAny())) + .ReturnsAsync([topItem]); + + var idleAgent = new AgentProfile { ItemId = "a1", PresenceChangedUtc = new DateTime(2026, 1, 1) }; + var busyAgent = new AgentProfile { ItemId = "a2", PresenceChangedUtc = new DateTime(2026, 1, 2) }; + var agentManager = new Mock(); + agentManager + .Setup(m => m.ListAvailableForQueueAsync("q1", It.IsAny())) + .ReturnsAsync([busyAgent, idleAgent]); + + var queueManager = new Mock(); + queueManager + .Setup(m => m.FindByIdAsync("q1", It.IsAny())) + .ReturnsAsync(new ActivityQueue { ItemId = "q1", ReservationTimeoutSeconds = 45 }); + + var reservationService = new Mock(); + reservationService + .Setup(s => s.ReserveAsync(topItem, idleAgent, 45, It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r1" }); + + var service = new ActivityAssignmentService(queueItemManager.Object, agentManager.Object, queueManager.Object, reservationService.Object); + + // Act + var reservation = await service.AssignNextAsync("q1"); + + // Assert + Assert.NotNull(reservation); + reservationService.Verify(s => s.ReserveAsync(topItem, idleAgent, 45, It.IsAny()), Times.Once); + } + + private static ActivityAssignmentService CreateService( + Mock queueItemManager, + Mock agentManager, + Mock reservationService) + { + var queueManager = new Mock(); + + return new ActivityAssignmentService(queueItemManager.Object, agentManager.Object, queueManager.Object, reservationService.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs new file mode 100644 index 000000000..862136808 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -0,0 +1,84 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ActivityReservationServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task ReserveAsync_SetsReservedStateAndPublishesEvents() + { + // Arrange + var reservationManager = new Mock(); + reservationManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActivityReservation()); + var queueItemManager = new Mock(); + var agentManager = new Mock(); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var publisher = new Mock(); + var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, publisher); + + var item = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; + var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; + + // Act + var reservation = await service.ReserveAsync(item, agent, 30); + + // Assert + Assert.Equal(ReservationStatus.Pending, reservation.Status); + Assert.Equal(QueueItemStatus.Reserved, item.Status); + Assert.Equal(AgentPresenceStatus.Reserved, agent.PresenceStatus); + publisher.Verify(p => p.PublishAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueue() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + var queueItem = new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + + // Act + var count = await service.ExpireDueAsync(); + + // Assert + Assert.Equal(1, count); + Assert.Equal(ReservationStatus.Expired, reservation.Status); + Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); + } + + private static ActivityReservationService CreateService( + Mock reservationManager, + Mock queueItemManager, + Mock agentManager, + Mock activityManager, + Mock publisher) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new ActivityReservationService( + reservationManager.Object, + queueItemManager.Object, + agentManager.Object, + activityManager.Object, + publisher.Object, + clock.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs new file mode 100644 index 000000000..79bc15043 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -0,0 +1,52 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class AgentPresenceManagerServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task SignInAsync_CreatesAvailableProfile_AndJoinsQueues() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync((AgentProfile)null); + agentManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync((AgentProfile)null); + var publisher = new Mock(); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, publisher.Object, clock.Object); + + // Act + var profile = await service.SignInAsync("u1", ["q1", "q2"], []); + + // Assert + Assert.Equal(AgentPresenceStatus.Available, profile.PresenceStatus); + Assert.Equal(2, profile.QueueIds.Count); + agentManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task SignOutAsync_SetsOffline() + { + // Arrange + var existing = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Available }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, clock.Object); + + // Act + var profile = await service.SignOutAsync("u1"); + + // Assert + Assert.Equal(AgentPresenceStatus.Offline, profile.PresenceStatus); + } +} From 56602a7d11b42bfff8c60b2003ed581a3511023c Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 09:26:27 -0700 Subject: [PATCH 04/56] fix NRE --- .../OmnichannelActivityContainer.Buttons.SummaryAdmin.cshtml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainer.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainer.Buttons.SummaryAdmin.cshtml index 8cbcef575..03ce8d1d8 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainer.Buttons.SummaryAdmin.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainer.Buttons.SummaryAdmin.cshtml @@ -7,7 +7,7 @@ @inject IAuthorizationService AuthorizationService -@if (await AuthorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ListContactActivities, Model.Value.Contact)) +@if (Model.Value.Contact is not null && await AuthorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ListContactActivities, Model.Value.Contact)) { @T["List Activities"] } From 7760740b89cb87838b9b6a0178b9e3140204c696 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 11:44:00 -0700 Subject: [PATCH 05/56] Add Contact Center inbound voice routing and Telephony incoming-call modal Telephony: - Add incoming-call extensibility contracts (IncomingCallCard, IncomingCallContext, IIncomingCallContextProvider, IIncomingCallDispatcher) and DefaultIncomingCallDispatcher, which pushes a ringing call to a user via IHubContext.Clients.User after running the registered context providers. - Add a capability-gated voicemail operation (ITelephonyProvider.SendToVoicemailAsync and TelephonyCapabilities.Voicemail), a Voicemail hub method, and a DialPad implementation. - Add the soft-phone incoming-call modal (Answer/Voicemail/Ignore) with contributed contact cards, an Answer-and-open shortcut, and accept/decline offer-lifecycle posts. Contact Center (Voice feature): - Add InboundVoiceEvent/IInboundVoiceService/InboundVoiceService, which route an inbound call into an OmnichannelActivity (Kind=Call, Source=Inbound) with its Subject and a voice Interaction, enqueue it, reserve the longest-idle available agent signed in to the inbound queue, and offer the call through the dispatcher; re-offer on decline. - Add ContactCenterIncomingCallContextProvider (matched customers + offer lifecycle), IInboundContactLookup, a provider-agnostic ingress endpoint, an offer-lifecycle VoiceController, and an optional ActivityQueue.InboundChannelEndpointId mapping. Disposition unification: - Implement DefaultActivityDispositionService (previously a contract only) and route ActivitiesController.Complete through it so inbound and outbound activities are dispositioned through the same subject workflow. Add 5 unit tests (25 Contact Center tests pass); update the Telephony and Contact Center docs and the v2.0.0 changelog; update the Contact Center plan progress. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 5 +- .../ContactCenterConstants.cs | 5 + .../Models/InboundVoiceEvent.cs | 44 +++ .../IIncomingCallContextProvider.cs | 20 ++ .../IIncomingCallDispatcher.cs | 20 ++ .../ITelephonyClient.cs | 3 +- .../ITelephonyProvider.cs | 9 + .../ITelephonyService.cs | 8 + .../Models/IncomingCallCard.cs | 66 ++++ .../Models/IncomingCallCardLink.cs | 29 ++ .../Models/IncomingCallContext.cs | 24 ++ .../Models/IncomingCallContributionContext.cs | 50 +++ .../Models/TelephonyCapabilities.cs | 5 + .../Models/ActivityQueue.cs | 7 + .../Models/InboundVoiceRoutingResult.cs | 37 +++ .../Services/IInboundVoiceService.cs | 30 ++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 15 + .../docs/contact-center/index.md | 59 +++- src/CrestApps.Docs/docs/telephony/index.md | 71 +++- .../Controllers/VoiceController.cs | 123 +++++++ .../Controllers/VoiceIngressController.cs | 61 ++++ ...CrestApps.OrchardCore.ContactCenter.csproj | 2 + .../Manifest.cs | 12 + ...ontactCenterIncomingCallContextProvider.cs | 167 ++++++++++ .../Services/IInboundContactLookup.cs | 18 + .../Services/InboundContactLookup.cs | 77 +++++ .../Services/InboundVoiceService.cs | 308 ++++++++++++++++++ .../Startup.cs | 17 + .../Services/DialPadTelephonyProvider.cs | 12 +- .../Controllers/ActivitiesController.cs | 35 +- .../DefaultActivityDispositionService.cs | 99 ++++++ .../Startup.cs | 3 +- .../Assets/js/soft-phone.js | 220 ++++++++++++- .../Assets/scss/soft-phone.scss | 149 +++++++++ .../Hubs/TelephonyHub.cs | 8 + .../Services/DefaultIncomingCallDispatcher.cs | 64 ++++ .../Services/DefaultTelephonyService.cs | 4 + .../Startup.cs | 1 + .../Views/SoftPhoneWidget.cshtml | 38 +++ .../wwwroot/scripts/soft-phone.js | 178 +++++++++- .../wwwroot/scripts/soft-phone.min.js | 2 +- .../wwwroot/styles/soft-phone.css | 127 ++++++++ .../wwwroot/styles/soft-phone.min.css | 2 +- .../InMemoryTelephonyProvider.cs | 6 +- .../ActivityAssignmentServiceTests.cs | 6 +- .../ActivityReservationServiceTests.cs | 4 +- .../AgentPresenceManagerServiceTests.cs | 4 +- .../ContactCenter/InboundVoiceServiceTests.cs | 233 +++++++++++++ .../DefaultActivityDispositionServiceTests.cs | 84 +++++ .../Doubles/FakeAuthTelephonyProvider.cs | 3 + .../Doubles/FakeTelephonyProviderA.cs | 3 + .../Doubles/FakeTelephonyProviderB.cs | 3 + .../Doubles/RecordingTelephonyProvider.cs | 3 + 53 files changed, 2530 insertions(+), 53 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InboundVoiceEvent.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInboundVoiceService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterIncomingCallContextProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IInboundContactLookup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultIncomingCallDispatcher.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultActivityDispositionServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 88ecfad63..ade50ad04 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1387,9 +1387,9 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] `Queues` feature: ActivityQueue, QueueItem, ActivityReservation models/stores/managers/indexes; queue + reservation lifecycle (reserve/accept/reject/expire); reservation-expiry background task - [x] Availability-based assignment service (longest-idle agent ↔ highest-priority item); agent/queue/dialer permissions; admin menu + CRUD UI; unit tests - [~] Phase 3 — Routing MVP (availability + priority + longest-idle assignment shipped; skills/sticky/business-hours/audit pending) -- [ ] Phase 4 — Voice integration with Telephony +- [~] Phase 4 — Voice integration with Telephony (`Voice` feature: inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService`), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; transfer/conference taxonomy and a standalone call-session aggregate pending) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, dialer-agnostic `IDialerProvider`/resolver, power/progressive pacing, dialer batch sources, DialPad.Dialer provider; retry/callback/suppression pending) -- [ ] Phase 6 — Wrap-up and disposition lifecycle +- [~] Phase 6 — Wrap-up and disposition lifecycle (source-neutral `IActivityDispositionService` implemented and CRM activity completion routed through it so inbound and outbound disposition share the subject workflow; wrap-up timers and required-disposition policies pending) - [ ] Phase 7 — Agent desktop and supervisor real-time UX - [ ] Phase 8 — Inbound entry points, IVR and self-service - [ ] Phase 9 — Recording and live monitoring @@ -1407,3 +1407,4 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). - Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. - Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, with power/progressive pacing and dialer batch sources (Phase 5 core). Added the `DialPad.Dialer` feature implementing `IDialerProvider` over the DialPad telephony provider. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). +- Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent`/`IInboundVoiceService`/`InboundVoiceService` (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index 3d15454b9..cda9d123c 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -44,6 +44,11 @@ public static class Feature /// The identifier of the outbound dialer feature. /// public const string Dialer = "CrestApps.OrchardCore.ContactCenter.Dialer"; + + /// + /// The identifier of the inbound voice integration feature. + /// + public const string Voice = "CrestApps.OrchardCore.ContactCenter.Voice"; } /// 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.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/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/ITelephonyProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs index 95cc4fda1..96e8808f9 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs @@ -107,6 +107,15 @@ public interface ITelephonyProvider /// A describing the outcome. Task RejectAsync(CallReference call, CancellationToken cancellationToken = default); + /// + /// Sends a ringing inbound call to voicemail. Only invoked when the provider advertises + /// . + /// + /// 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 provider. /// diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs index a59b60d8c..d57470df8 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. /// 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/TelephonyCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs index eb313759a..ba6e2f161 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs @@ -56,4 +56,9 @@ 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, } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs index eeba6d757..5981d231f 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityQueue.cs @@ -44,6 +44,13 @@ public sealed class ActivityQueue : CatalogItem, INameAwareModel, IModifiedUtcAw /// 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. /// 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..00b7c3478 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs @@ -0,0 +1,37 @@ +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 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; } +} 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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index a89b70814..ed5567335 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -181,6 +181,14 @@ At a high level, the platform changes are: - Adds the `CrestApps.OrchardCore.DialPad.Dialer` feature, a DialPad implementation of the dialer-agnostic provider over the existing DialPad telephony provider. - 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: inbound provider calls are normalized into a CRM activity (`Kind = Call`, `Source = Inbound`) with its Subject, recorded as a voice `Interaction`, enqueued, routed to the longest-idle available agent signed in to the inbound queue, and offered through the Telephony soft-phone incoming-call modal. +- 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 `IInboundVoiceService` directly. +- 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). + ### Content Access Control #### Role-based content restrictions @@ -391,6 +399,13 @@ At a high level, the platform changes are: - The **Default telephony provider** selector lives on the **Soft Phone** settings tab, alongside the widget display options, so the default provider and the soft phone configuration are managed together. - Phone Field editors and displays gain a click-to-dial button (through a neutral `data-phone-dial` placeholder) that places calls with the soft phone when it is present on the page. +#### Incoming-call modal and extensibility + +- The soft phone now raises an incoming-call modal for a ringing inbound call with **Answer**, **Send to voicemail**, and **Ignore** actions. Voicemail is a new capability-gated provider operation (`ITelephonyProvider.SendToVoicemailAsync` with the `TelephonyCapabilities.Voicemail` flag); the DialPad provider implements it. +- Inbound calls are pushed to a specific user through the new `IIncomingCallDispatcher`, which runs every registered `IIncomingCallContextProvider`, builds an `IncomingCallContext`, and sends `IncomingCall(call, context)` through `IHubContext`. +- Other modules enrich the modal by contributing `IncomingCallCard` records (title, subtitle, badges, links, and an open URL). Each card renders an **Answer & open** shortcut, and a provider can supply `acceptUrl`/`declineUrl` properties so the modal reports the offer lifecycle. The Contact Center Voice feature uses this seam to list customers matched by the caller's number. +- See [Incoming calls](../telephony/index.md#incoming-calls). + #### DialPad provider - `v2.0.0` adds the `CrestApps.OrchardCore.DialPad` module, which integrates the DialPad platform as a telephony provider through its REST API and contributes a **DialPad** tab to the telephony settings. diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 00fa0a1c4..ab77635c0 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -120,6 +120,62 @@ orchestration beyond basic call control: Contact Center remains the brain: it selects the Activity, queue, agent, campaign, dialer mode, and compliance gates, then sends provider-neutral intents to the provider adapter. +## Inbound voice + +> **Feature ID** `CrestApps.OrchardCore.ContactCenter.Voice` + +The **Contact Center Voice** feature routes inbound provider calls to an available agent and offers +them through the [Telephony](../telephony/index.md) soft-phone incoming-call modal. It depends on the +Queues feature and the Telephony soft phone. + +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. + +### 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 `IInboundVoiceService` directly, the same way the +Omnichannel SMS webhook handles inbound messages. + +### 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. + ## UI extensibility All Contact Center UI is built with Orchard Core display management: shapes, display drivers, @@ -133,6 +189,7 @@ decorations. The Contact Center is under active, phased development. The first milestone is a voice MVP that proves agents can run inbound and outbound voice work entirely inside the CRM while preserving the -Telephony boundary. This documentation will expand as each capability ships. See +Telephony boundary. Inbound voice routing and the soft-phone incoming-call modal now ship in the +[Inbound voice](#inbound-voice) feature. This documentation will expand as each capability ships. See [Agents, Queues & Dialer](agents-queues-dialer.md) for the agent, queue, reservation, and dialer features. diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index f07177331..f839695ef 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -55,6 +55,7 @@ operations: | Merge calls | `MergeAsync` | | Send DTMF digits | `SendDigitsAsync` | | Answer / Reject inbound | `AnswerAsync` / `RejectAsync` | +| Send to voicemail | `SendToVoicemailAsync` | | Client bootstrap | `GetClientCredentialsAsync` | Each provider also advertises the operations it supports through the `Capabilities` property (a @@ -73,8 +74,9 @@ 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. +pushes `CallStateChanged`, `IncomingCall` (with its contextual cards), and `ReceiveError` events to +the connected client through the strongly-typed `ITelephonyClient` interface. It also exposes +`Answer`, `Reject`, and `Voicemail` operations for a ringing inbound call. ## Site settings @@ -139,6 +141,64 @@ The footer is designed to be extensible, so additional tabs can be added in the is read from the hub's `GetInteractions` method and is backed by the persisted interaction store described below, so it survives page reloads and is available independently of the provider. +## Incoming calls + +When an inbound call is offered to a user, the soft phone raises an **incoming-call modal** with +three actions: + +- **Answer** connects the call (`AnswerAsync`). +- **Send to voicemail** routes the caller to voicemail (`SendToVoicemailAsync`); it is shown only when + the provider advertises the `Voicemail` capability. +- **Ignore** declines the ringing call on this device (`RejectAsync`). + +The modal appears for a ringing **inbound** call even when the panel is closed, and it hides itself +once the call connects or ends. + +### Offering a call to a user + +Inbound calls are pushed to a specific user through `IIncomingCallDispatcher`. It runs every +registered `IIncomingCallContextProvider`, builds the `IncomingCallContext`, and sends +`IncomingCall(call, context)` to that user's soft-phone connections through +`IHubContext`: + +```csharp +await _incomingCallDispatcher.DispatchAsync(agentUserId, call); +``` + +Telephony owns the modal and the media controls; it does not decide who receives a call. An +orchestration module (such as the [Contact Center](../contact-center/index.md)) resolves the target +user, reserves the agent, and calls the dispatcher. + +### Enriching the modal from another module + +Other modules contribute records and shortcuts to the modal by implementing +`IIncomingCallContextProvider` and adding `IncomingCallCard` entries: + +```csharp +public sealed class MyContextProvider : IIncomingCallContextProvider +{ + public Task ContributeAsync(IncomingCallContributionContext context, CancellationToken cancellationToken = default) + { + context.Cards.Add(new IncomingCallCard + { + Title = "Jane Doe", + Subtitle = context.Call.From, + Icon = "fa-solid fa-user", + Url = "/Admin/Contents/ContentItems//Edit", + }); + + return Task.CompletedTask; + } +} +``` + +Each card is rendered with an **Answer & open** shortcut (answers the call and opens the card's +`Url`) and an **Open** link. A provider can also set `context.Properties["acceptUrl"]` and +`context.Properties["declineUrl"]`; when present, the modal posts to them as the agent answers or +ignores the call, which lets an orchestration module track the offer lifecycle. The +[Contact Center Voice](../contact-center/index.md) feature uses this seam to list customers matched by +the caller's phone number and to drive its reservation lifecycle. + ## Adding the soft phone to the website There are two ways to add the soft phone to your site: @@ -203,9 +263,10 @@ provider** — the data remains even if the provider integration is later remove The soft phone reads this history through the hub's `GetInteractions` method to render its history panel. Outbound calls placed from the soft phone are recorded automatically. Inbound and missed -calls are fully modeled (direction and outcome), but populating them requires the provider module to -report inbound events (for example through a provider webhook); the soft phone records the outbound -calls it initiates. +calls are fully modeled (direction and outcome). Inbound calls reach the soft phone through +`IIncomingCallDispatcher` (see [Incoming calls](#incoming-calls)); a provider module or an +orchestration module such as the Contact Center reports the inbound event and offers the call to a +user. ## Writing a provider diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceController.cs new file mode 100644 index 000000000..e6cc8a73e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceController.cs @@ -0,0 +1,123 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Admin; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Handles the agent-facing offer lifecycle for inbound voice calls. The soft-phone incoming-call +/// modal posts to these actions when the agent answers or ignores a ringing inbound call. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Voice)] +public sealed class VoiceController : Controller +{ + private readonly IActivityReservationService _reservationService; + private readonly IInboundVoiceService _inboundVoiceService; + private readonly IInteractionManager _interactionManager; + private readonly IAuthorizationService _authorizationService; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The reservation service used to accept or reject the offer. + /// The inbound voice service used to re-offer a declined call. + /// The interaction manager used to update the interaction state. + /// The authorization service. + /// The clock used to stamp answer times. + public VoiceController( + IActivityReservationService reservationService, + IInboundVoiceService inboundVoiceService, + IInteractionManager interactionManager, + IAuthorizationService authorizationService, + IClock clock) + { + _reservationService = reservationService; + _inboundVoiceService = inboundVoiceService; + _interactionManager = interactionManager; + _authorizationService = authorizationService; + _clock = clock; + } + + /// + /// Accepts the offered inbound call: converts the reservation into an assignment and marks the + /// interaction connected. + /// + /// The reservation identifier of the offered call. + /// An empty success result, or a problem result when the offer is no longer valid. + [HttpPost] + [Admin("contact-center/voice/offer/accept", "ContactCenterVoiceAcceptOffer")] + [ValidateAntiForgeryToken] + public async Task AcceptOffer(string reservationId) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) + { + return Forbid(); + } + + if (string.IsNullOrEmpty(reservationId)) + { + return BadRequest(); + } + + var reservation = await _reservationService.AcceptAsync(reservationId); + + if (reservation is null) + { + return NotFound(); + } + + var interaction = await _interactionManager.FindByActivityIdAsync(reservation.ActivityItemId); + + if (interaction is not null) + { + interaction.Status = InteractionStatus.Connected; + interaction.StartedUtc ??= _clock.UtcNow; + interaction.AnsweredUtc = _clock.UtcNow; + await _interactionManager.UpdateAsync(interaction); + } + + return Ok(); + } + + /// + /// Declines the offered inbound call: returns the call to its queue and re-offers it to the next + /// available agent. + /// + /// The reservation identifier of the offered call. + /// An empty success result, or a problem result when the offer is no longer valid. + [HttpPost] + [Admin("contact-center/voice/offer/decline", "ContactCenterVoiceDeclineOffer")] + [ValidateAntiForgeryToken] + public async Task DeclineOffer(string reservationId) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) + { + return Forbid(); + } + + if (string.IsNullOrEmpty(reservationId)) + { + return BadRequest(); + } + + var reservation = await _reservationService.RejectAsync(reservationId); + + if (reservation is null) + { + return NotFound(); + } + + if (!string.IsNullOrEmpty(reservation.QueueId)) + { + await _inboundVoiceService.OfferNextAsync(reservation.QueueId); + } + + return Ok(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs new file mode 100644 index 000000000..61d924eda --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs @@ -0,0 +1,61 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provider-agnostic ingress for inbound voice events. A telephony provider or PBX integration posts +/// a normalized to this endpoint, which routes the call through the +/// Contact Center. Provider-specific webhooks that validate provider signatures may alternatively call +/// directly. +/// +[ApiController] +[Authorize] +[Route("api/contact-center/voice")] +[Feature(ContactCenterConstants.Feature.Voice)] +public sealed class VoiceIngressController : ControllerBase +{ + private readonly IInboundVoiceService _inboundVoiceService; + private readonly IAuthorizationService _authorizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The inbound voice service that routes the call. + /// The authorization service. + public VoiceIngressController( + IInboundVoiceService inboundVoiceService, + IAuthorizationService authorizationService) + { + _inboundVoiceService = inboundVoiceService; + _authorizationService = authorizationService; + } + + /// + /// Routes a normalized inbound voice event through the Contact Center. + /// + /// The normalized inbound voice event. + /// The routing outcome describing the created records and the offered agent. + [HttpPost("inbound")] + public async Task> Inbound([FromBody] InboundVoiceEvent inboundEvent) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageInteractions)) + { + return Forbid(); + } + + if (inboundEvent is null) + { + return BadRequest(); + } + + var result = await _inboundVoiceService.HandleInboundAsync(inboundEvent, HttpContext.RequestAborted); + + return Ok(result); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index cbdfcc9fc..e1adbbb2c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -28,6 +28,8 @@ + + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs index f2f3a815a..8d5afaf9e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -55,3 +55,15 @@ ContactCenterConstants.Feature.Queues, ] )] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.Voice, + Name = "Contact Center Voice", + Description = "Routes inbound provider calls into queued CRM activities and offers them to available agents through the Telephony soft phone.", + Category = "Communication", + Dependencies = + [ + ContactCenterConstants.Feature.Queues, + "CrestApps.OrchardCore.Telephony.SoftPhone", + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterIncomingCallContextProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterIncomingCallContextProvider.cs new file mode 100644 index 000000000..02baf12dc --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterIncomingCallContextProvider.cs @@ -0,0 +1,167 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using OrchardCore.ContentManagement; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Contributes Contact Center context to the soft-phone incoming-call modal. For a ringing inbound +/// call offered to an agent, it lists the customers matched by the caller's phone number (scoped by +/// the agent's signed-in inbound queue) and wires the accept and decline offer-lifecycle actions. +/// +public sealed class ContactCenterIncomingCallContextProvider : IIncomingCallContextProvider +{ + private readonly IAgentProfileManager _agentManager; + private readonly IActivityReservationManager _reservationManager; + private readonly IActivityQueueManager _queueManager; + private readonly IInboundContactLookup _contactLookup; + private readonly IContentManager _contentManager; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly LinkGenerator _linkGenerator; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The agent profile manager. + /// The reservation manager used to resolve the agent's pending offer. + /// The queue manager used to resolve the offered queue name. + /// The contact lookup used to match customers by phone number. + /// The content manager used to load matched contact content items. + /// The HTTP context accessor used to build same-origin URLs. + /// The link generator used to build the contact and offer-lifecycle URLs. + /// The string localizer. + public ContactCenterIncomingCallContextProvider( + IAgentProfileManager agentManager, + IActivityReservationManager reservationManager, + IActivityQueueManager queueManager, + IInboundContactLookup contactLookup, + IContentManager contentManager, + IHttpContextAccessor httpContextAccessor, + LinkGenerator linkGenerator, + IStringLocalizer stringLocalizer) + { + _agentManager = agentManager; + _reservationManager = reservationManager; + _queueManager = queueManager; + _contactLookup = contactLookup; + _contentManager = contentManager; + _httpContextAccessor = httpContextAccessor; + _linkGenerator = linkGenerator; + S = stringLocalizer; + } + + /// + public async Task ContributeAsync(IncomingCallContributionContext context, CancellationToken cancellationToken = default) + { + var call = context.Call; + + if (call is null || call.Direction != CallDirection.Inbound || string.IsNullOrEmpty(context.UserId)) + { + return; + } + + var httpContext = _httpContextAccessor.HttpContext; + var queueName = await ContributeOfferLifecycleAsync(context, httpContext, cancellationToken); + + if (string.IsNullOrEmpty(call.From)) + { + return; + } + + var contactIds = await _contactLookup.FindContactItemIdsAsync(call.From, cancellationToken); + + if (contactIds.Count == 0) + { + return; + } + + var contacts = await _contentManager.GetAsync(contactIds, VersionOptions.Latest); + context.Heading = S["Matched customers"]; + + var priority = 0; + + foreach (var contact in contacts) + { + var card = new IncomingCallCard + { + Id = contact.ContentItemId, + Title = string.IsNullOrEmpty(contact.DisplayText) ? call.From : contact.DisplayText, + Subtitle = call.From, + Icon = "fa-solid fa-user", + Source = ContactCenterConstants.Components.Voice, + Priority = priority++, + }; + + if (!string.IsNullOrEmpty(queueName)) + { + card.Badges.Add(queueName); + } + + if (httpContext is not null) + { + card.Url = _linkGenerator.GetPathByAction( + httpContext, + action: "Edit", + controller: "Admin", + values: new { area = "OrchardCore.Contents", contentItemId = contact.ContentItemId }); + } + + context.Cards.Add(card); + } + } + + private async Task ContributeOfferLifecycleAsync(IncomingCallContributionContext context, HttpContext httpContext, CancellationToken cancellationToken) + { + var agent = await _agentManager.FindByUserIdAsync(context.UserId, cancellationToken); + + if (agent is null) + { + return null; + } + + var reservation = await _reservationManager.FindPendingByAgentAsync(agent.ItemId, cancellationToken); + + if (reservation is null) + { + return null; + } + + if (httpContext is not null) + { + var acceptUrl = _linkGenerator.GetPathByName(httpContext, "ContactCenterVoiceAcceptOffer", new { reservationId = reservation.ItemId }); + var declineUrl = _linkGenerator.GetPathByName(httpContext, "ContactCenterVoiceDeclineOffer", new { reservationId = reservation.ItemId }); + + if (!string.IsNullOrEmpty(acceptUrl)) + { + context.Properties["acceptUrl"] = acceptUrl; + } + + if (!string.IsNullOrEmpty(declineUrl)) + { + context.Properties["declineUrl"] = declineUrl; + } + } + + if (string.IsNullOrEmpty(reservation.QueueId)) + { + return null; + } + + var queue = await _queueManager.FindByIdAsync(reservation.QueueId, cancellationToken); + + if (queue is null) + { + return null; + } + + context.Properties["queue"] = queue.Name; + + return queue.Name; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IInboundContactLookup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IInboundContactLookup.cs new file mode 100644 index 000000000..d1a620adc --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IInboundContactLookup.cs @@ -0,0 +1,18 @@ +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Resolves Omnichannel contact content items by a phone number. Used by the inbound voice flow to +/// attach a contact to the activity and by the incoming-call context provider to list customers +/// matched by the caller's number. +/// +public interface IInboundContactLookup +{ + /// + /// Finds the identifiers of the contact content items whose primary cell or home phone number + /// matches the supplied phone number. + /// + /// The phone number to match, in any format. + /// The token to monitor for cancellation requests. + /// The matching contact content item identifiers, or an empty list when none match. + Task> FindContactItemIdsAsync(string phoneNumber, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs new file mode 100644 index 000000000..7c09a9757 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs @@ -0,0 +1,77 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.PhoneNumbers; +using YesSql; +using YesSql.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Default implementation that matches contacts by their +/// normalized (E.164) primary cell and home phone numbers using the Omnichannel contact index. +/// +public sealed class InboundContactLookup : IInboundContactLookup +{ + private readonly ISession _session; + private readonly IPhoneNumberService _phoneNumberService; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session used to query the contact index. + /// The phone number service used to normalize numbers to E.164. + public InboundContactLookup( + ISession session, + IPhoneNumberService phoneNumberService) + { + _session = session; + _phoneNumberService = phoneNumberService; + } + + /// + public async Task> FindContactItemIdsAsync(string phoneNumber, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(phoneNumber)) + { + return []; + } + + var normalized = Normalize(phoneNumber); + + if (string.IsNullOrEmpty(normalized)) + { + return []; + } + + var numbers = new[] { normalized }; + + var cellMatches = await _session + .QueryIndex(index => index.NormalizedPrimaryCellPhoneNumber.IsIn(numbers)) + .ListAsync(cancellationToken); + + var homeMatches = await _session + .QueryIndex(index => index.NormalizedPrimaryHomePhoneNumber.IsIn(numbers)) + .ListAsync(cancellationToken); + + var ids = new List(); + + foreach (var index in cellMatches.Concat(homeMatches)) + { + if (!string.IsNullOrEmpty(index.ContentItemId) && !ids.Contains(index.ContentItemId)) + { + ids.Add(index.ContentItemId); + } + } + + return ids; + } + + private string Normalize(string phoneNumber) + { + if (_phoneNumberService.TryFormatToE164(phoneNumber, null, out var e164) && !string.IsNullOrEmpty(e164)) + { + return e164; + } + + return new string(phoneNumber.Where(char.IsDigit).ToArray()); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs new file mode 100644 index 000000000..ca9a5b1b8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs @@ -0,0 +1,308 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using OrchardCore.ContentManagement; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Default implementation. It creates the CRM activity and the +/// interaction for an inbound call, resolves the target queue and subject, enqueues the work, reserves +/// an available agent, and offers the ringing call to that agent through the Telephony soft phone. +/// +public sealed class InboundVoiceService : IInboundVoiceService +{ + private const string ServiceAddressMetadataKey = "serviceAddress"; + + private readonly IOmnichannelChannelEndpointManager _channelEndpointManager; + private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IContentManager _contentManager; + private readonly IInteractionManager _interactionManager; + private readonly IActivityQueueManager _queueManager; + private readonly IActivityQueueService _queueService; + private readonly IActivityAssignmentService _assignmentService; + private readonly IAgentProfileManager _agentManager; + private readonly IInboundContactLookup _contactLookup; + private readonly IIncomingCallDispatcher _incomingCallDispatcher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The channel endpoint manager used to map the dialed number to an endpoint. + /// The subject flow settings service used to resolve the subject and campaign. + /// The CRM activity manager. + /// The content manager used to create the subject and load contacts. + /// The interaction manager used to record communication history. + /// The queue manager used to resolve the inbound queue. + /// The queue service used to enqueue the activity. + /// The assignment service used to reserve an available agent. + /// The agent profile manager used to resolve the reserved agent. + /// The contact lookup used to resolve the caller. + /// The dispatcher used to offer the ringing call to the agent. + /// The clock used to stamp times. + public InboundVoiceService( + IOmnichannelChannelEndpointManager channelEndpointManager, + ISubjectFlowSettingsService subjectFlowSettingsService, + IOmnichannelActivityManager activityManager, + IContentManager contentManager, + IInteractionManager interactionManager, + IActivityQueueManager queueManager, + IActivityQueueService queueService, + IActivityAssignmentService assignmentService, + IAgentProfileManager agentManager, + IInboundContactLookup contactLookup, + IIncomingCallDispatcher incomingCallDispatcher, + IClock clock) + { + _channelEndpointManager = channelEndpointManager; + _subjectFlowSettingsService = subjectFlowSettingsService; + _activityManager = activityManager; + _contentManager = contentManager; + _interactionManager = interactionManager; + _queueManager = queueManager; + _queueService = queueService; + _assignmentService = assignmentService; + _agentManager = agentManager; + _contactLookup = contactLookup; + _incomingCallDispatcher = incomingCallDispatcher; + _clock = clock; + } + + /// + public async Task HandleInboundAsync(InboundVoiceEvent inboundEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(inboundEvent); + + var result = new InboundVoiceRoutingResult(); + var now = inboundEvent.ReceivedUtc ?? _clock.UtcNow; + var fromAddress = inboundEvent.FromAddress?.GetCleanedPhoneNumber(); + var serviceAddress = inboundEvent.ToAddress?.GetCleanedPhoneNumber(); + + if (string.IsNullOrEmpty(serviceAddress)) + { + result.Reason = "The inbound call did not include a destination address."; + + return result; + } + + var endpoint = await _channelEndpointManager.GetByServiceAddressAsync(OmnichannelConstants.Channels.Phone, serviceAddress, cancellationToken); + + var flow = await ResolveFlowAsync(endpoint, cancellationToken); + + var contactItemId = await ResolveContactAsync(fromAddress, cancellationToken); + + var activity = await CreateActivityAsync(endpoint, flow, fromAddress, contactItemId, now); + result.ActivityItemId = activity.ItemId; + + var queue = await ResolveQueueAsync(endpoint, cancellationToken); + + var interaction = await CreateInteractionAsync(inboundEvent, activity, queue, fromAddress, serviceAddress); + result.InteractionId = interaction.ItemId; + + if (queue is null) + { + result.Reason = "No inbound queue is configured to receive this call."; + + return result; + } + + result.QueueId = queue.ItemId; + + await _queueService.EnqueueAsync(activity.ItemId, queue.ItemId, priority: null, cancellationToken); + + var agentUserId = await OfferNextAsync(queue.ItemId, cancellationToken); + + if (string.IsNullOrEmpty(agentUserId)) + { + result.Reason = "The call was queued; no agent is currently available."; + + return result; + } + + result.Routed = true; + result.AgentUserId = agentUserId; + result.Reason = "Offered to an available agent."; + + return result; + } + + /// + public async Task OfferNextAsync(string queueId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(queueId); + + var reservation = await _assignmentService.AssignNextAsync(queueId, cancellationToken); + + if (reservation is null) + { + return null; + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + if (agent is null || string.IsNullOrEmpty(agent.UserId)) + { + return null; + } + + var interaction = await _interactionManager.FindByActivityIdAsync(reservation.ActivityItemId, cancellationToken); + + if (interaction is null) + { + return null; + } + + interaction.Status = InteractionStatus.Ringing; + interaction.AgentId = agent.ItemId; + interaction.QueueId = reservation.QueueId; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + var call = new TelephonyCall + { + CallId = interaction.ProviderInteractionId, + From = interaction.CustomerAddress, + To = ResolveServiceAddress(interaction), + State = CallState.Ringing, + Direction = CallDirection.Inbound, + ProviderName = interaction.ProviderName, + StartedUtc = _clock.UtcNow, + }; + + await _incomingCallDispatcher.DispatchAsync(agent.UserId, call, cancellationToken); + + return agent.UserId; + } + + private static string ResolveServiceAddress(Core.Models.Interaction interaction) + { + return interaction.TechnicalMetadata.TryGetValue(ServiceAddressMetadataKey, out var value) + ? value?.ToString() + : null; + } + + private async Task ResolveFlowAsync(OmnichannelChannelEndpoint endpoint, CancellationToken cancellationToken) + { + if (endpoint is null) + { + return null; + } + + var flows = await _subjectFlowSettingsService.GetConfiguredFlowSettingsAsync(cancellationToken); + + return flows.FirstOrDefault(flow => + string.Equals(flow.Channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase) && + string.Equals(flow.ChannelEndpointId, endpoint.ItemId, StringComparison.OrdinalIgnoreCase)); + } + + private async Task ResolveContactAsync(string fromAddress, CancellationToken cancellationToken) + { + var contactIds = await _contactLookup.FindContactItemIdsAsync(fromAddress, cancellationToken); + + return contactIds.Count > 0 ? contactIds[0] : null; + } + + private async Task CreateActivityAsync( + OmnichannelChannelEndpoint endpoint, + SubjectFlowSettings flow, + string fromAddress, + string contactItemId, + DateTime now) + { + var activity = await _activityManager.NewAsync(); + activity.Kind = ActivityKind.Call; + activity.Source = ActivitySources.Inbound; + activity.Channel = OmnichannelConstants.Channels.Phone; + activity.ChannelEndpointId = endpoint?.ItemId; + activity.InteractionType = ActivityInteractionType.Manual; + activity.PreferredDestination = fromAddress; + activity.CampaignId = flow?.CampaignId; + activity.SubjectContentType = flow?.SubjectContentType; + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + activity.Status = ActivityStatus.AwaitingAgentResponse; + activity.ScheduledUtc = now; + activity.CreatedUtc = now; + + if (!string.IsNullOrEmpty(contactItemId)) + { + var contact = await _contentManager.GetAsync(contactItemId); + + if (contact is not null) + { + activity.ContactContentItemId = contact.ContentItemId; + activity.ContactContentType = contact.ContentType; + } + } + + if (!string.IsNullOrEmpty(activity.SubjectContentType)) + { + activity.Subject = await _contentManager.NewAsync(activity.SubjectContentType); + } + + await _activityManager.CreateAsync(activity); + + return activity; + } + + private async Task CreateInteractionAsync( + InboundVoiceEvent inboundEvent, + OmnichannelActivity activity, + ActivityQueue queue, + string fromAddress, + string serviceAddress) + { + var interaction = await _interactionManager.NewAsync(); + interaction.Channel = InteractionChannel.Voice; + interaction.Direction = InteractionDirection.Inbound; + interaction.Status = InteractionStatus.Created; + interaction.ActivityItemId = activity.ItemId; + interaction.ProviderName = inboundEvent.ProviderName; + interaction.ProviderInteractionId = inboundEvent.ProviderCallId; + interaction.CustomerAddress = fromAddress; + interaction.QueueId = queue?.ItemId; + + if (!string.IsNullOrEmpty(serviceAddress)) + { + interaction.TechnicalMetadata[ServiceAddressMetadataKey] = serviceAddress; + } + + foreach (var entry in inboundEvent.Metadata) + { + interaction.TechnicalMetadata[entry.Key] = entry.Value; + } + + await _interactionManager.CreateAsync(interaction); + + return interaction; + } + + private async Task ResolveQueueAsync(OmnichannelChannelEndpoint endpoint, CancellationToken cancellationToken) + { + var queues = await _queueManager.ListEnabledAsync(cancellationToken); + + if (endpoint is not null) + { + var mapped = queues.FirstOrDefault(queue => + string.Equals(queue.InboundChannelEndpointId, endpoint.ItemId, StringComparison.OrdinalIgnoreCase)); + + if (mapped is not null) + { + return mapped; + } + } + + var unmapped = queues + .Where(queue => string.IsNullOrEmpty(queue.InboundChannelEndpointId)) + .ToList(); + + return unmapped.Count == 1 ? unmapped[0] : null; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 1e5955144..f139f9f5b 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -8,6 +8,7 @@ using CrestApps.OrchardCore.ContactCenter.Services; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.Models; +using CrestApps.OrchardCore.Telephony; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using OrchardCore.BackgroundTasks; @@ -143,3 +144,19 @@ public override void ConfigureServices(IServiceCollection services) }); } } + +/// +/// Registers inbound voice routing that turns provider calls into queued CRM activities and offers +/// them to available agents through the Telephony soft phone. +/// +[Feature(ContactCenterConstants.Feature.Voice)] +public sealed class VoiceStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs index ebb1349d0..78ef9afc3 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs @@ -81,7 +81,8 @@ public TelephonyCapabilities Capabilities TelephonyCapabilities.Transfer | TelephonyCapabilities.Merge | TelephonyCapabilities.SendDigits | - TelephonyCapabilities.ReceiveCalls; + TelephonyCapabilities.ReceiveCalls | + TelephonyCapabilities.Voicemail; } } @@ -252,6 +253,15 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => ExecuteCallActionAsync(call?.CallId, "reject", body: null, () => BuildCall(call?.CallId, CallState.Disconnected, direction: CallDirection.Inbound), cancellationToken); + /// + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => ExecuteCallActionAsync( + call?.CallId, + "transfer", + new Dictionary { ["to_voicemail"] = true }, + () => BuildCall(call?.CallId, CallState.Disconnected, direction: CallDirection.Inbound), + cancellationToken); + /// public async Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) { diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index 7b5cd5bc9..4a46e5a8a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -1,7 +1,6 @@ using System.Globalization; using System.Security.Claims; using CrestApps.Core; -using CrestApps.Core.Services; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; @@ -47,8 +46,7 @@ public sealed class ActivitiesController : Controller private readonly IAuthorizationService _authorizationService; private readonly IContentDefinitionManager _contentDefinitionManager; private readonly IContentItemDisplayManager _contentItemDisplayManager; - private readonly ISubjectActionExecutor _subjectActionExecutor; - private readonly INamedCatalog _dispositionsCatalog; + private readonly IActivityDispositionService _activityDispositionService; private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; private readonly IClock _clock; private readonly ILocalClock _localClock; @@ -69,8 +67,7 @@ public sealed class ActivitiesController : Controller /// The authorization service. /// The content definition manager. /// The content item display manager. - /// The subject action executor. - /// The dispositions catalog. + /// The activity disposition service. /// The subject flow settings service. /// The clock. /// The local clock. @@ -87,8 +84,7 @@ public ActivitiesController( IAuthorizationService authorizationService, IContentDefinitionManager contentDefinitionManager, IContentItemDisplayManager contentItemDisplayManager, - ISubjectActionExecutor subjectActionExecutor, - INamedCatalog dispositionsCatalog, + IActivityDispositionService activityDispositionService, ISubjectFlowSettingsService subjectFlowSettingsService, IClock clock, ILocalClock localClock, @@ -105,8 +101,7 @@ public ActivitiesController( _authorizationService = authorizationService; _contentDefinitionManager = contentDefinitionManager; _contentItemDisplayManager = contentItemDisplayManager; - _subjectActionExecutor = subjectActionExecutor; - _dispositionsCatalog = dispositionsCatalog; + _activityDispositionService = activityDispositionService; _subjectFlowSettingsService = subjectFlowSettingsService; _clock = clock; _localClock = localClock; @@ -536,25 +531,17 @@ public async Task CompleteAsync(string id) if (ModelState.IsValid) { - // Execute subject actions for the selected disposition. + // Disposition the activity through the source-neutral path so the configured subject flow runs. activity.Subject = subject; - activity.Status = ActivityStatus.Completed; - activity.CompletedById = User.FindFirstValue(ClaimTypes.NameIdentifier); - activity.CompletedByUsername = User.Identity?.Name; - activity.CompletedUtc = _clock.UtcNow; - await _omnichannelActivityManager.UpdateAsync(activity); - var disposition = await _dispositionsCatalog.FindByIdAsync(activity.DispositionId); - - var executionContext = new SubjectActionExecutionContext + await _activityDispositionService.ApplyAsync(new ActivityDispositionRequest { Activity = activity, - Contact = model.ContactContentItem, - Subject = subject, - Disposition = disposition, - }; - - await _subjectActionExecutor.ExecuteAsync(executionContext); + DispositionId = activity.DispositionId, + Source = ActivityDispositionSource.Agent, + ActorId = User.FindFirstValue(ClaimTypes.NameIdentifier), + ActorDisplayName = User.Identity?.Name, + }); await _notifier.SuccessAsync(H["The activity has been completed successfully."]); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs new file mode 100644 index 000000000..2d5aac789 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs @@ -0,0 +1,99 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using OrchardCore.ContentManagement; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Default implementation. It is the single, source-neutral +/// path for completing an activity with a disposition: it records the disposition and completion +/// metadata, then runs the configured subject actions. Agent, provider, AI, dialer, and inbound voice +/// outcomes all converge here so inbound and outbound work is dispositioned through the same subject flow. +/// +public sealed class DefaultActivityDispositionService : IActivityDispositionService +{ + private readonly IOmnichannelActivityManager _activityManager; + private readonly INamedCatalog _dispositionsCatalog; + private readonly IContentManager _contentManager; + private readonly ISubjectActionExecutor _subjectActionExecutor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The activity manager used to persist the dispositioned activity. + /// The dispositions catalog used to resolve the selected disposition. + /// The content manager used to load the contact for subject actions. + /// The subject action executor that runs the subject flow. + /// The clock used to stamp completion times. + public DefaultActivityDispositionService( + IOmnichannelActivityManager activityManager, + INamedCatalog dispositionsCatalog, + IContentManager contentManager, + ISubjectActionExecutor subjectActionExecutor, + IClock clock) + { + _activityManager = activityManager; + _dispositionsCatalog = dispositionsCatalog; + _contentManager = contentManager; + _subjectActionExecutor = subjectActionExecutor; + _clock = clock; + } + + /// + public async Task ApplyAsync(ActivityDispositionRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var activity = request.Activity; + + if (activity is null) + { + return ActivityDispositionResult.Failure("An activity is required to apply a disposition."); + } + + if (!string.IsNullOrEmpty(request.DispositionId)) + { + activity.DispositionId = request.DispositionId; + } + + if (!string.IsNullOrEmpty(request.Notes)) + { + activity.Notes = string.IsNullOrEmpty(activity.Notes) + ? request.Notes + : $"{activity.Notes}{Environment.NewLine}{request.Notes}"; + } + + activity.Status = ActivityStatus.Completed; + activity.CompletedById = request.ActorId; + activity.CompletedByUsername = request.ActorDisplayName; + activity.CompletedUtc = _clock.UtcNow; + + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + + var disposition = string.IsNullOrEmpty(activity.DispositionId) + ? null + : await _dispositionsCatalog.FindByIdAsync(activity.DispositionId, cancellationToken); + + ContentItem contact = null; + + if (!string.IsNullOrEmpty(activity.ContactContentItemId)) + { + contact = await _contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); + } + + var executionContext = new SubjectActionExecutionContext + { + Activity = activity, + Contact = contact, + Subject = activity.Subject, + Disposition = disposition, + }; + + await _subjectActionExecutor.ExecuteAsync(executionContext, cancellationToken); + + return ActivityDispositionResult.Success(activity); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 69c99efe0..73bbfd79a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -108,7 +108,8 @@ public override void ConfigureServices(IServiceCollection services) .AddDisplayDriver() .AddDisplayDriver() .AddDisplayDriver() - .AddScoped(); + .AddScoped() + .AddScoped(); // Subject Flow Settings. services diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js index fb7e9773a..feaa0bbe6 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js @@ -19,7 +19,8 @@ Transfer: 1 << 5, Merge: 1 << 6, SendDigits: 1 << 7, - ReceiveCalls: 1 << 8 + ReceiveCalls: 1 << 8, + Voicemail: 1 << 9 }; var STATE_NAMES = ['Idle', 'Connecting', 'Ringing', 'Connected', 'OnHold', 'Disconnected', 'Failed']; @@ -102,11 +103,19 @@ history: rootElement.querySelector('[data-telephony-history]'), historyList: rootElement.querySelector('[data-telephony-history-list]'), footer: rootElement.querySelector('[data-telephony-footer]'), - tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')) + tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')), + incoming: rootElement.querySelector('[data-telephony-incoming]'), + incomingCaller: rootElement.querySelector('[data-telephony-incoming-caller]'), + incomingQueue: rootElement.querySelector('[data-telephony-incoming-queue]'), + incomingCards: rootElement.querySelector('[data-telephony-incoming-cards]'), + incomingAnswer: rootElement.querySelector('[data-telephony-incoming-answer]'), + incomingVoicemail: rootElement.querySelector('[data-telephony-incoming-voicemail]'), + incomingIgnore: rootElement.querySelector('[data-telephony-incoming-ignore]') }; var connection = null; var currentCall = null; + var incomingContext = null; var requiresAuthentication = false; var isConnected = false; var isAvailable = false; @@ -366,6 +375,8 @@ } function render() { + renderIncoming(); + var stateName = currentCall ? normalizeState(currentCall.state) : 'Idle'; var active = isActive(stateName); @@ -605,6 +616,195 @@ render(); } + // ---- Incoming call modal ---- + + function isRingingInbound() { + if (!currentCall) { + return false; + } + + var inbound = currentCall.direction === 1 || currentCall.direction === 'Inbound'; + + return normalizeState(currentCall.state) === 'Ringing' && inbound; + } + + function renderIncoming() { + var visible = isRingingInbound(); + + show(dom.incoming, visible); + rootElement.classList.toggle('telephony-soft-phone--incoming', visible); + + if (!visible) { + incomingContext = null; + + return; + } + + if (dom.incomingCaller) { + dom.incomingCaller.textContent = (currentCall && (currentCall.from || currentCall.to)) || (strings.incomingCall || 'Incoming call'); + } + + var queueText = incomingContext && incomingContext.properties ? incomingContext.properties.queue : ''; + + if (dom.incomingQueue) { + dom.incomingQueue.textContent = queueText || ''; + dom.incomingQueue.hidden = !queueText; + } + + show(dom.incomingVoicemail, has(CAPABILITIES.Voicemail)); + renderIncomingCards(); + } + + function renderIncomingCards() { + if (!dom.incomingCards) { + return; + } + + var cards = incomingContext && incomingContext.cards ? incomingContext.cards : []; + + if (!cards.length) { + dom.incomingCards.innerHTML = ''; + dom.incomingCards.hidden = true; + + return; + } + + var html = ''; + var heading = (incomingContext && incomingContext.heading) || strings.matchedRecords; + + if (heading) { + html += '
' + escapeHtml(heading) + '
'; + } + + cards.forEach(function (card) { + html += buildIncomingCard(card); + }); + + dom.incomingCards.innerHTML = html; + dom.incomingCards.hidden = false; + + Array.prototype.forEach.call(dom.incomingCards.querySelectorAll('[data-telephony-card-answer]'), function (button) { + button.addEventListener('click', function () { + answerIncoming(button.getAttribute('data-url')); + }); + }); + } + + function buildIncomingCard(card) { + var icon = card.icon ? '' : ''; + var body = '
' + escapeHtml(card.title || '') + '
'; + + if (card.subtitle) { + body += '
' + escapeHtml(card.subtitle) + '
'; + } + + if (card.description) { + body += '
' + escapeHtml(card.description) + '
'; + } + + if (card.badges && card.badges.length) { + body += '
'; + + card.badges.forEach(function (badge) { + body += '' + escapeHtml(badge) + ''; + }); + + body += '
'; + } + + if (card.links && card.links.length) { + body += ''; + } + + var actions = ''; + + if (card.url) { + var openTarget = card.openInNewTab ? ' target="_blank" rel="noopener"' : ''; + actions += ''; + actions += ' ' + escapeHtml(strings.open || 'Open') + ''; + } + + return '
' + icon + + '
' + body + '
' + + (actions ? '
' + actions + '
' : '') + + '
'; + } + + function postLifecycle(key) { + if (!incomingContext || !incomingContext.properties) { + return; + } + + var url = incomingContext.properties[key]; + + if (!url) { + return; + } + + var headers = { 'Content-Type': 'application/json' }; + + if (config.antiForgeryToken) { + headers['RequestVerificationToken'] = config.antiForgeryToken; + } + + try { + fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: headers, + body: JSON.stringify({ callId: currentCallId() }) + }).catch(function () { }); + } catch (e) { /* lifecycle callbacks are best-effort */ } + } + + function answerIncoming(openUrl) { + var id = currentCallId(); + + if (openUrl) { + window.open(openUrl, '_blank', 'noopener'); + } + + postLifecycle('acceptUrl'); + + if (id) { + togglePanel(true); + invoke('Answer', { callId: id }); + } + } + + function voicemailIncoming() { + var id = currentCallId(); + + postLifecycle('declineUrl'); + + if (id) { + invoke('Voicemail', { callId: id }); + } + } + + function ignoreIncoming() { + var id = currentCallId(); + + postLifecycle('declineUrl'); + + if (id) { + invoke('Reject', { callId: id }); + } else { + currentCall = null; + render(); + } + } + // ---- Connection status and authentication ---- function refreshConnectionStatus() { @@ -786,10 +986,10 @@ render(); }); - connection.on('IncomingCall', function (call) { + connection.on('IncomingCall', function (call, context) { currentCall = call; + incomingContext = context || null; activeTab = 'keypad'; - togglePanel(true); render(); }); @@ -884,6 +1084,18 @@ dom.merge.addEventListener('click', merge); } + if (dom.incomingAnswer) { + dom.incomingAnswer.addEventListener('click', function () { answerIncoming(null); }); + } + + if (dom.incomingVoicemail) { + dom.incomingVoicemail.addEventListener('click', voicemailIncoming); + } + + if (dom.incomingIgnore) { + dom.incomingIgnore.addEventListener('click', ignoreIncoming); + } + if (dom.connect) { dom.connect.addEventListener('click', handleConnect); } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss index c1bd73f40..b939c7c35 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss @@ -323,3 +323,152 @@ box-shadow: 0 0.25rem 0.75rem rgba(210, 63, 63, 0.5); } } + +.telephony-incoming { + position: fixed; + inset: 0; + z-index: 1090; + display: flex; + align-items: center; + justify-content: center; + + &__backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.45); + } + + &__dialog { + position: relative; + width: min(26rem, calc(100vw - 2rem)); + max-height: calc(100vh - 2rem); + overflow-y: auto; + background-color: #fff; + border-radius: 0.75rem; + box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.35); + padding: 1.25rem; + } + + &__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 1rem; + } + + &__pulse { + flex: 0 0 auto; + width: 3rem; + height: 3rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background-color: #198754; + animation: telephony-soft-phone-pulse 1.4s ease-in-out infinite; + } + + &__heading { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6c757d; + } + + &__caller { + font-size: 1.15rem; + font-weight: 600; + word-break: break-word; + } + + &__queue { + font-size: 0.8rem; + color: #6c757d; + } + + &__cards { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + max-height: 14rem; + overflow-y: auto; + } + + &__cards-heading { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6c757d; + } + + &__card { + display: flex; + gap: 0.65rem; + padding: 0.6rem 0.7rem; + border: 1px solid #e3e6ea; + border-radius: 0.6rem; + background-color: #f8f9fa; + } + + &__card-icon { + flex: 0 0 auto; + color: var(--telephony-accent, #2f6fed); + font-size: 1.1rem; + } + + &__card-body { + flex: 1 1 auto; + min-width: 0; + } + + &__card-title { + font-weight: 600; + word-break: break-word; + } + + &__card-subtitle, + &__card-desc { + font-size: 0.8rem; + color: #6c757d; + word-break: break-word; + } + + &__card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.35rem; + } + + &__card-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; + font-size: 0.8rem; + } + + &__card-actions { + display: flex; + flex-direction: column; + gap: 0.35rem; + justify-content: center; + } + + &__actions { + display: flex; + gap: 0.5rem; + + .telephony-incoming__btn { + flex: 1 1 0; + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs index 7caec7b62..4310d5927 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs @@ -123,6 +123,14 @@ public Task Answer(CallReference call) public Task Reject(CallReference call) => ExecuteAsync((service, token) => service.RejectAsync(call, token)); + /// + /// Sends a ringing inbound call to voicemail. + /// + /// A reference to the inbound call to send to voicemail. + /// A describing the outcome. + public Task Voicemail(CallReference call) + => ExecuteAsync((service, token) => service.SendToVoicemailAsync(call, token)); + /// /// Issues the bootstrap configuration the soft phone client needs to connect to the provider. /// diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultIncomingCallDispatcher.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultIncomingCallDispatcher.cs new file mode 100644 index 000000000..7f03452bd --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultIncomingCallDispatcher.cs @@ -0,0 +1,64 @@ +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.Telephony.Services; + +/// +/// Default implementation. It gathers the contextual cards from +/// the registered instances and pushes the ringing inbound +/// call to every soft-phone connection the target user currently has open. +/// +public sealed class DefaultIncomingCallDispatcher : IIncomingCallDispatcher +{ + private readonly IHubContext _hubContext; + private readonly IEnumerable _contextProviders; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The telephony hub context used to push events to connected clients. + /// The registered incoming-call context providers. + /// The logger. + public DefaultIncomingCallDispatcher( + IHubContext hubContext, + IEnumerable contextProviders, + ILogger logger) + { + _hubContext = hubContext; + _contextProviders = contextProviders; + _logger = logger; + } + + /// + public async Task DispatchAsync(string userId, TelephonyCall call, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + ArgumentNullException.ThrowIfNull(call); + + var contributionContext = new IncomingCallContributionContext(call, userId); + + foreach (var provider in _contextProviders) + { + try + { + await provider.ContributeAsync(contributionContext, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "An incoming-call context provider of type '{ProviderType}' failed while enriching an inbound call.", provider.GetType().FullName); + } + } + + var context = new IncomingCallContext + { + Heading = contributionContext.Heading, + Cards = [.. contributionContext.Cards.OrderBy(card => card.Priority)], + Properties = contributionContext.Properties, + }; + + await _hubContext.Clients.User(userId).IncomingCall(call, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyService.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyService.cs index 28d47334c..fb882ef02 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyService.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyService.cs @@ -70,6 +70,10 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => InvokeAsync((provider, token) => provider.RejectAsync(call, token), cancellationToken); + /// + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => InvokeAsync((provider, token) => provider.SendToVoicemailAsync(call, token), cancellationToken); + /// public async Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) { diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs index 02e13f1dc..bc3d3eb26 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs @@ -29,6 +29,7 @@ public override void ConfigureServices(IServiceCollection services) { services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddTransient, TelephonySettingsConfiguration>(); services.AddScoped(); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml index ccb0905b7..a1d69190d 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml @@ -3,17 +3,20 @@ @using CrestApps.Core.SignalR.Services @using System.Text.Json @inject HubRouteManager HubRouteManager +@inject Microsoft.AspNetCore.Antiforgery.IAntiforgery Antiforgery @{ var hubUrl = HubRouteManager.GetPathByHub(); var accentColor = Model.AccentColor as string ?? "#2f6fed"; var capabilities = Model.Capabilities is int caps ? caps : 0; var connectUrl = Url.RouteUrl(TelephonyConstants.RouteNames.OAuthConnect); + var antiForgeryToken = Antiforgery.GetAndStoreTokens(Context).RequestToken; var config = new Dictionary { ["hubUrl"] = hubUrl, ["capabilities"] = capabilities, ["connectUrl"] = connectUrl, + ["antiForgeryToken"] = antiForgeryToken, ["storageKey"] = "telephony-soft-phone", ["strings"] = new Dictionary { @@ -40,6 +43,13 @@ ["completed"] = T["Completed"].Value, ["rejected"] = T["Rejected"].Value, ["canceled"] = T["Canceled"].Value, + ["incomingCall"] = T["Incoming call"].Value, + ["answer"] = T["Answer"].Value, + ["voicemail"] = T["Send to voicemail"].Value, + ["ignore"] = T["Ignore"].Value, + ["answerAndOpen"] = T["Answer & open"].Value, + ["open"] = T["Open"].Value, + ["matchedRecords"] = T["Matched records"].Value, }, }; } @@ -138,4 +148,32 @@
+ +
diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js index 49653a549..ed6d5dfc9 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js @@ -24,7 +24,8 @@ Transfer: 1 << 5, Merge: 1 << 6, SendDigits: 1 << 7, - ReceiveCalls: 1 << 8 + ReceiveCalls: 1 << 8, + Voicemail: 1 << 9 }; var STATE_NAMES = ['Idle', 'Connecting', 'Ringing', 'Connected', 'OnHold', 'Disconnected', 'Failed']; function normalizeState(state) { @@ -100,10 +101,18 @@ history: rootElement.querySelector('[data-telephony-history]'), historyList: rootElement.querySelector('[data-telephony-history-list]'), footer: rootElement.querySelector('[data-telephony-footer]'), - tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')) + tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')), + incoming: rootElement.querySelector('[data-telephony-incoming]'), + incomingCaller: rootElement.querySelector('[data-telephony-incoming-caller]'), + incomingQueue: rootElement.querySelector('[data-telephony-incoming-queue]'), + incomingCards: rootElement.querySelector('[data-telephony-incoming-cards]'), + incomingAnswer: rootElement.querySelector('[data-telephony-incoming-answer]'), + incomingVoicemail: rootElement.querySelector('[data-telephony-incoming-voicemail]'), + incomingIgnore: rootElement.querySelector('[data-telephony-incoming-ignore]') }; var connection = null; var currentCall = null; + var incomingContext = null; var requiresAuthentication = false; var isConnected = false; var isAvailable = false; @@ -322,6 +331,7 @@ } } function render() { + renderIncoming(); var stateName = currentCall ? normalizeState(currentCall.state) : 'Idle'; var active = isActive(stateName); rootElement.classList.toggle('telephony-soft-phone--in-call', active); @@ -530,6 +540,155 @@ render(); } + // ---- Incoming call modal ---- + + function isRingingInbound() { + if (!currentCall) { + return false; + } + var inbound = currentCall.direction === 1 || currentCall.direction === 'Inbound'; + return normalizeState(currentCall.state) === 'Ringing' && inbound; + } + function renderIncoming() { + var visible = isRingingInbound(); + show(dom.incoming, visible); + rootElement.classList.toggle('telephony-soft-phone--incoming', visible); + if (!visible) { + incomingContext = null; + return; + } + if (dom.incomingCaller) { + dom.incomingCaller.textContent = currentCall && (currentCall.from || currentCall.to) || strings.incomingCall || 'Incoming call'; + } + var queueText = incomingContext && incomingContext.properties ? incomingContext.properties.queue : ''; + if (dom.incomingQueue) { + dom.incomingQueue.textContent = queueText || ''; + dom.incomingQueue.hidden = !queueText; + } + show(dom.incomingVoicemail, has(CAPABILITIES.Voicemail)); + renderIncomingCards(); + } + function renderIncomingCards() { + if (!dom.incomingCards) { + return; + } + var cards = incomingContext && incomingContext.cards ? incomingContext.cards : []; + if (!cards.length) { + dom.incomingCards.innerHTML = ''; + dom.incomingCards.hidden = true; + return; + } + var html = ''; + var heading = incomingContext && incomingContext.heading || strings.matchedRecords; + if (heading) { + html += '
' + escapeHtml(heading) + '
'; + } + cards.forEach(function (card) { + html += buildIncomingCard(card); + }); + dom.incomingCards.innerHTML = html; + dom.incomingCards.hidden = false; + Array.prototype.forEach.call(dom.incomingCards.querySelectorAll('[data-telephony-card-answer]'), function (button) { + button.addEventListener('click', function () { + answerIncoming(button.getAttribute('data-url')); + }); + }); + } + function buildIncomingCard(card) { + var icon = card.icon ? '' : ''; + var body = '
' + escapeHtml(card.title || '') + '
'; + if (card.subtitle) { + body += '
' + escapeHtml(card.subtitle) + '
'; + } + if (card.description) { + body += '
' + escapeHtml(card.description) + '
'; + } + if (card.badges && card.badges.length) { + body += '
'; + card.badges.forEach(function (badge) { + body += '' + escapeHtml(badge) + ''; + }); + body += '
'; + } + if (card.links && card.links.length) { + body += ''; + } + var actions = ''; + if (card.url) { + var openTarget = card.openInNewTab ? ' target="_blank" rel="noopener"' : ''; + actions += ''; + actions += ' ' + escapeHtml(strings.open || 'Open') + ''; + } + return '
' + icon + '
' + body + '
' + (actions ? '
' + actions + '
' : '') + '
'; + } + function postLifecycle(key) { + if (!incomingContext || !incomingContext.properties) { + return; + } + var url = incomingContext.properties[key]; + if (!url) { + return; + } + var headers = { + 'Content-Type': 'application/json' + }; + if (config.antiForgeryToken) { + headers['RequestVerificationToken'] = config.antiForgeryToken; + } + try { + fetch(url, { + method: 'POST', + credentials: 'same-origin', + headers: headers, + body: JSON.stringify({ + callId: currentCallId() + }) + })["catch"](function () {}); + } catch (e) {/* lifecycle callbacks are best-effort */} + } + function answerIncoming(openUrl) { + var id = currentCallId(); + if (openUrl) { + window.open(openUrl, '_blank', 'noopener'); + } + postLifecycle('acceptUrl'); + if (id) { + togglePanel(true); + invoke('Answer', { + callId: id + }); + } + } + function voicemailIncoming() { + var id = currentCallId(); + postLifecycle('declineUrl'); + if (id) { + invoke('Voicemail', { + callId: id + }); + } + } + function ignoreIncoming() { + var id = currentCallId(); + postLifecycle('declineUrl'); + if (id) { + invoke('Reject', { + callId: id + }); + } else { + currentCall = null; + render(); + } + } + // ---- Connection status and authentication ---- function refreshConnectionStatus() { @@ -670,10 +829,10 @@ } render(); }); - connection.on('IncomingCall', function (call) { + connection.on('IncomingCall', function (call, context) { currentCall = call; + incomingContext = context || null; activeTab = 'keypad'; - togglePanel(true); render(); }); connection.on('ReceiveError', function (message) { @@ -744,6 +903,17 @@ if (dom.merge) { dom.merge.addEventListener('click', merge); } + if (dom.incomingAnswer) { + dom.incomingAnswer.addEventListener('click', function () { + answerIncoming(null); + }); + } + if (dom.incomingVoicemail) { + dom.incomingVoicemail.addEventListener('click', voicemailIncoming); + } + if (dom.incomingIgnore) { + dom.incomingIgnore.addEventListener('click', ignoreIncoming); + } if (dom.connect) { dom.connect.addEventListener('click', handleConnect); } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js index 5922c5685..a4e729a18 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js @@ -1 +1 @@ -!function(){"use strict";var e=2,t=4,n=8,o=16,r=32,i=64,a=128,l=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function c(e){return"number"==typeof e?l[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function u(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function s(e){var t=document.createElement("div");return t.textContent=null==e?"":String(e),t.innerHTML}function d(e,t,n){return Math.min(Math.max(e,t),n)}function h(l,h){h=h||{};var p=function(e){var t=e.getAttribute("data-config");if(!t)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(t)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(l),f=p.strings||{},y=p.capabilities||0,g=(p.storageKey||"telephony-soft-phone")+"-layout",m=h.signalRFactory||("undefined"!=typeof signalR?signalR:null),v={toggle:l.querySelector("[data-telephony-toggle]"),toggleIcon:l.querySelector("[data-telephony-toggle-icon]"),panel:l.querySelector("[data-telephony-panel]"),dragHandle:l.querySelector("[data-telephony-drag-handle]"),close:l.querySelector("[data-telephony-close]"),status:l.querySelector("[data-telephony-status]"),number:l.querySelector("[data-telephony-number]"),peer:l.querySelector("[data-telephony-peer]"),error:l.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(l.querySelectorAll("[data-telephony-key]")),dial:l.querySelector("[data-telephony-dial]"),hold:l.querySelector("[data-telephony-hold]"),resume:l.querySelector("[data-telephony-resume]"),mute:l.querySelector("[data-telephony-mute]"),unmute:l.querySelector("[data-telephony-unmute]"),transfer:l.querySelector("[data-telephony-transfer]"),merge:l.querySelector("[data-telephony-merge]"),hangup:l.querySelector("[data-telephony-hangup]"),connectPanel:l.querySelector("[data-telephony-connect-panel]"),connect:l.querySelector("[data-telephony-connect]"),unavailable:l.querySelector("[data-telephony-unavailable]"),unavailableText:l.querySelector("[data-telephony-unavailable-text]"),keypadView:l.querySelector('[data-telephony-view="keypad"]'),history:l.querySelector("[data-telephony-history]"),historyList:l.querySelector("[data-telephony-history-list]"),footer:l.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(l.querySelectorAll("[data-telephony-tab]"))},b=null,w=null,S=!1,C=!1,L=!1,k=null,E="keypad",q=!1;function I(e){return(y&e)===e}function R(e,t){e&&(e.hidden=!t)}function _(e){v.status&&(v.status.textContent=e)}function A(e){v.error&&(e?(v.error.textContent=e,v.error.hidden=!1):(v.error.textContent="",v.error.hidden=!0))}function x(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function P(e){try{var t=x();Object.assign(t,e),localStorage.setItem(g,JSON.stringify(t))}catch(e){}}function M(e,t){l.style.left=e+"px",l.style.top=t+"px",l.style.right="auto",l.style.bottom="auto"}function H(e,t){var n=l.getBoundingClientRect(),o=n.width||56,r=n.height||56,i=Math.max(8,window.innerWidth-o-8),a=Math.max(8,window.innerHeight-r-8),c=8,u=8;if(v.panel&&!v.panel.hidden){var s=v.panel.getBoundingClientRect(),h=s.width||o,p=s.height||0;c=Math.min(i,Math.max(8,h-o+8)),u=Math.min(a,p+20)}return{left:d(e,c,i),top:d(t,u,a)}}function N(){if(l.style.left){var e=l.getBoundingClientRect(),t=H(e.left,e.top);M(t.left,t.top)}}function U(e,t){if(e){t=t||{};var n=null,o=0,r=0,i=0,a=0,c=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||t.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=l.getBoundingClientRect();M(d.left,d.top),n=e.pointerId,c=!1,o=e.clientX,r=e.clientY,i=d.left,a=d.top,l.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==n&&e.pointerId===n){var t=e.clientX-o,l=e.clientY-r;if(c||!(Math.hypot(t,l)<4)){c=!0;var u=H(i+t,a+l);M(u.left,u.top)}}}function s(){var e;null!==n&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),l.classList.remove("telephony-soft-phone--dragging"),n=null,c&&(P({position:{left:(e=l.getBoundingClientRect()).left,top:e.top,leftRatio:e.left/Math.max(1,window.innerWidth),topRatio:e.top/Math.max(1,window.innerHeight)}}),t.suppressClick&&(q=!0)))}}function O(e){E=e,B(),"history"===e&&function(){if(!b)return void ne([]);b.invoke("GetInteractions",50).then(function(e){ne(e||[])}).catch(function(){ne([])})}()}function B(){var a=w?c(w.state):"Idle",s=u(a);l.classList.toggle("telephony-soft-phone--in-call",s),v.toggleIcon&&(v.toggleIcon.className=s?"fa-solid fa-phone-slash":"fa-solid fa-phone");var d=L&&S&&!C;if(!L&&!s)return v.unavailableText&&(v.unavailableText.textContent=f.notConfigured||"No telephony provider is configured."),R(v.unavailable,!0),R(v.connectPanel,!1),R(v.keypadView,!1),R(v.history,!1),R(v.footer,!1),void _(f.notReady||"Not Ready");if(R(v.unavailable,!1),d&&!s)return R(v.connectPanel,!0),R(v.keypadView,!1),R(v.history,!1),R(v.footer,!1),void _(f.notConnected||"Not connected");R(v.connectPanel,!1),R(v.footer,!0),v.tabs.forEach(function(e){var t=e.getAttribute("data-telephony-tab")===E;e.classList.toggle("is-active",t),e.setAttribute("aria-selected",t?"true":"false")});var h="history"===E;R(v.history,h),R(v.keypadView,!h),_(w?function(e){var t=e.charAt(0).toLowerCase()+e.slice(1);return f[t]||e}(a):f.idle||"Ready"),v.peer&&(v.peer.textContent=s&&w&&(w.to||w.from)||""),R(v.dial,!s),R(v.hangup,s&&I(e)),R(v.hold,s&&"Connected"===a&&I(t)),R(v.resume,s&&"OnHold"===a&&I(n));var p=w&&w.isMuted;R(v.mute,s&&!p&&I(o)),R(v.unmute,s&&p&&I(o)),R(v.transfer,s&&I(r)),R(v.merge,s&&I(i)),v.number&&(v.number.disabled=s)}function D(e,t){return b?b.invoke(e,t).then(function(e){return function(e){!!e&&(!1===e.succeeded?A(e.error||f.failed||"Call failed"):(A(null),e.call&&("Disconnected"!==c((w=e.call).state)&&"Failed"!==c(w.state)||(w=null)),B()))}(e),e}).catch(function(e){throw A(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function T(){return w?w.callId:null}function F(){var e=v.number?v.number.value.trim():"";e?D("Dial",{to:e}):A(f.invalidNumber||"Enter a phone number to call.")}function j(e){e&&(E="keypad",Q(!0),v.number&&(v.number.value=e),D("Dial",{to:e}))}function V(){var e=T();e&&D("Hangup",{callId:e})}function G(){var e=T();e&&D("Hold",{callId:e})}function J(){var e=T();e&&D("Resume",{callId:e})}function W(){var e=T();e&&D("Mute",{callId:e})}function z(){var e=T();e&&D("Unmute",{callId:e})}function K(){var e=T();if(e){var t=window.prompt(f.transferPrompt||"Transfer to number");t&&D("Transfer",{callId:e,to:t,mode:0})}}function X(){var e=T();if(e){var t=window.prompt(f.mergePrompt||"Second call id to merge");t&&D("Merge",{primaryCallId:e,secondaryCallId:t})}}function Y(e){var t=w?c(w.state):"Idle";"Connected"===t&&I(a)?D("SendDigits",{callId:T(),digits:e}):!u(t)&&v.number&&(v.number.value+=e)}function Q(e){if(v.panel){var t="boolean"==typeof e?e:v.panel.hidden;v.panel.hidden=!t,P({open:t}),N(),B()}}function Z(){return b?b.invoke("GetConnectionStatus").then(function(e){e&&(L=!!e.isAvailable,S=!!e.requiresAuthentication,C=!!e.isConnected,k=e.authenticationScheme||"oauth2",B())}).catch(function(){}):Promise.resolve()}function $(){if(p.connectUrl){var e=p.connectUrl.indexOf("?")>=0?"&":"?",t=p.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(t,"telephony-oauth","width=520,height=640")||(window.location.href=t)}}function ee(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,t=e&&(e[k]||e.oauth2),n={scheme:k,connectUrl:p.connectUrl,startOAuth:$,refreshStatus:Z};"function"==typeof t?t(n):$()}function te(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Z()}function ne(e){v.historyList&&(e.length?(v.historyList.innerHTML=e.map(function(e){var t=function(e){return 1===e.direction||"Inbound"===e.direction}(e),n=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),r=t?"fa-arrow-down-left":"fa-arrow-up-right",i=t?e.from||"":e.to||"",a=n?f.missed||"Missed":t?f.incoming||"Incoming":f.outgoing||"Outgoing",l=function(e){try{var t=new Date(e);return isNaN(t.getTime())?"":t.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(n?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=s(a)+(o?" • "+s(f.inProgress||"In progress"):"")+(l?" • "+s(l):"");return'"}).join(""),Array.prototype.forEach.call(v.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var t=e.getAttribute("data-telephony-history-number");t&&(E="keypad",j(t))})})):v.historyList.innerHTML='
'+s(f.noInteractions||"No recent calls.")+"
")}v.toggle&&v.toggle.addEventListener("click",function(){q?q=!1:Q()}),v.close&&v.close.addEventListener("click",function(){Q(!1)}),v.tabs.forEach(function(e){e.addEventListener("click",function(){O(e.getAttribute("data-telephony-tab"))})}),v.dial&&v.dial.addEventListener("click",F),v.hangup&&v.hangup.addEventListener("click",V),v.hold&&v.hold.addEventListener("click",G),v.resume&&v.resume.addEventListener("click",J),v.mute&&v.mute.addEventListener("click",W),v.unmute&&v.unmute.addEventListener("click",z),v.transfer&&v.transfer.addEventListener("click",K),v.merge&&v.merge.addEventListener("click",X),v.connect&&v.connect.addEventListener("click",ee),v.keys.forEach(function(e){e.addEventListener("click",function(){Y(e.getAttribute("data-telephony-key"))})}),U(v.dragHandle,{ignoreButtons:!0,suppressClick:!1}),U(v.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",te),window.addEventListener("resize",N),function(){var e=x();if(e.open&&v.panel&&(v.panel.hidden=!1),e.position&&isFinite(e.position.left)){var t=e.position.left,n=e.position.top;isFinite(e.position.leftRatio)&&(t=e.position.leftRatio*window.innerWidth,n=e.position.topRatio*window.innerHeight);var o=H(t,n);M(o.left,o.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var t=e.getBoundingClientRect(),n=l.getBoundingClientRect().width||56,o=t.left-n-14;o<8&&(o=t.right+14);var r=H(o,t.top);M(r.left,r.top)}}()}(),B(),l.style.visibility="";var oe=m&&p.hubUrl?((b=(new m.HubConnectionBuilder).withUrl(p.hubUrl).withAutomaticReconnect().build())&&(b.on("CallStateChanged",function(e){w=e,"Disconnected"!==c(e.state)&&"Failed"!==c(e.state)||(w=null),B()}),b.on("IncomingCall",function(e){w=e,E="keypad",Q(!0),B()}),b.on("ReceiveError",function(e){A(e)}),b.on("CredentialsIssued",function(){}),b.onclose(function(){_(f.disconnectedHub||"Disconnected")})),b.start().then(function(){return A(null),Promise.all([b?b.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(y=e,B())}).catch(function(){}):Promise.resolve(),Z()])}).then(function(){B()}).catch(function(e){A(e&&e.message?e.message:String(e))})):(B(),Promise.resolve());return{element:l,config:p,dial:F,dialNumber:j,hangup:V,hold:G,resume:J,mute:W,unmute:z,transfer:K,merge:X,pressKey:Y,togglePanel:Q,open:function(){Q(!0)},getCurrentCall:function(){return w},getConnection:function(){return b},started:oe}}function p(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=h(e))})}function f(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:h,initializeAll:p,getInstance:f,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var t=f();t&&t.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",p):p()}(); +!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function s(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function u(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function h(c,h){h=h||{};var f=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),g=f.strings||{},y=f.capabilities||0,m=(f.storageKey||"telephony-soft-phone")+"-layout",v=h.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,S=null,C=null,k=!1,L=!1,_=!1,q=null,I="keypad",E=!1;function A(e){return(y&e)===e}function R(e,n){e&&(e.hidden=!n)}function x(e){b.status&&(b.status.textContent=e)}function M(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function P(){try{return JSON.parse(localStorage.getItem(m))||{}}catch(e){return{}}}function H(e){try{var n=P();Object.assign(n,e),localStorage.setItem(m,JSON.stringify(n))}catch(e){}}function T(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function N(e,n){var t=c.getBoundingClientRect(),o=t.width||56,i=t.height||56,r=Math.max(8,window.innerWidth-o-8),a=Math.max(8,window.innerHeight-i-8),l=8,s=8;if(b.panel&&!b.panel.hidden){var u=b.panel.getBoundingClientRect(),d=u.width||o,h=u.height||0;l=Math.min(r,Math.max(8,d-o+8)),s=Math.min(a,h+20)}return{left:p(e,l,r),top:p(n,s,a)}}function U(){if(c.style.left){var e=c.getBoundingClientRect(),n=N(e.left,e.top);T(n.left,n.top)}}function O(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();T(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",s),document.addEventListener("pointerup",u),document.addEventListener("pointercancel",u)}})}function s(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var s=N(r+n,a+c);T(s.left,s.top)}}}function u(){var e;null!==t&&(document.removeEventListener("pointermove",s),document.removeEventListener("pointerup",u),document.removeEventListener("pointercancel",u),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(H({position:{left:(e=c.getBoundingClientRect()).left,top:e.top,leftRatio:e.left/Math.max(1,window.innerWidth),topRatio:e.top/Math.max(1,window.innerHeight)}}),n.suppressClick&&(E=!0)))}}function B(e){I=e,V(),"history"===e&&function(){if(!w)return void ce([]);w.invoke("GetInteractions",50).then(function(e){ce(e||[])}).catch(function(){ce([])})}()}function V(){!function(){var e=function(){if(!S)return!1;var e=1===S.direction||"Inbound"===S.direction;return"Ringing"===s(S.state)&&e}();if(R(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return void(C=null);b.incomingCaller&&(b.incomingCaller.textContent=S&&(S.from||S.to)||g.incomingCall||"Incoming call");var n=C&&C.properties?C.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);R(b.incomingVoicemail,A(l)),function(){if(!b.incomingCards)return;var e=C&&C.cards?C.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=C&&C.heading||g.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(g.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ne(e.getAttribute("data-url"))})})}()}();var a=S?s(S.state):"Idle",p=u(a);c.classList.toggle("telephony-soft-phone--in-call",p),b.toggleIcon&&(b.toggleIcon.className=p?"fa-solid fa-phone-slash":"fa-solid fa-phone");var h=_&&k&&!L;if(!_&&!p)return b.unavailableText&&(b.unavailableText.textContent=g.notConfigured||"No telephony provider is configured."),R(b.unavailable,!0),R(b.connectPanel,!1),R(b.keypadView,!1),R(b.history,!1),R(b.footer,!1),void x(g.notReady||"Not Ready");if(R(b.unavailable,!1),h&&!p)return R(b.connectPanel,!0),R(b.keypadView,!1),R(b.history,!1),R(b.footer,!1),void x(g.notConnected||"Not connected");R(b.connectPanel,!1),R(b.footer,!0),b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===I;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")});var f="history"===I;R(b.history,f),R(b.keypadView,!f),x(S?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return g[n]||e}(a):g.idle||"Ready"),b.peer&&(b.peer.textContent=p&&S&&(S.to||S.from)||""),R(b.dial,!p),R(b.hangup,p&&A(e)),R(b.hold,p&&"Connected"===a&&A(n)),R(b.resume,p&&"OnHold"===a&&A(t));var y=S&&S.isMuted;R(b.mute,p&&!y&&A(o)),R(b.unmute,p&&y&&A(o)),R(b.transfer,p&&A(i)),R(b.merge,p&&A(r)),b.number&&(b.number.disabled=p)}function D(e,n){return w?w.invoke(e,n).then(function(e){return function(e){!!e&&(!1===e.succeeded?M(e.error||g.failed||"Call failed"):(M(null),e.call&&("Disconnected"!==s((S=e.call).state)&&"Failed"!==s(S.state)||(S=null)),V()))}(e),e}).catch(function(e){throw M(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function F(){return S?S.callId:null}function j(){var e=b.number?b.number.value.trim():"";e?D("Dial",{to:e}):M(g.invalidNumber||"Enter a phone number to call.")}function J(e){e&&(I="keypad",$(!0),b.number&&(b.number.value=e),D("Dial",{to:e}))}function Q(){var e=F();e&&D("Hangup",{callId:e})}function G(){var e=F();e&&D("Hold",{callId:e})}function W(){var e=F();e&&D("Resume",{callId:e})}function z(){var e=F();e&&D("Mute",{callId:e})}function K(){var e=F();e&&D("Unmute",{callId:e})}function X(){var e=F();if(e){var n=window.prompt(g.transferPrompt||"Transfer to number");n&&D("Transfer",{callId:e,to:n,mode:0})}}function Y(){var e=F();if(e){var n=window.prompt(g.mergePrompt||"Second call id to merge");n&&D("Merge",{primaryCallId:e,secondaryCallId:n})}}function Z(e){var n=S?s(S.state):"Idle";"Connected"===n&&A(a)?D("SendDigits",{callId:F(),digits:e}):!u(n)&&b.number&&(b.number.value+=e)}function $(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,H({open:n}),U(),V()}}function ee(e){if(C&&C.properties){var n=C.properties[e];if(n){var t={"Content-Type":"application/json"};f.antiForgeryToken&&(t.RequestVerificationToken=f.antiForgeryToken);try{fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:F()})}).catch(function(){})}catch(e){}}}}function ne(e){var n=F();e&&window.open(e,"_blank","noopener"),ee("acceptUrl"),n&&($(!0),D("Answer",{callId:n}))}function te(){var e=F();ee("declineUrl"),e&&D("Voicemail",{callId:e})}function oe(){var e=F();ee("declineUrl"),e?D("Reject",{callId:e}):(S=null,V())}function ie(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(_=!!e.isAvailable,k=!!e.requiresAuthentication,L=!!e.isConnected,q=e.authenticationScheme||"oauth2",V())}).catch(function(){}):Promise.resolve()}function re(){if(f.connectUrl){var e=f.connectUrl.indexOf("?")>=0?"&":"?",n=f.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function ae(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[q]||e.oauth2),t={scheme:q,connectUrl:f.connectUrl,startOAuth:re,refreshStatus:ie};"function"==typeof n?n(t):re()}function le(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&ie()}function ce(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"fa-arrow-down-left":"fa-arrow-up-right",r=n?e.from||"":e.to||"",a=t?g.missed||"Missed":n?g.incoming||"Incoming":g.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),s=d(a)+(o?" • "+d(g.inProgress||"In progress"):"")+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&(I="keypad",J(n))})})):b.historyList.innerHTML='
'+d(g.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){E?E=!1:$()}),b.close&&b.close.addEventListener("click",function(){$(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){B(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",j),b.hangup&&b.hangup.addEventListener("click",Q),b.hold&&b.hold.addEventListener("click",G),b.resume&&b.resume.addEventListener("click",W),b.mute&&b.mute.addEventListener("click",z),b.unmute&&b.unmute.addEventListener("click",K),b.transfer&&b.transfer.addEventListener("click",X),b.merge&&b.merge.addEventListener("click",Y),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ne(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",te),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",oe),b.connect&&b.connect.addEventListener("click",ae),b.keys.forEach(function(e){e.addEventListener("click",function(){Z(e.getAttribute("data-telephony-key"))})}),O(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),O(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",le),window.addEventListener("resize",U),function(){var e=P();if(e.open&&b.panel&&(b.panel.hidden=!1),e.position&&isFinite(e.position.left)){var n=e.position.left,t=e.position.top;isFinite(e.position.leftRatio)&&(n=e.position.leftRatio*window.innerWidth,t=e.position.topRatio*window.innerHeight);var o=N(n,t);T(o.left,o.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=N(o,n.top);T(i.left,i.top)}}()}(),V(),c.style.visibility="";var se=v&&f.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(f.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){S=e,"Disconnected"!==s(e.state)&&"Failed"!==s(e.state)||(S=null),V()}),w.on("IncomingCall",function(e,n){S=e,C=n||null,I="keypad",V()}),w.on("ReceiveError",function(e){M(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){x(g.disconnectedHub||"Disconnected")})),w.start().then(function(){return M(null),Promise.all([w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(y=e,V())}).catch(function(){}):Promise.resolve(),ie()])}).then(function(){V()}).catch(function(e){M(e&&e.message?e.message:String(e))})):(V(),Promise.resolve());return{element:c,config:f,dial:j,dialNumber:J,hangup:Q,hold:G,resume:W,mute:z,unmute:K,transfer:X,merge:Y,pressKey:Z,togglePanel:$,open:function(){$(!0)},getCurrentCall:function(){return S},getConnection:function(){return w},started:se}}function f(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=h(e))})}function g(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:h,initializeAll:f,getInstance:g,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=g();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",f):f()}(); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css index 6cf4c93df..29111e82e 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css @@ -273,3 +273,130 @@ box-shadow: 0 0.25rem 0.75rem rgba(210, 63, 63, 0.5); } } +.telephony-incoming { + position: fixed; + inset: 0; + z-index: 1090; + display: flex; + align-items: center; + justify-content: center; +} +.telephony-incoming__backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.45); +} +.telephony-incoming__dialog { + position: relative; + width: min(26rem, 100vw - 2rem); + max-height: calc(100vh - 2rem); + overflow-y: auto; + background-color: #fff; + border-radius: 0.75rem; + box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.35); + padding: 1.25rem; +} +.telephony-incoming__header { + display: flex; + align-items: center; + gap: 0.85rem; + margin-bottom: 1rem; +} +.telephony-incoming__pulse { + flex: 0 0 auto; + width: 3rem; + height: 3rem; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + background-color: #198754; + animation: telephony-soft-phone-pulse 1.4s ease-in-out infinite; +} +.telephony-incoming__heading { + display: flex; + flex-direction: column; + min-width: 0; +} +.telephony-incoming__label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6c757d; +} +.telephony-incoming__caller { + font-size: 1.15rem; + font-weight: 600; + word-break: break-word; +} +.telephony-incoming__queue { + font-size: 0.8rem; + color: #6c757d; +} +.telephony-incoming__cards { + display: flex; + flex-direction: column; + gap: 0.5rem; + margin-bottom: 1rem; + max-height: 14rem; + overflow-y: auto; +} +.telephony-incoming__cards-heading { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #6c757d; +} +.telephony-incoming__card { + display: flex; + gap: 0.65rem; + padding: 0.6rem 0.7rem; + border: 1px solid #e3e6ea; + border-radius: 0.6rem; + background-color: #f8f9fa; +} +.telephony-incoming__card-icon { + flex: 0 0 auto; + color: var(--telephony-accent, #2f6fed); + font-size: 1.1rem; +} +.telephony-incoming__card-body { + flex: 1 1 auto; + min-width: 0; +} +.telephony-incoming__card-title { + font-weight: 600; + word-break: break-word; +} +.telephony-incoming__card-subtitle, .telephony-incoming__card-desc { + font-size: 0.8rem; + color: #6c757d; + word-break: break-word; +} +.telephony-incoming__card-badges { + display: flex; + flex-wrap: wrap; + gap: 0.25rem; + margin-top: 0.35rem; +} +.telephony-incoming__card-links { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; + font-size: 0.8rem; +} +.telephony-incoming__card-actions { + display: flex; + flex-direction: column; + gap: 0.35rem; + justify-content: center; +} +.telephony-incoming__actions { + display: flex; + gap: 0.5rem; +} +.telephony-incoming__actions .telephony-incoming__btn { + flex: 1 1 0; +} diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css index d3fd1738e..c97ca3253 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css @@ -1 +1 @@ -.telephony-soft-phone{--telephony-accent:#2f6fed;position:fixed;right:1.5rem;bottom:1.5rem;z-index:1080;font-size:.9rem}.telephony-soft-phone__toggle{width:3.25rem;height:3.25rem;border:none;border-radius:50%;color:#fff;background-color:var(--telephony-accent);box-shadow:0 .25rem .75rem rgba(0,0,0,.25);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.1rem;transition:transform .15s ease-in-out}.telephony-soft-phone__toggle:hover{transform:scale(1.05)}.telephony-soft-phone__panel{position:absolute;right:0;bottom:4rem;width:18rem;background-color:#fff;border-radius:.75rem;box-shadow:0 .5rem 1.5rem rgba(0,0,0,.25);overflow:hidden}.telephony-soft-phone__header{display:flex;align-items:center;gap:.5rem;padding:.65rem .85rem;color:#fff;background-color:var(--telephony-accent);cursor:move;touch-action:none;user-select:none}.telephony-soft-phone__title{font-weight:600}.telephony-soft-phone__status{margin-left:auto;font-size:.78rem;opacity:.9}.telephony-soft-phone__icon-btn{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1;padding:.15rem .25rem}.telephony-soft-phone__close{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1}.telephony-soft-phone__unavailable{padding:1.25rem 1rem;text-align:center;color:#6c757d}.telephony-soft-phone__unavailable i{font-size:1.5rem;margin-bottom:.5rem;color:#adb5bd}.telephony-soft-phone__unavailable p{margin:0;font-size:.85rem}.telephony-soft-phone__history{max-height:18rem;overflow-y:auto;padding:.35rem .5rem .65rem}.telephony-soft-phone__history-empty{padding:1rem;text-align:center;color:#6c757d;font-size:.85rem}.telephony-soft-phone__history-item{display:flex;align-items:center;gap:.6rem;width:100%;border:none;background:0 0;text-align:left;padding:.45rem .5rem;border-radius:.5rem;cursor:pointer}.telephony-soft-phone__history-item:hover{background-color:#f2f4f7}.telephony-soft-phone__history-item--missed{color:#d23f3f}.telephony-soft-phone__history-item--missed .telephony-soft-phone__history-dir{color:#d23f3f}.telephony-soft-phone__history-item--active .telephony-soft-phone__history-dir{color:#1f9d55}.telephony-soft-phone__history-dir{flex:0 0 1.25rem;color:#6c757d;text-align:center}.telephony-soft-phone__history-body{display:flex;flex-direction:column;min-width:0}.telephony-soft-phone__history-number{font-weight:600;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.telephony-soft-phone__history-meta{font-size:.72rem;color:#8a929b}.telephony-soft-phone__footer{display:flex;border-top:1px solid #e9ecef;background-color:#f8f9fb}.telephony-soft-phone__tab{flex:1 1 0;border:none;background:0 0;padding:.5rem .5rem .55rem;font-size:.74rem;color:#6c757d;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.2rem}.telephony-soft-phone__tab i{font-size:.95rem}.telephony-soft-phone__tab:hover{color:#343a40}.telephony-soft-phone__tab.is-active{color:var(--telephony-accent);box-shadow:inset 0 -2px 0 var(--telephony-accent);font-weight:600}.telephony-soft-phone--dragging{user-select:none}.telephony-soft-phone--in-call .telephony-soft-phone__toggle{background-color:#d23f3f;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-soft-phone__display{padding:.85rem .85rem .35rem}.telephony-soft-phone__number{width:100%;border:1px solid #d4d8dd;border-radius:.5rem;padding:.5rem .65rem;font-size:1.1rem;letter-spacing:.05em}.telephony-soft-phone__peer{min-height:1rem;margin-top:.35rem;font-size:.8rem;color:#6c757d;text-align:center}.telephony-soft-phone__connect{padding:1rem .85rem;text-align:center}.telephony-soft-phone__connect-text{margin-bottom:.75rem;font-size:.85rem;color:#495057}.telephony-soft-phone__error{margin:0 .85rem .5rem;padding:.4rem .6rem;font-size:.78rem}.telephony-soft-phone__keypad{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;padding:0 .85rem .5rem}.telephony-soft-phone__key{border:1px solid #e3e6ea;border-radius:.5rem;background-color:#f7f8fa;padding:.5rem 0;font-size:1.05rem;cursor:pointer}.telephony-soft-phone__key:hover{background-color:#eef1f5}.telephony-soft-phone__actions{display:flex;flex-wrap:wrap;gap:.4rem;justify-content:center;padding:.5rem .85rem .95rem}.telephony-soft-phone__btn{width:2.6rem;height:2.6rem;border:none;border-radius:50%;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;background-color:#6c757d}.telephony-soft-phone__btn--call{background-color:#1f9d55}.telephony-soft-phone__btn--hangup{background-color:#d23f3f}.telephony-soft-phone__btn--hold,.telephony-soft-phone__btn--merge,.telephony-soft-phone__btn--mute,.telephony-soft-phone__btn--resume,.telephony-soft-phone__btn--transfer,.telephony-soft-phone__btn--unmute{background-color:var(--telephony-accent)}.telephony-phone-dial-btn{margin-inline-start:.5rem;line-height:1}.phone-field-dial{display:inline-flex;align-items:center}@keyframes telephony-soft-phone-pulse{0%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}50%{box-shadow:0 .25rem 1.25rem rgba(210,63,63,.85)}100%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}} +.telephony-soft-phone{--telephony-accent:#2f6fed;position:fixed;right:1.5rem;bottom:1.5rem;z-index:1080;font-size:.9rem}.telephony-soft-phone__toggle{width:3.25rem;height:3.25rem;border:none;border-radius:50%;color:#fff;background-color:var(--telephony-accent);box-shadow:0 .25rem .75rem rgba(0,0,0,.25);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.1rem;transition:transform .15s ease-in-out}.telephony-soft-phone__toggle:hover{transform:scale(1.05)}.telephony-soft-phone__panel{position:absolute;right:0;bottom:4rem;width:18rem;background-color:#fff;border-radius:.75rem;box-shadow:0 .5rem 1.5rem rgba(0,0,0,.25);overflow:hidden}.telephony-soft-phone__header{display:flex;align-items:center;gap:.5rem;padding:.65rem .85rem;color:#fff;background-color:var(--telephony-accent);cursor:move;touch-action:none;user-select:none}.telephony-soft-phone__title{font-weight:600}.telephony-soft-phone__status{margin-left:auto;font-size:.78rem;opacity:.9}.telephony-soft-phone__icon-btn{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1;padding:.15rem .25rem}.telephony-soft-phone__close{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1}.telephony-soft-phone__unavailable{padding:1.25rem 1rem;text-align:center;color:#6c757d}.telephony-soft-phone__unavailable i{font-size:1.5rem;margin-bottom:.5rem;color:#adb5bd}.telephony-soft-phone__unavailable p{margin:0;font-size:.85rem}.telephony-soft-phone__history{max-height:18rem;overflow-y:auto;padding:.35rem .5rem .65rem}.telephony-soft-phone__history-empty{padding:1rem;text-align:center;color:#6c757d;font-size:.85rem}.telephony-soft-phone__history-item{display:flex;align-items:center;gap:.6rem;width:100%;border:none;background:0 0;text-align:left;padding:.45rem .5rem;border-radius:.5rem;cursor:pointer}.telephony-soft-phone__history-item:hover{background-color:#f2f4f7}.telephony-soft-phone__history-item--missed{color:#d23f3f}.telephony-soft-phone__history-item--missed .telephony-soft-phone__history-dir{color:#d23f3f}.telephony-soft-phone__history-item--active .telephony-soft-phone__history-dir{color:#1f9d55}.telephony-soft-phone__history-dir{flex:0 0 1.25rem;color:#6c757d;text-align:center}.telephony-soft-phone__history-body{display:flex;flex-direction:column;min-width:0}.telephony-soft-phone__history-number{font-weight:600;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.telephony-soft-phone__history-meta{font-size:.72rem;color:#8a929b}.telephony-soft-phone__footer{display:flex;border-top:1px solid #e9ecef;background-color:#f8f9fb}.telephony-soft-phone__tab{flex:1 1 0;border:none;background:0 0;padding:.5rem .5rem .55rem;font-size:.74rem;color:#6c757d;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.2rem}.telephony-soft-phone__tab i{font-size:.95rem}.telephony-soft-phone__tab:hover{color:#343a40}.telephony-soft-phone__tab.is-active{color:var(--telephony-accent);box-shadow:inset 0 -2px 0 var(--telephony-accent);font-weight:600}.telephony-soft-phone--dragging{user-select:none}.telephony-soft-phone--in-call .telephony-soft-phone__toggle{background-color:#d23f3f;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-soft-phone__display{padding:.85rem .85rem .35rem}.telephony-soft-phone__number{width:100%;border:1px solid #d4d8dd;border-radius:.5rem;padding:.5rem .65rem;font-size:1.1rem;letter-spacing:.05em}.telephony-soft-phone__peer{min-height:1rem;margin-top:.35rem;font-size:.8rem;color:#6c757d;text-align:center}.telephony-soft-phone__connect{padding:1rem .85rem;text-align:center}.telephony-soft-phone__connect-text{margin-bottom:.75rem;font-size:.85rem;color:#495057}.telephony-soft-phone__error{margin:0 .85rem .5rem;padding:.4rem .6rem;font-size:.78rem}.telephony-soft-phone__keypad{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;padding:0 .85rem .5rem}.telephony-soft-phone__key{border:1px solid #e3e6ea;border-radius:.5rem;background-color:#f7f8fa;padding:.5rem 0;font-size:1.05rem;cursor:pointer}.telephony-soft-phone__key:hover{background-color:#eef1f5}.telephony-soft-phone__actions{display:flex;flex-wrap:wrap;gap:.4rem;justify-content:center;padding:.5rem .85rem .95rem}.telephony-soft-phone__btn{width:2.6rem;height:2.6rem;border:none;border-radius:50%;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;background-color:#6c757d}.telephony-soft-phone__btn--call{background-color:#1f9d55}.telephony-soft-phone__btn--hangup{background-color:#d23f3f}.telephony-soft-phone__btn--hold,.telephony-soft-phone__btn--merge,.telephony-soft-phone__btn--mute,.telephony-soft-phone__btn--resume,.telephony-soft-phone__btn--transfer,.telephony-soft-phone__btn--unmute{background-color:var(--telephony-accent)}.telephony-phone-dial-btn{margin-inline-start:.5rem;line-height:1}.phone-field-dial{display:inline-flex;align-items:center}@keyframes telephony-soft-phone-pulse{0%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}50%{box-shadow:0 .25rem 1.25rem rgba(210,63,63,.85)}100%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}}.telephony-incoming{position:fixed;inset:0;z-index:1090;display:flex;align-items:center;justify-content:center}.telephony-incoming__backdrop{position:absolute;inset:0;background-color:rgba(0,0,0,.45)}.telephony-incoming__dialog{position:relative;width:min(26rem,100vw - 2rem);max-height:calc(100vh - 2rem);overflow-y:auto;background-color:#fff;border-radius:.75rem;box-shadow:0 1rem 2.5rem rgba(0,0,0,.35);padding:1.25rem}.telephony-incoming__header{display:flex;align-items:center;gap:.85rem;margin-bottom:1rem}.telephony-incoming__pulse{flex:0 0 auto;width:3rem;height:3rem;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;background-color:#198754;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-incoming__heading{display:flex;flex-direction:column;min-width:0}.telephony-incoming__label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#6c757d}.telephony-incoming__caller{font-size:1.15rem;font-weight:600;word-break:break-word}.telephony-incoming__queue{font-size:.8rem;color:#6c757d}.telephony-incoming__cards{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1rem;max-height:14rem;overflow-y:auto}.telephony-incoming__cards-heading{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#6c757d}.telephony-incoming__card{display:flex;gap:.65rem;padding:.6rem .7rem;border:1px solid #e3e6ea;border-radius:.6rem;background-color:#f8f9fa}.telephony-incoming__card-icon{flex:0 0 auto;color:var(--telephony-accent,#2f6fed);font-size:1.1rem}.telephony-incoming__card-body{flex:1 1 auto;min-width:0}.telephony-incoming__card-title{font-weight:600;word-break:break-word}.telephony-incoming__card-desc,.telephony-incoming__card-subtitle{font-size:.8rem;color:#6c757d;word-break:break-word}.telephony-incoming__card-badges{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.35rem}.telephony-incoming__card-links{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.35rem;font-size:.8rem}.telephony-incoming__card-actions{display:flex;flex-direction:column;gap:.35rem;justify-content:center}.telephony-incoming__actions{display:flex;gap:.5rem}.telephony-incoming__actions .telephony-incoming__btn{flex:1 1 0} diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs index d426418c9..38506e6b9 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs @@ -28,7 +28,8 @@ public TelephonyCapabilities Capabilities TelephonyCapabilities.Transfer | TelephonyCapabilities.Merge | TelephonyCapabilities.SendDigits | - TelephonyCapabilities.ReceiveCalls; + TelephonyCapabilities.ReceiveCalls | + TelephonyCapabilities.Voicemail; } } @@ -87,6 +88,9 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => HangupAsync(call, cancellationToken); + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => HangupAsync(call, cancellationToken); + public Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) => Task.FromResult(new TelephonyClientCredentials { ProviderName = "InMemory" }); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs index b3aca65d9..245b391ea 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs @@ -19,7 +19,7 @@ public async Task AssignNextAsync_WhenNoWaitingItems_ReturnsNull() var service = CreateService(queueItemManager, new Mock(), new Mock()); // Act - var reservation = await service.AssignNextAsync("q1"); + var reservation = await service.AssignNextAsync("q1", TestContext.Current.CancellationToken); // Assert Assert.Null(reservation); @@ -42,7 +42,7 @@ public async Task AssignNextAsync_WhenNoAvailableAgents_ReturnsNull() var service = CreateService(queueItemManager, agentManager, new Mock()); // Act - var reservation = await service.AssignNextAsync("q1"); + var reservation = await service.AssignNextAsync("q1", TestContext.Current.CancellationToken); // Assert Assert.Null(reservation); @@ -78,7 +78,7 @@ public async Task AssignNextAsync_ReservesTopItemForLongestIdleAgent() var service = new ActivityAssignmentService(queueItemManager.Object, agentManager.Object, queueManager.Object, reservationService.Object); // Act - var reservation = await service.AssignNextAsync("q1"); + var reservation = await service.AssignNextAsync("q1", TestContext.Current.CancellationToken); // Assert Assert.NotNull(reservation); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs index 862136808..085bbe596 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -29,7 +29,7 @@ public async Task ReserveAsync_SetsReservedStateAndPublishesEvents() var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; // Act - var reservation = await service.ReserveAsync(item, agent, 30); + var reservation = await service.ReserveAsync(item, agent, 30, TestContext.Current.CancellationToken); // Assert Assert.Equal(ReservationStatus.Pending, reservation.Status); @@ -55,7 +55,7 @@ public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueu var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); // Act - var count = await service.ExpireDueAsync(); + var count = await service.ExpireDueAsync(TestContext.Current.CancellationToken); // Assert Assert.Equal(1, count); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index 79bc15043..5a95142f9 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -24,7 +24,7 @@ public async Task SignInAsync_CreatesAvailableProfile_AndJoinsQueues() var service = new AgentPresenceManagerService(agentManager.Object, publisher.Object, clock.Object); // Act - var profile = await service.SignInAsync("u1", ["q1", "q2"], []); + var profile = await service.SignInAsync("u1", ["q1", "q2"], [], TestContext.Current.CancellationToken); // Assert Assert.Equal(AgentPresenceStatus.Available, profile.PresenceStatus); @@ -44,7 +44,7 @@ public async Task SignOutAsync_SetsOffline() var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, clock.Object); // Act - var profile = await service.SignOutAsync("u1"); + var profile = await service.SignOutAsync("u1", TestContext.Current.CancellationToken); // Assert Assert.Equal(AgentPresenceStatus.Offline, profile.PresenceStatus); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs new file mode 100644 index 000000000..267f293f9 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -0,0 +1,233 @@ +using System.Text.Json.Nodes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Moq; +using OrchardCore.ContentManagement; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class InboundVoiceServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task HandleInboundAsync_WhenAgentAvailable_CreatesInboundActivityAndOffersAgent() + { + // Arrange + var harness = new Harness(); + + harness.ChannelEndpointManager + .Setup(m => m.GetByServiceAddressAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask(new OmnichannelChannelEndpoint { ItemId = "ep1", Channel = "Phone", Value = "+15553334444" })); + + harness.SubjectFlowSettingsService + .Setup(m => m.GetConfiguredFlowSettingsAsync(It.IsAny())) + .ReturnsAsync([new SubjectFlowSettings { Channel = "Phone", ChannelEndpointId = "ep1", SubjectContentType = "CallSubject", CampaignId = "camp1" }]); + + harness.ContactLookup + .Setup(m => m.FindContactItemIdsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(["contact1"]); + + harness.ContentManager + .Setup(m => m.GetAsync("contact1", It.IsAny())) + .ReturnsAsync(new ContentItem { ContentType = "Customer" }); + + harness.ContentManager + .Setup(m => m.NewAsync("CallSubject")) + .ReturnsAsync(new ContentItem { ContentType = "CallSubject" }); + + var activity = new OmnichannelActivity { ItemId = "act1" }; + harness.ActivityManager + .Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(activity); + + harness.QueueManager + .Setup(m => m.ListEnabledAsync(It.IsAny())) + .ReturnsAsync([new ActivityQueue { ItemId = "q1", Enabled = true, InboundChannelEndpointId = "ep1" }]); + + var interaction = new Interaction { ItemId = "int1" }; + harness.InteractionManager + .Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(interaction); + harness.InteractionManager + .Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())) + .ReturnsAsync(interaction); + + harness.QueueService + .Setup(m => m.EnqueueAsync("act1", "q1", It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi1", ActivityItemId = "act1", QueueId = "q1" }); + + harness.AssignmentService + .Setup(m => m.AssignNextAsync("q1", It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r1", AgentId = "a1", ActivityItemId = "act1", QueueId = "q1" }); + + harness.AgentManager + .Setup(m => m.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a1", UserId = "u1" }); + + var service = harness.CreateService(); + + // Act + var result = await service.HandleInboundAsync( + new InboundVoiceEvent + { + ProviderName = "TestProvider", + ProviderCallId = "call-1", + FromAddress = "+15551112222", + ToAddress = "+15553334444", + }, + TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Routed); + Assert.Equal("u1", result.AgentUserId); + Assert.Equal("act1", result.ActivityItemId); + Assert.Equal("q1", result.QueueId); + + Assert.Equal(ActivityKind.Call, activity.Kind); + Assert.Equal(ActivitySources.Inbound, activity.Source); + Assert.Equal("CallSubject", activity.SubjectContentType); + Assert.Equal(ActivityAssignmentStatus.Available, activity.AssignmentStatus); + Assert.NotNull(activity.Subject); + + Assert.Equal(InteractionChannel.Voice, interaction.Channel); + Assert.Equal(InteractionDirection.Inbound, interaction.Direction); + + harness.IncomingCallDispatcher.Verify( + d => d.DispatchAsync("u1", It.Is(call => call.Direction == CallDirection.Inbound && call.CallId == "call-1"), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleInboundAsync_WhenNoQueueResolved_DoesNotRoute() + { + // Arrange + var harness = new Harness(); + harness.SetupNoContext(); + + harness.QueueManager + .Setup(m => m.ListEnabledAsync(It.IsAny())) + .ReturnsAsync([]); + + var service = harness.CreateService(); + + // Act + var result = await service.HandleInboundAsync( + new InboundVoiceEvent { ProviderName = "TestProvider", ProviderCallId = "call-1", FromAddress = "+15551112222", ToAddress = "+15553334444" }, + TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Routed); + Assert.Null(result.QueueId); + Assert.Equal("int1", result.InteractionId); + harness.IncomingCallDispatcher.Verify(d => d.DispatchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleInboundAsync_WhenNoAgentAvailable_QueuesWithoutOffering() + { + // Arrange + var harness = new Harness(); + harness.SetupNoContext(); + + harness.QueueManager + .Setup(m => m.ListEnabledAsync(It.IsAny())) + .ReturnsAsync([new ActivityQueue { ItemId = "q1", Enabled = true }]); + + harness.QueueService + .Setup(m => m.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi1" }); + + harness.AssignmentService + .Setup(m => m.AssignNextAsync("q1", It.IsAny())) + .ReturnsAsync((ActivityReservation)null); + + var service = harness.CreateService(); + + // Act + var result = await service.HandleInboundAsync( + new InboundVoiceEvent { ProviderName = "TestProvider", ProviderCallId = "call-1", FromAddress = "+15551112222", ToAddress = "+15553334444" }, + TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Routed); + Assert.Equal("q1", result.QueueId); + harness.IncomingCallDispatcher.Verify(d => d.DispatchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private sealed class Harness + { + public Mock ChannelEndpointManager { get; } = new(); + + public Mock SubjectFlowSettingsService { get; } = new(); + + public Mock ActivityManager { get; } = new(); + + public Mock ContentManager { get; } = new(); + + public Mock InteractionManager { get; } = new(); + + public Mock QueueManager { get; } = new(); + + public Mock QueueService { get; } = new(); + + public Mock AssignmentService { get; } = new(); + + public Mock AgentManager { get; } = new(); + + public Mock ContactLookup { get; } = new(); + + public Mock IncomingCallDispatcher { get; } = new(); + + public void SetupNoContext() + { + ChannelEndpointManager + .Setup(m => m.GetByServiceAddressAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new ValueTask((OmnichannelChannelEndpoint)null)); + + SubjectFlowSettingsService + .Setup(m => m.GetConfiguredFlowSettingsAsync(It.IsAny())) + .ReturnsAsync([]); + + ContactLookup + .Setup(m => m.FindContactItemIdsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync([]); + + ActivityManager + .Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new OmnichannelActivity { ItemId = "act1" }); + + InteractionManager + .Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new Interaction { ItemId = "int1" }); + } + + public InboundVoiceService CreateService() + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new InboundVoiceService( + ChannelEndpointManager.Object, + SubjectFlowSettingsService.Object, + ActivityManager.Object, + ContentManager.Object, + InteractionManager.Object, + QueueManager.Object, + QueueService.Object, + AssignmentService.Object, + AgentManager.Object, + ContactLookup.Object, + IncomingCallDispatcher.Object, + clock.Object); + } + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultActivityDispositionServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultActivityDispositionServiceTests.cs new file mode 100644 index 000000000..e3e7d86f3 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultActivityDispositionServiceTests.cs @@ -0,0 +1,84 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using Moq; +using OrchardCore.ContentManagement; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements; + +public sealed class DefaultActivityDispositionServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task ApplyAsync_CompletesActivityAndRunsSubjectActions() + { + // Arrange + var activity = new OmnichannelActivity { ItemId = "act1", DispositionId = "d1", ContactContentItemId = "c1", SubjectContentType = "S" }; + + var activityManager = new Mock(); + var dispositionsCatalog = new Mock>(); + dispositionsCatalog + .Setup(m => m.FindByIdAsync("d1", It.IsAny())) + .ReturnsAsync(new OmnichannelDisposition { Name = "Sale" }); + var contentManager = new Mock(); + contentManager + .Setup(m => m.GetAsync("c1", It.IsAny())) + .ReturnsAsync(new ContentItem { ContentType = "Customer" }); + var executor = new Mock(); + + var service = CreateService(activityManager, dispositionsCatalog, contentManager, executor); + + // Act + var result = await service.ApplyAsync( + new ActivityDispositionRequest { Activity = activity, DispositionId = "d1", ActorId = "u1", ActorDisplayName = "Agent" }, + TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.Equal(ActivityStatus.Completed, activity.Status); + Assert.Equal("u1", activity.CompletedById); + Assert.Equal(_now, activity.CompletedUtc); + + executor.Verify( + e => e.ExecuteAsync(It.Is(context => context.Activity == activity && context.Disposition.Name == "Sale"), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ApplyAsync_WhenActivityIsNull_ReturnsFailure() + { + // Arrange + var service = CreateService( + new Mock(), + new Mock>(), + new Mock(), + new Mock()); + + // Act + var result = await service.ApplyAsync(new ActivityDispositionRequest { Activity = null }, TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + Assert.NotNull(result.ErrorMessage); + } + + private static DefaultActivityDispositionService CreateService( + Mock activityManager, + Mock> dispositionsCatalog, + Mock contentManager, + Mock executor) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new DefaultActivityDispositionService( + activityManager.Object, + dispositionsCatalog.Object, + contentManager.Object, + executor.Object, + clock.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeAuthTelephonyProvider.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeAuthTelephonyProvider.cs index b6c21be67..de7b7dfa0 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeAuthTelephonyProvider.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeAuthTelephonyProvider.cs @@ -72,6 +72,9 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => Task.FromResult(TelephonyResult.Success()); + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Success()); + public Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) => Task.FromResult(new TelephonyClientCredentials { ProviderName = "FakeAuth" }); } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderA.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderA.cs index 626f96496..04ca0ec8c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderA.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderA.cs @@ -46,6 +46,9 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => Task.FromResult(TelephonyResult.Success()); + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Success()); + public Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) => Task.FromResult(new TelephonyClientCredentials { ProviderName = "A" }); } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderB.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderB.cs index 1e56fba73..91c90f708 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderB.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/FakeTelephonyProviderB.cs @@ -46,6 +46,9 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => Task.FromResult(TelephonyResult.Success()); + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Success()); + public Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) => Task.FromResult(new TelephonyClientCredentials { ProviderName = "B" }); } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/RecordingTelephonyProvider.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/RecordingTelephonyProvider.cs index 7aa7f6b87..c780b9769 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/RecordingTelephonyProvider.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/RecordingTelephonyProvider.cs @@ -52,6 +52,9 @@ public Task AnswerAsync(CallReference call, CancellationToken c public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) => Record("Reject", call); + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => Record("SendToVoicemail", call); + public Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) { LastOperation = "GetClientCredentials"; From fae2c01a80ca1328c6f0f24c0a9e3ee267a013f6 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 12:13:16 -0700 Subject: [PATCH 06/56] Add phase 2 --- .github/contact-center/PLAN.md | 3 +- .../ContactCenterConstants.cs | 5 + .../IContactCenterVoiceProvider.cs | 5 + .../IContactCenterVoiceProviderResolver.cs | 20 ++ .../Models/ActivityRoutingCandidate.cs | 50 +++++ .../ActivityRoutingCandidateDecisionData.cs | 32 ++++ .../Models/ActivityRoutingContext.cs | 42 +++++ .../Models/ActivityRoutingDecision.cs | 37 ++++ .../ActivityRoutingDecisionEventData.cs | 42 +++++ .../Services/ActivityAssignmentService.cs | 65 ++++++- .../Services/ActivityReservationService.cs | 21 +++ .../Services/ActivityRoutingService.cs | 81 ++++++++ .../Services/AgentPresenceManagerService.cs | 26 +++ .../ContactCenterVoiceProviderResolver.cs | 35 ++++ .../Services/DialerService.cs | 88 +++++++-- .../Services/IActivityReservationService.cs | 8 + .../Services/IActivityRoutingService.cs | 23 +++ .../Services/IActivityRoutingStrategy.cs | 21 +++ .../Services/LongestIdleRoutingStrategy.cs | 32 ++++ .../Services/RequiredSkillsRoutingStrategy.cs | 56 ++++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 3 + .../contact-center/agents-queues-dialer.md | 35 ++-- .../docs/contact-center/index.md | 8 + .../Controllers/AgentWorkspaceController.cs | 34 +++- .../Controllers/DialerProfilesController.cs | 6 + .../Controllers/QueuesController.cs | 4 + .../Services/InboundVoiceService.cs | 8 + .../Startup.cs | 4 + .../ViewModels/AgentWorkspaceViewModel.cs | 10 + .../ViewModels/ContactCenterFormHelpers.cs | 21 +++ .../ViewModels/DialerProfileViewModel.cs | 18 ++ .../ViewModels/QueueViewModel.cs | 12 ++ .../Views/AgentWorkspace/Index.cshtml | 101 ++++++++-- .../Views/DialerProfiles/Create.cshtml | 130 +++++++++++-- .../Views/DialerProfiles/Edit.cshtml | 130 +++++++++++-- .../Views/DialerProfiles/Index.cshtml | 7 +- .../Views/Queues/Create.cshtml | 94 +++++++++- .../Views/Queues/Edit.cshtml | 94 +++++++++- .../Views/Queues/Index.cshtml | 9 +- .../ActivityAssignmentServiceTests.cs | 83 +++++++- .../ActivityReservationServiceTests.cs | 25 +++ .../ActivityRoutingServiceTests.cs | 61 ++++++ .../AgentPresenceManagerServiceTests.cs | 16 +- .../ContactCenter/DialerServiceTests.cs | 177 ++++++++++++++++++ .../ContactCenter/InboundVoiceServiceTests.cs | 26 +++ 45 files changed, 1706 insertions(+), 102 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProviderResolver.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidate.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingCandidateDecisionData.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingContext.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecision.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ActivityRoutingDecisionEventData.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityRoutingService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterVoiceProviderResolver.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityRoutingStrategy.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LongestIdleRoutingStrategy.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RequiredSkillsRoutingStrategy.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityRoutingServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index ade50ad04..2273c0667 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1386,7 +1386,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] `Agents` feature: AgentProfile (presence, capacity, skills, queue/campaign membership), store/manager/index, presence manager, Agent Workspace sign-in/out - [x] `Queues` feature: ActivityQueue, QueueItem, ActivityReservation models/stores/managers/indexes; queue + reservation lifecycle (reserve/accept/reject/expire); reservation-expiry background task - [x] Availability-based assignment service (longest-idle agent ↔ highest-priority item); agent/queue/dialer permissions; admin menu + CRUD UI; unit tests -- [~] Phase 3 — Routing MVP (availability + priority + longest-idle assignment shipped; skills/sticky/business-hours/audit pending) +- [~] Phase 3 — Routing MVP (availability + priority + required-skills filtering + longest-idle assignment + auditable routing decision events shipped; sticky-agent and business-hours strategies pending) - [~] Phase 4 — Voice integration with Telephony (`Voice` feature: inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService`), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; transfer/conference taxonomy and a standalone call-session aggregate pending) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, dialer-agnostic `IDialerProvider`/resolver, power/progressive pacing, dialer batch sources, DialPad.Dialer provider; retry/callback/suppression pending) - [~] Phase 6 — Wrap-up and disposition lifecycle (source-neutral `IActivityDispositionService` implemented and CRM activity completion routed through it so inbound and outbound disposition share the subject workflow; wrap-up timers and required-disposition policies pending) @@ -1407,4 +1407,5 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). - Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. - Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, with power/progressive pacing and dialer batch sources (Phase 5 core). Added the `DialPad.Dialer` feature implementing `IDialerProvider` over the DialPad telephony provider. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). +- 2026-06-29: Phase 3 routing advanced with an extensible `IActivityRoutingStrategy` pipeline, required-skills eligibility, longest-idle scoring, and `RoutingDecisionMade` audit events that capture candidate scores and reasons. Contact Center voice-provider resolution was added through `IContactCenterVoiceProviderResolver`; outbound dialer failure paths now cancel reservations and enforce max-attempt boundaries; inbound offer failures release reservations immediately; agent sign-in clears stale reservations and serializes profile creation. Queue, dialer, and agent workspace admin UI now uses Orchard `ocat-*` layout and exposes routing skills, inbound endpoint mapping, retry/do-not-call settings, and agent skill sign-in. Added routing/dialer/reservation tests and updated docs/changelog. Next: sticky-agent and business-hours routing, then wrap-up timers and required-disposition policies before Phase 7 real-time desktop work. - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent`/`IInboundVoiceService`/`InboundVoiceService` (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index cda9d123c..f4a701228 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -148,6 +148,11 @@ public static class Events /// 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. /// diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs index ee85d8aa2..894443bf0 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterVoiceProvider.cs @@ -8,6 +8,11 @@ namespace CrestApps.OrchardCore.ContactCenter; /// 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. /// 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/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/Services/ActivityAssignmentService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs index da873346f..7c5266172 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs @@ -11,7 +11,9 @@ public sealed class ActivityAssignmentService : IActivityAssignmentService private readonly IQueueItemManager _queueItemManager; private readonly IAgentProfileManager _agentManager; private readonly IActivityQueueManager _queueManager; + private readonly IActivityRoutingService _routingService; private readonly IActivityReservationService _reservationService; + private readonly IContactCenterEventPublisher _publisher; /// /// Initializes a new instance of the class. @@ -19,17 +21,23 @@ public sealed class ActivityAssignmentService : IActivityAssignmentService /// The queue item manager. /// The agent profile manager. /// The queue manager. + /// The routing service. /// The reservation service. + /// The Contact Center event publisher. public ActivityAssignmentService( IQueueItemManager queueItemManager, IAgentProfileManager agentManager, IActivityQueueManager queueManager, - IActivityReservationService reservationService) + IActivityRoutingService routingService, + IActivityReservationService reservationService, + IContactCenterEventPublisher publisher) { _queueItemManager = queueItemManager; _agentManager = agentManager; _queueManager = queueManager; + _routingService = routingService; _reservationService = reservationService; + _publisher = publisher; } /// @@ -46,17 +54,26 @@ public async Task AssignNextAsync(string queueId, Cancellat } var agents = await _agentManager.ListAvailableForQueueAsync(queueId, cancellationToken); - var agent = agents.OrderBy(a => a.PresenceChangedUtc ?? DateTime.MaxValue).FirstOrDefault(); + var queue = await _queueManager.FindByIdAsync(queueId, cancellationToken); - if (agent is null) + if (queue is null || !queue.Enabled) { return null; } - var queue = await _queueManager.FindByIdAsync(queueId, cancellationToken); - var timeout = queue?.ReservationTimeoutSeconds ?? 30; + var decision = await _routingService.SelectAgentAsync(queue, topItem, agents, cancellationToken); + await PublishRoutingDecisionAsync(decision, cancellationToken); + + if (!decision.Succeeded || decision.Agent is null) + { + return null; + } - return await _reservationService.ReserveAsync(topItem, agent, timeout, cancellationToken); + var timeout = queue.ReservationTimeoutSeconds > 0 + ? queue.ReservationTimeoutSeconds + : 30; + + return await _reservationService.ReserveAsync(topItem, decision.Agent, timeout, cancellationToken); } /// @@ -71,4 +88,40 @@ public async Task AssignQueueAsync(string queueId, CancellationToken cancel return count; } + + 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); + } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs index c027eed73..a5ed606fc 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -91,6 +91,8 @@ await UpdateActivityAsync(queueItem.ActivityItemId, activity => /// public async Task AcceptAsync(string reservationId, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); if (reservation is null || reservation.Status != ReservationStatus.Pending) @@ -135,6 +137,8 @@ await UpdateActivityAsync(reservation.ActivityItemId, activity => /// public async Task RejectAsync(string reservationId, CancellationToken cancellationToken = default) { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); if (reservation is null || reservation.Status != ReservationStatus.Pending) @@ -147,6 +151,23 @@ public async Task RejectAsync(string reservationId, Cancell return reservation; } + /// + public async Task CancelAsync(string reservationId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(reservationId); + + var reservation = await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + + if (reservation is null || reservation.Status != ReservationStatus.Pending) + { + return null; + } + + await ReleaseAsync(reservation, ReservationStatus.Canceled, cancellationToken); + + return reservation; + } + /// public async Task ExpireDueAsync(CancellationToken cancellationToken = default) { 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/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs index 29767e16b..836418b03 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -1,6 +1,7 @@ using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Models; using OrchardCore; +using OrchardCore.Locking.Distributed; using OrchardCore.Modules; namespace CrestApps.OrchardCore.ContactCenter.Core.Services; @@ -10,8 +11,12 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// 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 IContactCenterEventPublisher _publisher; + private readonly IDistributedLock _distributedLock; private readonly IClock _clock; /// @@ -19,14 +24,17 @@ public sealed class AgentPresenceManagerService : IAgentPresenceManager /// /// The agent profile manager. /// The Contact Center event publisher. + /// The distributed lock used to serialize sign-in updates. /// The clock used to stamp presence changes. public AgentPresenceManagerService( IAgentProfileManager agentManager, IContactCenterEventPublisher publisher, + IDistributedLock distributedLock, IClock clock) { _agentManager = agentManager; _publisher = publisher; + _distributedLock = distributedLock; _clock = clock; } @@ -35,6 +43,18 @@ public async Task SignInAsync(string userId, IEnumerable q { ArgumentException.ThrowIfNullOrEmpty(userId); + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetSignInLockKey(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) @@ -48,6 +68,7 @@ public async Task SignInAsync(string userId, IEnumerable q profile.CampaignIds = campaignIds?.Distinct().ToList() ?? []; profile.PresenceStatus = AgentPresenceStatus.Available; profile.PresenceChangedUtc = _clock.UtcNow; + profile.ActiveReservationId = null; await SaveAsync(profile, cancellationToken); await PublishAsync(ContactCenterConstants.Events.AgentSignedIn, profile, cancellationToken); @@ -123,4 +144,9 @@ private Task PublishAsync(string eventType, AgentProfile profile, CancellationTo SourceComponent = ContactCenterConstants.Components.Agents, }, cancellationToken); } + + private static string GetSignInLockKey(string userId) + { + return $"ContactCenterAgentSignIn:{userId}"; + } } 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/DialerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs index d397aef05..d2d18d154 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs @@ -14,6 +14,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; public sealed class DialerService : IDialerService { private readonly IActivityAssignmentService _assignmentService; + private readonly IActivityReservationService _reservationService; private readonly IInteractionManager _interactionManager; private readonly IOmnichannelActivityManager _activityManager; private readonly IDialerProviderResolver _providerResolver; @@ -25,6 +26,7 @@ public sealed class DialerService : IDialerService /// Initializes a new instance of the class. /// /// The assignment service used to reserve agents and activities. + /// The reservation service used to release failed attempts. /// The interaction manager used to record attempts. /// The CRM activity manager. /// The dialer provider resolver. @@ -33,6 +35,7 @@ public sealed class DialerService : IDialerService /// The logger instance. public DialerService( IActivityAssignmentService assignmentService, + IActivityReservationService reservationService, IInteractionManager interactionManager, IOmnichannelActivityManager activityManager, IDialerProviderResolver providerResolver, @@ -41,6 +44,7 @@ public DialerService( ILogger logger) { _assignmentService = assignmentService; + _reservationService = reservationService; _interactionManager = interactionManager; _activityManager = activityManager; _providerResolver = providerResolver; @@ -73,18 +77,37 @@ public async Task RunCycleAsync(DialerProfile profile, CancellationToken ca return 0; } + if (!provider.Capabilities.HasFlag(DialerProviderCapabilities.Outbound)) + { + _logger.LogWarning( + "Dialer provider '{Provider}' does not support outbound dialing for dialer profile '{Profile}'.", + provider.TechnicalName, + profile.Name); + + return 0; + } + + var attempted = 0; + var maxAttemptsThisCycle = profile.Mode == DialerMode.Power + ? Math.Max(profile.CallsPerAgent, 1) + : 1; var started = 0; var reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); - while (reservation is not null) + while (reservation is not null && attempted < maxAttemptsThisCycle) { + attempted++; + if (await TryDialAsync(profile, reservation, provider, cancellationToken)) { started++; } - reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); + if (attempted < maxAttemptsThisCycle) + { + reservation = await _assignmentService.AssignNextAsync(profile.QueueId, cancellationToken); + } } return started; @@ -94,8 +117,19 @@ private async Task TryDialAsync(DialerProfile profile, ActivityReservation { var activity = await _activityManager.FindByIdAsync(reservation.ActivityItemId, cancellationToken); - if (activity is null || activity.Attempts > profile.MaxAttempts) + if (activity is null) { + await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + + return false; + } + + if (activity.Attempts >= profile.MaxAttempts || string.IsNullOrEmpty(activity.PreferredDestination)) + { + activity.Status = ActivityStatus.Failed; + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + return false; } @@ -110,16 +144,37 @@ private async Task TryDialAsync(DialerProfile profile, ActivityReservation interaction.CustomerAddress = activity.PreferredDestination; await _interactionManager.CreateAsync(interaction, cancellationToken: cancellationToken); - var result = await provider.PlaceCallAsync(new DialerDialRequest + DialerDialResult result; + + try { - ActivityId = activity.ItemId, - InteractionId = interaction.ItemId, - AgentId = reservation.AgentId, - QueueId = profile.QueueId, - CampaignId = profile.CampaignId, - Destination = activity.PreferredDestination, - CallerId = profile.CallerId, - }, cancellationToken); + result = await provider.PlaceCallAsync(new DialerDialRequest + { + ActivityId = activity.ItemId, + InteractionId = interaction.ItemId, + AgentId = reservation.AgentId, + QueueId = profile.QueueId, + CampaignId = profile.CampaignId, + Destination = activity.PreferredDestination, + CallerId = profile.CallerId, + }, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Dialer provider '{Provider}' failed while dialing activity '{ActivityItemId}' for profile '{Profile}'.", + provider.TechnicalName, + activity.ItemId, + profile.Name); + + result = DialerDialResult.Failure("provider_exception", ex.Message); + } + + if (result.Succeeded && string.IsNullOrEmpty(result.ProviderCallId)) + { + result = DialerDialResult.Failure("missing_provider_call_id", "The dialer provider did not return a call identifier."); + } activity.Attempts++; activity.Status = result.Succeeded ? ActivityStatus.Dialing : ActivityStatus.Failed; @@ -132,6 +187,15 @@ private async Task TryDialAsync(DialerProfile profile, ActivityReservation interaction.StartedUtc = _clock.UtcNow; await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); } + else + { + interaction.Status = InteractionStatus.Failed; + interaction.EndedUtc = _clock.UtcNow; + interaction.TechnicalMetadata["providerErrorCode"] = result.ErrorCode; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + } await _publisher.PublishAsync(new InteractionEvent { diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs index 0538432e4..f441843fd 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationService.cs @@ -33,6 +33,14 @@ public interface IActivityReservationService /// The rejected reservation, or when not found or no longer pending. Task RejectAsync(string reservationId, CancellationToken cancellationToken = default); + /// + /// Cancels a pending 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 pending. + Task CancelAsync(string reservationId, CancellationToken cancellationToken = default); + /// /// Expires every pending reservation that has passed its timeout and returns items to their queues. /// 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/LongestIdleRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LongestIdleRoutingStrategy.cs new file mode 100644 index 000000000..6fbfe59f2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/LongestIdleRoutingStrategy.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Scores eligible agents by how long they have been available. +/// +public sealed class LongestIdleRoutingStrategy : IActivityRoutingStrategy +{ + /// + public int Order => 100; + + /// + public ValueTask ApplyAsync(ActivityRoutingContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + 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/RequiredSkillsRoutingStrategy.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RequiredSkillsRoutingStrategy.cs new file mode 100644 index 000000000..720d6c82f --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/RequiredSkillsRoutingStrategy.cs @@ -0,0 +1,56 @@ +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); + + var requiredSkills = context.Queue.RequiredSkills + .Where(skill => !string.IsNullOrWhiteSpace(skill)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + if (requiredSkills.Length == 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( + candidate.Agent.Skills.Where(skill => !string.IsNullOrWhiteSpace(skill)), + StringComparer.OrdinalIgnoreCase); + + var missingSkills = requiredSkills + .Where(skill => !agentSkills.Contains(skill)) + .ToArray(); + + if (missingSkills.Length > 0) + { + candidate.IsEligible = false; + candidate.AddReason($"Missing required skills: {string.Join(", ", missingSkills)}."); + } + else + { + candidate.AddReason("Matched every required queue skill."); + } + } + + return ValueTask.CompletedTask; + } +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index ed5567335..d328c0a9c 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -179,6 +179,9 @@ At a high level, the platform changes are: - 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 activity batches. The dialer is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, so the Contact Center owns assignment, queue, pacing, and compliance while any platform places calls. - Adds the `CrestApps.OrchardCore.DialPad.Dialer` feature, a DialPad implementation of the dialer-agnostic provider 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 queue, dialer, and agent workspace admin screens now use the standard Orchard admin field layout, expose routing skills and inbound endpoint mapping, and preserve agent campaign/skill sign-in state. - See [Agents, Queues & Dialer](../contact-center/agents-queues-dialer.md). #### Inbound voice routing and the incoming-call modal diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 9395d508e..9020a6522 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -1,13 +1,13 @@ --- sidebar_label: Agents, Queues & Dialer sidebar_position: 1 -title: Agents, Queues, and Dialer -description: Phase 2 and 5 of the Contact Center - agent presence, queues, reservations, availability-based assignment, and dialer-agnostic outbound dialing with the DialPad provider. +title: Agents, Queues, Routing, and Dialer +description: Contact Center agent presence, queues, skill-aware routing, reservations, availability-based assignment, and dialer-agnostic outbound dialing. --- This phase adds the operational core of the Contact Center: agent presence, work queues, -reservations, availability-based assignment, and a dialer-agnostic outbound dialer. Each capability -is a separate, feature-gated module so tenants enable only what they need. +reservations, skill-aware routing, availability-based assignment, and a dialer-agnostic outbound +dialer. Each capability is a separate, feature-gated module so tenants enable only what they need. ## Features @@ -30,15 +30,21 @@ The `SignIntoQueues` permission grants self-service sign-in and presence changes ## Queues, reservations, and assignment -A **queue** holds activities waiting for an agent, with a default priority, an SLA threshold, and a -reservation timeout. Activities enter a queue as **queue items**; the system pairs the highest -priority, oldest waiting item with the **longest-idle available agent** who is signed in to that -queue and creates a short-lived **reservation**. +A **queue** holds activities waiting for an agent, with a default priority, an SLA threshold, required +skills, an optional inbound channel endpoint mapping, and a reservation timeout. Activities enter a +queue as **queue items**; the system pairs the highest-priority, oldest waiting item with an eligible +available agent signed in to that queue and creates a short-lived **reservation**. -A reservation locks the activity for one agent and can be accepted, rejected, or expired. The CRM -activity moves through `Available → Reserved → Assigned`, mirrored on the queue item and agent -presence. Expired reservations return the item to the queue automatically. A background task expires -stale reservations and assigns waiting work every minute. +Routing is strategy-based. The default strategy chain first rejects agents that do not have every +required queue skill, then scores the remaining candidates by longest idle time. Each assignment +publishes an auditable routing-decision event that records the queue item, selected agent, candidate +scores, and reasons, so later supervisor and analytics features can explain why work was offered to +an agent. + +A reservation locks the activity for one agent and can be accepted, rejected, canceled, or expired. +The CRM activity moves through `Available → Reserved → Assigned`, mirrored on the queue item and +agent presence. Expired or canceled reservations return the item to the queue automatically. A +background task expires stale reservations and assigns waiting work every minute. ## Dialer @@ -57,6 +63,11 @@ while the Contact Center keeps all assignment, queue, pacing, and compliance log `DialPad.Dialer` feature implements `IDialerProvider` over the DialPad telephony provider; enable it to dial through DialPad. +Voice providers that support contact-center orchestration beyond soft-phone call control can also +register `IContactCenterVoiceProvider`. The `IContactCenterVoiceProviderResolver` resolves those +providers by technical name so future PBX integrations can participate in provider-side queueing, +call assignment, and voice-specific orchestration without coupling Contact Center to one provider. + ## Enable via recipe ```json diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index ab77635c0..e49650f42 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -106,6 +106,11 @@ Managers configure queue membership, campaign assignment, dialer mode, priority, compliance rules. Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all offer Activities through the same real-time workspace model. +The current workspace lets agents choose queues, campaigns, skills, and presence using the standard +Orchard admin layout. Queue and dialer admin screens expose routing skills, inbound endpoint mapping, +provider names, pacing limits, retry settings, and do-not-call preference flags 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 @@ -120,6 +125,9 @@ orchestration beyond basic call control: Contact Center remains the brain: it selects the Activity, queue, agent, campaign, dialer mode, and compliance gates, then sends provider-neutral intents to the provider adapter. +Providers register `IContactCenterVoiceProvider` implementations and are resolved through +`IContactCenterVoiceProviderResolver`, mirroring the dialer provider resolver pattern. + ## Inbound voice > **Feature ID** `CrestApps.OrchardCore.ContactCenter.Voice` diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs index 2bec6b7a9..c7c5e3ebe 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs @@ -18,6 +18,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; public sealed class AgentWorkspaceController : Controller { private readonly IAgentPresenceManager _presenceManager; + private readonly IAgentProfileManager _agentManager; private readonly IActivityQueueManager _queueManager; private readonly IAuthorizationService _authorizationService; @@ -25,14 +26,17 @@ public sealed class AgentWorkspaceController : Controller /// Initializes a new instance of the class. /// /// The agent presence manager. + /// The agent profile manager. /// The queue manager. /// The authorization service. public AgentWorkspaceController( IAgentPresenceManager presenceManager, + IAgentProfileManager agentManager, IActivityQueueManager queueManager, IAuthorizationService authorizationService) { _presenceManager = presenceManager; + _agentManager = agentManager; _queueManager = queueManager; _authorizationService = authorizationService; } @@ -50,10 +54,16 @@ public async Task Index() } var queues = await _queueManager.ListEnabledAsync(); + var profile = await _agentManager.FindByUserIdAsync(GetUserId()); return View(new AgentWorkspaceViewModel { + Profile = profile, AvailableQueues = [.. queues], + SelectedQueueIds = profile?.QueueIds ?? [], + CampaignIds = ContactCenterFormHelpers.FormatList(profile?.CampaignIds), + Skills = ContactCenterFormHelpers.FormatList(profile?.Skills), + PresenceReason = profile?.PresenceReason, }); } @@ -62,20 +72,22 @@ public async Task Index() /// /// The queues to sign in to. /// The comma-separated campaigns to sign in to. + /// The comma-separated skills to keep on the agent profile. /// A redirect to the workspace. [HttpPost] - public async Task SignIn(IEnumerable selectedQueueIds, string campaignIds) + [ValidateAntiForgeryToken] + public async Task SignIn(IEnumerable selectedQueueIds, string campaignIds, string skills) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) { return Forbid(); } - var campaigns = string.IsNullOrEmpty(campaignIds) - ? [] - : campaignIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var campaigns = ContactCenterFormHelpers.ParseList(campaignIds); + var profile = await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); + profile.Skills = ContactCenterFormHelpers.ParseList(skills); + await _agentManager.UpdateAsync(profile); - await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); return RedirectToAction(nameof(Index)); } @@ -85,6 +97,7 @@ public async Task SignIn(IEnumerable selectedQueueIds, st /// /// A redirect to the workspace. [HttpPost] + [ValidateAntiForgeryToken] public async Task SignOutAgent() { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) @@ -101,21 +114,24 @@ public async Task SignOutAgent() /// Sets the agent presence state. /// /// The presence state. - /// The optional reason code. + /// The optional reason code. /// A redirect to the workspace. [HttpPost] - public async Task SetPresence(AgentPresenceStatus status, string reason) + [ValidateAntiForgeryToken] + public async Task SetPresence(AgentPresenceStatus status, string presenceReason) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) { return Forbid(); } - await _presenceManager.SetPresenceAsync(GetUserId(), status, reason); + await _presenceManager.SetPresenceAsync(GetUserId(), status, presenceReason); return RedirectToAction(nameof(Index)); } private string GetUserId() - => User.FindFirstValue(ClaimTypes.NameIdentifier); + { + return User.FindFirstValue(ClaimTypes.NameIdentifier); + } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs index 09556c22b..843f2973e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs @@ -113,13 +113,16 @@ public async Task Edit(string id) { Id = profile.ItemId, Name = profile.Name, + Description = profile.Description, CampaignId = profile.CampaignId, QueueId = profile.QueueId, Mode = profile.Mode, ProviderName = profile.ProviderName, CallsPerAgent = profile.CallsPerAgent, MaxAttempts = profile.MaxAttempts, + RetryDelayMinutes = profile.RetryDelayMinutes, CallerId = profile.CallerId, + RespectDoNotCall = profile.RespectDoNotCall, Enabled = profile.Enabled, }); } @@ -182,13 +185,16 @@ public async Task Delete(string id) private static void Apply(Core.Models.DialerProfile profile, DialerProfileViewModel model) { profile.Name = model.Name; + profile.Description = model.Description; profile.CampaignId = model.CampaignId; profile.QueueId = model.QueueId; profile.Mode = model.Mode; profile.ProviderName = model.ProviderName; profile.CallsPerAgent = model.CallsPerAgent; profile.MaxAttempts = model.MaxAttempts; + profile.RetryDelayMinutes = model.RetryDelayMinutes; profile.CallerId = model.CallerId; + profile.RespectDoNotCall = model.RespectDoNotCall; profile.Enabled = model.Enabled; } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs index 62ba3c2ad..b5c750d7a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs @@ -117,6 +117,8 @@ public async Task Edit(string id) DefaultPriority = queue.DefaultPriority, SlaThresholdSeconds = queue.SlaThresholdSeconds, ReservationTimeoutSeconds = queue.ReservationTimeoutSeconds, + RequiredSkills = ContactCenterFormHelpers.FormatList(queue.RequiredSkills), + InboundChannelEndpointId = queue.InboundChannelEndpointId, Enabled = queue.Enabled, }); } @@ -183,6 +185,8 @@ private static void Apply(Core.Models.ActivityQueue queue, QueueViewModel model) queue.DefaultPriority = model.DefaultPriority; queue.SlaThresholdSeconds = model.SlaThresholdSeconds; queue.ReservationTimeoutSeconds = model.ReservationTimeoutSeconds; + queue.RequiredSkills = ContactCenterFormHelpers.ParseList(model.RequiredSkills); + queue.InboundChannelEndpointId = model.InboundChannelEndpointId; queue.Enabled = model.Enabled; } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs index ca9a5b1b8..6cba5dd20 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs @@ -29,6 +29,7 @@ public sealed class InboundVoiceService : IInboundVoiceService private readonly IActivityQueueManager _queueManager; private readonly IActivityQueueService _queueService; private readonly IActivityAssignmentService _assignmentService; + private readonly IActivityReservationService _reservationService; private readonly IAgentProfileManager _agentManager; private readonly IInboundContactLookup _contactLookup; private readonly IIncomingCallDispatcher _incomingCallDispatcher; @@ -45,6 +46,7 @@ public sealed class InboundVoiceService : IInboundVoiceService /// The queue manager used to resolve the inbound queue. /// The queue service used to enqueue the activity. /// The assignment service used to reserve an available agent. + /// The reservation service used to release invalid offers. /// The agent profile manager used to resolve the reserved agent. /// The contact lookup used to resolve the caller. /// The dispatcher used to offer the ringing call to the agent. @@ -58,6 +60,7 @@ public InboundVoiceService( IActivityQueueManager queueManager, IActivityQueueService queueService, IActivityAssignmentService assignmentService, + IActivityReservationService reservationService, IAgentProfileManager agentManager, IInboundContactLookup contactLookup, IIncomingCallDispatcher incomingCallDispatcher, @@ -71,6 +74,7 @@ public InboundVoiceService( _queueManager = queueManager; _queueService = queueService; _assignmentService = assignmentService; + _reservationService = reservationService; _agentManager = agentManager; _contactLookup = contactLookup; _incomingCallDispatcher = incomingCallDispatcher; @@ -151,6 +155,8 @@ public async Task OfferNextAsync(string queueId, CancellationToken cance if (agent is null || string.IsNullOrEmpty(agent.UserId)) { + await _reservationService.RejectAsync(reservation.ItemId, cancellationToken); + return null; } @@ -158,6 +164,8 @@ public async Task OfferNextAsync(string queueId, CancellationToken cance if (interaction is null) { + await _reservationService.RejectAsync(reservation.ItemId, cancellationToken); + return null; } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index f139f9f5b..0816aa8c3 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -85,6 +85,9 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped(); services @@ -156,6 +159,7 @@ public override void ConfigureServices(IServiceCollection services) { services .AddScoped() + .AddScoped() .AddScoped() .AddScoped(); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs index 83c9c46bf..28ef2ee62 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs @@ -27,4 +27,14 @@ public class AgentWorkspaceViewModel /// Gets or sets the campaign identifiers, comma separated, the agent is signed in to. /// public string CampaignIds { get; set; } + + /// + /// Gets or sets the skill identifiers, comma separated, used by routing strategies. + /// + public string Skills { get; set; } + + /// + /// Gets or sets the optional presence reason submitted by the agent. + /// + public string PresenceReason { get; set; } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs new file mode 100644 index 000000000..7f84be834 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs @@ -0,0 +1,21 @@ +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +internal static class ContactCenterFormHelpers +{ + internal static IList ParseList(string value) + { + return string.IsNullOrWhiteSpace(value) + ? [] + : value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + internal static string FormatList(IEnumerable values) + { + return values is null + ? null + : string.Join(", ", values.Where(value => !string.IsNullOrWhiteSpace(value))); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs index a967627ea..82fe963bd 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs @@ -19,6 +19,11 @@ public class DialerProfileViewModel [Required] public string Name { get; set; } + /// + /// Gets or sets the dialer profile description. + /// + public string Description { get; set; } + /// /// Gets or sets the campaign identifier. /// @@ -42,18 +47,31 @@ public class DialerProfileViewModel /// /// Gets or sets the number of calls per available agent. /// + [Range(1, int.MaxValue)] public int CallsPerAgent { get; set; } = 1; /// /// Gets or sets the maximum number of attempts per activity. /// + [Range(1, int.MaxValue)] public int MaxAttempts { get; set; } = 3; + /// + /// Gets or sets the retry delay, in minutes. + /// + [Range(1, int.MaxValue)] + public int RetryDelayMinutes { get; set; } = 60; + /// /// Gets or sets the caller identifier. /// public string CallerId { get; set; } + /// + /// Gets or sets a value indicating whether do-not-call and communication preferences are honored. + /// + public bool RespectDoNotCall { get; set; } = true; + /// /// Gets or sets a value indicating whether the dialer profile is enabled. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs index 605e8e79e..7dcdf36a7 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs @@ -32,13 +32,25 @@ public class QueueViewModel /// /// Gets or sets the SLA threshold in seconds. /// + [Range(1, int.MaxValue)] public int SlaThresholdSeconds { get; set; } = 120; /// /// Gets or sets the reservation timeout in seconds. /// + [Range(1, int.MaxValue)] public int ReservationTimeoutSeconds { get; set; } = 30; + /// + /// Gets or sets the comma-separated skills required to receive work from the queue. + /// + public string RequiredSkills { get; set; } + + /// + /// Gets or sets the inbound channel endpoint identifier mapped to this queue. + /// + public string InboundChannelEndpointId { get; set; } + /// /// Gets or sets a value indicating whether the queue is enabled. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml index cbd4be0e4..bb63397e9 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml @@ -1,17 +1,94 @@ @model AgentWorkspaceViewModel -

@T["Agent Workspace"]

+ +

@T["Agent workspace"]

+ +@if (Model.Profile is not null) +{ +
+ @T["Current presence: {0}", Model.Profile.PresenceStatus] +
+} +
-

@T["Select the queues you want to receive work from, then sign in."]

- @foreach (var queue in Model.AvailableQueues) - { -
- - -
- } -
- +
+
+

@T["Select the queues you want to receive work from, then sign in."]

+
+
+ +
+ +
+ @foreach (var queue in Model.AvailableQueues) + { + var selected = Model.SelectedQueueIds.Contains(queue.ItemId); + +
+ + +
+ } + @T["Only selected queues can reserve and offer work to you."] +
+
+ +
+ +
+ + + @T["Enter comma-separated campaign identifiers for outbound dialer work."] +
+
+ +
+ +
+ + + @T["Enter comma-separated skill names used by queue routing."] +
+
+ +
+
+ +
+
+
- +
+
+ +
+
+
+ +
+
+ +
+ +
+
+ +
+ +
+ + +
+
+ +
+
+ +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml index 5e5bdbdbd..c7c1b360d 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml @@ -1,15 +1,121 @@ @model DialerProfileViewModel -

@T["Add Dialer Profile"]

+ +

@T["Add dialer profile"]

+
-
-
-
-
-
-
-
-
-
- - @T["Cancel"] +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Agents signed in to this queue are reserved before power or progressive dialing."] +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Use the dialer provider technical name, or leave empty to use the default registered provider."] +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+ @T["Suppression enforcement is shared with the outbound compliance phase."] +
+
+ +
+
+
+ + +
+
+
+ +
+
+ + @T["Cancel"] +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml index c3488ad13..57ef87208 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml @@ -1,16 +1,122 @@ @model DialerProfileViewModel -

@T["Edit Dialer Profile"]

+ +

@T["Edit dialer profile"]

+
-
-
-
-
-
-
-
-
-
- - @T["Cancel"] +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Agents signed in to this queue are reserved before power or progressive dialing."] +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Use the dialer provider technical name, or leave empty to use the default registered provider."] +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+ @T["Suppression enforcement is shared with the outbound compliance phase."] +
+
+ +
+
+
+ + +
+
+
+ +
+
+ + @T["Cancel"] +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml index 8194d6731..cdf2241d8 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml @@ -1,8 +1,11 @@ @model IEnumerable -

@T["Dialer Profiles"]

+ +

@T["Dialer profiles"]

+ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml index 947ec7ed9..55f335d6e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml @@ -1,12 +1,88 @@ @model QueueViewModel -

@T["Add Queue"]

+ +

@T["Add queue"]

+ -
-
-
-
-
-
- - @T["Cancel"] +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Enter comma-separated skill names. Agents must have every listed skill to receive work from this queue."] +
+
+ +
+ +
+ + + @T["Optional. Calls received on this Omnichannel endpoint route to this queue. Leave empty for a default inbound queue."] +
+
+ +
+
+
+ + +
+ @T["Disabled queues do not receive routing assignments."] +
+
+ +
+
+ + @T["Cancel"] +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml index 7e83b046c..4270801e6 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml @@ -1,13 +1,89 @@ @model QueueViewModel -

@T["Edit Queue"]

+ +

@T["Edit queue"]

+ -
-
-
-
-
-
- - @T["Cancel"] +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Enter comma-separated skill names. Agents must have every listed skill to receive work from this queue."] +
+
+ +
+ +
+ + + @T["Optional. Calls received on this Omnichannel endpoint route to this queue. Leave empty for a default inbound queue."] +
+
+ +
+
+
+ + +
+ @T["Disabled queues do not receive routing assignments."] +
+
+ +
+
+ + @T["Cancel"] +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml index 4af9cece3..ef721abbe 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml @@ -1,14 +1,18 @@ @model IEnumerable +

@T["Queues"]

+ +
- + + @@ -20,6 +24,7 @@ + '; + + return; + } + + refs.calls.innerHTML = calls.map(function (call) { + return '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }).join(''); + } + + function renderBridges(snapshot) { + if (!refs.bridges) { + return; + } + + var bridges = snapshot.bridges || []; + + if (!bridges.length) { + refs.bridges.innerHTML = ''; + + return; + } + + refs.bridges.innerHTML = bridges.map(function (bridge) { + return '' + + '' + + '' + + '' + + '' + + ''; + }).join(''); + } + + function renderJson(snapshot) { + if (refs.infoJson) { + refs.infoJson.textContent = snapshot.infoJson || '{}'; + } + + if (refs.channelsJson) { + refs.channelsJson.textContent = snapshot.channelsJson || '[]'; + } + + if (refs.bridgesJson) { + refs.bridgesJson.textContent = snapshot.bridgesJson || '[]'; + } + } + + function render(snapshot) { + if (!snapshot) { + return; + } + + renderStatus(snapshot); + renderSummary(snapshot); + renderCalls(snapshot); + renderBridges(snapshot); + renderJson(snapshot); + } + + function describeChange(previousSnapshot, nextSnapshot) { + if (!previousSnapshot) { + pushNotification('Live dashboard connected to the Asterisk event stream.', 'success'); + return; + } + + if (previousSnapshot.reachable && !nextSnapshot.reachable) { + pushNotification(nextSnapshot.errorMessage || 'Asterisk became unavailable.', 'danger'); + } else if (!previousSnapshot.reachable && nextSnapshot.reachable) { + pushNotification('Asterisk connectivity was restored.', 'success'); + } + + if (previousSnapshot.activeCallCount !== nextSnapshot.activeCallCount) { + pushNotification('Active calls changed from ' + previousSnapshot.activeCallCount + ' to ' + nextSnapshot.activeCallCount + '.', nextSnapshot.activeCallCount > previousSnapshot.activeCallCount ? 'info' : 'warning'); + } + + if (previousSnapshot.ringingChannelCount !== nextSnapshot.ringingChannelCount && nextSnapshot.ringingChannelCount > 0) { + pushNotification(nextSnapshot.ringingChannelCount + ' channel(s) are ringing right now.', 'info'); + } + } + + function applySnapshot(snapshot) { + var previous = current; + current = snapshot; + describeChange(previous, snapshot); + render(snapshot); + } + + function fetchSnapshot() { + if (!config.snapshotUrl) { + return Promise.resolve(); + } + + return fetch(config.snapshotUrl, { + credentials: 'same-origin', + headers: { 'Accept': 'application/json' } + }) + .then(function (response) { return response.ok ? response.json() : null; }) + .then(function (snapshot) { + if (snapshot) { + applySnapshot(snapshot); + } + }) + .catch(function () { }); + } + + function disconnectChannel(channelId) { + if (!channelId) { + return Promise.resolve(); + } + + return fetch('/api/asterisk/channels/' + encodeURIComponent(channelId), { + method: 'DELETE', + credentials: 'same-origin' + }) + .then(function (response) { + if (!response.ok) { + throw new Error('The channel could not be disconnected.'); + } + + pushNotification('Disconnected channel ' + channelId + '.', 'warning'); + return fetchSnapshot(); + }) + .catch(function (error) { + pushNotification(error && error.message ? error.message : 'The channel could not be disconnected.', 'danger'); + }); + } + + root.addEventListener('click', function (event) { + var button = event.target.closest('[data-dashboard-hangup]'); + + if (!button) { + return; + } + + event.preventDefault(); + disconnectChannel(button.getAttribute('data-channel-id')); + }); + + if (window.signalR && config.hubUrl) { + var connection = new window.signalR.HubConnectionBuilder() + .withUrl(config.hubUrl) + .withAutomaticReconnect() + .build(); + + connection.on('dashboardSnapshot', function (snapshot) { + applySnapshot(snapshot); + }); + + connection.start() + .then(fetchSnapshot) + .catch(function () { + pushNotification('SignalR could not connect; falling back to reconciliation polling.', 'warning'); + fetchSnapshot(); + }); + } else { + pushNotification('SignalR is unavailable; showing reconciliation polling only.', 'warning'); + fetchSnapshot(); + } + + if (current) { + render(current); + renderNotifications(); + } + + window.setInterval(fetchSnapshot, Math.max((config.reconciliationSeconds || 15) * 1000, 5000)); + } + + function boot() { + var root = document.querySelector('[data-asterisk-dashboard]'); + + if (root) { + init(root); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', boot); + } else { + boot(); + } +})(window, document); diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs index dfdd420c1..bffa6c505 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs @@ -94,7 +94,7 @@ private static string BuildHtml()
- + + diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs index ff853ce87..7a4021bf5 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs @@ -63,6 +63,9 @@ public async Task Dial_ThenHangup_TransitionsSoftPhoneUi() var status = await page.Locator("[data-telephony-status]").InnerTextAsync(); Assert.Equal("In call", status.Trim()); Assert.True(await page.Locator("[data-telephony-dial]").IsHiddenAsync()); + Assert.Equal("fa-solid fa-phone", await page.Locator("[data-telephony-toggle-icon]").GetAttributeAsync("class")); + Assert.True(await page.Locator("[data-telephony-mute]").IsVisibleAsync()); + Assert.True(await page.Locator("[data-telephony-merge]").IsVisibleAsync()); // Act - hang up await page.ClickAsync("[data-telephony-hangup]"); @@ -142,6 +145,165 @@ public async Task ExtensionTab_ShowsExtensionView_AndHidesBuiltInViews() Assert.True(await page.Locator("[data-telephony-view=\"history\"]").IsHiddenAsync()); } + [Theory] + [InlineData("keypad")] + [InlineData("history")] + [InlineData("contact-center")] + public async Task SelectedTab_PersistsAcrossReload(string tab) + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + + await page.ClickAsync("[data-telephony-toggle]"); + await page.ClickAsync($"[data-telephony-tab=\"{tab}\"]"); + + // Act + await page.ReloadAsync(); + await WaitForConnectedAsync(page); + + // Assert + await page.Locator($"[data-telephony-view=\"{tab}\"]") + .WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + Assert.Equal("true", await page.Locator($"[data-telephony-tab=\"{tab}\"]").GetAttributeAsync("aria-selected")); + + if (tab == "history") + { + var historyText = await page.Locator("[data-telephony-history-list]").InnerTextAsync(); + Assert.Contains("15551234567", historyText); + } + } + + [Fact] + public async Task AllTabs_KeepTheSameBodyHeight() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + + // Act + await page.ClickAsync("[data-telephony-tab=\"keypad\"]"); + await page.Locator("[data-telephony-view=\"keypad\"]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + var keypadHeight = await GetConfiguredHeightAsync(page); + await page.ClickAsync("[data-telephony-tab=\"history\"]"); + await page.Locator("[data-telephony-view=\"history\"]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + var historyHeight = await GetConfiguredHeightAsync(page); + await page.ClickAsync("[data-telephony-tab=\"contact-center\"]"); + await page.Locator("[data-telephony-view=\"contact-center\"]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + var extensionHeight = await GetConfiguredHeightAsync(page); + + // Assert + Assert.NotEqual(string.Empty, keypadHeight); + Assert.Equal(keypadHeight, historyHeight); + Assert.Equal(keypadHeight, extensionHeight); + } + + [Fact] + public async Task RingingInboundCall_DoesNotShowHangup_UntilConnected() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + + await page.EvaluateAsync( + """ + () => { + const api = window.telephonySoftPhone.getInstance(); + api.setIncomingOffer( + { + callId: 'call-inbound-1', + from: '+15550001000', + direction: 'Inbound', + state: 'Ringing', + providerName: 'InMemory' + }, + { + properties: { + acceptUrl: '/accept', + reservationId: 'res-1' + } + }); + + window.fetch = async () => ({ + ok: true, + json: async () => ({ succeeded: true, requiresDeviceAnswer: false }) + }); + } + """); + + // Assert - ringing should not expose hangup yet. + Assert.True(await page.Locator("[data-telephony-hangup]").IsHiddenAsync()); + Assert.Equal("Ringing...", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + + // Act + await page.ClickAsync("[data-telephony-incoming-answer]"); + + // Assert - once the authoritative accept completes, the call is live. + await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + Assert.Equal("In call", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + Assert.Equal("+15550001000", (await page.Locator("[data-telephony-peer]").InnerTextAsync()).Trim()); + } + + [Fact] + public async Task AcceptedInboundOffer_RemainsActive_WhenOfferIsRevokedDuringAccept() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + + await page.EvaluateAsync( + """ + () => { + const api = window.telephonySoftPhone.getInstance(); + api.setIncomingOffer( + { + callId: 'call-inbound-2', + from: '+15550001000', + direction: 'Inbound', + state: 'Ringing', + providerName: 'InMemory' + }, + { + properties: { + acceptUrl: '/accept', + reservationId: 'res-2' + } + }); + + window.__completeInboundAccept = null; + window.fetch = () => new Promise(resolve => { + window.__completeInboundAccept = () => resolve({ + ok: true, + json: async () => ({ succeeded: true, requiresDeviceAnswer: false }) + }); + }); + } + """); + + // Act + await page.ClickAsync("[data-telephony-incoming-answer]"); + await page.EvaluateAsync( + """ + () => { + const api = window.telephonySoftPhone.getInstance(); + api.clearIncomingOffer({ preserveCurrentCall: true, preservePendingAccept: true }); + window.__completeInboundAccept(); + } + """); + + // Assert + await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + Assert.Equal("In call", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + Assert.Equal("+15550001000", (await page.Locator("[data-telephony-peer]").InnerTextAsync()).Trim()); + } + private static async Task WaitForConnectedAsync(IPage page) { await page.WaitForFunctionAsync( @@ -154,4 +316,10 @@ await page.WaitForFunctionAsync( } """); } + + private static async Task GetConfiguredHeightAsync(IPage page) + { + return await page.Locator("#telephony-soft-phone").EvaluateAsync( + "element => element.style.getPropertyValue('--telephony-view-height').trim()"); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs index ab0eed61c..04870f74c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs @@ -38,6 +38,31 @@ public async Task EnqueueAsync_CapturesAssignedUserAsStickyHint() Assert.Equal(QueueItemStatus.Waiting, item.Status); } + [Fact] + public async Task EnqueueAsync_WhenPriorityIsNotProvided_UsesQueueDefaultPriority() + { + // Arrange + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync((QueueItem)null); + queueItemManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new QueueItem()); + + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())) + .ReturnsAsync(new ActivityQueue { ItemId = "q1", DefaultPriority = InteractionPriority.High }); + + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())) + .ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + + var service = CreateService(queueItemManager, queueManager, activityManager, new Mock()); + + // Act + var item = await service.EnqueueAsync("act-1", "q1", null, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(InteractionPriority.High, item.Priority); + } + [Fact] public async Task OverflowDueAsync_WhenNoOverflowTarget_ReturnsZero() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs index 86e638dff..2a05ca9fc 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -3,6 +3,9 @@ using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Logging; using Moq; using OrchardCore.Modules; @@ -20,10 +23,14 @@ public async Task ReserveAsync_SetsReservedStateAndPublishesEvents() reservationManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new ActivityReservation()); var queueItemManager = new Mock(); var agentManager = new Mock(); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + var queueService = new Mock(); + var interactionManager = new Mock(); var activityManager = new Mock(); activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); var publisher = new Mock(); - var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, publisher); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, publisher, new Mock()); var item = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; @@ -49,8 +56,11 @@ public async Task ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving() .Setup(m => m.FindByIdAsync("qi-1", It.IsAny())) .ReturnsAsync(new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }); var agentManager = new Mock(); + var queueManager = new Mock(); + var queueService = new Mock(); + var interactionManager = new Mock(); var activityManager = new Mock(); - var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); var staleItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; @@ -73,9 +83,13 @@ public async Task ReserveAsync_WhenBreakWasGrantedAfterRoutingDecision_Preserves var currentAgent = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Break }; var agentManager = new Mock(); agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(currentAgent); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + var queueService = new Mock(); + var interactionManager = new Mock(); var activityManager = new Mock(); activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); - var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); var item = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; var selectedAgent = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Available }; @@ -101,9 +115,15 @@ public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueu queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); var agentManager = new Mock(); agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + var queueService = new Mock(); + var interaction = new Interaction { ItemId = "i1", ActivityItemId = "act-1", AgentId = "a1", Status = InteractionStatus.Ringing }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync(interaction); var activityManager = new Mock(); activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); - var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); // Act var count = await service.ExpireDueAsync(TestContext.Current.CancellationToken); @@ -112,6 +132,40 @@ public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueu Assert.Equal(1, count); Assert.Equal(ReservationStatus.Expired, reservation.Status); Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); + Assert.Equal(InteractionStatus.Created, interaction.Status); + Assert.Null(interaction.AgentId); + } + + [Fact] + public async Task ExpireDueAsync_WhenRequeueing_ClearsStaleRingingAssignmentFromInteraction() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", QueueId = "q1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + var queueItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", Status = QueueItemStatus.Reserved, ReservationId = "r1", AgentId = "a1" }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); + var agent = new AgentProfile { ItemId = "a1", PresenceStatus = AgentPresenceStatus.Available, QueueIds = ["q1"] }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(agent); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1", Name = "Voice" }); + var queueService = new Mock(); + var interaction = new Interaction { ItemId = "i1", ActivityItemId = "act-1", AgentId = "a1", Status = InteractionStatus.Ringing }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync(interaction); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); + + // Act + await service.ExpireDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(InteractionStatus.Created, interaction.Status); + Assert.Null(interaction.AgentId); + interactionManager.Verify(m => m.UpdateAsync(interaction, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -126,9 +180,13 @@ public async Task ExpireDueAsync_WhenBreakIsPending_GrantsBreak() var agent = new AgentProfile { ItemId = "a1", RequestedPresenceStatus = AgentPresenceStatus.Break }; var agentManager = new Mock(); agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(agent); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + var queueService = new Mock(); + var interactionManager = new Mock(); var activityManager = new Mock(); activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); - var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); // Act await service.ExpireDueAsync(TestContext.Current.CancellationToken); @@ -138,6 +196,100 @@ public async Task ExpireDueAsync_WhenBreakIsPending_GrantsBreak() Assert.Null(agent.RequestedPresenceStatus); } + [Fact] + public async Task ExpireDueAsync_WhenAgentSignedOut_KeepsAgentOffline() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }); + var agent = new AgentProfile { ItemId = "a1", PresenceStatus = AgentPresenceStatus.Offline }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(agent); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + var queueService = new Mock(); + var interactionManager = new Mock(); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); + + // Act + await service.ExpireDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(AgentPresenceStatus.Offline, agent.PresenceStatus); + Assert.Null(agent.RequestedPresenceStatus); + } + + [Fact] + public async Task ExpireDueAsync_WhenQueueUsesVoicemail_RemovesItemAndSendsToVoicemail() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", QueueId = "q1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + var queueItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", Status = QueueItemStatus.Reserved, ReservationId = "r1", AgentId = "a1" }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); + var agent = new AgentProfile { ItemId = "a1", UserId = "u1", UserName = "agent", DisplayName = "Agent" }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(agent); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1", Name = "Voice", UnansweredOfferAction = UnansweredOfferAction.Voicemail }); + var queueService = new Mock(); + var interaction = new Interaction { ItemId = "i1", ActivityItemId = "act-1", ProviderInteractionId = "call-1" }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync(interaction); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var telephonyService = new Mock(); + telephonyService.Setup(m => m.SendToVoicemailAsync(It.IsAny(), It.IsAny())).ReturnsAsync(TelephonyResult.Success()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), telephonyService); + + // Act + await service.ExpireDueAsync(TestContext.Current.CancellationToken); + + // Assert + queueService.Verify(m => m.DequeueAsync(It.Is(item => item.ItemId == "qi-1"), QueueItemStatus.Removed, It.IsAny()), Times.Once); + telephonyService.Verify(m => m.SendToVoicemailAsync(It.Is(call => call.CallId == "call-1"), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExpireDueAsync_WhenQueueUsesReject_RemovesItemAndRejectsCall() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", QueueId = "q1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + var queueItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", Status = QueueItemStatus.Reserved, ReservationId = "r1", AgentId = "a1" }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); + var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(agent); + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1", Name = "Voice", UnansweredOfferAction = UnansweredOfferAction.Reject }); + var queueService = new Mock(); + var interaction = new Interaction { ItemId = "i1", ActivityItemId = "act-1", ProviderInteractionId = "call-1" }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync(interaction); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var telephonyService = new Mock(); + telephonyService.Setup(m => m.RejectAsync(It.IsAny(), It.IsAny())).ReturnsAsync(TelephonyResult.Success()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), telephonyService); + + // Act + await service.ExpireDueAsync(TestContext.Current.CancellationToken); + + // Assert + queueService.Verify(m => m.DequeueAsync(It.Is(item => item.ItemId == "qi-1"), QueueItemStatus.Removed, It.IsAny()), Times.Once); + telephonyService.Verify(m => m.RejectAsync(It.Is(call => call.CallId == "call-1"), It.IsAny()), Times.Once); + } + [Fact] public async Task CancelAsync_ReleasesPendingReservationAndMarksCanceled() { @@ -150,9 +302,12 @@ public async Task CancelAsync_ReleasesPendingReservationAndMarksCanceled() queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); var agentManager = new Mock(); agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + var queueManager = new Mock(); + var queueService = new Mock(); + var interactionManager = new Mock(); var activityManager = new Mock(); activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); - var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); // Act var canceled = await service.CancelAsync("r1", TestContext.Current.CancellationToken); @@ -167,8 +322,12 @@ private static ActivityReservationService CreateService( Mock reservationManager, Mock queueItemManager, Mock agentManager, + Mock queueManager, + Mock queueService, + Mock interactionManager, Mock activityManager, - Mock publisher) + Mock publisher, + Mock telephonyService) { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); @@ -177,8 +336,13 @@ private static ActivityReservationService CreateService( reservationManager.Object, queueItemManager.Object, agentManager.Object, + queueManager.Object, + queueService.Object, + interactionManager.Object, activityManager.Object, publisher.Object, - clock.Object); + [telephonyService.Object], + clock.Object, + new Mock>().Object); } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index fd94a3131..a27134248 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -1,6 +1,8 @@ +using CrestApps.OrchardCore.ContactCenter; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging; using Moq; using OrchardCore.Locking.Distributed; using OrchardCore.Modules; @@ -22,7 +24,7 @@ public async Task SignInAsync_CreatesAvailableProfile_AndJoinsQueues() var publisher = new Mock(); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, publisher.Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], publisher.Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.SignInAsync("u1", ["q1", "q2"], [], TestContext.Current.CancellationToken); @@ -35,21 +37,99 @@ public async Task SignInAsync_CreatesAvailableProfile_AndJoinsQueues() } [Fact] - public async Task SignOutAsync_SetsOffline() + public async Task SignInAsync_PublishesAgentSignedInEvent_ForQueuedOfferHandlers() { // Arrange - var existing = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Available }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync((AgentProfile)null); + agentManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync((AgentProfile)null); + var publisher = new Mock(); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], publisher.Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); + + // Act + await service.SignInAsync("u1", ["q1"], [], TestContext.Current.CancellationToken); + + // Assert + publisher.Verify(publisher => publisher.PublishAsync( + It.Is(interactionEvent => + interactionEvent.EventType == ContactCenterConstants.Events.AgentSignedIn && + interactionEvent.AggregateId == "a1" && + interactionEvent.ActorId == "u1"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task SignOutAsync_ClearsMembershipAndSetsOffline() + { + // Arrange + var existing = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Available, + PresenceReason = "Ready", + QueueIds = ["q1"], + CampaignIds = ["c1"], + }; + var agentManager = new Mock(); agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.SignOutAsync("u1", TestContext.Current.CancellationToken); // Assert Assert.Equal(AgentPresenceStatus.Offline, profile.PresenceStatus); + Assert.Null(profile.PresenceReason); + Assert.Empty(profile.QueueIds); + Assert.Empty(profile.CampaignIds); + } + + [Fact] + public async Task SignOutAsync_WhenLiveSessionExists_ClearsSessionMembership() + { + // Arrange + var existing = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1"], + CampaignIds = ["c1"], + }; + var session = new AgentSession + { + ItemId = "s1", + UserId = "u1", + QueueIds = ["q1"], + CampaignIds = ["c1"], + }; + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(session); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, [sessionManager.Object], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); + + // Act + await service.SignOutAsync("u1", TestContext.Current.CancellationToken); + + // Assert + Assert.Empty(session.QueueIds); + Assert.Empty(session.CampaignIds); + Assert.Equal(_now, session.ModifiedUtc); + sessionManager.Verify(m => m.UpdateAsync(session, It.IsAny(), It.IsAny()), Times.Once); } [Fact] @@ -62,7 +142,7 @@ public async Task SetPresenceAsync_WhenProfileDoesNotExist_CreatesProfile() agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync((AgentProfile)null); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.SetPresenceAsync("u1", AgentPresenceStatus.DoNotDisturb, "Focus time", TestContext.Current.CancellationToken); @@ -83,7 +163,7 @@ public async Task SetPresenceAsync_RequestBreakWhenAvailable_GrantsBreakImmediat agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.SetPresenceAsync("u1", AgentPresenceStatus.RequestBreak, null, TestContext.Current.CancellationToken); @@ -109,7 +189,7 @@ public async Task SetPresenceAsync_RequestBreakWhenReserved_KeepsReservationAndS agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.SetPresenceAsync("u1", AgentPresenceStatus.RequestBreak, null, TestContext.Current.CancellationToken); @@ -129,7 +209,7 @@ public async Task StartWrapUpAsync_MovesBusyAgentIntoWrapUp() agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(existing); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.StartWrapUpAsync("a1", TestContext.Current.CancellationToken); @@ -156,7 +236,7 @@ public async Task CompleteWorkAsync_WhenBreakRequested_AppliesBreak() agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(existing); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, [], [], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); // Act var profile = await service.CompleteWorkAsync("a1", TestContext.Current.CancellationToken); @@ -166,6 +246,60 @@ public async Task CompleteWorkAsync_WhenBreakRequested_AppliesBreak() Assert.Null(profile.RequestedPresenceStatus); } + [Fact] + public async Task SignInAsync_WhenExistingProfilePresent_HealsStaleWorkBeforeResettingAvailability() + { + // Arrange + var existing = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1"], + }; + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(existing); + var healer = new Mock(); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, [], [healer.Object], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); + + // Act + await service.SignInAsync("u1", ["q2"], [], TestContext.Current.CancellationToken); + + // Assert + healer.Verify(manager => manager.HealForResetAsync("a1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task SignOutAsync_WhenProfileExists_HealsStaleWorkBeforeSigningOut() + { + // Arrange + var existing = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Busy, + QueueIds = ["q1"], + }; + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(existing); + var healer = new Mock(); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, [], [healer.Object], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); + + // Act + await service.SignOutAsync("u1", TestContext.Current.CancellationToken); + + // Assert + healer.Verify(manager => manager.HealForResetAsync("a1", It.IsAny()), Times.Once); + } + private static Mock CreateDistributedLock() { var distributedLock = new Mock(); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs new file mode 100644 index 000000000..a5d34387b --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs @@ -0,0 +1,129 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Controllers; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class AgentSoftPhoneControllerTests +{ + [Fact] + public async Task SyncQueuedVoiceWork_WhenAuthorized_OffersQueuedVoiceWorkForCurrentUser() + { + // Arrange + var queuedVoiceWorkOfferService = new Mock(); + var controller = CreateController(true); + + // Act + var result = await controller.SyncQueuedVoiceWork([queuedVoiceWorkOfferService.Object]); + + // Assert + Assert.IsType(result); + queuedVoiceWorkOfferService.Verify(service => service.OfferForUserAsync("user-1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task SyncQueuedVoiceWork_WhenUnauthorized_ReturnsForbid() + { + // Arrange + var queuedVoiceWorkOfferService = new Mock(); + var controller = CreateController(false); + + // Act + var result = await controller.SyncQueuedVoiceWork([queuedVoiceWorkOfferService.Object]); + + // Assert + Assert.IsType(result); + queuedVoiceWorkOfferService.Verify(service => service.OfferForUserAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task CurrentIncomingOffer_WhenOfferExists_ReturnsJson() + { + // Arrange + var pendingIncomingCallOfferService = new Mock(); + pendingIncomingCallOfferService.Setup(service => service.GetForUserAsync("user-1", It.IsAny())) + .ReturnsAsync(new PendingIncomingCallOffer()); + var controller = CreateController(true); + + // Act + var result = await controller.CurrentIncomingOffer([pendingIncomingCallOfferService.Object]); + + // Assert + Assert.IsType(result); + pendingIncomingCallOfferService.Verify(service => service.GetForUserAsync("user-1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task CurrentIncomingOffer_WhenOfferMissing_ReturnsNotFound() + { + // Arrange + var pendingIncomingCallOfferService = new Mock(); + var controller = CreateController(true); + + // Act + var result = await controller.CurrentIncomingOffer([pendingIncomingCallOfferService.Object]); + + // Assert + Assert.IsType(result); + } + + private static AgentSoftPhoneController CreateController(bool isAuthorized) + { + var authorizationService = new TestAuthorizationService(isAuthorized); + var controller = new AgentSoftPhoneController( + Mock.Of(), + authorizationService, + NullLogger.Instance) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, "user-1"), + ], "Test")), + }, + }, + }; + + return controller; + } + + private sealed class TestAuthorizationService : IAuthorizationService + { + private readonly bool _isAuthorized; + + public TestAuthorizationService(bool isAuthorized) + { + _isAuthorized = isAuthorized; + } + + public Task AuthorizeAsync( + ClaimsPrincipal user, + object resource, + IEnumerable requirements) + { + return Task.FromResult(_isAuthorized + ? AuthorizationResult.Success() + : AuthorizationResult.Failed()); + } + + public Task AuthorizeAsync( + ClaimsPrincipal user, + object resource, + string policyName) + { + return Task.FromResult(_isAuthorized + ? AuthorizationResult.Success() + : AuthorizationResult.Failed()); + } + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs new file mode 100644 index 000000000..62527262e --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs @@ -0,0 +1,241 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class AgentWorkStateHealingServiceTests +{ + private static readonly DateTime _now = new(2026, 7, 9, 17, 30, 0, DateTimeKind.Utc); + + [Fact] + public async Task HealForAvailabilityAsync_WhenPendingReservationIsStale_CancelsIt() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("a1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "r1", + ActivityItemId = "act-1", + QueueItemId = "qi-1", + AgentId = "a1", + Status = ReservationStatus.Pending, + ExpiresUtc = _now.AddSeconds(30), + }); + + var reservationService = new Mock(); + reservationService.Setup(service => service.CancelAsync("r1", It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r1" }); + + var queueItemManager = new Mock(); + queueItemManager.Setup(manager => manager.FindByIdAsync("qi-1", It.IsAny())) + .ReturnsAsync(new QueueItem + { + ItemId = "qi-1", + ReservationId = "r1", + AgentId = "a1", + Status = QueueItemStatus.Reserved, + }); + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByActivityIdAsync("act-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "i1", + ActivityItemId = "act-1", + AgentId = null, + Status = InteractionStatus.Created, + }); + + var service = CreateService(agentManager, reservationManager, reservationService, queueItemManager, interactionManager); + + // Act + var healed = await service.HealForAvailabilityAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, healed); + reservationService.Verify(manager => manager.CancelAsync("r1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task HealForAvailabilityAsync_WhenAvailableAgentHasConnectedInteraction_RequeuesIt() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("a1", It.IsAny())) + .ReturnsAsync((ActivityReservation)null); + + var reservationService = new Mock(); + var queueItem = new QueueItem + { + ItemId = "qi-1", + ActivityItemId = "act-1", + QueueId = "q1", + ReservationId = "r1", + AgentId = "a1", + Status = QueueItemStatus.Assigned, + }; + + var queueItemManager = new Mock(); + queueItemManager.Setup(manager => manager.FindByActivityIdAsync("act-1", It.IsAny())) + .ReturnsAsync(queueItem); + + var interaction = new Interaction + { + ItemId = "i1", + ActivityItemId = "act-1", + QueueId = "q1", + AgentId = "a1", + Status = InteractionStatus.Connected, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindActiveByAgentAsync("a1", It.IsAny())) + .ReturnsAsync(interaction); + + var activityManager = new Mock(); + var activity = new OmnichannelActivity + { + ItemId = "act-1", + AssignmentStatus = ActivityAssignmentStatus.Assigned, + AssignedToId = "u1", + ReservationId = "r1", + }; + activityManager.Setup(manager => manager.FindByIdAsync("act-1", It.IsAny())) + .ReturnsAsync(activity); + + var service = CreateService(agentManager, reservationManager, reservationService, queueItemManager, interactionManager, activityManager); + + // Act + var healed = await service.HealForAvailabilityAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, healed); + Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); + Assert.Null(queueItem.AgentId); + Assert.Null(queueItem.ReservationId); + Assert.Equal(InteractionStatus.Created, interaction.Status); + Assert.Null(interaction.AgentId); + Assert.Equal(ActivityAssignmentStatus.Available, activity.AssignmentStatus); + Assert.Null(activity.AssignedToId); + Assert.Null(activity.ReservationId); + } + + [Fact] + public async Task HealForResetAsync_WhenPendingReservationExists_CancelsItEvenWhenOtherwiseValid() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Busy, + ActiveReservationId = "r1", + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Busy, + }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindByIdAsync("r1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "r1", + ActivityItemId = "act-1", + QueueItemId = "qi-1", + AgentId = "a1", + Status = ReservationStatus.Pending, + ExpiresUtc = _now.AddSeconds(30), + }); + + var reservationService = new Mock(); + reservationService.Setup(service => service.CancelAsync("r1", It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r1" }); + + var queueItemManager = new Mock(); + queueItemManager.Setup(manager => manager.FindByIdAsync("qi-1", It.IsAny())) + .ReturnsAsync(new QueueItem + { + ItemId = "qi-1", + ReservationId = "r1", + AgentId = "a1", + Status = QueueItemStatus.Reserved, + }); + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByActivityIdAsync("act-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "i1", + ActivityItemId = "act-1", + AgentId = "a1", + Status = InteractionStatus.Ringing, + }); + + var service = CreateService(agentManager, reservationManager, reservationService, queueItemManager, interactionManager); + + // Act + var healed = await service.HealForResetAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, healed); + reservationService.Verify(manager => manager.CancelAsync("r1", It.IsAny()), Times.Once); + } + + private static AgentWorkStateHealingService CreateService( + Mock agentManager, + Mock reservationManager, + Mock reservationService, + Mock queueItemManager, + Mock interactionManager, + Mock activityManager = null) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new AgentWorkStateHealingService( + agentManager.Object, + reservationManager.Object, + reservationService.Object, + queueItemManager.Object, + interactionManager.Object, + activityManager?.Object ?? Mock.Of(), + clock.Object, + NullLogger.Instance); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs index 828eaa93e..a200c81e9 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs @@ -3,6 +3,8 @@ using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; using Moq; using OrchardCore.Modules; @@ -74,6 +76,55 @@ public async Task AcceptInboundOfferAsync_WhenServerSideAcdProvider_BridgesAndDo Times.Once); } + [Fact] + public async Task AcceptInboundOfferAsync_WhenNoContactCenterVoiceProvider_DoesNotRequireDeviceAnswer() + { + // Arrange + var harness = new Harness(); + harness.SetupAcceptedReservation(); + harness.SetupInteraction(); + harness.SetupNewCallSession(); + harness.SetupTelephonyProviderAnswer(); + + var service = harness.CreateService(); + + // Act + var result = await service.AcceptInboundOfferAsync("r1", "u1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.False(result.RequiresDeviceAnswer); + Assert.Equal(VoiceProviderDeliveryModel.ServerSideAcd, harness.CreatedCallSession?.DeliveryModel); + harness.TelephonyProvider.Verify( + p => p.AnswerAsync(It.Is(call => call.CallId == "call-1"), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task AcceptInboundOfferAsync_WhenTelephonyFallbackAnswerFails_ReturnsFailure() + { + // Arrange + var harness = new Harness(); + harness.SetupAcceptedReservation(); + harness.SetupInteraction(); + harness.SetupTelephonyProviderAnswer(TelephonyResult.Failed("The call could not be answered.")); + + var service = harness.CreateService(); + + // Act + var result = await service.AcceptInboundOfferAsync("r1", "u1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("The call could not be answered.", result.Reason); + harness.ReservationService.Verify( + s => s.CancelAsync("r1", It.IsAny()), + Times.Once); + harness.InboundVoiceService.Verify( + s => s.OfferNextAsync("q1", It.IsAny()), + Times.Once); + } + [Fact] public async Task AcceptInboundOfferAsync_WhenProviderConnectFails_DoesNotConnectInteraction() { @@ -186,6 +237,8 @@ private sealed class Harness public Mock VoiceProviderResolver { get; } = new(); + public Mock TelephonyProviderResolver { get; } = new(); + public Mock CallSessionManager { get; } = new(); public Mock InboundVoiceService { get; } = new(); @@ -194,8 +247,12 @@ private sealed class Harness public Mock Provider { get; } = new(); + public Mock TelephonyProvider { get; } = new(); + public Interaction Interaction { get; } = new() { ItemId = "int1", ProviderName = "dp", ProviderInteractionId = "call-1", Direction = InteractionDirection.Inbound }; + public CallSession CreatedCallSession { get; private set; } + public void SetupAcceptedReservation() { SetupPendingReservation(); @@ -247,6 +304,30 @@ public void SetupNewCallSession() CallSessionManager .Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) .ReturnsAsync(new CallSession()); + + CallSessionManager + .Setup(m => m.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((session, _) => CreatedCallSession = session) + .Returns(ValueTask.CompletedTask); + } + + public void SetupTelephonyProviderAnswer(TelephonyResult result = null) + { + result ??= TelephonyResult.Success(new TelephonyCall + { + CallId = "call-1", + State = CallState.Connected, + Direction = CallDirection.Inbound, + ProviderName = "dp", + }); + + TelephonyProviderResolver + .Setup(r => r.GetAsync("dp")) + .ReturnsAsync(TelephonyProvider.Object); + + TelephonyProvider + .Setup(p => p.AnswerAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(result); } public ContactCenterCallCommandService CreateService() @@ -262,6 +343,7 @@ public ContactCenterCallCommandService CreateService() InteractionManager.Object, AgentManager.Object, VoiceProviderResolver.Object, + TelephonyProviderResolver.Object, CallSessionManager.Object, InboundVoiceService.Object, Publisher.Object, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index cbf170ea5..84d879f9b 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -164,6 +164,46 @@ public async Task HandleInboundAsync_WhenNoAgentAvailable_QueuesWithoutOffering( harness.IncomingCallDispatcher.Verify(d => d.DispatchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task HandleInboundAsync_WhenEndpointSpecificQueueExists_PrefersItOverGenericQueue() + { + // Arrange + var harness = new Harness(); + harness.SetupNoContext(); + + harness.ChannelEndpointManager + .Setup(m => m.GetByServiceAddressAsync(It.IsAny(), "+15553334444", It.IsAny())) + .Returns(new ValueTask(new OmnichannelChannelEndpoint { ItemId = "ep1", Channel = "Phone", Value = "+15553334444" })); + + harness.QueueManager + .Setup(m => m.ListEnabledAsync(It.IsAny())) + .ReturnsAsync( + [ + new ActivityQueue { ItemId = "q-generic", Enabled = true }, + new ActivityQueue { ItemId = "q-endpoint", Enabled = true, InboundChannelEndpointId = "ep1" }, + ]); + + harness.QueueService + .Setup(m => m.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi1" }); + + harness.AssignmentService + .Setup(m => m.AssignNextAsync("q-endpoint", It.IsAny())) + .ReturnsAsync((ActivityReservation)null); + + var service = harness.CreateService(); + + // Act + var result = await service.HandleInboundAsync( + new InboundVoiceEvent { ProviderName = "TestProvider", ProviderCallId = "call-1", FromAddress = "+15551112222", ToAddress = "+15553334444" }, + TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("q-endpoint", result.QueueId); + harness.QueueService.Verify(m => m.EnqueueAsync("act1", "q-endpoint", It.IsAny(), It.IsAny()), Times.Once); + harness.QueueService.Verify(m => m.EnqueueAsync("act1", "q-generic", It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task OfferNextAsync_WhenReservedAgentCannotBeLoaded_ReleasesReservation() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/OfferQueuedVoiceWorkOnAvailabilityHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/OfferQueuedVoiceWorkOnAvailabilityHandlerTests.cs new file mode 100644 index 000000000..52e75d90e --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/OfferQueuedVoiceWorkOnAvailabilityHandlerTests.cs @@ -0,0 +1,71 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Handlers; +using CrestApps.OrchardCore.ContactCenter.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class OfferQueuedVoiceWorkOnAvailabilityHandlerTests +{ + [Fact] + public async Task HandleAsync_WhenAgentSignsIn_OffersQueuedVoiceWorkForAgent() + { + // Arrange + var queuedVoiceWorkOfferService = new Mock(); + var handler = new OfferQueuedVoiceWorkOnAvailabilityHandler(CreateServices(queuedVoiceWorkOfferService.Object)); + + // Act + await handler.HandleAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.AgentSignedIn, + AggregateId = "a1", + }, TestContext.Current.CancellationToken); + + // Assert + queuedVoiceWorkOfferService.Verify(service => service.OfferForAgentAsync("a1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenEventTypeDoesNotMatch_DoesNotOfferQueuedVoiceWork() + { + // Arrange + var queuedVoiceWorkOfferService = new Mock(); + var handler = new OfferQueuedVoiceWorkOnAvailabilityHandler(CreateServices(queuedVoiceWorkOfferService.Object)); + + // Act + await handler.HandleAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemAdded, + AggregateId = "a1", + }, TestContext.Current.CancellationToken); + + // Assert + queuedVoiceWorkOfferService.Verify(service => service.OfferForAgentAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task HandleAsync_WhenQueuedVoiceOfferServiceIsMissing_ReturnsWithoutFailure() + { + // Arrange + var handler = new OfferQueuedVoiceWorkOnAvailabilityHandler(new ServiceCollection().BuildServiceProvider()); + + // Act + await handler.HandleAsync(new InteractionEvent + { + EventType = ContactCenterConstants.Events.AgentSignedIn, + AggregateId = "a1", + }, TestContext.Current.CancellationToken); + + // Assert + } + + private static ServiceProvider CreateServices(IQueuedVoiceWorkOfferService queuedVoiceWorkOfferService) + { + return new ServiceCollection() + .AddSingleton(queuedVoiceWorkOfferService) + .AddSingleton(queuedVoiceWorkOfferService) + .BuildServiceProvider(); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/PendingIncomingCallOfferServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/PendingIncomingCallOfferServiceTests.cs new file mode 100644 index 000000000..7fc6bcaea --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/PendingIncomingCallOfferServiceTests.cs @@ -0,0 +1,166 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class PendingIncomingCallOfferServiceTests +{ + private static readonly DateTime _now = new(2026, 7, 9, 16, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetForUserAsync_WhenPendingOfferExists_ReturnsRestorableIncomingOffer() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByUserIdAsync("user-1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "agent-1", UserId = "user-1" }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("agent-1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "reservation-1", + ActivityItemId = "activity-1", + QueueId = "queue-1", + ExpiresUtc = _now.AddSeconds(30), + }); + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByActivityIdAsync("activity-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "interaction-1", + ActivityItemId = "activity-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Ringing, + ProviderInteractionId = "call-1", + ProviderName = "Asterisk", + CustomerAddress = "+15550001000", + CreatedUtc = _now.AddSeconds(-5), + TechnicalMetadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["serviceAddress"] = "+15550002000", + }, + }); + + var provider = new Mock(); + provider.Setup(p => p.ContributeAsync(It.IsAny(), It.IsAny())) + .Callback((context, _) => + { + context.Heading = "Matched customers"; + context.Properties["acceptUrl"] = "/accept"; + context.Properties["declineUrl"] = "/decline"; + context.Cards.Add(new IncomingCallCard { Id = "card-1", Title = "Caller", Priority = 0 }); + }) + .Returns(Task.CompletedTask); + + var service = CreateService(agentManager, reservationManager, interactionManager, [provider.Object]); + + // Act + var offer = await service.GetForUserAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(offer); + Assert.Equal("call-1", offer.Call.CallId); + Assert.Equal(CallDirection.Inbound, offer.Call.Direction); + Assert.Equal(CallState.Ringing, offer.Call.State); + Assert.Equal("+15550001000", offer.Call.From); + Assert.Equal("+15550002000", offer.Call.To); + Assert.Equal("Matched customers", offer.Context.Heading); + Assert.Equal("/accept", offer.Context.Properties["acceptUrl"]); + Assert.Equal("/decline", offer.Context.Properties["declineUrl"]); + Assert.Equal("reservation-1", offer.Context.Properties["reservationId"]); + Assert.Equal(_now.AddSeconds(30).ToString("O"), offer.Context.Properties["expiresUtc"]); + Assert.Single(offer.Context.Cards); + } + + [Fact] + public async Task GetForUserAsync_WhenReservationExpired_ReturnsNull() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByUserIdAsync("user-1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "agent-1", UserId = "user-1" }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("agent-1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "reservation-1", + ActivityItemId = "activity-1", + ExpiresUtc = _now.AddSeconds(-1), + }); + + var interactionManager = new Mock(); + var service = CreateService(agentManager, reservationManager, interactionManager, []); + + // Act + var offer = await service.GetForUserAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(offer); + interactionManager.Verify(manager => manager.FindByActivityIdAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetForUserAsync_WhenInteractionIsNotRinging_ReturnsNull() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByUserIdAsync("user-1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "agent-1", UserId = "user-1" }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("agent-1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "reservation-1", + ActivityItemId = "activity-1", + ExpiresUtc = _now.AddSeconds(30), + }); + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByActivityIdAsync("activity-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "interaction-1", + ActivityItemId = "activity-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Created, + ProviderInteractionId = "call-1", + }); + + var service = CreateService(agentManager, reservationManager, interactionManager, []); + + // Act + var offer = await service.GetForUserAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(offer); + } + + private static PendingIncomingCallOfferService CreateService( + Mock agentManager, + Mock reservationManager, + Mock interactionManager, + IEnumerable providers) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new PendingIncomingCallOfferService( + agentManager.Object, + reservationManager.Object, + interactionManager.Object, + providers, + clock.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/QueuedVoiceWorkOfferServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/QueuedVoiceWorkOfferServiceTests.cs new file mode 100644 index 000000000..8d5f43499 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/QueuedVoiceWorkOfferServiceTests.cs @@ -0,0 +1,132 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class QueuedVoiceWorkOfferServiceTests +{ + [Fact] + public async Task OfferForAgentAsync_WhenAgentIsAvailable_OffersQueuedVoiceWorkUntilReserved() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1", "q2"], + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1", "q2"], + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Reserved, + ActiveReservationId = "r1", + QueueIds = ["q1", "q2"], + }); + + var healer = new Mock(); + var inboundVoiceService = new Mock(); + inboundVoiceService + .Setup(service => service.OfferNextAsync("q1", It.IsAny())) + .ReturnsAsync("user-1"); + + var service = CreateService(agentManager, healer, inboundVoiceService); + + // Act + var offered = await service.OfferForAgentAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, offered); + inboundVoiceService.Verify(voice => voice.OfferNextAsync("q1", It.IsAny()), Times.Once); + inboundVoiceService.Verify(voice => voice.OfferNextAsync("q2", It.IsAny()), Times.Never); + } + + [Fact] + public async Task OfferForUserAsync_WhenAgentIsNotAvailable_DoesNotOfferQueuedVoiceWork() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByUserIdAsync("user-1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Break, + QueueIds = ["q1"], + }); + + var healer = new Mock(); + var inboundVoiceService = new Mock(); + var service = CreateService(agentManager, healer, inboundVoiceService); + + // Act + var offered = await service.OfferForUserAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, offered); + inboundVoiceService.Verify(voice => voice.OfferNextAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task OfferForAgentAsync_WhenAgentIsAvailable_RunsAvailabilityHealingBeforeOffering() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1"], + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1"], + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Reserved, + ActiveReservationId = "r1", + QueueIds = ["q1"], + }); + + var healer = new Mock(); + var inboundVoiceService = new Mock(); + inboundVoiceService + .Setup(service => service.OfferNextAsync("q1", It.IsAny())) + .ReturnsAsync("user-1"); + + var service = CreateService(agentManager, healer, inboundVoiceService); + + // Act + var offered = await service.OfferForAgentAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, offered); + healer.Verify(manager => manager.HealForAvailabilityAsync("a1", It.IsAny()), Times.Once); + inboundVoiceService.Verify(voice => voice.OfferNextAsync("q1", It.IsAny()), Times.Once); + } + + private static QueuedVoiceWorkOfferService CreateService( + Mock agentManager, + Mock healer, + Mock inboundVoiceService) + { + return new QueuedVoiceWorkOfferService( + agentManager.Object, + [healer.Object], + inboundVoiceService.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs index d66a340c9..61805e2e8 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs @@ -32,11 +32,30 @@ public async Task DialAsync_WhenConfigured_PostsToAsteriskAriWithBasicAuthentica Assert.Equal(CallDirection.Outbound, result.Call.Direction); Assert.Equal(HttpMethod.Post, handler.LastRequest.Method); - Assert.Equal($"{BaseUrl}channels?endpoint=PJSIP%2F%2B15551234567@phones&app=crestapps-telephony&timeout=30&callerId=%2B15550000000", handler.LastRequest.RequestUri.AbsoluteUri); + Assert.Equal($"{BaseUrl}channels?endpoint=PJSIP%2F%2B15551234567@phones&timeout=30&app=crestapps-telephony&callerId=%2B15550000000", handler.LastRequest.RequestUri.AbsoluteUri); Assert.Equal("Basic", handler.LastRequest.Headers.Authorization.Scheme); Assert.Equal(Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes($"ari-user:{PlainPassword}")), handler.LastRequest.Headers.Authorization.Parameter); } + [Fact] + public async Task DialAsync_WhenUsingLocalEndpoint_ReturnsConnectedStateForLocalSimulation() + { + // Arrange + var handler = new StubHttpMessageHandler(HttpStatusCode.OK, "{\"id\":\"call-1\"}"); + var provider = CreateProvider(handler, out _, isEnabled: true, endpointTemplate: "Local/{number}@default"); + + // Act + var result = await provider.DialAsync(new DialRequest { To = "1000" }, TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Call); + Assert.Equal(CallState.Connected, result.Call.State); + Assert.Equal( + $"{BaseUrl}channels?endpoint=Local%2F1000@default&timeout=30&app=crestapps-telephony&callerId=%2B15550000000", + handler.LastRequest.RequestUri.AbsoluteUri); + } + [Fact] public async Task DialAsync_WhenDisabled_ReturnsFailedAndDoesNotCallApi() { @@ -68,10 +87,153 @@ public async Task GetClientCredentialsAsync_WhenConfigured_ReturnsProviderName() Assert.Null(handler.LastRequest); } + [Fact] + public async Task AnswerAsync_WhenConfigured_PostsToAnswerEndpoint() + { + // Arrange + var handler = new StubHttpMessageHandler(HttpStatusCode.OK); + var provider = CreateProvider(handler, out _, isEnabled: true); + + // Act + var result = await provider.AnswerAsync(new CallReference { CallId = "call-1" }, TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Call); + Assert.Equal(CallState.Connected, result.Call.State); + Assert.Equal(CallDirection.Inbound, result.Call.Direction); + Assert.Equal($"{BaseUrl}channels/call-1/answer", handler.LastRequest.RequestUri.AbsoluteUri); + } + + [Fact] + public async Task RejectAsync_WhenConfigured_DeletesChannel() + { + // Arrange + var handler = new StubHttpMessageHandler(HttpStatusCode.OK); + var provider = CreateProvider(handler, out _, isEnabled: true); + + // Act + var result = await provider.RejectAsync(new CallReference { CallId = "call-1" }, TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Call); + Assert.Equal(CallState.Disconnected, result.Call.State); + Assert.Equal(CallDirection.Inbound, result.Call.Direction); + Assert.Equal($"{BaseUrl}channels/call-1", handler.LastRequest.RequestUri.AbsoluteUri); + Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); + } + + [Fact] + public void Capabilities_WhenUsingLocalLoopback_KeepAdvancedActionsEnabled() + { + // Arrange + var provider = CreateProvider( + new StubHttpMessageHandler(HttpStatusCode.OK), + out _, + isEnabled: true, + endpointTemplate: "Local/{number}@default"); + + // Act + var capabilities = provider.Capabilities; + + // Assert + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Dial)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Hangup)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.SendDigits)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.ReceiveCalls)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Hold)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Resume)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Mute)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Transfer)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Merge)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Voicemail)); + } + + [Fact] + public void Capabilities_WhenUsingStasisEndpoint_KeepAdvancedActionsEnabled() + { + // Arrange + var provider = CreateProvider( + new StubHttpMessageHandler(HttpStatusCode.OK), + out _, + isEnabled: true, + endpointTemplate: "PJSIP/{number}@phones"); + + // Act + var capabilities = provider.Capabilities; + + // Assert + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Hold)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Resume)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Mute)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Merge)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.ReceiveCalls)); + Assert.False(capabilities.HasFlag(TelephonyCapabilities.Transfer)); + Assert.True(capabilities.HasFlag(TelephonyCapabilities.Voicemail)); + } + + [Fact] + public async Task TransferAsync_WhenUsingLocalLoopback_ContinuesChannelToTargetExtension() + { + // Arrange + var handler = new StubHttpMessageHandler(HttpStatusCode.OK); + var provider = CreateProvider(handler, out _, isEnabled: true, endpointTemplate: "Local/{number}@default"); + + // Act + var result = await provider.TransferAsync( + new TransferRequest + { + CallId = "call-1", + To = "2000", + Mode = TransferMode.Blind, + }, + TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Call); + Assert.Equal(CallState.Disconnected, result.Call.State); + Assert.Equal( + $"{BaseUrl}channels/call-1/continue?context=default&extension=2000&priority=1", + handler.LastRequest.RequestUri.AbsoluteUri); + } + + [Fact] + public async Task SendToVoicemailAsync_WhenConfigured_SetsMetadataAndContinuesToResolvedExtension() + { + // Arrange + var handler = new StubHttpMessageHandler(HttpStatusCode.OK); + var provider = CreateProvider(handler, out _, isEnabled: true, endpointTemplate: "Local/{number}@default"); + + // Act + var result = await provider.SendToVoicemailAsync( + new CallReference + { + CallId = "call-1", + Metadata = new Dictionary + { + ["voicemailRecipientUserName"] = "mike", + ["queueName"] = "Support", + }, + }, + TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Call); + Assert.Equal(CallState.Disconnected, result.Call.State); + Assert.Equal(3, handler.Requests.Count); + Assert.Equal($"{BaseUrl}channels/call-1/variable?variable=CRESTAPPS_METADATA_VOICEMAILRECIPIENTUSERNAME&value=mike", handler.Requests[0].RequestUri.AbsoluteUri); + Assert.Equal($"{BaseUrl}channels/call-1/variable?variable=CRESTAPPS_METADATA_QUEUENAME&value=Support", handler.Requests[1].RequestUri.AbsoluteUri); + Assert.Equal($"{BaseUrl}channels/call-1/continue?context=voicemail&extension=mike&priority=1", handler.Requests[2].RequestUri.AbsoluteUri); + } + private static AsteriskTelephonyProvider CreateProvider( StubHttpMessageHandler handler, out IDataProtectionProvider dataProtectionProvider, - bool isEnabled) + bool isEnabled, + string endpointTemplate = "PJSIP/{number}@phones") { dataProtectionProvider = new EphemeralDataProtectionProvider(); @@ -86,9 +248,12 @@ private static AsteriskTelephonyProvider CreateProvider( UserName = "ari-user", Password = protectedPassword, ApplicationName = "crestapps-telephony", - EndpointTemplate = "PJSIP/{number}@phones", + EndpointTemplate = endpointTemplate, OutboundCallerId = "+15550000000", TimeoutSeconds = 30, + VoicemailContext = "voicemail", + VoicemailExtensionTemplate = "{voicemailRecipientUserName}", + VoicemailPriority = 1, }; return new AsteriskTelephonyProvider( diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/DefaultIncomingCallDispatcherTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/DefaultIncomingCallDispatcherTests.cs new file mode 100644 index 000000000..18a56cb22 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/DefaultIncomingCallDispatcherTests.cs @@ -0,0 +1,124 @@ +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using CrestApps.OrchardCore.Telephony.Services; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Telephony; + +public sealed class DefaultIncomingCallDispatcherTests +{ + private static readonly DateTimeOffset _now = new(2026, 7, 9, 18, 0, 0, TimeSpan.Zero); + + [Fact] + public async Task DispatchAsync_CreatesInboundInteraction_AndPushesIncomingCall() + { + // Arrange + var hubContext = new Mock>(); + var clients = new Mock>(); + var client = new Mock(); + var store = new Mock(); + var clock = new Mock(); + var logger = new Mock>(); + + hubContext.SetupGet(context => context.Clients).Returns(clients.Object); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + clock.SetupGet(value => value.UtcNow).Returns(_now.UtcDateTime); + store.Setup(value => value.FindByCallIdAsync("user-1", "call-1", It.IsAny())) + .ReturnsAsync((TelephonyInteraction)null); + + TelephonyInteraction createdInteraction = null; + store.Setup(value => value.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((interaction, _) => createdInteraction = interaction) + .Returns(() => Task.CompletedTask); + + var dispatcher = new DefaultIncomingCallDispatcher( + hubContext.Object, + [], + store.Object, + clock.Object, + logger.Object); + var call = new TelephonyCall + { + CallId = "call-1", + ProviderName = "Asterisk", + From = "+15550001000", + To = "+15550002000", + Direction = CallDirection.Inbound, + StartedUtc = _now, + }; + + // Act + await dispatcher.DispatchAsync("user-1", call, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(createdInteraction); + Assert.Equal("call-1", createdInteraction.CallId); + Assert.Equal("+15550001000", createdInteraction.From); + Assert.Equal(CallDirection.Inbound, createdInteraction.Direction); + Assert.Equal(CallOutcome.InProgress, createdInteraction.Outcome); + + client.Verify( + value => value.IncomingCall( + It.Is(incoming => incoming.CallId == "call-1"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task DispatchAsync_RefreshesExistingInboundInteraction() + { + // Arrange + var hubContext = new Mock>(); + var clients = new Mock>(); + var client = new Mock(); + var store = new Mock(); + var clock = new Mock(); + var logger = new Mock>(); + var existing = new TelephonyInteraction + { + InteractionId = "int-1", + CallId = "call-1", + From = "+15551110000", + To = "+15552220000", + Direction = CallDirection.Outbound, + Outcome = CallOutcome.InProgress, + StartedUtc = _now.UtcDateTime.AddMinutes(-1), + }; + + hubContext.SetupGet(context => context.Clients).Returns(clients.Object); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + clock.SetupGet(value => value.UtcNow).Returns(_now.UtcDateTime); + store.Setup(value => value.FindByCallIdAsync("user-1", "call-1", It.IsAny())) + .ReturnsAsync(existing); + + var dispatcher = new DefaultIncomingCallDispatcher( + hubContext.Object, + [], + store.Object, + clock.Object, + logger.Object); + var call = new TelephonyCall + { + CallId = "call-1", + ProviderName = "Asterisk", + From = "+15550001000", + To = "+15550002000", + Direction = CallDirection.Inbound, + }; + + // Act + await dispatcher.DispatchAsync("user-1", call, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("+15550001000", existing.From); + Assert.Equal("+15550002000", existing.To); + Assert.Equal(CallDirection.Inbound, existing.Direction); + + store.Verify(value => value.UpdateAsync(existing, It.IsAny()), Times.Once); + store.Verify(value => value.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs index 9d9bb132b..0dfe79ab7 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs @@ -20,13 +20,23 @@ public StubHttpMessageHandler(HttpStatusCode statusCode, string responseBody = " public string LastRequestBody { get; private set; } + public IList Requests { get; } = []; + + public IList RequestBodies { get; } = []; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { LastRequest = request; + Requests.Add(request); if (request.Content is not null) { LastRequestBody = await request.Content.ReadAsStringAsync(cancellationToken); + RequestBodies.Add(LastRequestBody); + } + else + { + RequestBodies.Add(null); } return new HttpResponseMessage(_statusCode) From 2675ac78c06dec586dfe1be296a785752a82b084 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 9 Jul 2026 22:09:16 -0700 Subject: [PATCH 43/56] Review hardening: fix CA1873 warnings, agent reserve guard, Asterisk conference state - TelephonyHub: guard completion-log LogInformation calls with IsEnabled to resolve 5 CA1873 warnings (clean -warnaserror build) - ActivityReservationService.ReserveAsync: re-read and re-validate the agent (compare-and-set on ActiveReservationId) before booking so a concurrent assignment on another shared queue cannot double-book the agent - Asterisk dashboard: report 'In conference' for bridged 3+ party calls instead of collapsing to 'Connected' - Add self-healing test (stale Ringing interaction requeued) and reserve double-book guard test - Update contact-center PLAN.md change log with review findings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: a71f4e1c-3ca9-4e08-8d68-81a75d79e03d --- .github/contact-center/PLAN.md | 1 + .../Services/ActivityReservationService.cs | 14 ++- .../Hubs/TelephonyHub.cs | 87 +++++++++++-------- .../Services/AsteriskDiagnosticsService.cs | 2 +- .../ActivityReservationServiceTests.cs | 37 ++++++++ .../AgentWorkStateHealingServiceTests.cs | 78 +++++++++++++++++ 6 files changed, 180 insertions(+), 39 deletions(-) diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index a3899774c..28843e64a 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1551,3 +1551,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-02: **Reports generalized into a reusable framework + industry-standard CRM reports (maintainer direction).** Per the maintainer's request for a reusable reports structure, a top-level Reports tab, extensible (display-driver) filters with a from/to range, exports, and CRM reports, the Contact Center-specific reports UI was replaced by a shared framework. **New `CrestApps.OrchardCore.Reports` module** (+ `CrestApps.OrchardCore.Reports.Abstractions`): `IReport` (name/category/permission/`RunAsync`→`ReportDocument`), a display-driver-extensible `ReportFilter` with a built-in from/to date-range driver, a uniform `ReportDocument` (metric-card / table / bar sections, with emphasized totals rows for aggregated reports), a generic `ReportsController` (Index landing + `Display(id)` + `Export(id, format)`), a top-level **Reports** admin menu (`ReportsAdminMenu`) that groups registered reports by category and gates each by its own permission, an `IReportManager` registry, and a pluggable `IReportExportFormat` with a built-in `CsvReportExportFormat`. Registered in `.slnx` and the `Cms.Core.Targets` bundle. **Contact Center migration:** deleted the CC-specific `ReportsController`, `ContactCenterReportsAdminMenu`, `ReportCsvBuilder`, `ReportFormat`, report `ViewModels`, and `Views/Reports`; kept `IContactCenterReportingService` + its DTOs (and the 6 tests) and added five thin `IReport` adapters (`*ReportProvider`) that map the DTOs to `ReportDocument`. The Analytics feature now depends on the Reports feature and registers the adapters. **Omnichannel CRM reports:** added an **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) in the Managements module with `OmnichannelReportAggregator` (pure, tested) + `OmnichannelReportQuery` and three `IReport`s — **Activity summary**, **Campaign performance**, and **Disposition breakdown** — plus a `ViewOmnichannelReports` permission (implied by `ManageActivities`). **Validation:** clean full-solution `-warnaserror` build; 226 ContactCenter/Omnichannel/Report tests pass (+3 new Omnichannel aggregator tests); and a live end-to-end smoke test confirmed the top-level **Reports** menu, all eight report pages (5 CC + 3 CRM) rendering with the from/to filter, and CSV export, on a fresh tenant. Docs: new `modules/reports.md`, updated `modules/index.md`, `contact-center/index.md`, `omnichannel/index.md`, and the `v2.0.0` changelog. **Remaining:** per-agent self-service report scoping, additional export formats (Excel/PDF), and SLA/adherence trend snapshots. - 2026-07-06: Fixed the default Debug solution build by re-enabling `CrestApps.OrchardCore.Reports.Abstractions` and `CrestApps.OrchardCore.Reports` in `CrestApps.OrchardCore.slnx`, which removed the 45 reporting-related errors from plain `dotnet build`. Load Inventory was also tightened back to the Contact Center design: the picker now exposes only **Manual** and **Dialer** sources, dialer inventory loads require a dialer profile, and the default batch loader applies the selected profile's dialing mode and campaign to the created activities while leaving them unassigned for later dialer reservation. Legacy hidden source registrations remain available so older inventory loads do not lose their source metadata. - 2026-07-06: Terminology was aligned again by renaming the Omnichannel Management UI/docs label from **Contact Lists** to **Load Inventory** while keeping the internal `OmnichannelActivityBatch` model/API stable for compatibility. The Contact Center plan also records the dialer workflow decision: technical call outcomes (no answer, busy, disconnected, rejected, failed, voicemail, and similar provider states) must be classified by the dialer/voice layer and then mapped at the Subject level into business dispositions or retry/callback actions; the dialer profile remains an execution policy, not a workflow owner. +- 2026-07-09: **Reliability/build-quality review pass (Softphone + Contact Center + Asterisk + DialPad).** Ran a comprehensive review against the stated objectives (code quality, real-time event sync, inbound real-time processing, self-healing, dashboard call-state visibility, unit coverage, build quality). **Shipped:** (1) Fixed 5 `CA1873` analyzer warnings in `TelephonyHub.cs` that failed CI `-warnaserror` — the five completion-log `_logger.LogInformation` sites now guard on `_logger.IsEnabled(LogLevel.Information)`, matching the file's existing `LogHubActionStart` helper pattern; the full solution now builds clean with `-warnaserror` (0 warnings, 0 errors) and all 1219 tests pass. (2) Improved Asterisk dashboard call-state granularity: `AsteriskDiagnosticsService.SummarizeCallState` now reports **"In conference"** for bridged multi-party (3+) calls instead of collapsing them to "Connected" (Ringing/Offering/Offered/On hold/Connected/In conference are now distinguished, with Muted/On hold/bridge-type badges already rendered by `dashboard.js`). (3) Added a self-healing unit test (`HealForAvailabilityAsync_WhenRingingInteractionHasNoActiveReservation_RequeuesIt`) covering the critical path where an agent stuck with a stale **Ringing** interaction and no live reservation is reclaimed so future inbound calls can route — the exact "never stay falsely on a call" objective; 245 ContactCenter+Telephony tests pass. **Findings reported for maintainer decision (not auto-fixed because correct fixes need multi-node/optimistic-concurrency design + live validation):** (a) *[High]* `ActivityReservationService.ReserveAsync` is serialized only by the per-**queue** lock in `ActivityAssignmentService`, but the over-committed resource is the **agent**; two concurrent inbound calls on two queues that share one available agent can both reserve that agent. **Partially fixed this pass:** `ReserveAsync` now re-reads the agent and aborts (compare-and-set on `ActiveReservationId`) before booking, so it no longer double-books once the prior reservation is visible (+1 unit test). **Still recommended:** a per-agent distributed lock around the reserve/accept/release transition (plus an optimistic-concurrency/version guard) to fully close the multi-node TOCTOU window, since YesSql session identity-map caching + commit-at-scope-end make an in-session re-read alone insufficient across nodes. (b) *[Medium]* `ReserveAsync`/`AcceptAsync` vs `ReleaseAsync` (expiry/cancel) are not serialized, so an accepted (live) call can race an expiry pass and be requeued to a second agent; `ReleaseAsync` should re-validate the reservation is still `Pending` under the same per-agent lock before mutating. (c) *[High]* the **Asterisk** module is command-only — it registers no ARI WebSocket/event listener and never emits `ProviderVoiceEvent`/`CallStateChanged`, so remote answer/hangup/hold are never observed and call state cannot stay in sync with the PBX (unlike DialPad's signed webhook → `IProviderVoiceEventService.IngestAsync`). Recommend an in-module ARI event subscriber (hosted service) mapping `ChannelStateChange`/`ChannelHangupRequest`/`StasisEnd`/hold events to normalized provider voice events, mirroring the DialPad path. (d) *[Medium]* DialPad webhook accepts unsigned bodies when no signing secret is configured (`[AllowAnonymous]` + no-secret bypass) — recommend requiring a secret before enabling the webhook. Self-healing (`AgentWorkStateHealingService`), `AgentSessionService` heartbeat/expiry locking, `ProviderVoiceEventService` state mapping/idempotency, OAuth/PKCE + token encryption, and the DialPad JWT signature path all reviewed clean. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs index 2843ec17d..1c8ea37ef 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -82,6 +82,18 @@ public async Task ReserveAsync(QueueItem queueItem, AgentPr queueItem = current; + // Re-read and re-validate the agent before booking them. Assignment is serialized only by a + // per-queue lock, but an agent can belong to several queues; if a concurrent assignment on + // another queue already reserved this agent, abort here instead of double-booking the seat. + // (This compare-and-set narrows the window; full multi-node safety additionally needs a + // per-agent distributed lock around this transition.) + agent = await _agentManager.FindByIdAsync(agent.ItemId, cancellationToken) ?? agent; + + if (!string.IsNullOrWhiteSpace(agent.ActiveReservationId)) + { + return null; + } + var now = _clock.UtcNow; var reservation = await _reservationManager.NewAsync(cancellationToken: cancellationToken); reservation.ActivityItemId = queueItem.ActivityItemId; @@ -99,8 +111,6 @@ public async Task ReserveAsync(QueueItem queueItem, AgentPr queueItem.AgentId = agent.ItemId; await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); - agent = await _agentManager.FindByIdAsync(agent.ItemId, cancellationToken) ?? agent; - if (!agent.RequestedPresenceStatus.HasValue && agent.PresenceStatus is not AgentPresenceStatus.Available and not AgentPresenceStatus.Reserved and not AgentPresenceStatus.Busy and not AgentPresenceStatus.WrapUp) { diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs index e6fc7b6db..b846b7f49 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs @@ -152,12 +152,15 @@ await ShellScope.UsingChildScopeAsync(async scope => credentials = await service.GetClientCredentialsAsync(Context.ConnectionAborted); }); - _logger.LogInformation( - "Telephony hub action {Action} completed for user {UserId}. Provider={ProviderName}, HasCredentials={HasCredentials}.", - "GetCredentials", - Context.UserIdentifier ?? "(anonymous)", - credentials?.ProviderName ?? "(none)", - credentials is not null); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Telephony hub action {Action} completed for user {UserId}. Provider={ProviderName}, HasCredentials={HasCredentials}.", + "GetCredentials", + Context.UserIdentifier ?? "(anonymous)", + credentials?.ProviderName ?? "(none)", + credentials is not null); + } return credentials; } @@ -193,14 +196,17 @@ await ShellScope.UsingChildScopeAsync(async scope => } }); - _logger.LogInformation( - "Telephony hub action {Action} completed for user {UserId}. Provider={ProviderName}, Available={IsAvailable}, RequiresAuthentication={RequiresAuthentication}, Connected={IsConnected}.", - "GetConnectionStatus", - Context.UserIdentifier ?? "(anonymous)", - status.ProviderName ?? "(none)", - status.IsAvailable, - status.RequiresAuthentication, - status.IsConnected); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Telephony hub action {Action} completed for user {UserId}. Provider={ProviderName}, Available={IsAvailable}, RequiresAuthentication={RequiresAuthentication}, Connected={IsConnected}.", + "GetConnectionStatus", + Context.UserIdentifier ?? "(anonymous)", + status.ProviderName ?? "(none)", + status.IsAvailable, + status.RequiresAuthentication, + status.IsConnected); + } return status; } @@ -236,12 +242,15 @@ await ShellScope.UsingChildScopeAsync(async scope => interactions = await store.GetRecentAsync(userId, take, Context.ConnectionAborted); }); - _logger.LogInformation( - "Telephony hub action {Action} completed for user {UserId}. Requested={RequestedCount}, Returned={ReturnedCount}.", - "GetInteractions", - Context.UserIdentifier ?? "(anonymous)", - take, - interactions.Count); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Telephony hub action {Action} completed for user {UserId}. Requested={RequestedCount}, Returned={ReturnedCount}.", + "GetInteractions", + Context.UserIdentifier ?? "(anonymous)", + take, + interactions.Count); + } return interactions; } @@ -267,11 +276,14 @@ await ShellScope.UsingChildScopeAsync(async scope => capabilities = await service.GetCapabilitiesAsync(Context.ConnectionAborted); }); - _logger.LogInformation( - "Telephony hub action {Action} completed for user {UserId}. Capabilities={Capabilities}.", - "GetCapabilities", - Context.UserIdentifier ?? "(anonymous)", - capabilities); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Telephony hub action {Action} completed for user {UserId}. Capabilities={Capabilities}.", + "GetCapabilities", + Context.UserIdentifier ?? "(anonymous)", + capabilities); + } return (int)capabilities; } @@ -326,17 +338,20 @@ await ShellScope.UsingChildScopeAsync(async scope => await Clients.Caller.CallStateChanged(result.Call); } - var completionRequest = BuildLogRequest(requestFactory); - - _logger.LogInformation( - "Telephony hub action {Action} completed for user {UserId}. Request: {Request}. Succeeded={Succeeded}, Error={Error}, CallId={CallId}, CallState={CallState}.", - actionName, - Context.UserIdentifier ?? "(anonymous)", - completionRequest, - result?.Succeeded, - result?.Error ?? "(none)", - result?.Call?.CallId ?? "(none)", - result?.Call?.State.ToString() ?? "(none)"); + if (_logger.IsEnabled(LogLevel.Information)) + { + var completionRequest = BuildLogRequest(requestFactory); + + _logger.LogInformation( + "Telephony hub action {Action} completed for user {UserId}. Request: {Request}. Succeeded={Succeeded}, Error={Error}, CallId={CallId}, CallState={CallState}.", + actionName, + Context.UserIdentifier ?? "(anonymous)", + completionRequest, + result?.Succeeded, + result?.Error ?? "(none)", + result?.Call?.CallId ?? "(none)", + result?.Call?.State.ToString() ?? "(none)"); + } return result; } diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs index e7e2b1a2d..7998be983 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs @@ -444,7 +444,7 @@ private static string SummarizeCallState( if (hasConnectedLeg) { - return "Connected"; + return partyCount >= 3 ? "In conference" : "Connected"; } return channels diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs index 2a05ca9fc..9c4915c9b 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -73,6 +73,43 @@ public async Task ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving() reservationManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task ReserveAsync_WhenAgentAlreadyHasActiveReservation_AbortsWithoutReserving() + { + // Arrange + var reservationManager = new Mock(); + var queueItemManager = new Mock(); + queueItemManager + .Setup(m => m.FindByIdAsync("qi-1", It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1", Status = QueueItemStatus.Waiting }); + + var alreadyReservedAgent = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Reserved, + ActiveReservationId = "r-existing", + }; + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(alreadyReservedAgent); + + var queueManager = new Mock(); + var queueService = new Mock(); + var interactionManager = new Mock(); + var activityManager = new Mock(); + var service = CreateService(reservationManager, queueItemManager, agentManager, queueManager, queueService, interactionManager, activityManager, new Mock(), new Mock()); + + var item = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; + var selectedAgent = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Available }; + + // Act + var reservation = await service.ReserveAsync(item, selectedAgent, 30, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(reservation); + reservationManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task ReserveAsync_WhenBreakWasGrantedAfterRoutingDecision_PreservesPendingBreak() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs index 62527262e..038f6d2c8 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs @@ -153,6 +153,84 @@ public async Task HealForAvailabilityAsync_WhenAvailableAgentHasConnectedInterac Assert.Null(activity.ReservationId); } + [Fact] + public async Task HealForAvailabilityAsync_WhenRingingInteractionHasNoActiveReservation_RequeuesIt() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("a1", It.IsAny())) + .ReturnsAsync((ActivityReservation)null); + + var reservationService = new Mock(); + + var queueItem = new QueueItem + { + ItemId = "qi-1", + ActivityItemId = "act-1", + QueueId = "q1", + ReservationId = "r1", + AgentId = "a1", + Status = QueueItemStatus.Reserved, + }; + + var queueItemManager = new Mock(); + queueItemManager.Setup(manager => manager.FindByActivityIdAsync("act-1", It.IsAny())) + .ReturnsAsync(queueItem); + + var interaction = new Interaction + { + ItemId = "i1", + ActivityItemId = "act-1", + QueueId = "q1", + AgentId = "a1", + Status = InteractionStatus.Ringing, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindActiveByAgentAsync("a1", It.IsAny())) + .ReturnsAsync(interaction); + + var activityManager = new Mock(); + var activity = new OmnichannelActivity + { + ItemId = "act-1", + AssignmentStatus = ActivityAssignmentStatus.Reserved, + AssignedToId = "u1", + ReservationId = "r1", + }; + activityManager.Setup(manager => manager.FindByIdAsync("act-1", It.IsAny())) + .ReturnsAsync(activity); + + var service = CreateService(agentManager, reservationManager, reservationService, queueItemManager, interactionManager, activityManager); + + // Act + var healed = await service.HealForAvailabilityAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, healed); + Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); + Assert.Null(queueItem.AgentId); + Assert.Null(queueItem.ReservationId); + Assert.Equal(InteractionStatus.Created, interaction.Status); + Assert.Null(interaction.AgentId); + Assert.Equal(ActivityAssignmentStatus.Available, activity.AssignmentStatus); + Assert.Null(activity.AssignedToId); + Assert.Null(activity.ReservationId); + } + [Fact] public async Task HealForResetAsync_WhenPendingReservationExists_CancelsItEvenWhenOtherwiseValid() { From 1a6a0fc74b7d562c8a0c40a31f3985cec68bbd2f Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 09:47:33 -0700 Subject: [PATCH 44/56] fix call flow --- .github/contact-center/PLAN.md | 9 +- .../ContactCenterConstants.cs | 25 ++ .../Models/ProviderVoiceEvent.cs | 29 ++ .../ITelephonyCallStateProvider.cs | 18 + .../ITelephonyInteractionStore.cs | 10 + .../Models/TelephonyCallLookupResult.cs | 27 ++ .../TelephonyConstants.cs | 5 - .../Models/CallSession.cs | 26 ++ .../ContactCenterCallCommandService.cs | 71 +++- .../Services/IInteractionManager.cs | 8 + .../Services/IInteractionStore.cs | 8 + ...ProviderCallStateSynchronizationService.cs | 25 ++ ...roviderVoiceOfferSynchronizationService.cs | 16 + .../Services/InteractionManager.cs | 13 + .../Services/InteractionStore.cs | 12 + ...ProviderCallStateSynchronizationService.cs | 185 ++++++++++ .../Services/ProviderVoiceEventService.cs | 167 ++++++++- ...roviderVoiceOfferSynchronizationService.cs | 152 ++++++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 7 + .../docs/contact-center/agent-desktop.md | 8 +- .../docs/contact-center/index.md | 2 + .../docs/contact-center/voice-routing.md | 344 ++++++++++++++++++ .../docs/telephony/custom-providers.md | 216 +++++++++++ src/CrestApps.Docs/docs/telephony/index.md | 22 +- .../AsteriskConstants.cs | 10 + .../CrestApps.OrchardCore.Asterisk.csproj | 6 + .../Services/AsteriskRealtimeVoiceEvent.cs | 32 ++ .../AsteriskRealtimeVoiceEventDispatcher.cs | 187 ++++++++++ .../AsteriskRealtimeVoiceEventMapper.cs | 297 +++++++++++++++ .../Services/AsteriskRealtimeVoiceListener.cs | 204 +++++++++++ .../AsteriskRealtimeVoiceTenantEvents.cs | 132 +++++++ .../Services/AsteriskSettingsUtilities.cs | 58 +++ .../Services/AsteriskTelephonyProviderBase.cs | 150 +++++++- .../CrestApps.OrchardCore.Asterisk/Startup.cs | 5 + ...erCallStateReconciliationBackgroundTask.cs | 35 ++ ...CrestApps.OrchardCore.ContactCenter.csproj | 1 + .../ContactCenterSoftPhoneEventHandler.cs | 309 ++++++++++++++++ ...ctCenterVoiceOfferReconciliationHandler.cs | 36 ++ .../ContactCenterVoiceTenantEvents.cs | 43 +++ .../Startup.cs | 8 +- .../Controllers/DialPadWebhookController.cs | 7 +- .../Services/DialPadCallEvent.cs | 36 +- .../Services/DialPadJsonSerializerOptions.cs | 18 + .../Services/DialPadTelephonyProvider.cs | 171 ++++++++- .../Services/DialPadWebhookService.cs | 25 ++ .../DefaultTelephonyInteractionStore.cs | 13 + .../ContactCenterCallCommandServiceTests.cs | 42 ++- ...ContactCenterSoftPhoneEventHandlerTests.cs | 296 +++++++++++++++ .../ProviderVoiceEventServiceTests.cs | 177 +++++++++ ...erVoiceOfferSynchronizationServiceTests.cs | 125 +++++++ .../DialPadJsonSerializerOptionsTests.cs | 49 +++ .../DialPad/DialPadWebhookServiceTests.cs | 39 ++ ...teriskRealtimeVoiceEventDispatcherTests.cs | 129 +++++++ .../AsteriskRealtimeVoiceEventMapperTests.cs | 46 +++ 54 files changed, 4027 insertions(+), 64 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceOfferSynchronizationService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs create mode 100644 src/CrestApps.Docs/docs/contact-center/voice-routing.md create mode 100644 src/CrestApps.Docs/docs/telephony/custom-providers.md create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEvent.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceTenantEvents.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ProviderCallStateReconciliationBackgroundTask.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSoftPhoneEventHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterVoiceTenantEvents.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJsonSerializerOptions.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterSoftPhoneEventHandlerTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadJsonSerializerOptionsTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 28843e64a..cedf8d101 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -996,7 +996,7 @@ Goals: - Add Contact Center Voice feature that depends on Telephony. - Map provider call/session identifiers to Contact Center interactions. - Normalize call session events into Contact Center domain events. -- Define a provider inbound-event normalization boundary so inbound calls and provider webhooks enter Contact Center reliably. +- Define a provider inbound-event normalization boundary so inbound calls and provider transports (webhook, WebSocket, or both) enter Contact Center reliably. - Keep Telephony as the execution and provider state boundary. - Surface current interaction and call state in CRM UI. @@ -1329,7 +1329,7 @@ All dialer strategies must share compliance checks, retry policies, callbacks, p 1. Current Omnichannel Management removed OrchardCore.Workflows. The plan keeps Subject Flows and Subject Actions as the default workflow model and adds Workflows as an optional Contact Center feature. 2. Existing `OmnichannelActivity` statuses are not rich enough for Contact Center. Breaking changes are acceptable, so the activity lifecycle can be expanded or bridged with Interaction state. 3. Telephony currently records soft-phone call history and pushes current-user call state. Contact Center needs a separate business interaction history and may need Telephony/provider event ingress improvements for inbound routing. -4. The Telephony abstraction may need a provider event normalization boundary so inbound calls and provider webhooks can enter Contact Center reliably. +4. The Telephony abstraction may need a provider event normalization boundary so inbound calls and provider transports such as webhooks or WebSockets can enter Contact Center reliably. 5. Contact Center real-time events should not reuse TelephonyHub for routing or supervisor data. A separate Contact Center real-time stream is needed. 6. Dialer pacing and predictive dialing require reliable historical metrics before advanced modes are safe. 7. Queue and presence accuracy depend on robust disconnect/reconnect handling and stale reservation cleanup. @@ -1484,13 +1484,13 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 4 — Voice integration with Telephony (`Voice` feature: Voice Contact Center Call Router (`IVoiceContactCenterCallRouter`) for inbound and outbound voice routing, inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService` compatibility), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, outbound provider dispatch through `IContactCenterVoiceProvider`, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; **G1 backend shipped: provider delivery models (`AgentDeviceNative`/`ServerSideAcd`) + `ConnectToAgentAsync`, `CallSession` aggregate, normalized `ProviderVoiceEvent`/`IProviderVoiceEventService` ingestion, and the authoritative `IContactCenterCallCommandService` that accepts the reservation, bridges media, and advances interaction+call-session together. all G1 items shipped: the soft-phone JS accept-then-answer coordination now awaits the server accept and only answers the device when `RequiresDeviceAnswer` (asset rebuilt), per-provider signed webhook adapters emit `ProviderVoiceEvent`, and the blind/consultative transfer + conference taxonomy landed** — see "Design review" P0 #1, #2, #3 and P1 #9) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider. **G4 dialer safety shipped (2026-06-30):** each mode is now a dedicated `IDialerStrategy` (Predictive disabled in editor + rejected server-side + refused at runtime; Power hard-capped via `PowerDialerStrategy.MaxCallsPerAgent`); the new `IDialerEligibilityService` compliance gate runs before every attempt and audits `DialSuppressed` (destination, max-attempts, retry cool-down, contact do-not-call, calling window in the contact's time zone, and national DNC registries); single-attempt logic moved to `IDialerAttemptService`. Remaining: callback scheduling (`CallbackRequest`) + callback queues, and dialer run/attempt projections.) - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call work is represented by agent presence (`WrapUp`) plus the active/wrap-up interaction on the Agent Workspace, not by a separate domain aggregate.) -- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. Remaining: reason-code deployment-plan step and browser coverage for the core agent/supervisor flows.) +- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. **Local test harness shipped 2026-07-08:** `src/Startup/CrestApps.OrchardCore.Asterisk.Web` signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards normalized `InboundVoiceEvent` payloads using the real Asterisk channel ids so routing and agent offers can be exercised through the local PBX flow. The sample dashboard now also surfaces provider-tracked hold/mute state plus inferred party counts, moves live notifications beside the raw ARI payload drill-down to free more width for active-call tables, and lowers the default polling cadence to one second so state changes show up faster. The simulator now makes it explicit that **To address** drives inbound queue or entry-point routing while **Caller number seed** only changes caller identity generation. **2026-07-08 queue hardening:** queues now expose an unanswered-offer policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; when no live provider call exists the timeout safely falls back to requeue. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) - [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. **Reusable Reports framework shipped 2026-07-02:** new `CrestApps.OrchardCore.Reports` module (`IReport` registry + display-driver-extensible `ReportFilter` with a from/to range + uniform `ReportDocument` renderer + pluggable `IReportExportFormat`/CSV) surfaced under a single top-level **Reports** admin menu grouped by category. The five Contact Center reports were migrated into this framework (moved off Interaction Center) and a new **Omnichannel Reports** feature adds CRM reports — activity summary, campaign performance, and disposition breakdown. Remaining: SLA/adherence trend snapshots, operational alerts, and per-agent self-service report scoping.) -- [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, and retention for call sessions/metrics/recordings.) +- [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. **2026-07-10 voice restart reconciliation:** providers can now expose per-call live-state lookup through `ITelephonyCallStateProvider`, Contact Center runs a reconciliation pass during tenant activation and on a periodic background task, and active provider-backed interactions are revalidated against current provider truth after restarts so stale queued or assigned voice work is cleared before routing continues. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, retention for call sessions/metrics/recordings, and full replay/bootstrap strategies for providers that need server-pushed event streams.) - [~] Phase 14 — Advanced capabilities (**AI assist seam shipped 2026-07-01:** `IContactCenterAssistProvider` + `IContactCenterAssistService` orchestrate optional summarization + disposition-suggestion providers by order, decoupled from any specific AI provider. Remaining: concrete AI provider (summaries/disposition/sentiment) wired to the AI module, virtual-agent handoff, and AI routing recommendations.) ### Gap-closure backlog (from the 2026-06-30 design review) @@ -1519,6 +1519,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-08: Converted the Contact Center's JSON/result-oriented voice, workspace, supervisor, and provider-webhook routes from MVC controller actions to Minimal API endpoint registrations while preserving their existing URLs, route names, permissions, and antiforgery behavior. This keeps the feature aligned with the repository's endpoint pattern and leaves the remaining MVC controllers focused on HTML surfaces only. - 2026-07-08: Adjusted the Minimal API inbound voice ingress endpoint to explicitly disable antiforgery so the local Dialer Simulator can continue posting authenticated JSON `InboundVoiceEvent` requests without the 403 regression introduced during the endpoint conversion. - 2026-07-09: Hardened the soft-phone widget UX and reconnect flow. The Telephony widget now remembers the selected Keypad/Recent/Work tab across reloads, waits for the real provider status before showing the unconfigured warning, and keeps a stable body height across tabs. Contact Center Voice now re-checks already-waiting inbound voice queues after the soft phone reconnects, so a refresh or reconnect no longer leaves queued calls waiting indefinitely for the newly reconnected agent. +- 2026-07-10: Added a dedicated Contact Center technical architecture guide for inbound voice routing, outbound dialer flow, provider-truth synchronization, and restart reconciliation, and removed the last explicit Telephony soft-phone feature-id compatibility alias so the WIP voice surface no longer carries that obsolete constant. - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). - 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent sign-in campaign and skill fields now use managed catalog data; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; agent sign-in and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. - 2026-06-29: The standalone agent sign-in admin navigation item was removed from the Contact Center menu. Telephony soft-phone extensibility now uses `DisplayDriver` zones for contributed tabs/views, and Contact Center contributes a **Work** tab for queue/campaign sign-in, sign-out, and presence so agent availability controls stay inside the soft phone instead of a separate navigation page. diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index e2b2e307a..d0fe31193 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -273,6 +273,31 @@ public static class Events /// 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. /// diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceEvent.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceEvent.cs index d7cc81077..0dff4b305 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceEvent.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceEvent.cs @@ -49,6 +49,35 @@ public sealed class ProviderVoiceEvent /// public string IdempotencyKey { get; set; } + /// + /// Gets or sets 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; set; } + + /// + /// Gets or sets the provider-reported recording state. + /// When , the event does not change the current recording state. + /// + public RecordingState? RecordingState { get; set; } + + /// + /// Gets or sets the provider recording reference for the session, when recording is active or retained. + /// + public string RecordingReference { get; set; } + + /// + /// Gets or sets 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; set; } + + /// + /// Gets or sets 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; set; } + /// /// Gets or sets additional provider metadata to retain for troubleshooting. /// 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/ITelephonyInteractionStore.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs index 8244e8b00..137885c01 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs @@ -32,6 +32,16 @@ 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); + /// /// Gets the most recent interactions for the given user, newest first. /// 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/TelephonyConstants.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs index f53f44b3e..e57bc843c 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs @@ -64,10 +64,5 @@ public static class Feature /// public const string SoftPhone = "CrestApps.OrchardCore.Telephony.SoftPhone"; - /// - /// The legacy identifier of the soft phone feature. - /// - [Obsolete("Use SoftPhone instead.")] - public const string SoftPhoneWidget = SoftPhone; } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs index 481e507a0..2c277a931 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs @@ -71,6 +71,32 @@ public sealed class CallSession : CatalogItem, IModifiedUtcAwareModel /// 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 a value indicating whether the provider reports the call as a conference or + /// multi-party session. + /// + public bool IsConference { get; set; } + + /// + /// Gets or sets the number of active participants the provider reports for the call. + /// + public int ParticipantCount { get; set; } + /// /// Gets or sets the UTC time the call session was created. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs index 1e7f3eeda..608cd53e7 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs @@ -22,6 +22,7 @@ public sealed class ContactCenterCallCommandService : IContactCenterCallCommandS private readonly ITelephonyProviderResolver _telephonyProviderResolver; private readonly ICallSessionManager _callSessionManager; private readonly IInboundVoiceService _inboundVoiceService; + private readonly IProviderCallStateSynchronizationService _providerCallStateSynchronizationService; private readonly IContactCenterEventPublisher _publisher; private readonly IClock _clock; private readonly ILogger _logger; @@ -34,8 +35,10 @@ public sealed class ContactCenterCallCommandService : IContactCenterCallCommandS /// The interaction manager used to advance the interaction. /// The agent profile manager used to resolve the reserved agent. /// The voice provider resolver used to connect media. + /// The telephony provider resolver used for server-side answer operations. /// The call session manager used to project the voice session. /// The inbound voice service used to re-offer a declined call. + /// The provider call-state synchronization service. /// The Contact Center event publisher. /// The clock used to stamp times. /// The logger instance. @@ -48,6 +51,7 @@ public ContactCenterCallCommandService( ITelephonyProviderResolver telephonyProviderResolver, ICallSessionManager callSessionManager, IInboundVoiceService inboundVoiceService, + IProviderCallStateSynchronizationService providerCallStateSynchronizationService, IContactCenterEventPublisher publisher, IClock clock, ILogger logger) @@ -60,6 +64,7 @@ public ContactCenterCallCommandService( _telephonyProviderResolver = telephonyProviderResolver; _callSessionManager = callSessionManager; _inboundVoiceService = inboundVoiceService; + _providerCallStateSynchronizationService = providerCallStateSynchronizationService; _publisher = publisher; _clock = clock; _logger = logger; @@ -85,13 +90,22 @@ public async Task AcceptInboundOfferAsync(string reservationI return CallCommandResult.Failure("No interaction is linked to the offered activity."); } + interaction = await _providerCallStateSynchronizationService.RefreshInteractionAsync(interaction, cancellationToken); + + 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; + var requiresDeviceAnswer = hasProvider && + deliveryModel == VoiceProviderDeliveryModel.AgentDeviceNative && + interaction.Status != InteractionStatus.Connected; reservation = await _reservationService.AcceptAsync(reservationId, cancellationToken); @@ -168,17 +182,40 @@ provider is not null && var now = _clock.UtcNow; - interaction.Status = InteractionStatus.Connected; - interaction.StartedUtc ??= now; - interaction.AnsweredUtc = now; interaction.AgentId = reservation.AgentId; interaction.QueueId ??= reservation.QueueId; + + if (deliveryModel == VoiceProviderDeliveryModel.AgentDeviceNative && interaction.Status != InteractionStatus.Connected) + { + interaction.Status = InteractionStatus.Ringing; + interaction.StartedUtc ??= now; + } + else + { + interaction.Status = InteractionStatus.Connected; + interaction.StartedUtc ??= now; + interaction.AnsweredUtc ??= now; + } + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); - var session = await EnsureConnectedSessionAsync(interaction, reservation, deliveryModel, now, cancellationToken); + var session = await EnsureSessionAsync( + interaction, + reservation, + deliveryModel, + interaction.Status == InteractionStatus.Connected + ? ContactCenterCallState.Connected + : ContactCenterCallState.Ringing, + interaction.Status == InteractionStatus.Connected, + now, + cancellationToken); await PublishAsync(ContactCenterConstants.Events.OfferAccepted, interaction.ItemId, reservation.AgentId, cancellationToken); - await PublishAsync(ContactCenterConstants.Events.CallConnected, interaction.ItemId, reservation.AgentId, cancellationToken); + + if (deliveryModel != VoiceProviderDeliveryModel.AgentDeviceNative) + { + await PublishAsync(ContactCenterConstants.Events.CallConnected, interaction.ItemId, reservation.AgentId, cancellationToken); + } return new CallCommandResult { @@ -244,10 +281,12 @@ private async Task FindAuthorizedPendingReservationAsync( return reservation; } - private async Task EnsureConnectedSessionAsync( + private async Task EnsureSessionAsync( Interaction interaction, ActivityReservation reservation, VoiceProviderDeliveryModel deliveryModel, + ContactCenterCallState state, + bool answered, DateTime now, CancellationToken cancellationToken) { @@ -265,10 +304,15 @@ private async Task EnsureConnectedSessionAsync( session.FromAddress = interaction.CustomerAddress; session.QueueId = reservation.QueueId; session.AgentId = reservation.AgentId; - session.State = ContactCenterCallState.Connected; + session.State = state; session.CreatedUtc = now; session.StartedUtc = now; - session.AnsweredUtc = now; + + if (answered) + { + session.AnsweredUtc = now; + } + await _callSessionManager.CreateAsync(session, cancellationToken: cancellationToken); await PublishAsync(ContactCenterConstants.Events.CallSessionCreated, interaction.ItemId, reservation.AgentId, cancellationToken); @@ -276,10 +320,15 @@ private async Task EnsureConnectedSessionAsync( return session; } - session.State = ContactCenterCallState.Connected; + session.State = state; session.AgentId = reservation.AgentId; session.StartedUtc ??= now; - session.AnsweredUtc ??= now; + + if (answered) + { + session.AnsweredUtc ??= now; + } + await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); return session; diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs index 2fd026fde..31ba314db 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs @@ -68,4 +68,12 @@ public interface IInteractionManager : ICatalogManager /// 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. + /// + /// The token to monitor for cancellation requests. + /// The active provider-backed interactions. + Task> ListActiveWithProviderCallIdAsync(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 index e4a7f0ffa..7c0027458 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs @@ -68,4 +68,12 @@ public interface IInteractionStore : ICatalog /// 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. + /// + /// The token to monitor for cancellation requests. + /// The active provider-backed interactions. + Task> ListActiveWithProviderCallIdAsync(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..a4b6f8841 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs @@ -0,0 +1,25 @@ +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); +} 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/InteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs index 241605eff..8d1712a5a 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs @@ -112,4 +112,17 @@ public async Task> ListRecentByAgentAsync(strin return interactions; } + + /// + public async Task> ListActiveWithProviderCallIdAsync(CancellationToken cancellationToken = default) + { + var interactions = await _store.ListActiveWithProviderCallIdAsync(cancellationToken); + + foreach (var interaction in interactions) + { + await LoadAsync(interaction, cancellationToken); + } + + return interactions; + } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs index afb7c3d08..8282fa582 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs @@ -116,4 +116,16 @@ public async Task> ListRecentByAgentAsync(strin .Take(take) .ListAsync(cancellationToken)).ToArray(); } + + /// + public async Task> ListActiveWithProviderCallIdAsync(CancellationToken cancellationToken = default) + { + return (await Session.Query( + index => index.Status != InteractionStatus.Ended && + index.Status != InteractionStatus.Failed, + collection: ContactCenterConstants.CollectionName) + .Where(index => index.ProviderInteractionId != null && index.ProviderInteractionId != string.Empty) + .OrderByDescending(index => index.CreatedUtc) + .ListAsync(cancellationToken)).ToArray(); + } } 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..2ae215b41 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs @@ -0,0 +1,185 @@ +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.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 readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IProviderVoiceEventService _providerVoiceEventService; + private readonly ITelephonyProviderResolver _telephonyProviderResolver; + 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 telephony provider resolver. + /// The clock used to stamp reconciliation events. + /// The logger. + public ProviderCallStateSynchronizationService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IProviderVoiceEventService providerVoiceEventService, + ITelephonyProviderResolver telephonyProviderResolver, + IClock clock, + ILogger logger) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _providerVoiceEventService = providerVoiceEventService; + _telephonyProviderResolver = telephonyProviderResolver; + _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 provider = await _telephonyProviderResolver.GetAsync(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, + interaction.ProviderName, + interaction.ProviderInteractionId, + lookup.Error); + } + + return interaction; + } + + var currentSession = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (lookup.Found && IsEquivalent(currentSession, lookup.Call)) + { + return interaction; + } + + var providerEvent = BuildProviderEvent(interaction, lookup); + await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); + + return await _interactionManager.FindByProviderInteractionIdAsync(interaction.ProviderInteractionId, cancellationToken) + ?? interaction; + } + + /// + public async Task ReconcileActiveInteractionsAsync(CancellationToken cancellationToken = default) + { + var interactions = await _interactionManager.ListActiveWithProviderCallIdAsync(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, TelephonyCallLookupResult lookup) + { + if (!lookup.Found) + { + return new ProviderVoiceEvent + { + ProviderName = interaction.ProviderName, + ProviderCallId = interaction.ProviderInteractionId, + State = ContactCenterCallState.Ended, + OccurredUtc = _clock.UtcNow, + IdempotencyKey = $"reconcile-missing:{interaction.ProviderName}:{interaction.ProviderInteractionId}:ended", + }; + } + + var call = lookup.Call; + var state = call.State switch + { + CallState.Connecting => ContactCenterCallState.Dialing, + CallState.Ringing => ContactCenterCallState.Ringing, + CallState.Connected when call.IsOnHold => ContactCenterCallState.OnHold, + CallState.Connected => ContactCenterCallState.Connected, + CallState.OnHold => ContactCenterCallState.OnHold, + CallState.Disconnected => ContactCenterCallState.Ended, + CallState.Failed => ContactCenterCallState.Failed, + _ => ContactCenterCallState.Ended, + }; + + return new ProviderVoiceEvent + { + ProviderName = interaction.ProviderName, + ProviderCallId = interaction.ProviderInteractionId, + State = state, + FromAddress = call.From, + ToAddress = call.To, + OccurredUtc = _clock.UtcNow, + IdempotencyKey = $"reconcile:{interaction.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 = call.State switch + { + CallState.Connecting => ContactCenterCallState.Dialing, + CallState.Ringing => ContactCenterCallState.Ringing, + CallState.Connected when call.IsOnHold => ContactCenterCallState.OnHold, + CallState.Connected => ContactCenterCallState.Connected, + CallState.OnHold => ContactCenterCallState.OnHold, + CallState.Disconnected => ContactCenterCallState.Ended, + CallState.Failed => ContactCenterCallState.Failed, + _ => ContactCenterCallState.Ended, + }; + + return session.State == mappedState && + session.IsMuted == call.IsMuted && + session.IsOnHold == (mappedState == ContactCenterCallState.OnHold); + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs index 9f8ebb2d7..2ae42718b 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs @@ -89,8 +89,14 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc var now = providerEvent.OccurredUtc ?? _clock.UtcNow; var session = await EnsureSessionAsync(interaction, providerEvent, now, cancellationToken); + 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); await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); @@ -99,12 +105,27 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc { await TryBridgeAnsweredOutboundAsync(session, interaction, cancellationToken); } - else if (IsTerminalState(providerEvent.State) && !string.IsNullOrEmpty(session.AgentId)) + else if (IsTerminalState(providerEvent.State) && + !string.IsNullOrEmpty(session.AgentId) && + (session.AnsweredUtc.HasValue || interaction.AnsweredUtc.HasValue)) { await _presenceManager.StartWrapUpAsync(session.AgentId, cancellationToken); } - await PublishAsync(ResolveEventType(providerEvent.State), interaction.ItemId, session.AgentId, providerEvent.IdempotencyKey, cancellationToken); + foreach (var eventType in ResolveEventTypes( + previousState, + session.State, + previousIsMuted, + session.IsMuted, + previousRecordingState, + session.RecordingState, + previousIsConference, + session.IsConference, + previousParticipantCount, + session.ParticipantCount)) + { + await PublishAsync(eventType, interaction.ItemId, session.AgentId, providerEvent.IdempotencyKey, cancellationToken); + } return session; } @@ -134,6 +155,8 @@ private async Task EnsureSessionAsync( session.FromAddress = providerEvent.FromAddress ?? interaction.CustomerAddress; session.ToAddress = providerEvent.ToAddress; session.State = providerEvent.State; + session.RecordingState = interaction.RecordingState; + session.RecordingReference = interaction.RecordingReference; session.CreatedUtc = now; await _callSessionManager.CreateAsync(session, cancellationToken: cancellationToken); @@ -145,6 +168,14 @@ private async Task EnsureSessionAsync( private static void ApplyState(CallSession session, Interaction interaction, ContactCenterCallState state, DateTime now) { session.State = state; + session.IsMuted = state is ContactCenterCallState.Ended or + ContactCenterCallState.Failed or + ContactCenterCallState.NoAnswer or + ContactCenterCallState.Rejected or + ContactCenterCallState.Canceled or + ContactCenterCallState.Transferred + ? false + : session.IsMuted; switch (state) { @@ -198,6 +229,54 @@ private static void ApplyState(CallSession session, Interaction interaction, Con } } + private static void ApplyProviderDetails(CallSession session, Interaction interaction, ProviderVoiceEvent providerEvent) + { + if (!string.IsNullOrWhiteSpace(providerEvent.FromAddress)) + { + session.FromAddress = providerEvent.FromAddress; + } + + if (!string.IsNullOrWhiteSpace(providerEvent.ToAddress)) + { + session.ToAddress = providerEvent.ToAddress; + } + + if (providerEvent.IsMuted.HasValue) + { + 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; + } + + if (providerEvent.IsConference.HasValue) + { + session.IsConference = providerEvent.IsConference.Value; + } + + if (providerEvent.ParticipantCount.HasValue) + { + session.ParticipantCount = Math.Max(0, providerEvent.ParticipantCount.Value); + } + + if (providerEvent.Metadata.Count > 0) + { + foreach (var entry in providerEvent.Metadata) + { + session.Metadata[entry.Key] = entry.Value; + } + } + } + private static InteractionStatus MapInteractionStatus(ContactCenterCallState state) { return state switch @@ -218,17 +297,83 @@ private static InteractionStatus MapInteractionStatus(ContactCenterCallState sta }; } - private static string ResolveEventType(ContactCenterCallState state) + private static List ResolveEventTypes( + ContactCenterCallState previousState, + ContactCenterCallState currentState, + bool previousIsMuted, + bool currentIsMuted, + RecordingState previousRecordingState, + RecordingState currentRecordingState, + bool previousIsConference, + bool currentIsConference, + int previousParticipantCount, + int currentParticipantCount) { - return state switch + var eventTypes = new List { - ContactCenterCallState.Connected => ContactCenterConstants.Events.CallConnected, - ContactCenterCallState.Ended or - ContactCenterCallState.Failed or - ContactCenterCallState.NoAnswer or - ContactCenterCallState.Rejected or - ContactCenterCallState.Canceled => ContactCenterConstants.Events.CallEnded, - _ => ContactCenterConstants.Events.CallSessionUpdated, + ContactCenterConstants.Events.CallSessionUpdated, + }; + + if (currentState == ContactCenterCallState.Connected && previousState != ContactCenterCallState.Connected) + { + eventTypes.Add(ContactCenterConstants.Events.CallConnected); + } + + if (currentState == ContactCenterCallState.OnHold && previousState != ContactCenterCallState.OnHold) + { + eventTypes.Add(ContactCenterConstants.Events.CallHeld); + } + + if (previousState == ContactCenterCallState.OnHold && currentState == ContactCenterCallState.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)); + } + + if (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], + _ => [], }; } 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..74f801d6c --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs @@ -0,0 +1,152 @@ +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.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Releases stale routing state when provider truth reports that a queued or offered call ended before it +/// was actually answered. +/// +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 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 clock. + /// The logger. + public ProviderVoiceOfferSynchronizationService( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IQueueItemManager queueItemManager, + IActivityReservationManager reservationManager, + IAgentProfileManager agentManager, + IOmnichannelActivityManager activityManager, + IClock clock, + ILogger logger) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _queueItemManager = queueItemManager; + _reservationManager = reservationManager; + _agentManager = agentManager; + _activityManager = activityManager; + _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; + } + + if (interaction.Status is not InteractionStatus.Ended and not InteractionStatus.Failed) + { + return; + } + + var session = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (interaction.AnsweredUtc.HasValue || session?.AnsweredUtc.HasValue == true) + { + return; + } + + var queueItem = await _queueItemManager.FindByActivityIdAsync(interaction.ActivityItemId, cancellationToken); + ActivityReservation reservation = null; + + if (!string.IsNullOrWhiteSpace(queueItem?.ReservationId)) + { + reservation = await _reservationManager.FindByIdAsync(queueItem.ReservationId, cancellationToken); + } + + 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, + interaction.ActivityItemId); + } + + if (queueItem is not null && queueItem.Status is QueueItemStatus.Reserved or QueueItemStatus.Assigned) + { + queueItem.Status = QueueItemStatus.Removed; + queueItem.DequeuedUtc = _clock.UtcNow; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + + if (reservation is not null && reservation.Status is ReservationStatus.Pending or ReservationStatus.Accepted) + { + reservation.Status = ReservationStatus.Canceled; + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + } + + var agentId = reservation?.AgentId ?? session?.AgentId ?? interaction.AgentId; + + if (!string.IsNullOrWhiteSpace(agentId)) + { + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is not null) + { + if (!string.IsNullOrWhiteSpace(reservation?.ItemId) && + string.Equals(agent.ActiveReservationId, reservation.ItemId, StringComparison.Ordinal)) + { + agent.ActiveReservationId = null; + } + + if (agent.PresenceStatus is AgentPresenceStatus.Reserved or AgentPresenceStatus.Busy) + { + agent.PresenceStatus = AgentPresenceUtilities.ResolveDefaultReadyState(agent); + } + + agent.RequestedPresenceStatus = null; + agent.PresenceChangedUtc = _clock.UtcNow; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + } + } + + var activity = await _activityManager.FindByIdAsync(interaction.ActivityItemId, cancellationToken); + + if (activity is null) + { + return; + } + + activity.AssignmentStatus = ActivityAssignmentStatus.Released; + activity.AssignedToId = null; + activity.AssignedToUsername = null; + activity.AssignedToUtc = null; + activity.ReservationId = null; + activity.ReservedById = null; + activity.ReservedByUsername = null; + activity.ReservedUtc = null; + activity.ReservationExpiresUtc = null; + await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); + } +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index f7e4f286f..b1a1aba95 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -65,6 +65,13 @@ At a high level, the platform changes are: - 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 now advances an accepted server-side inbound offer out of `Ringing` immediately and keeps the keypad **Hangup** control hidden until the call is actually connected or held, preventing the stale post-answer `Ringing...` state that could not really be hung up. +- 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. +- 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`. diff --git a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md index c989321bf..f8816de42 100644 --- a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md +++ b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md @@ -115,7 +115,7 @@ The top bar also shows a live chip per signed-in queue with its current waiting When routing selects you for a piece of work, a **ringing offer card** appears with the customer name (or number), the queue, and a countdown showing how long you have to respond. You have two choices: -- **Accept** - accepts the reservation, connects the media, and moves the work into your active panel. For providers that ring your device (such as DialPad's soft phone), your device rings and you answer there; the workspace and the incoming-call modal coordinate so the call is only answered after the reservation is confirmed - you will never pick up a call that has already been re-offered to someone else, and the soft-phone incoming modal now uses the same authoritative reservation lookup as the workspace accept flow instead of firing a second raw device answer when the Contact Center server has already accepted the offer. For server-side queue delivery on provider-only integrations such as the current Asterisk path, Contact Center now also answers the live provider call during the authoritative accept so the connected call stays visible and controllable after the ringing offer is accepted. +- **Accept** - accepts the reservation, connects the media, and moves the work into your active panel. For providers that ring your device (such as DialPad's soft phone), your device rings and you answer there; the workspace and the incoming-call modal now revalidate the current provider call state before accept and do not mark the interaction connected until the provider's authoritative event says it is connected. That means you do not get stuck on a call the server already ended while the offer was in flight. For server-side queue delivery on provider-only integrations such as the current Asterisk path, Contact Center still answers the live provider call during the authoritative accept so the connected call stays visible and controllable after the ringing offer is accepted. - **Decline** - releases the offer so it is immediately re-offered to the next available agent, and the incoming modal no longer follows that reservation decline with a second raw telephony reject against the same call. If you do not respond before the countdown ends, the offer is revoked and routed elsewhere. @@ -133,6 +133,10 @@ Use the soft phone for hold, mute, transfer, and hang-up. The workspace reflects The soft phone also keeps the active remote number visible while you are on the call, and the **Recent** tab now includes inbound calls as well as outbound history. +When Contact Center owns the assigned voice interaction, server-side call-session changes now flow back into the Telephony soft phone in real time, so provider-side disconnects, failed calls, transfers, hold/resume, mute/unmute, and other normalized call-state changes immediately update the live call card and the persisted **Recent** history instead of waiting for the next browser reconnect. + +Contact Center also runs a provider-truth reconciliation pass when the tenant activates and on a periodic safety cadence. If Orchard Core restarts during busy hours, persisted ringing or active interactions are revalidated against the telephony server before routing resumes, and a pre-connect offer that already ended on the provider side is removed from the queue instead of being re-offered as a ghost call. + ### 4. Complete the activity in the CRM When the conversation ends, click **Complete activity** in the active panel. This opens the shared Omnichannel completion page for the assigned activity, so contact-center work follows the same CRM experience as manual activities: @@ -153,7 +157,7 @@ The **Recent activity** panel lists your most recent interactions with their dir - The workspace loads a **state snapshot** from the server and then keeps itself current from the real-time hub's presence, offer, and queue events. It re-reads the authoritative state after you act, so what you see always matches the server. - Contact Center domain events are persisted immediately and the handler fan-out runs as deferred outbox work, so slow workflow or real-time projections do not block the soft-phone sign-in or sign-out postback. -- **Accept** calls a single server-side command that accepts the reservation, tells the voice provider to connect the call to you, and advances the interaction and call session together - one atomic, audited transition rather than several best-effort client actions. +- **Accept** calls a single server-side command that accepts the reservation, revalidates the provider's current call state, tells the voice provider to connect the call when needed, and advances the interaction and call session only when the provider truth supports that transition. - **Complete** goes through the source-neutral `IActivityDispositionService`, so dispositions, required-disposition rules, and subject-flow actions behave identically across every channel and source. ## Permissions and roles diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index e85ebed19..08761b65a 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -112,6 +112,8 @@ A small client helper (`contact-center-realtime` script resource, depending on t 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). + ## 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. 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..358011fb6 --- /dev/null +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -0,0 +1,344 @@ +--- +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 | + +## 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-specific webhook adapter or controller translates the provider payload into Contact Center events first. + +Examples: + +- Generic provider webhook path: `POST /api/contact-center/voice/webhook/{provider}` +- 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. + +### 2. Contact Center creates the CRM work item and interaction + +`VoiceContactCenterCallRouter` takes the inbound event and: + +1. resolves the dialed number (`ToAddress`) to the configured phone channel endpoint +2. resolves the matching subject flow and optional CRM contact +3. creates the `OmnichannelActivity` +4. creates the `Interaction` +5. resolves the entry-point plan and target queue + +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 + +### 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. moves the interaction to `Ringing` +3. builds a telephony `TelephonyCall` +4. dispatches it through `IIncomingCallDispatcher` + +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. leaves device-native providers in `Ringing` until the provider later reports `Connected` +7. creates or updates the `CallSession` +8. 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 + +## 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` and calls its dial method. + +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 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. **tenant-startup 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 +- reservation +- agent active reservation/presence +- activity assignment metadata + +That prevents abandoned or already-ended calls from being re-offered as ghost work. + +### 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. + +### Tenant activation reconciliation + +When the tenant activates, `ContactCenterVoiceTenantEvents` immediately runs a reconciliation pass across active provider-backed interactions. + +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 a 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 + +### 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. + +## Why the current implementation is resilient + +The current voice flow stays consistent because it combines these protections: + +1. **Per-queue distributed locks** prevent double assignment. +2. **Reservations** make offers explicit and auditable. +3. **Provider call ids** let Contact Center correlate server truth back to local interactions. +4. **Idempotent provider-event ingestion** prevents duplicate webhook deliveries from corrupting state. +5. **Pre-accept provider refresh** stops agents from accepting already-ended calls. +6. **Ended-offer reconciliation** clears stale queue and agent state immediately. +7. **Tenant-startup and periodic reconciliation** repair drift after restarts or missed events. +8. **Server-driven soft-phone projection** keeps the browser as a mirror instead of a source of truth. + +## Current limitations and important notes + +- `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. +- 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. + +## 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/telephony/custom-providers.md b/src/CrestApps.Docs/docs/telephony/custom-providers.md new file mode 100644 index 000000000..2505351e9 --- /dev/null +++ b/src/CrestApps.Docs/docs/telephony/custom-providers.md @@ -0,0 +1,216 @@ +--- +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 three separate seams, and a provider may implement one, two, or all three: + +| Seam | Interface | Responsibility | +| --- | --- | --- | +| Soft-phone call control | `ITelephonyProvider` | Dial, hang up, hold, resume, mute, transfer, merge, answer, reject, voicemail, and provider capabilities | +| 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 orchestration | `IContactCenterVoiceProvider` | Dialer dialing, server-side agent bridging, provider-side queue ownership, and other Contact Center voice operations | +| Provider event ingress | `IProviderVoiceWebhookAdapter` or provider-specific stream listener | Convert provider webhooks or stream events 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-specific adapter + | + 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`. + +At minimum, your provider should: + +1. Return a stable technical name and display name +2. Advertise accurate `TelephonyCapabilities` +3. Implement the call-control methods your backend really supports +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. + +## 2. Implement the Contact Center voice provider when the backend can do more than keypad calling + +If the provider also participates in queue delivery, dialer placement, or server-side bridging, implement `IContactCenterVoiceProvider`. + +Use this interface when the provider can: + +- place dialer calls for reserved activities +- bridge an already-live provider call to the assigned agent +- assign an existing provider call to an agent +- place or move a call into a provider-owned queue + +This layer is optional for browser-only or device-native flows, but it is required when Contact Center needs the provider to own more than basic soft-phone actions. + +## 3. 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 `IProviderVoiceEventService.IngestAsync()`. + +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 + +The Contact Center pipeline then updates 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. + +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 + +## 4. Choose the provider transport model + +Providers usually fall into one of these transport models: + +### Webhook model + +The provider sends HTTP callbacks to Orchard. + +Use `IProviderVoiceWebhookAdapter` when: + +- the provider signs webhook requests +- the payload can be parsed synchronously per request +- Orchard only needs to accept inbound HTTP events + +Typical flow: + +1. controller/endpoint receives the webhook +2. adapter validates the signature +3. adapter parses one or more `ProviderVoiceEvent` records +4. `IProviderVoiceEventService` ingests them + +### 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** + +## 5. 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. + +## 6. Registration checklist + +For a new provider module, the usual registration checklist is: + +1. Register the telephony provider implementation and settings UI +2. Register `IContactCenterVoiceProvider` if the backend supports Contact Center orchestration features +3. Register webhook endpoints or the tenant-safe live-stream listener +4. Implement `ITelephonyCallStateProvider` when the backend can query the current state of a call by id +5. Normalize every provider event into `ProviderVoiceEvent` +6. Ensure the provider's current-state lookup and live-event mapping agree on lifecycle semantics so reconciliation never "undoes" provider truth +7. Add targeted tests for: + - state mapping + - idempotency + - inbound routing + - live state updates such as hold, resume, mute, unmute, recording, and multi-party changes + - call-state lookup and restart reconciliation +8. 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 | +| Asterisk | ARI HTTP control + per-call ARI lookup + tenant-scoped ARI event stream | Handles call control, call-state lookup by channel id, and a live ARI stream that maps server-side channel events back into the normalized voice-event pipeline and the persisted soft-phone interaction store | + +## Related interfaces + +- `ITelephonyProvider` +- `ITelephonyCallStateProvider` +- `IContactCenterVoiceProvider` +- `IProviderVoiceWebhookAdapter` +- `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/index.md b/src/CrestApps.Docs/docs/telephony/index.md index 47f84ef9c..6d1fc25c4 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -33,7 +33,7 @@ TelephonyHub ──► ITelephonyService ──► ITelephonyProviderResolve ``` - **`CrestApps.OrchardCore.Telephony.Abstractions`** contains the provider-agnostic contracts: - `ITelephonyProvider`, `ITelephonyService`, `ITelephonyProviderResolver`, `ITelephonyClient`, + `ITelephonyProvider`, `ITelephonyCallStateProvider`, `ITelephonyService`, `ITelephonyProviderResolver`, `ITelephonyClient`, `ITelephonyAuthenticationProvider`, `ITelephonyAuthenticationService`, `ITelephonyUserTokenStore`, `ITelephonyInteractionStore`, the request/response and interaction models, `TelephonyProviderOptions`, `TelephonySettings`, and `TelephonyPermissions`. @@ -43,6 +43,8 @@ TelephonyHub ──► ITelephonyService ──► ITelephonyProviderResolve - 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 is the adapter contract between @@ -70,6 +72,22 @@ Call operations can also carry an optional provider-neutral metadata bag through `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. 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`, Contact Center can query the provider's current server truth for an individual call during authoritative offer accept, tenant-startup recovery, and periodic reconciliation. That keeps queued and assigned work aligned with the telephony backend even across short Orchard Core restarts. + +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 The hub is registered with the [SignalR](../modules/signalr) module's `HubRouteManager`: @@ -146,7 +164,7 @@ The widget's footer is a tab bar that switches the panel between built-in and co The history is read from the hub's `GetInteractions` method and is backed by the persisted interaction store described below, so it survives page reloads and is available independently of the -provider. Inbound calls are now persisted as soon as they are offered, so the **Recent** tab shows inbound and outbound history instead of only calls placed from the keypad. When the latest interaction is still in progress, the soft phone now restores that active call on reconnect or page reload so agents do not lose the connected-call state just because the page refreshed. The widget also keeps the **Keypad** tab's natural height as the shared body height for **Recent** and contributed tabs such as Contact Center **Work**, so switching tabs does not resize the panel unless the user moves it. When a non-keypad tab needs more room than that shared height, it scrolls within the panel instead of clipping its contents. +provider. Inbound calls are now persisted as soon as they are offered, so the **Recent** tab shows inbound and outbound history instead of only calls placed from the keypad. When the latest interaction is still in progress, the soft phone now restores that active call on reconnect or page reload so agents do not lose the connected-call state just because the page refreshed. When Contact Center owns the voice interaction, normalized server-side call-session changes now also upsert that same telephony history and push a `CallStateChanged` event back through the Telephony hub, so provider-driven disconnects, hold/resume changes, mute/unmute updates, and other server-side lifecycle changes clear or update the live soft-phone state immediately. Provider-side Asterisk ARI events now do the same for plain Telephony calls, so a server-initiated disconnect updates the active soft phone and closes the persisted in-progress record before the next page reload can resurrect it. The widget also keeps the **Keypad** tab's natural height as the shared body height for **Recent** and contributed tabs such as Contact Center **Work**, so switching tabs does not resize the panel unless the user moves it. When a non-keypad tab needs more room than that shared height, it scrolls within the panel instead of clipping its contents. ## Incoming calls diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs index ae022f767..8f8e183ee 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs @@ -35,6 +35,16 @@ public static class AsteriskConstants /// public const int DefaultTimeoutSeconds = 30; + /// + /// The channel variable used to mirror the hold state back through ARI events. + /// + public const string HoldStateVariableName = "CRESTAPPS_STATE_ONHOLD"; + + /// + /// The channel variable used to mirror the mute state back through ARI events. + /// + public const string MuteStateVariableName = "CRESTAPPS_STATE_MUTED"; + /// /// The shell configuration section used for the configuration-backed default provider. /// diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj b/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj index 18b5b6390..0a9bde586 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj @@ -12,6 +12,10 @@ $(PackageTags) OrchardCoreCMS Telephony Asterisk + + + + @@ -25,7 +29,9 @@ + + diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEvent.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEvent.cs new file mode 100644 index 000000000..50f7bac4b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEvent.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal sealed class AsteriskRealtimeVoiceEvent +{ + public string ProviderName { get; set; } + + public string CallId { get; set; } + + public string EventType { get; set; } + + public string FromAddress { get; set; } + + public string ToAddress { get; set; } + + public CallState State { get; set; } + + public bool? IsMuted { get; set; } + + public bool IsOnHold { get; set; } + + public DateTime? OccurredUtc { get; set; } + + public string IdempotencyKey { get; set; } + + public bool? IsConference { get; set; } + + public int? ParticipantCount { get; set; } + + public IDictionary Metadata { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs new file mode 100644 index 000000000..9528249ed --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs @@ -0,0 +1,187 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal sealed class AsteriskRealtimeVoiceEventDispatcher +{ + private readonly IEnumerable _providerVoiceEventServices; + private readonly ITelephonyInteractionStore _telephonyInteractionStore; + private readonly IHubContext _hubContext; + private readonly IClock _clock; + private readonly ILogger _logger; + + public AsteriskRealtimeVoiceEventDispatcher( + IEnumerable providerVoiceEventServices, + ITelephonyInteractionStore telephonyInteractionStore, + IHubContext hubContext, + IClock clock, + ILogger logger) + { + _providerVoiceEventServices = providerVoiceEventServices; + _telephonyInteractionStore = telephonyInteractionStore; + _hubContext = hubContext; + _clock = clock; + _logger = logger; + } + + public async Task HandleAsync(AsteriskRealtimeVoiceEvent voiceEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(voiceEvent); + + if (string.IsNullOrWhiteSpace(voiceEvent.ProviderName) || string.IsNullOrWhiteSpace(voiceEvent.CallId)) + { + return; + } + + var providerVoiceEventService = _providerVoiceEventServices.FirstOrDefault(); + + if (providerVoiceEventService is not null) + { + var session = await providerVoiceEventService.IngestAsync(BuildProviderVoiceEvent(voiceEvent), cancellationToken); + + if (session is not null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Asterisk real-time event {EventType} for provider {ProviderName} call {CallId} flowed into Contact Center session {SessionId}.", + voiceEvent.EventType, + voiceEvent.ProviderName, + voiceEvent.CallId, + session.ItemId); + } + + return; + } + } + + var interaction = await _telephonyInteractionStore.FindByProviderCallIdAsync(voiceEvent.ProviderName, voiceEvent.CallId, cancellationToken); + + if (interaction is null) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Asterisk real-time event {EventType} for provider {ProviderName} call {CallId} did not match any telephony interaction.", + voiceEvent.EventType, + voiceEvent.ProviderName, + voiceEvent.CallId); + } + + return; + } + + ApplyInteractionState(interaction, voiceEvent); + await _telephonyInteractionStore.UpdateAsync(interaction, cancellationToken); + await _hubContext.Clients.User(interaction.UserId).CallStateChanged(BuildTelephonyCall(interaction, voiceEvent)); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Projected Asterisk real-time event {EventType} for provider {ProviderName} call {CallId} to soft-phone user {UserId} as state {State}.", + voiceEvent.EventType, + voiceEvent.ProviderName, + voiceEvent.CallId, + interaction.UserId, + voiceEvent.State); + } + } + + private static ProviderVoiceEvent BuildProviderVoiceEvent(AsteriskRealtimeVoiceEvent voiceEvent) + { + return new ProviderVoiceEvent + { + ProviderName = voiceEvent.ProviderName, + ProviderCallId = voiceEvent.CallId, + State = ToContactCenterState(voiceEvent.State), + FromAddress = voiceEvent.FromAddress, + ToAddress = voiceEvent.ToAddress, + OccurredUtc = voiceEvent.OccurredUtc, + IdempotencyKey = voiceEvent.IdempotencyKey, + IsMuted = voiceEvent.IsMuted, + IsConference = voiceEvent.IsConference, + ParticipantCount = voiceEvent.ParticipantCount, + Metadata = new Dictionary(voiceEvent.Metadata, StringComparer.OrdinalIgnoreCase), + }; + } + + private void ApplyInteractionState(TelephonyInteraction interaction, AsteriskRealtimeVoiceEvent voiceEvent) + { + var now = voiceEvent.OccurredUtc ?? _clock.UtcNow; + + interaction.ProviderName = voiceEvent.ProviderName; + interaction.StartedUtc = interaction.StartedUtc == default ? now : interaction.StartedUtc; + + if (!string.IsNullOrWhiteSpace(voiceEvent.FromAddress)) + { + interaction.From = voiceEvent.FromAddress; + } + + if (!string.IsNullOrWhiteSpace(voiceEvent.ToAddress)) + { + interaction.To = voiceEvent.ToAddress; + } + + if (voiceEvent.State is CallState.Disconnected or CallState.Failed) + { + interaction.EndedUtc = now; + interaction.DurationSeconds = Math.Max(0, (interaction.EndedUtc.Value - interaction.StartedUtc).TotalSeconds); + interaction.Outcome = voiceEvent.State == CallState.Failed + ? CallOutcome.Failed + : CallOutcome.Completed; + + return; + } + + interaction.EndedUtc = null; + interaction.DurationSeconds = 0; + interaction.Outcome = CallOutcome.InProgress; + } + + private static ContactCenterCallState ToContactCenterState(CallState state) + { + return state switch + { + CallState.Connecting => ContactCenterCallState.Dialing, + CallState.Ringing => ContactCenterCallState.Ringing, + CallState.Connected => ContactCenterCallState.Connected, + CallState.OnHold => ContactCenterCallState.OnHold, + CallState.Disconnected => ContactCenterCallState.Ended, + CallState.Failed => ContactCenterCallState.Failed, + _ => ContactCenterCallState.Dialing, + }; + } + + private static TelephonyCall BuildTelephonyCall(TelephonyInteraction interaction, AsteriskRealtimeVoiceEvent voiceEvent) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var item in voiceEvent.Metadata) + { + metadata[item.Key] = item.Value; + } + + return new TelephonyCall + { + CallId = interaction.CallId, + From = interaction.From, + To = interaction.To, + State = voiceEvent.State, + Direction = interaction.Direction, + IsMuted = voiceEvent.IsMuted ?? false, + IsOnHold = voiceEvent.IsOnHold, + ProviderName = interaction.ProviderName, + StartedUtc = interaction.StartedUtc == default + ? null + : new DateTimeOffset(DateTime.SpecifyKind(interaction.StartedUtc, DateTimeKind.Utc)), + Metadata = metadata, + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs new file mode 100644 index 000000000..4f5983f96 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs @@ -0,0 +1,297 @@ +using System.Globalization; +using System.Text.Json; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal static class AsteriskRealtimeVoiceEventMapper +{ + public static bool TryMap(string providerName, string payload, out AsteriskRealtimeVoiceEvent voiceEvent) + { + voiceEvent = null; + + if (string.IsNullOrWhiteSpace(providerName) || string.IsNullOrWhiteSpace(payload)) + { + return false; + } + + using var document = JsonDocument.Parse(payload); + var root = document.RootElement; + var eventType = ReadString(root, "type"); + + if (string.IsNullOrWhiteSpace(eventType) || + !TryResolveChannel(root, out var channel)) + { + return false; + } + + var callId = ReadString(channel, "id"); + + if (string.IsNullOrWhiteSpace(callId) || + !TryMapState(root, channel, eventType, out var state, out var isMuted, out var isOnHold)) + { + return false; + } + + var occurredUtc = TryReadDateTime(root, "timestamp"); + var metadata = BuildMetadata(root, channel, eventType); + + voiceEvent = new AsteriskRealtimeVoiceEvent + { + ProviderName = providerName, + CallId = callId, + EventType = eventType, + FromAddress = ReadNestedString(channel, "caller", "number"), + ToAddress = ReadNestedString(channel, "connected", "number") ?? ReadNestedString(channel, "dialplan", "exten"), + State = state, + IsMuted = isMuted, + IsOnHold = isOnHold, + OccurredUtc = occurredUtc, + IdempotencyKey = BuildIdempotencyKey(providerName, eventType, callId, occurredUtc, metadata), + IsConference = TryReadParticipantCount(root, out var participantCount) + ? participantCount > 2 + : null, + ParticipantCount = participantCount, + Metadata = metadata, + }; + + return true; + } + + private static Dictionary BuildMetadata(JsonElement root, JsonElement channel, string eventType) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["asteriskEventType"] = eventType, + }; + + var state = ReadString(channel, "state"); + + if (!string.IsNullOrWhiteSpace(state)) + { + metadata["asteriskState"] = state; + } + + var application = ReadString(root, "application"); + + if (!string.IsNullOrWhiteSpace(application)) + { + metadata["asteriskApplication"] = application; + } + + var bridgeId = ReadNestedString(root, "bridge", "id"); + + if (!string.IsNullOrWhiteSpace(bridgeId)) + { + metadata["bridgeId"] = bridgeId; + } + + var cause = ReadString(root, "cause"); + + if (!string.IsNullOrWhiteSpace(cause)) + { + metadata["cause"] = cause; + } + + var causeText = ReadString(root, "cause_txt"); + + if (!string.IsNullOrWhiteSpace(causeText)) + { + metadata["causeText"] = causeText; + } + + var asteriskId = ReadString(root, "asterisk_id"); + + if (!string.IsNullOrWhiteSpace(asteriskId)) + { + metadata["asteriskId"] = asteriskId; + } + + return metadata; + } + + private static string BuildIdempotencyKey( + string providerName, + string eventType, + string callId, + DateTime? occurredUtc, + Dictionary metadata) + { + metadata.TryGetValue("bridgeId", out var bridgeId); + metadata.TryGetValue("asteriskId", out var asteriskId); + + return $"{providerName}:{eventType}:{callId}:{occurredUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty}:{bridgeId ?? string.Empty}:{asteriskId ?? string.Empty}"; + } + + private static bool TryResolveChannel(JsonElement root, out JsonElement channel) + { + if (root.TryGetProperty("channel", out channel)) + { + return true; + } + + if (root.TryGetProperty("peer", out channel)) + { + return true; + } + + channel = default; + + return false; + } + + private static bool TryMapState( + JsonElement root, + JsonElement channel, + string eventType, + out CallState state, + out bool? isMuted, + out bool isOnHold) + { + state = CallState.Idle; + isMuted = null; + isOnHold = false; + + if (string.Equals(eventType, "ChannelHold", StringComparison.OrdinalIgnoreCase)) + { + state = CallState.OnHold; + isOnHold = true; + + return true; + } + + if (string.Equals(eventType, "ChannelUnhold", StringComparison.OrdinalIgnoreCase)) + { + state = CallState.Connected; + + return true; + } + + if (string.Equals(eventType, "ChannelEnteredBridge", StringComparison.OrdinalIgnoreCase)) + { + state = CallState.Connected; + + return true; + } + + if (string.Equals(eventType, "ChannelVarset", StringComparison.OrdinalIgnoreCase)) + { + var variable = ReadString(root, "variable"); + var value = ReadString(root, "value"); + + if (string.Equals(variable, AsteriskConstants.HoldStateVariableName, StringComparison.OrdinalIgnoreCase)) + { + isOnHold = string.Equals(value, bool.TrueString, StringComparison.OrdinalIgnoreCase); + state = isOnHold ? CallState.OnHold : CallState.Connected; + + return true; + } + + if (string.Equals(variable, AsteriskConstants.MuteStateVariableName, StringComparison.OrdinalIgnoreCase)) + { + isMuted = string.Equals(value, bool.TrueString, StringComparison.OrdinalIgnoreCase); + state = MapChannelState(ReadString(channel, "state"), isTerminalEvent: false); + + if (state == CallState.Idle) + { + state = CallState.Connected; + } + + return true; + } + + return false; + } + + var terminalEvent = string.Equals(eventType, "ChannelDestroyed", StringComparison.OrdinalIgnoreCase) || + string.Equals(eventType, "ChannelHangupRequest", StringComparison.OrdinalIgnoreCase) || + string.Equals(eventType, "StasisEnd", StringComparison.OrdinalIgnoreCase); + + state = MapChannelState(ReadString(channel, "state"), terminalEvent); + + if (state != CallState.Idle) + { + isOnHold = state == CallState.OnHold; + + return true; + } + + if (string.Equals(eventType, "StasisStart", StringComparison.OrdinalIgnoreCase)) + { + state = CallState.Connecting; + + return true; + } + + return false; + } + + private static CallState MapChannelState(string channelState, bool isTerminalEvent) + { + if (isTerminalEvent) + { + return CallState.Disconnected; + } + + return channelState?.Trim() switch + { + "Ring" => CallState.Ringing, + "Ringing" => CallState.Ringing, + "Up" => CallState.Connected, + "Busy" => CallState.Failed, + "Pre-ring" => CallState.Connecting, + "Down" => CallState.Connecting, + "Dialing" => CallState.Connecting, + _ => CallState.Idle, + }; + } + + private static bool TryReadParticipantCount(JsonElement root, out int? participantCount) + { + participantCount = null; + + if (!root.TryGetProperty("bridge", out var bridge) || + !bridge.TryGetProperty("channels", out var channels) || + channels.ValueKind != JsonValueKind.Array) + { + return false; + } + + participantCount = channels.GetArrayLength(); + + return true; + } + + private static DateTime? TryReadDateTime(JsonElement element, string propertyName) + { + var text = ReadString(element, propertyName); + + if (string.IsNullOrWhiteSpace(text) || + !DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out var value)) + { + return null; + } + + return value; + } + + private static string ReadString(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.String) + { + return null; + } + + return value.GetString(); + } + + private static string ReadNestedString(JsonElement element, string parentPropertyName, string propertyName) + { + if (!element.TryGetProperty(parentPropertyName, out var parent)) + { + return null; + } + + return ReadString(parent, propertyName); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs new file mode 100644 index 000000000..9ffe09371 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs @@ -0,0 +1,204 @@ +using System.Net.WebSockets; +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.Environment.Shell.Scope; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal sealed class AsteriskRealtimeVoiceListener : IAsyncDisposable +{ + private readonly ILogger _logger; + private readonly Lock _lock = new(); + private CancellationTokenSource _listenerCancellationTokenSource; + private Task _listenerTask; + + public AsteriskRealtimeVoiceListener(ILogger logger) + { + _logger = logger; + } + + public Task StartAsync(IReadOnlyList listeners) + { + ArgumentNullException.ThrowIfNull(listeners); + + lock (_lock) + { + if (_listenerTask is not null) + { + return Task.CompletedTask; + } + + _listenerCancellationTokenSource = new CancellationTokenSource(); + _listenerTask = RunAsync(listeners, _listenerCancellationTokenSource.Token); + } + + return Task.CompletedTask; + } + + public async Task StopAsync() + { + Task listenerTask; + CancellationTokenSource cancellationTokenSource; + + lock (_lock) + { + listenerTask = _listenerTask; + cancellationTokenSource = _listenerCancellationTokenSource; + _listenerTask = null; + _listenerCancellationTokenSource = null; + } + + if (cancellationTokenSource is null || listenerTask is null) + { + return; + } + + await cancellationTokenSource.CancelAsync(); + + try + { + await listenerTask; + } + catch (OperationCanceledException) + { + } + finally + { + cancellationTokenSource.Dispose(); + } + } + + public async ValueTask DisposeAsync() + { + await StopAsync(); + } + + private async Task RunAsync(IReadOnlyList listeners, CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + if (listeners.Count == 0) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + + continue; + } + + try + { + await Task.WhenAll(listeners.Select(listener => ListenAsync(listener, cancellationToken))); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + break; + } + catch (Exception ex) + { + _logger.LogError(ex, "The Asterisk real-time voice listener failed unexpectedly."); + } + + if (!cancellationToken.IsCancellationRequested) + { + await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + } + } + } + + private async Task ListenAsync(AsteriskResolvedSettings settings, CancellationToken cancellationToken) + { + if (!AsteriskSettingsUtilities.HasRequiredConfiguration(settings)) + { + return; + } + + using var socket = new ClientWebSocket(); + var eventsUri = AsteriskSettingsUtilities.CreateEventsUri(settings); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Connecting the Asterisk real-time voice listener for provider {ProviderName} to {EventsUri}.", + settings.ProviderName, + eventsUri); + } + + await socket.ConnectAsync(eventsUri, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Connected the Asterisk real-time voice listener for provider {ProviderName}.", + settings.ProviderName); + } + + var buffer = new byte[8 * 1024]; + + while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) + { + using var message = new MemoryStream(); + WebSocketReceiveResult result; + + do + { + result = await socket.ReceiveAsync(buffer, cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogWarning( + "The Asterisk real-time voice listener for provider {ProviderName} received a close frame. Status={Status}, Description={Description}.", + settings.ProviderName, + result.CloseStatus, + result.CloseStatusDescription); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", cancellationToken); + + return; + } + + await message.WriteAsync(buffer.AsMemory(0, result.Count), cancellationToken); + } + while (!result.EndOfMessage); + + var payload = Encoding.UTF8.GetString(message.ToArray()); + + await DispatchAsync(settings.ProviderName, payload, cancellationToken); + } + } + + private async Task DispatchAsync(string providerName, string payload, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(payload)) + { + return; + } + + if (!AsteriskRealtimeVoiceEventMapper.TryMap(providerName, payload, out var voiceEvent)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Ignored an Asterisk real-time payload for provider {ProviderName} because it did not map to a voice-state update.", + providerName); + } + + return; + } + + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Received Asterisk real-time event {EventType} for provider {ProviderName} call {CallId}; mapped to state {State}.", + voiceEvent.EventType, + voiceEvent.ProviderName, + voiceEvent.CallId, + voiceEvent.State); + } + + await ShellScope.UsingChildScopeAsync(async scope => + { + var dispatcher = scope.ServiceProvider.GetRequiredService(); + await dispatcher.HandleAsync(voiceEvent, cancellationToken); + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceTenantEvents.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceTenantEvents.cs new file mode 100644 index 000000000..fb7afae13 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceTenantEvents.cs @@ -0,0 +1,132 @@ +using CrestApps.OrchardCore.Asterisk.Models; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal sealed class AsteriskRealtimeVoiceTenantEvents : ModularTenantEvents +{ + private readonly ISiteService _siteService; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly DefaultAsteriskOptions _defaultOptions; + private readonly AsteriskRealtimeVoiceListener _listener; + private readonly ILogger _logger; + + public AsteriskRealtimeVoiceTenantEvents( + ISiteService siteService, + IDataProtectionProvider dataProtectionProvider, + IOptions defaultOptions, + AsteriskRealtimeVoiceListener listener, + ILogger logger) + { + _siteService = siteService; + _dataProtectionProvider = dataProtectionProvider; + _defaultOptions = defaultOptions.Value; + _listener = listener; + _logger = logger; + } + + public override Task ActivatingAsync() + { + var listeners = ResolveListeners(); + + if (listeners.Count == 0) + { + return Task.CompletedTask; + } + + _listener.StartAsync(listeners); + + return Task.CompletedTask; + } + + public override async Task TerminatingAsync() + { + try + { + await _listener.StopAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while stopping the Asterisk real-time voice listener."); + } + } + + private List ResolveListeners() + { + var listeners = new List(); + var tenantSettings = _siteService.GetSettings(); + var protectedPassword = tenantSettings.Password; + var password = string.IsNullOrWhiteSpace(protectedPassword) + ? null + : TryUnprotectPassword(protectedPassword); + + var tenantResolvedSettings = new AsteriskResolvedSettings + { + IsEnabled = tenantSettings.IsEnabled, + ProviderName = AsteriskConstants.ProviderTechnicalName, + BaseUrl = tenantSettings.BaseUrl, + UserName = tenantSettings.UserName, + Password = password, + ApplicationName = tenantSettings.ApplicationName, + EndpointTemplate = tenantSettings.EndpointTemplate, + OutboundCallerId = tenantSettings.OutboundCallerId, + TimeoutSeconds = tenantSettings.TimeoutSeconds, + VoicemailContext = tenantSettings.VoicemailContext, + VoicemailExtensionTemplate = tenantSettings.VoicemailExtensionTemplate, + VoicemailPriority = tenantSettings.VoicemailPriority, + }; + + AsteriskSettingsUtilities.ApplyDefaults(tenantResolvedSettings); + + if (AsteriskSettingsUtilities.HasRequiredConfiguration(tenantResolvedSettings)) + { + listeners.Add(tenantResolvedSettings); + } + + if (_defaultOptions.IsEnabled) + { + var defaultResolvedSettings = new AsteriskResolvedSettings + { + IsEnabled = _defaultOptions.IsEnabled, + ProviderName = AsteriskConstants.DefaultProviderTechnicalName, + BaseUrl = _defaultOptions.BaseUrl, + UserName = _defaultOptions.UserName, + Password = _defaultOptions.Password, + ApplicationName = _defaultOptions.ApplicationName, + EndpointTemplate = _defaultOptions.EndpointTemplate, + OutboundCallerId = _defaultOptions.OutboundCallerId, + TimeoutSeconds = _defaultOptions.TimeoutSeconds, + VoicemailContext = _defaultOptions.VoicemailContext, + VoicemailExtensionTemplate = _defaultOptions.VoicemailExtensionTemplate, + VoicemailPriority = _defaultOptions.VoicemailPriority, + }; + + AsteriskSettingsUtilities.ApplyDefaults(defaultResolvedSettings); + + if (AsteriskSettingsUtilities.HasRequiredConfiguration(defaultResolvedSettings)) + { + listeners.Add(defaultResolvedSettings); + } + } + + return listeners; + } + + private string TryUnprotectPassword(string protectedPassword) + { + try + { + return _dataProtectionProvider.CreateProtector(AsteriskConstants.ProtectorName).Unprotect(protectedPassword); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to unprotect the tenant-configured Asterisk password for the real-time listener."); + + return null; + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs index 418541693..f1cad8846 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs @@ -1,5 +1,6 @@ using System.Globalization; using CrestApps.OrchardCore.Asterisk.Models; +using Microsoft.AspNetCore.WebUtilities; namespace CrestApps.OrchardCore.Asterisk.Services; @@ -26,12 +27,42 @@ public static void ApplyDefaults(AsteriskConnectionSettings settings) : 1; } + public static void ApplyDefaults(AsteriskResolvedSettings settings) + { + settings.ApplicationName = string.IsNullOrWhiteSpace(settings.ApplicationName) + ? AsteriskConstants.DefaultApplicationName + : settings.ApplicationName.Trim(); + + settings.TimeoutSeconds = settings.TimeoutSeconds > 0 + ? settings.TimeoutSeconds + : AsteriskConstants.DefaultTimeoutSeconds; + + settings.BaseUrl = NormalizeBaseUrl(settings.BaseUrl); + settings.UserName = settings.UserName?.Trim(); + settings.Password = settings.Password?.Trim(); + settings.EndpointTemplate = settings.EndpointTemplate?.Trim(); + settings.OutboundCallerId = settings.OutboundCallerId?.Trim(); + settings.VoicemailContext = settings.VoicemailContext?.Trim(); + settings.VoicemailExtensionTemplate = settings.VoicemailExtensionTemplate?.Trim(); + settings.VoicemailPriority = settings.VoicemailPriority > 0 + ? settings.VoicemailPriority + : 1; + } + public static bool HasRequiredConfiguration(AsteriskConnectionSettings settings, string password) => !string.IsNullOrWhiteSpace(settings.BaseUrl) && !string.IsNullOrWhiteSpace(settings.UserName) && !string.IsNullOrWhiteSpace(password) && !string.IsNullOrWhiteSpace(settings.ApplicationName); + public static bool HasRequiredConfiguration(AsteriskResolvedSettings settings) + => settings is not null && + settings.IsEnabled && + !string.IsNullOrWhiteSpace(settings.BaseUrl) && + !string.IsNullOrWhiteSpace(settings.UserName) && + !string.IsNullOrWhiteSpace(settings.Password) && + !string.IsNullOrWhiteSpace(settings.ApplicationName); + public static string ResolveEndpoint(string endpointTemplate, string destination) { if (string.IsNullOrWhiteSpace(destination)) @@ -116,6 +147,33 @@ public static string NormalizeBaseUrl(string baseUrl) return builder.Uri.ToString(); } + public static Uri CreateEventsUri(AsteriskResolvedSettings settings) + { + if (settings is null || string.IsNullOrWhiteSpace(settings.BaseUrl)) + { + return null; + } + + var baseUri = new Uri(NormalizeBaseUrl(settings.BaseUrl), UriKind.Absolute); + var builder = new UriBuilder(baseUri) + { + Scheme = string.Equals(baseUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) + ? "wss" + : "ws", + Path = $"{baseUri.AbsolutePath.TrimEnd('/')}/events", + }; + + builder.Query = QueryHelpers.AddQueryString( + string.Empty, + new Dictionary + { + ["app"] = settings.ApplicationName, + ["api_key"] = $"{settings.UserName}:{settings.Password}", + }).TrimStart('?'); + + return builder.Uri; + } + public static string ToInvariantString(int value) => value.ToString(CultureInfo.InvariantCulture); diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs index 6f07b21da..c37a55e4c 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs @@ -1,4 +1,5 @@ using System.Globalization; +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text; @@ -13,11 +14,8 @@ namespace CrestApps.OrchardCore.Asterisk.Services; -internal abstract class AsteriskTelephonyProviderBase : ITelephonyProvider +internal abstract class AsteriskTelephonyProviderBase : ITelephonyProvider, ITelephonyCallStateProvider { - private const string HoldStateVariableName = "CRESTAPPS_STATE_ONHOLD"; - private const string MuteStateVariableName = "CRESTAPPS_STATE_MUTED"; - private readonly IHttpClientFactory _httpClientFactory; private readonly IClock _clock; private readonly ILogger _logger; @@ -132,6 +130,102 @@ public async Task DialAsync(DialRequest request, CancellationTo } } + public async Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(callId)) + { + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["A call id is required to query the call state."].Value, + }; + } + + var settings = await GetResolvedSettingsAsync(cancellationToken); + + if (!IsConfigured(settings)) + { + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = NotConfigured().Error, + }; + } + + try + { + using var response = await SendAsync( + settings, + HttpMethod.Get, + $"channels/{Uri.EscapeDataString(callId)}", + null, + null, + cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }; + } + + if (!response.IsSuccessStatusCode) + { + var responseBody = await ReadResponseBodyAsync(response, cancellationToken); + + _logger.LogError( + "Asterisk rejected a call-state lookup for provider {ProviderName}. CallId: {CallId}. Status code: {StatusCode}. Response: {ResponseBody}", + ProviderName, + callId, + response.StatusCode, + responseBody); + + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["Asterisk could not query the call state."].Value, + }; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var root = document.RootElement; + var stateText = ReadString(root, "state"); + var state = TryMapLookupState(stateText, out var mappedState) + ? mappedState + : CallState.Connected; + var call = BuildCall( + callId, + state, + isOnHold: state == CallState.OnHold, + direction: ResolveDirection(ReadString(root, "direction"))); + + call.From = ReadNestedString(root, "caller", "number"); + call.To = ReadNestedString(root, "connected", "number") ?? ReadNestedString(root, "dialplan", "exten"); + call.StartedUtc = _clock.UtcNow; + call.Metadata["asteriskState"] = stateText ?? string.Empty; + + return new TelephonyCallLookupResult + { + Succeeded = true, + Found = true, + Call = call, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while querying the Asterisk call state for provider {ProviderName}.", ProviderName); + + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["Asterisk could not query the call state."].Value, + }; + } + } + public Task HangupAsync(CallReference call, CancellationToken cancellationToken = default) => ExecuteCallActionAsync( call?.CallId, @@ -150,7 +244,7 @@ public Task HoldAsync(CallReference call, CancellationToken can HttpMethod.Post, "channels/{callId}/hold", null, - new Dictionary { [HoldStateVariableName] = bool.TrueString }, + new Dictionary { [AsteriskConstants.HoldStateVariableName] = bool.TrueString }, () => BuildCall(call?.CallId, CallState.OnHold, isOnHold: true, metadata: call?.Metadata), S["The call could not be placed on hold."].Value, S["A call id is required to hold the call."].Value, @@ -162,7 +256,7 @@ public Task ResumeAsync(CallReference call, CancellationToken c HttpMethod.Delete, "channels/{callId}/hold", null, - new Dictionary { [HoldStateVariableName] = bool.FalseString }, + new Dictionary { [AsteriskConstants.HoldStateVariableName] = bool.FalseString }, () => BuildCall(call?.CallId, CallState.Connected, metadata: call?.Metadata), S["The call could not be resumed."].Value, S["A call id is required to resume the call."].Value, @@ -174,7 +268,7 @@ public Task MuteAsync(CallReference call, CancellationToken can HttpMethod.Post, "channels/{callId}/mute", new Dictionary { ["direction"] = "both" }, - new Dictionary { [MuteStateVariableName] = bool.TrueString }, + new Dictionary { [AsteriskConstants.MuteStateVariableName] = bool.TrueString }, () => BuildCall(call?.CallId, CallState.Connected, isMuted: true, metadata: call?.Metadata), S["The call could not be muted."].Value, S["A call id is required to mute the call."].Value, @@ -186,7 +280,7 @@ public Task UnmuteAsync(CallReference call, CancellationToken c HttpMethod.Delete, "channels/{callId}/mute", new Dictionary { ["direction"] = "both" }, - new Dictionary { [MuteStateVariableName] = bool.FalseString }, + new Dictionary { [AsteriskConstants.MuteStateVariableName] = bool.FalseString }, () => BuildCall(call?.CallId, CallState.Connected, metadata: call?.Metadata), S["The call could not be unmuted."].Value, S["A call id is required to unmute the call."].Value, @@ -512,6 +606,46 @@ private async Task ExecuteCallActionAsync( } } + private static bool TryMapLookupState(string state, out CallState mapped) + { + mapped = state?.Trim().ToLowerInvariant() switch + { + "down" or "dialing" or "reserved" or "offhook" or "pre-ring" => CallState.Connecting, + "ring" or "ringing" => CallState.Ringing, + "up" or "connected" => CallState.Connected, + "hold" or "held" => CallState.OnHold, + "hungup" or "destroyed" or "disconnected" => CallState.Disconnected, + "busy" => CallState.Failed, + _ => (CallState)(-1), + }; + + return Enum.IsDefined(mapped); + } + + private static CallDirection ResolveDirection(string direction) + { + return string.Equals(direction?.Trim(), "inbound", StringComparison.OrdinalIgnoreCase) + ? CallDirection.Inbound + : CallDirection.Outbound; + } + + private static string ReadString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static string ReadNestedString(JsonElement element, string propertyName, string nestedPropertyName) + { + return element.TryGetProperty(propertyName, out var value) && + value.ValueKind == JsonValueKind.Object && + value.TryGetProperty(nestedPropertyName, out var nestedValue) && + nestedValue.ValueKind == JsonValueKind.String + ? nestedValue.GetString() + : null; + } + private HttpClient CreateClient(AsteriskResolvedSettings settings) { var client = _httpClientFactory.CreateClient(AsteriskConstants.HttpClientName); diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs index f1fca09aa..976356c68 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs @@ -38,5 +38,10 @@ public override void ConfigureServices(IServiceCollection services) .AddTelephonyProviderOptionsConfiguration() .AddSiteDisplayDriver() .AddTransient, DefaultAsteriskOptionsConfiguration>(); + + services + .AddSingleton() + .AddScoped() + .AddScoped(); } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ProviderCallStateReconciliationBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ProviderCallStateReconciliationBackgroundTask.cs new file mode 100644 index 000000000..bcb8994e6 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ProviderCallStateReconciliationBackgroundTask.cs @@ -0,0 +1,35 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Revalidates active provider-backed interactions against the telephony server so restarts and missed +/// live events do not leave queued voice work out of sync. +/// +[BackgroundTask( + Title = "Contact Center Provider Call Reconciliation", + Schedule = "* * * * *", + Description = "Reconciles active Contact Center voice interactions against current provider call state.", + LockTimeout = 5_000, + LockExpiration = 120_000)] +public sealed class ProviderCallStateReconciliationBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var synchronizationService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + try + { + await synchronizationService.ReconcileActiveInteractionsAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while reconciling Contact Center provider call state."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index 71bace125..a3be724b5 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -37,6 +37,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSoftPhoneEventHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSoftPhoneEventHandler.cs new file mode 100644 index 000000000..8dead7a81 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSoftPhoneEventHandler.cs @@ -0,0 +1,309 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.SignalR; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +/// +/// Projects Contact Center call-session events back onto the telephony soft phone so the assigned +/// agent's widget reacts immediately when the server advances or ends the call. +/// +public sealed class ContactCenterSoftPhoneEventHandler : IContactCenterEventHandler +{ + private readonly IInteractionManager _interactionManager; + private readonly ICallSessionManager _callSessionManager; + private readonly IAgentProfileManager _agentProfileManager; + private readonly ITelephonyInteractionStore _telephonyInteractionStore; + private readonly IHubContext _hubContext; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager used to resolve the business interaction. + /// The call-session manager used to resolve the normalized voice session. + /// The agent profile manager used to resolve the assigned Orchard user. + /// The telephony interaction store used by the soft phone's recent-call history. + /// The telephony hub context used to push call-state changes to the soft phone. + public ContactCenterSoftPhoneEventHandler( + IInteractionManager interactionManager, + ICallSessionManager callSessionManager, + IAgentProfileManager agentProfileManager, + ITelephonyInteractionStore telephonyInteractionStore, + IHubContext hubContext) + { + _interactionManager = interactionManager; + _callSessionManager = callSessionManager; + _agentProfileManager = agentProfileManager; + _telephonyInteractionStore = telephonyInteractionStore; + _hubContext = hubContext; + } + + /// + public async Task HandleAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + if (!ShouldHandle(interactionEvent.EventType)) + { + return; + } + + var interactionId = ResolveInteractionId(interactionEvent); + + if (string.IsNullOrEmpty(interactionId)) + { + return; + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null || string.IsNullOrEmpty(interaction.ProviderInteractionId)) + { + return; + } + + var session = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + var agentId = session?.AgentId ?? interaction.AgentId; + + if (string.IsNullOrEmpty(agentId)) + { + return; + } + + var agent = await _agentProfileManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is null || string.IsNullOrEmpty(agent.UserId)) + { + return; + } + + var call = BuildCall(interaction, session); + + await UpsertTelephonyInteractionAsync(agent, interaction, session, call, cancellationToken); + await _hubContext.Clients.User(agent.UserId).CallStateChanged(call); + } + + private static bool ShouldHandle(string eventType) + { + return eventType == ContactCenterConstants.Events.CallSessionCreated || + eventType == ContactCenterConstants.Events.CallSessionUpdated || + eventType == ContactCenterConstants.Events.CallConnected || + eventType == ContactCenterConstants.Events.CallHeld || + eventType == ContactCenterConstants.Events.CallResumed || + eventType == ContactCenterConstants.Events.CallMuted || + eventType == ContactCenterConstants.Events.CallUnmuted || + eventType == ContactCenterConstants.Events.CallConferenceChanged || + eventType == ContactCenterConstants.Events.RecordingStarted || + eventType == ContactCenterConstants.Events.RecordingPaused || + eventType == ContactCenterConstants.Events.RecordingResumed || + eventType == ContactCenterConstants.Events.RecordingStopped || + eventType == ContactCenterConstants.Events.CallEnded; + } + + private static string ResolveInteractionId(InteractionEvent interactionEvent) + { + return !string.IsNullOrEmpty(interactionEvent.InteractionId) + ? interactionEvent.InteractionId + : interactionEvent.AggregateId; + } + + private async Task UpsertTelephonyInteractionAsync( + AgentProfile agent, + Interaction interaction, + CallSession session, + TelephonyCall call, + CancellationToken cancellationToken) + { + var existing = await _telephonyInteractionStore.FindByCallIdAsync(agent.UserId, call.CallId, cancellationToken); + var startedUtc = call.StartedUtc?.UtcDateTime ?? + session?.StartedUtc ?? + interaction.StartedUtc ?? + interaction.CreatedUtc; + var endedUtc = session?.EndedUtc ?? interaction.EndedUtc; + var outcome = ResolveOutcome(session?.State, call.State, interaction.Direction); + + if (existing is null) + { + existing = new TelephonyInteraction + { + InteractionId = interaction.ItemId, + CallId = call.CallId, + ProviderName = call.ProviderName, + UserId = agent.UserId, + UserName = agent.UserName ?? agent.DisplayName, + From = call.From, + To = call.To, + Direction = call.Direction, + StartedUtc = startedUtc, + Outcome = outcome, + }; + + ApplyTerminalState(existing, call.State, endedUtc); + await _telephonyInteractionStore.CreateAsync(existing, cancellationToken); + + return; + } + + existing.ProviderName = call.ProviderName; + existing.UserName = string.IsNullOrEmpty(existing.UserName) + ? agent.UserName ?? agent.DisplayName + : existing.UserName; + existing.From = string.IsNullOrEmpty(call.From) ? existing.From : call.From; + existing.To = string.IsNullOrEmpty(call.To) ? existing.To : call.To; + existing.Direction = call.Direction; + existing.StartedUtc = existing.StartedUtc == default ? startedUtc : existing.StartedUtc; + existing.Outcome = outcome; + + ApplyTerminalState(existing, call.State, endedUtc); + await _telephonyInteractionStore.UpdateAsync(existing, cancellationToken); + } + + private static void ApplyTerminalState(TelephonyInteraction interaction, CallState state, DateTime? endedUtc) + { + if (state is not CallState.Disconnected and not CallState.Failed) + { + interaction.EndedUtc = null; + interaction.DurationSeconds = 0; + + return; + } + + interaction.EndedUtc = endedUtc ?? interaction.StartedUtc; + interaction.DurationSeconds = Math.Max(0, (interaction.EndedUtc.Value - interaction.StartedUtc).TotalSeconds); + } + + private static TelephonyCall BuildCall(Interaction interaction, CallSession session) + { + var startedUtc = session?.StartedUtc ?? + interaction.StartedUtc ?? + interaction.AnsweredUtc ?? + interaction.CreatedUtc; + + return new TelephonyCall + { + CallId = session?.ProviderCallId ?? interaction.ProviderInteractionId, + From = session?.FromAddress ?? interaction.CustomerAddress, + To = session?.ToAddress ?? ResolveServiceAddress(interaction), + State = MapCallState(session?.State, interaction.Status), + Direction = MapDirection(interaction.Direction), + IsMuted = session?.IsMuted ?? false, + IsOnHold = session?.IsOnHold ?? interaction.Status == InteractionStatus.Held, + ProviderName = session?.ProviderName ?? interaction.ProviderName, + StartedUtc = startedUtc == default + ? null + : new DateTimeOffset(DateTime.SpecifyKind(startedUtc, DateTimeKind.Utc)), + Metadata = BuildMetadata(interaction, session), + }; + } + + private static Dictionary BuildMetadata(Interaction interaction, CallSession session) + { + var metadata = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (!string.IsNullOrEmpty(interaction.ItemId)) + { + metadata["interactionId"] = interaction.ItemId; + } + + if (!string.IsNullOrEmpty(interaction.ActivityItemId)) + { + metadata["activityItemId"] = interaction.ActivityItemId; + } + + if (!string.IsNullOrEmpty(interaction.QueueId)) + { + metadata["queueId"] = interaction.QueueId; + } + + if (!string.IsNullOrEmpty(session?.ItemId)) + { + metadata["callSessionId"] = session.ItemId; + } + + metadata["recordingState"] = session?.RecordingState ?? interaction.RecordingState; + metadata["participantCount"] = session?.ParticipantCount ?? 0; + metadata["isConference"] = session?.IsConference ?? false; + + if (!string.IsNullOrEmpty(session?.RecordingReference ?? interaction.RecordingReference)) + { + metadata["recordingReference"] = session?.RecordingReference ?? interaction.RecordingReference; + } + + return metadata; + } + + private static string ResolveServiceAddress(Interaction interaction) + { + return interaction.TechnicalMetadata.TryGetValue("serviceAddress", out var value) + ? value?.ToString() + : null; + } + + private static CallState MapCallState(ContactCenterCallState? sessionState, InteractionStatus interactionStatus) + { + if (sessionState.HasValue) + { + return sessionState.Value switch + { + ContactCenterCallState.Dialing => CallState.Connecting, + ContactCenterCallState.Ringing => CallState.Ringing, + ContactCenterCallState.Connected => CallState.Connected, + ContactCenterCallState.OnHold => CallState.OnHold, + ContactCenterCallState.Ending => CallState.Disconnected, + ContactCenterCallState.Ended => CallState.Disconnected, + ContactCenterCallState.Transferred => CallState.Disconnected, + ContactCenterCallState.Canceled => CallState.Disconnected, + ContactCenterCallState.NoAnswer => CallState.Failed, + ContactCenterCallState.Rejected => CallState.Failed, + ContactCenterCallState.Failed => CallState.Failed, + _ => CallState.Idle, + }; + } + + return interactionStatus switch + { + InteractionStatus.Ringing => CallState.Ringing, + InteractionStatus.Connected => CallState.Connected, + InteractionStatus.Held => CallState.OnHold, + InteractionStatus.Ended => CallState.Disconnected, + InteractionStatus.Failed => CallState.Failed, + InteractionStatus.Transferring => CallState.Connected, + _ => CallState.Idle, + }; + } + + private static CallDirection MapDirection(InteractionDirection direction) + { + return direction switch + { + InteractionDirection.Inbound => CallDirection.Inbound, + _ => CallDirection.Outbound, + }; + } + + private static CallOutcome ResolveOutcome(ContactCenterCallState? sessionState, CallState callState, InteractionDirection direction) + { + if (sessionState.HasValue) + { + return sessionState.Value switch + { + ContactCenterCallState.Ended or ContactCenterCallState.Transferred => CallOutcome.Completed, + ContactCenterCallState.NoAnswer => direction == InteractionDirection.Inbound ? CallOutcome.Missed : CallOutcome.Failed, + ContactCenterCallState.Rejected => CallOutcome.Rejected, + ContactCenterCallState.Canceled => CallOutcome.Canceled, + ContactCenterCallState.Failed => CallOutcome.Failed, + _ => CallOutcome.InProgress, + }; + } + + return callState == CallState.Failed + ? CallOutcome.Failed + : callState == CallState.Disconnected + ? CallOutcome.Completed + : CallOutcome.InProgress; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs new file mode 100644 index 000000000..337b4eb30 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs @@ -0,0 +1,36 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +/// +/// Clears stale queue and reservation state when provider truth reports that an offered voice call ended +/// before it was actually answered. +/// +public sealed class ContactCenterVoiceOfferReconciliationHandler : IContactCenterEventHandler +{ + private readonly IProviderVoiceOfferSynchronizationService _offerSynchronizationService; + + /// + /// Initializes a new instance of the class. + /// + /// The offer synchronization service. + public ContactCenterVoiceOfferReconciliationHandler(IProviderVoiceOfferSynchronizationService offerSynchronizationService) + { + _offerSynchronizationService = offerSynchronizationService; + } + + /// + public Task HandleAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + if (interactionEvent.EventType != ContactCenterConstants.Events.CallEnded || + string.IsNullOrWhiteSpace(interactionEvent.InteractionId)) + { + return Task.CompletedTask; + } + + return _offerSynchronizationService.ReconcileEndedOfferAsync(interactionEvent.InteractionId, cancellationToken); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterVoiceTenantEvents.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterVoiceTenantEvents.cs new file mode 100644 index 000000000..368f566c0 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterVoiceTenantEvents.cs @@ -0,0 +1,43 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Runs a provider-truth reconciliation pass when the tenant activates so short restarts do not leave +/// persisted voice offers out of sync with the telephony server. +/// +public sealed class ContactCenterVoiceTenantEvents : ModularTenantEvents +{ + private readonly IProviderCallStateSynchronizationService _synchronizationService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The provider call-state synchronization service. + /// The logger. + public ContactCenterVoiceTenantEvents( + IProviderCallStateSynchronizationService synchronizationService, + ILogger logger) + { + _synchronizationService = synchronizationService; + _logger = logger; + } + + /// + /// Reconciles active voice interactions during tenant activation. + /// + public override async Task ActivatingAsync() + { + try + { + await _synchronizationService.ReconcileActiveInteractionsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while reconciling Contact Center voice state during tenant activation."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 7c89f9491..3d88de9b3 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -337,20 +337,25 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped() .AddScoped(sp => sp.GetRequiredService()) .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(); + .AddScoped() + .AddScoped(); services .AddDisplayDriver() @@ -360,6 +365,7 @@ public override void ConfigureServices(IServiceCollection services) .AddDataMigration(); services.AddNavigationProvider(); + services.AddSingleton(); } public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs index 82e30e3b2..4c85f3bfd 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs @@ -21,11 +21,6 @@ namespace CrestApps.OrchardCore.DialPad.Controllers; [Feature(DialPadConstants.Feature.Dialer)] public sealed class DialPadWebhookController : ControllerBase { - private static readonly JsonSerializerOptions _jsonOptions = new() - { - PropertyNameCaseInsensitive = true, - }; - private readonly IDialPadWebhookService _webhookService; private readonly ISiteService _siteService; private readonly IDataProtectionProvider _dataProtectionProvider; @@ -80,7 +75,7 @@ public async Task Call() try { - callEvent = JsonSerializer.Deserialize(payloadJson, _jsonOptions); + callEvent = JsonSerializer.Deserialize(payloadJson, DialPadJsonSerializerOptions.Default); } catch (JsonException) { diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs index 1518e6aad..3321b2cda 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs @@ -1,5 +1,3 @@ -using System.Text.Json.Serialization; - namespace CrestApps.OrchardCore.DialPad.Services; /// @@ -10,48 +8,66 @@ public sealed class DialPadCallEvent /// /// Gets or sets the DialPad call identifier. /// - [JsonPropertyName("call_id")] public string CallId { get; set; } /// /// Gets or sets the DialPad call state (for example ringing, connected, hangup). /// - [JsonPropertyName("state")] public string State { get; set; } /// /// Gets or sets the call direction (inbound or outbound). /// - [JsonPropertyName("direction")] public string Direction { get; set; } /// /// Gets or sets the external party number (the customer number). /// - [JsonPropertyName("external_number")] public string ExternalNumber { get; set; } /// /// Gets or sets the internal number the call was placed to (the dialed DID for inbound calls). /// - [JsonPropertyName("internal_number")] public string InternalNumber { get; set; } /// /// Gets or sets the target number, used as a fallback for the dialed DID. /// - [JsonPropertyName("target")] public string Target { get; set; } /// /// Gets or sets the contact display name supplied by DialPad, when available. /// - [JsonPropertyName("contact_name")] public string ContactName { get; set; } /// /// Gets or sets the epoch milliseconds the event occurred. /// - [JsonPropertyName("event_timestamp")] public long? EventTimestamp { get; set; } + + /// + /// Gets or sets a value indicating whether DialPad reports the call as muted. + /// + public bool? IsMuted { get; set; } + + /// + /// Gets or sets the provider-reported recording state, when present. + /// + public string RecordingState { get; set; } + + /// + /// Gets or sets the recording identifier, when the provider includes one. + /// + public string RecordingId { get; set; } + + /// + /// Gets or sets a value indicating whether the provider reports the call as a conference or + /// multi-party session. + /// + public bool? IsConference { get; set; } + + /// + /// Gets or sets the number of active participants reported by the provider. + /// + public int? ParticipantCount { get; set; } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJsonSerializerOptions.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJsonSerializerOptions.cs new file mode 100644 index 000000000..1d9157382 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJsonSerializerOptions.cs @@ -0,0 +1,18 @@ +using System.Text.Json; + +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Provides JSON serialization options for DialPad provider payloads. +/// +public static class DialPadJsonSerializerOptions +{ + /// + /// Gets the serializer options used for DialPad request and response payloads. + /// + public static JsonSerializerOptions Default { get; } = new(JsonSerializerDefaults.Web) + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true, + }; +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs index 78ef9afc3..41defd8ca 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Net.Http.Headers; using System.Net.Http.Json; using System.Text.Json; @@ -18,7 +19,7 @@ namespace CrestApps.OrchardCore.DialPad.Services; /// API key and per-user OAuth 2.0 authentication. All call control happens server-side, so the soft /// phone client never talks to DialPad directly. /// -public sealed class DialPadTelephonyProvider : ITelephonyProvider, ITelephonyAuthenticationProvider +public sealed class DialPadTelephonyProvider : ITelephonyProvider, ITelephonyAuthenticationProvider, ITelephonyCallStateProvider { private readonly ISiteService _siteService; private readonly IDataProtectionProvider _dataProtectionProvider; @@ -175,6 +176,103 @@ public async Task DialAsync(DialRequest request, CancellationTo } } + /// + public async Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(callId)) + { + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["A call id is required to query the call state."].Value, + }; + } + + var settings = await GetResolvedSettingsAsync(); + + if (!IsConfigured(settings)) + { + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = NotConfigured().Error, + }; + } + + var bearerToken = await GetBearerTokenAsync(settings, cancellationToken); + + if (string.IsNullOrEmpty(bearerToken)) + { + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = NotConnected().Error, + }; + } + + try + { + var client = CreateClient(settings, bearerToken); + using var response = await client.GetAsync($"call/{Uri.EscapeDataString(callId)}", cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("DialPad rejected a call-state lookup for call {CallId} with status code {StatusCode}.", callId, response.StatusCode); + + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["DialPad could not query the call state."].Value, + }; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var root = document.RootElement; + var stateText = ReadString(root, "status") ?? ReadString(root, "state"); + var state = TryMapLookupState(stateText, out var mappedState) + ? mappedState + : CallState.Connected; + var call = BuildCall( + callId, + state, + isMuted: ReadBoolean(root, "is_muted"), + isOnHold: state == CallState.OnHold, + direction: ResolveDirection(ReadString(root, "direction"))); + + call.From = ReadString(root, "external_number") ?? ReadString(root, "from"); + call.To = ReadString(root, "target") ?? ReadString(root, "internal_number") ?? ReadString(root, "to"); + call.StartedUtc = ReadDateTimeOffset(root, "date_started") ?? ReadDateTimeOffset(root, "date_connected"); + call.Metadata["dialPadStatus"] = stateText ?? string.Empty; + + return new TelephonyCallLookupResult + { + Succeeded = true, + Found = true, + Call = call, + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while querying the DialPad call state for call {CallId}.", callId); + + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["DialPad could not query the call state."].Value, + }; + } + } + /// public Task HangupAsync(CallReference call, CancellationToken cancellationToken = default) => ExecuteCallActionAsync(call?.CallId, "hangup", body: null, () => BuildCall(call?.CallId, CallState.Disconnected), cancellationToken); @@ -602,6 +700,77 @@ private static TelephonyCall BuildCall( }; } + private static bool TryMapLookupState(string state, out CallState mapped) + { + mapped = state?.Trim().ToLowerInvariant() switch + { + "calling" or "dialing" or "connecting" or "preanswer" => CallState.Connecting, + "ringing" => CallState.Ringing, + "connected" or "active" => CallState.Connected, + "hold" or "on_hold" or "parked" => CallState.OnHold, + "hangup" or "ended" or "disconnected" or "completed" or "voicemail" => CallState.Disconnected, + "missed" or "no_answer" or "noanswer" => CallState.Failed, + "rejected" or "declined" or "busy" => CallState.Failed, + "canceled" or "cancelled" or "abandoned" => CallState.Disconnected, + _ => (CallState)(-1), + }; + + return Enum.IsDefined(mapped); + } + + private static CallDirection ResolveDirection(string direction) + { + return string.Equals(direction?.Trim(), "inbound", StringComparison.OrdinalIgnoreCase) + ? CallDirection.Inbound + : CallDirection.Outbound; + } + + private static string ReadString(JsonElement element, string propertyName) + { + return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static bool ReadBoolean(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return false; + } + + return value.ValueKind switch + { + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed, + _ => false, + }; + } + + private static DateTimeOffset? ReadDateTimeOffset(JsonElement element, string propertyName) + { + if (!element.TryGetProperty(propertyName, out var value)) + { + return null; + } + + if (value.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(value.GetString(), out var parsed)) + { + return parsed; + } + + if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var unixValue)) + { + return unixValue > 100_000_000_000 + ? DateTimeOffset.FromUnixTimeMilliseconds(unixValue) + : DateTimeOffset.FromUnixTimeSeconds(unixValue); + } + + return null; + } + private static DialPadAuthenticationType GetEffectiveAuthenticationType(DialPadSettings settings) { if (settings.AuthenticationType != DialPadAuthenticationType.NotConfigured) diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs index b1cf5431b..db90c2f8e 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs @@ -56,6 +56,17 @@ public async Task ProcessAsync(DialPadCallEvent callEvent, ToAddress = toAddress, OccurredUtc = occurredUtc, IdempotencyKey = $"{callEvent.CallId}:{callEvent.State}:{callEvent.EventTimestamp}", + IsMuted = callEvent.IsMuted, + RecordingState = TryMapRecordingState(callEvent.RecordingState, out var recordingState) + ? recordingState + : null, + RecordingReference = callEvent.RecordingId, + IsConference = callEvent.IsConference, + ParticipantCount = callEvent.ParticipantCount, + Metadata = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["dialPadState"] = callEvent.State ?? string.Empty, + }, }; var session = await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); @@ -111,4 +122,18 @@ private static bool TryMapState(string state, out ContactCenterCallState mapped) return Enum.IsDefined(mapped); } + + private static bool TryMapRecordingState(string state, out RecordingState mapped) + { + mapped = state?.Trim().ToLowerInvariant() switch + { + "recording" or "started" or "active" => RecordingState.Recording, + "paused" => RecordingState.Paused, + "stopped" or "completed" => RecordingState.Stopped, + "none" or "not_recording" => RecordingState.None, + _ => (RecordingState)(-1), + }; + + return Enum.IsDefined(mapped); + } } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs index d1ed28536..49ee1e24b 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs @@ -49,6 +49,19 @@ public async Task FindByCallIdAsync(string userId, string .FirstOrDefaultAsync(cancellationToken); } + /// + public async Task FindByProviderCallIdAsync(string providerName, string callId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(providerName) || string.IsNullOrEmpty(callId)) + { + return null; + } + + return await _session + .Query(x => x.ProviderName == providerName && x.CallId == callId) + .FirstOrDefaultAsync(cancellationToken); + } + /// public async Task> GetRecentAsync(string userId, int count, CancellationToken cancellationToken = default) { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs index a200c81e9..37e93978f 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs @@ -33,15 +33,15 @@ public async Task AcceptInboundOfferAsync_WhenAgentDeviceNativeProvider_Requires Assert.True(result.Succeeded); Assert.True(result.RequiresDeviceAnswer); Assert.Equal("int1", result.InteractionId); - Assert.Equal(InteractionStatus.Connected, harness.Interaction.Status); - Assert.Equal(_now, harness.Interaction.AnsweredUtc); + Assert.Equal(InteractionStatus.Ringing, harness.Interaction.Status); + Assert.Null(harness.Interaction.AnsweredUtc); harness.Provider.Verify( p => p.ConnectToAgentAsync(It.IsAny(), It.IsAny()), Times.Never); harness.CallSessionManager.Verify( - m => m.CreateAsync(It.Is(s => s.State == ContactCenterCallState.Connected), It.IsAny()), + m => m.CreateAsync(It.Is(s => s.State == ContactCenterCallState.Ringing && !s.AnsweredUtc.HasValue), It.IsAny()), Times.Once); } @@ -205,6 +205,35 @@ public async Task AcceptInboundOfferAsync_WhenReservationBelongsToAnotherAgent_R Times.Never); } + [Fact] + public async Task AcceptInboundOfferAsync_WhenProviderTruthAlreadyEnded_ReturnsFailureWithoutAcceptingReservation() + { + // Arrange + var harness = new Harness(); + harness.SetupPendingReservation(); + harness.SetupInteraction(); + harness.ProviderCallStateSynchronizationService + .Setup(s => s.RefreshInteractionAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + harness.Interaction.Status = InteractionStatus.Ended; + + return harness.Interaction; + }); + + var service = harness.CreateService(); + + // Act + var result = await service.AcceptInboundOfferAsync("r1", "u1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("The offer is no longer available.", result.Reason); + harness.ReservationService.Verify( + s => s.AcceptAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task DeclineInboundOfferAsync_RejectsAndReoffersQueue() { @@ -243,6 +272,8 @@ private sealed class Harness public Mock InboundVoiceService { get; } = new(); + public Mock ProviderCallStateSynchronizationService { get; } = new(); + public Mock Publisher { get; } = new(); public Mock Provider { get; } = new(); @@ -282,6 +313,10 @@ public void SetupInteraction() AgentManager .Setup(m => m.FindByIdAsync("a1", It.IsAny())) .ReturnsAsync(new AgentProfile { ItemId = "a1", UserId = "u1", UserName = "agent" }); + + ProviderCallStateSynchronizationService + .Setup(s => s.RefreshInteractionAsync(Interaction, It.IsAny())) + .ReturnsAsync(Interaction); } public void SetupProvider(VoiceProviderDeliveryModel deliveryModel, ContactCenterVoiceProviderCapabilities capabilities) @@ -346,6 +381,7 @@ public ContactCenterCallCommandService CreateService() TelephonyProviderResolver.Object, CallSessionManager.Object, InboundVoiceService.Object, + ProviderCallStateSynchronizationService.Object, Publisher.Object, clock.Object, logger.Object); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterSoftPhoneEventHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterSoftPhoneEventHandlerTests.cs new file mode 100644 index 000000000..c479dd314 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterSoftPhoneEventHandlerTests.cs @@ -0,0 +1,296 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Handlers; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.SignalR; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterSoftPhoneEventHandlerTests +{ + [Fact] + public async Task HandleAsync_CallConnected_CreatesTelephonyInteraction_AndPushesConnectedState() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ActivityItemId = "activity-1", + ProviderName = "Asterisk", + ProviderInteractionId = "call-1", + CustomerAddress = "+15550001000", + QueueId = "queue-1", + AgentId = "agent-1", + Direction = InteractionDirection.Outbound, + Status = InteractionStatus.Connected, + CreatedUtc = new DateTime(2026, 7, 10, 13, 0, 0, DateTimeKind.Utc), + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderName = "Asterisk", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Outbound, + State = ContactCenterCallState.Connected, + FromAddress = "+15550002000", + ToAddress = "+15550001000", + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + AnsweredUtc = new DateTime(2026, 7, 10, 13, 0, 9, DateTimeKind.Utc), + }; + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(session); + + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByIdAsync("agent-1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "agent-1", + UserId = "user-1", + UserName = "agent.one", + }); + + var store = new Mock(); + store.Setup(value => value.FindByCallIdAsync("user-1", "call-1", It.IsAny())) + .ReturnsAsync((TelephonyInteraction)null); + + TelephonyInteraction createdInteraction = null; + store.Setup(value => value.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((telephonyInteraction, _) => createdInteraction = telephonyInteraction) + .Returns(Task.CompletedTask); + + var client = new Mock(); + var clients = new Mock>(); + var hubContext = new Mock>(); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + hubContext.SetupGet(value => value.Clients).Returns(clients.Object); + + var handler = new ContactCenterSoftPhoneEventHandler( + interactionManager.Object, + callSessionManager.Object, + agentManager.Object, + store.Object, + hubContext.Object); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.CallConnected, + InteractionId = "interaction-1", + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(createdInteraction); + Assert.Equal("interaction-1", createdInteraction.InteractionId); + Assert.Equal(CallOutcome.InProgress, createdInteraction.Outcome); + Assert.Null(createdInteraction.EndedUtc); + + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.Connected && + call.Direction == CallDirection.Outbound)), + Times.Once); + } + + [Fact] + public async Task HandleAsync_CallEnded_UpdatesTelephonyInteraction_AndPushesDisconnectedState() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ActivityItemId = "activity-1", + ProviderName = "Asterisk", + ProviderInteractionId = "call-1", + CustomerAddress = "+15550001000", + QueueId = "queue-1", + AgentId = "agent-1", + Direction = InteractionDirection.Outbound, + Status = InteractionStatus.Ended, + CreatedUtc = new DateTime(2026, 7, 10, 13, 0, 0, DateTimeKind.Utc), + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + EndedUtc = new DateTime(2026, 7, 10, 13, 1, 0, DateTimeKind.Utc), + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderName = "Asterisk", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Outbound, + State = ContactCenterCallState.Ended, + FromAddress = "+15550002000", + ToAddress = "+15550001000", + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + EndedUtc = new DateTime(2026, 7, 10, 13, 1, 0, DateTimeKind.Utc), + }; + var existing = new TelephonyInteraction + { + InteractionId = "interaction-1", + CallId = "call-1", + UserId = "user-1", + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + Outcome = CallOutcome.InProgress, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(session); + + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByIdAsync("agent-1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "agent-1", + UserId = "user-1", + UserName = "agent.one", + }); + + var store = new Mock(); + store.Setup(value => value.FindByCallIdAsync("user-1", "call-1", It.IsAny())) + .ReturnsAsync(existing); + + var client = new Mock(); + var clients = new Mock>(); + var hubContext = new Mock>(); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + hubContext.SetupGet(value => value.Clients).Returns(clients.Object); + + var handler = new ContactCenterSoftPhoneEventHandler( + interactionManager.Object, + callSessionManager.Object, + agentManager.Object, + store.Object, + hubContext.Object); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.CallEnded, + InteractionId = "interaction-1", + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(CallOutcome.Completed, existing.Outcome); + Assert.Equal(new DateTime(2026, 7, 10, 13, 1, 0, DateTimeKind.Utc), existing.EndedUtc); + Assert.Equal(55, existing.DurationSeconds); + + store.Verify(value => value.UpdateAsync(existing, It.IsAny()), Times.Once); + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.Disconnected)), + Times.Once); + } + + [Fact] + public async Task HandleAsync_CallSessionUpdated_PushesMutedHoldState() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ActivityItemId = "activity-1", + ProviderName = "Asterisk", + ProviderInteractionId = "call-1", + CustomerAddress = "+15550001000", + QueueId = "queue-1", + AgentId = "agent-1", + Direction = InteractionDirection.Outbound, + Status = InteractionStatus.Held, + CreatedUtc = new DateTime(2026, 7, 10, 13, 0, 0, DateTimeKind.Utc), + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderName = "Asterisk", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Outbound, + State = ContactCenterCallState.OnHold, + IsOnHold = true, + IsMuted = true, + FromAddress = "+15550002000", + ToAddress = "+15550001000", + StartedUtc = new DateTime(2026, 7, 10, 13, 0, 5, DateTimeKind.Utc), + AnsweredUtc = new DateTime(2026, 7, 10, 13, 0, 9, DateTimeKind.Utc), + }; + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(session); + + var agentManager = new Mock(); + agentManager.Setup(manager => manager.FindByIdAsync("agent-1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "agent-1", + UserId = "user-1", + UserName = "agent.one", + }); + + var store = new Mock(); + store.Setup(value => value.FindByCallIdAsync("user-1", "call-1", It.IsAny())) + .ReturnsAsync((TelephonyInteraction)null); + + var client = new Mock(); + var clients = new Mock>(); + var hubContext = new Mock>(); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + hubContext.SetupGet(value => value.Clients).Returns(clients.Object); + + var handler = new ContactCenterSoftPhoneEventHandler( + interactionManager.Object, + callSessionManager.Object, + agentManager.Object, + store.Object, + hubContext.Object); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.CallSessionUpdated, + InteractionId = "interaction-1", + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.OnHold && + call.IsOnHold && + call.IsMuted)), + Times.Once); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs new file mode 100644 index 000000000..b442156a0 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs @@ -0,0 +1,177 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ProviderVoiceEventServiceTests +{ + [Fact] + public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvents() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderInteractionId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Connected, + RecordingState = RecordingState.None, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + State = ContactCenterCallState.Connected, + IsMuted = false, + RecordingState = RecordingState.None, + IsConference = false, + ParticipantCount = 1, + }; + var publishedEvents = new List(); + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(manager => manager.FindByProviderCallIdAsync("call-1", It.IsAny())) + .ReturnsAsync(session); + + var eventStore = new Mock(); + eventStore.Setup(store => store.ExistsByIdempotencyKeyAsync("key-1", It.IsAny())) + .ReturnsAsync(false); + + var publisher = new Mock(); + publisher.Setup(value => value.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((interactionEvent, _) => publishedEvents.Add(interactionEvent)) + .Returns(Task.CompletedTask); + + var presenceManager = new Mock(); + var voiceProviderResolver = new Mock(); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + voiceProviderResolver.Object, + eventStore.Object, + publisher.Object, + presenceManager.Object, + clock.Object, + NullLogger.Instance); + + // Act + await service.IngestAsync(new ProviderVoiceEvent + { + ProviderCallId = "call-1", + State = ContactCenterCallState.Connected, + IdempotencyKey = "key-1", + IsMuted = true, + RecordingState = RecordingState.Recording, + RecordingReference = "rec-1", + IsConference = true, + ParticipantCount = 3, + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(RecordingState.Recording, interaction.RecordingState); + Assert.Equal("rec-1", interaction.RecordingReference); + Assert.True(session.IsMuted); + Assert.True(session.IsConference); + Assert.Equal(3, session.ParticipantCount); + Assert.Equal("rec-1", session.RecordingReference); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallSessionUpdated); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallMuted); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.RecordingStarted); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallConferenceChanged); + } + + [Fact] + public async Task IngestAsync_WhenCallResumesAndRecordingStops_PublishesResumeAndStopEvents() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderInteractionId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Held, + RecordingState = RecordingState.Paused, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + State = ContactCenterCallState.OnHold, + IsMuted = true, + RecordingState = RecordingState.Paused, + IsConference = true, + ParticipantCount = 3, + }; + var publishedEvents = new List(); + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(manager => manager.FindByProviderCallIdAsync("call-1", It.IsAny())) + .ReturnsAsync(session); + + var eventStore = new Mock(); + eventStore.Setup(store => store.ExistsByIdempotencyKeyAsync("key-2", It.IsAny())) + .ReturnsAsync(false); + + var publisher = new Mock(); + publisher.Setup(value => value.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((interactionEvent, _) => publishedEvents.Add(interactionEvent)) + .Returns(Task.CompletedTask); + + var presenceManager = new Mock(); + var voiceProviderResolver = new Mock(); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + voiceProviderResolver.Object, + eventStore.Object, + publisher.Object, + presenceManager.Object, + clock.Object, + NullLogger.Instance); + + // Act + await service.IngestAsync(new ProviderVoiceEvent + { + ProviderCallId = "call-1", + State = ContactCenterCallState.Connected, + IdempotencyKey = "key-2", + IsMuted = false, + RecordingState = RecordingState.Stopped, + IsConference = false, + ParticipantCount = 2, + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallResumed); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallUnmuted); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.RecordingStopped); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallConferenceChanged); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs new file mode 100644 index 000000000..435487fd8 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs @@ -0,0 +1,125 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ProviderVoiceOfferSynchronizationServiceTests +{ + [Fact] + public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueueAndReleasesAgent() + { + // Arrange + var interaction = new Interaction + { + ItemId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + Status = InteractionStatus.Ended, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + State = ContactCenterCallState.Ended, + }; + var queueItem = new QueueItem + { + ItemId = "queue-1", + ActivityItemId = "act1", + ReservationId = "res-1", + Status = QueueItemStatus.Reserved, + }; + var reservation = new ActivityReservation + { + ItemId = "res-1", + AgentId = "agent-1", + ActivityItemId = "act1", + Status = ReservationStatus.Pending, + }; + var agent = new AgentProfile + { + ItemId = "agent-1", + ActiveReservationId = "res-1", + PresenceStatus = AgentPresenceStatus.Reserved, + QueueIds = ["queue-1"], + }; + var activity = new OmnichannelActivity + { + ItemId = "act1", + AssignmentStatus = ActivityAssignmentStatus.Reserved, + AssignedToId = "user-1", + ReservationId = "res-1", + }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(m => m.FindByInteractionIdAsync("int1", It.IsAny())).ReturnsAsync(session); + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); + + var reservationManager = new Mock(); + reservationManager.Setup(m => m.FindByIdAsync("res-1", It.IsAny())).ReturnsAsync(reservation); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("agent-1", It.IsAny())).ReturnsAsync(agent); + + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act1", It.IsAny())).ReturnsAsync(activity); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + + var logger = new Mock>(); + var service = new ProviderVoiceOfferSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + queueItemManager.Object, + reservationManager.Object, + agentManager.Object, + activityManager.Object, + clock.Object, + logger.Object); + + // Act + await service.ReconcileEndedOfferAsync("int1", TestContext.Current.CancellationToken); + + // Assert + queueItemManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == QueueItemStatus.Removed && value.DequeuedUtc.HasValue), + null, + It.IsAny()), + Times.Once); + reservationManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == ReservationStatus.Canceled), + null, + It.IsAny()), + Times.Once); + agentManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.ActiveReservationId == null && value.PresenceStatus == AgentPresenceStatus.Available), + null, + It.IsAny()), + Times.Once); + activityManager.Verify( + m => m.UpdateAsync( + It.Is(value => + value.AssignmentStatus == ActivityAssignmentStatus.Released && + value.AssignedToId == null && + value.ReservationId == null), + null, + It.IsAny()), + Times.Once); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadJsonSerializerOptionsTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadJsonSerializerOptionsTests.cs new file mode 100644 index 000000000..15006f4ef --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadJsonSerializerOptionsTests.cs @@ -0,0 +1,49 @@ +using System.Text.Json; +using CrestApps.OrchardCore.DialPad.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.DialPad; + +public sealed class DialPadJsonSerializerOptionsTests +{ + [Fact] + public void Default_DeserializesSnakeCasePayloadWithoutJsonAttributes() + { + // Arrange + const string json = """ + { + "call_id": "call-1", + "state": "connected", + "direction": "inbound", + "external_number": "+15551112222", + "internal_number": "+15553334444", + "target": "+15553334444", + "contact_name": "Jane Doe", + "event_timestamp": 1736510400000, + "is_muted": true, + "recording_state": "paused", + "recording_id": "rec-1", + "is_conference": true, + "participant_count": 3 + } + """; + + // Act + var model = JsonSerializer.Deserialize(json, DialPadJsonSerializerOptions.Default); + + // Assert + Assert.NotNull(model); + Assert.Equal("call-1", model.CallId); + Assert.Equal("connected", model.State); + Assert.Equal("inbound", model.Direction); + Assert.Equal("+15551112222", model.ExternalNumber); + Assert.Equal("+15553334444", model.InternalNumber); + Assert.Equal("+15553334444", model.Target); + Assert.Equal("Jane Doe", model.ContactName); + Assert.Equal(1736510400000, model.EventTimestamp); + Assert.True(model.IsMuted); + Assert.Equal("paused", model.RecordingState); + Assert.Equal("rec-1", model.RecordingId); + Assert.True(model.IsConference); + Assert.Equal(3, model.ParticipantCount); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs index 086da3174..9180c1539 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs @@ -98,6 +98,45 @@ public async Task ProcessAsync_UnknownState_IgnoredWithoutIngest() eventService.Verify(s => s.IngestAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task ProcessAsync_WithRicherProviderDetails_PassesNormalizedDetailsToEventService() + { + // Arrange + ProviderVoiceEvent providerEvent = null; + var eventService = new Mock(); + eventService.Setup(s => s.IngestAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => providerEvent = value) + .ReturnsAsync(new CallSession { ItemId = "cs1" }); + + var router = new Mock(); + var service = CreateService(eventService, router); + + var callEvent = new DialPadCallEvent + { + CallId = "c1", + State = "connected", + Direction = "inbound", + IsMuted = true, + RecordingState = "paused", + RecordingId = "rec-1", + IsConference = true, + ParticipantCount = 3, + }; + + // Act + var result = await service.ProcessAsync(callEvent, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(DialPadWebhookResult.Updated, result); + Assert.NotNull(providerEvent); + Assert.True(providerEvent.IsMuted); + Assert.Equal(RecordingState.Paused, providerEvent.RecordingState); + Assert.Equal("rec-1", providerEvent.RecordingReference); + Assert.True(providerEvent.IsConference); + Assert.Equal(3, providerEvent.ParticipantCount); + Assert.Equal("connected", providerEvent.Metadata["dialPadState"]); + } + private static DialPadWebhookService CreateService(Mock eventService, Mock router) { var clock = new Mock(); diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs new file mode 100644 index 000000000..98b9560f7 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs @@ -0,0 +1,129 @@ +using CrestApps.OrchardCore.Asterisk.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Telephony; + +public sealed class AsteriskRealtimeVoiceEventDispatcherTests +{ + [Fact] + public async Task HandleAsync_WhenPlainTelephonyInteractionMatches_EndsInteraction_AndPushesDisconnectedState() + { + // Arrange + var store = new Mock(); + var providerVoiceEventService = new Mock(); + var hubContext = new Mock>(); + var clients = new Mock>(); + var client = new Mock(); + var clock = new Mock(); + var startedUtc = new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc); + var endedUtc = new DateTime(2026, 7, 10, 15, 3, 0, DateTimeKind.Utc); + var interaction = new TelephonyInteraction + { + InteractionId = "interaction-1", + CallId = "call-1", + ProviderName = "Asterisk", + UserId = "user-1", + UserName = "mike", + From = "+15550001000", + To = "+15550002000", + Direction = CallDirection.Outbound, + Outcome = CallOutcome.InProgress, + StartedUtc = startedUtc, + }; + + providerVoiceEventService + .Setup(service => service.IngestAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((CallSession)null); + store + .Setup(value => value.FindByProviderCallIdAsync("Asterisk", "call-1", It.IsAny())) + .ReturnsAsync(interaction); + hubContext.SetupGet(value => value.Clients).Returns(clients.Object); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + clock.SetupGet(value => value.UtcNow).Returns(endedUtc); + + var dispatcher = new AsteriskRealtimeVoiceEventDispatcher( + [providerVoiceEventService.Object], + store.Object, + hubContext.Object, + clock.Object, + NullLogger.Instance); + var voiceEvent = new AsteriskRealtimeVoiceEvent + { + ProviderName = "Asterisk", + CallId = "call-1", + EventType = "ChannelDestroyed", + State = CallState.Disconnected, + FromAddress = "+15550001000", + ToAddress = "+15550002000", + OccurredUtc = endedUtc, + }; + + // Act + await dispatcher.HandleAsync(voiceEvent, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(CallOutcome.Completed, interaction.Outcome); + Assert.Equal(endedUtc, interaction.EndedUtc); + Assert.Equal(180, interaction.DurationSeconds); + + store.Verify(value => value.UpdateAsync(interaction, It.IsAny()), Times.Once); + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.Disconnected && + call.Direction == CallDirection.Outbound)), + Times.Once); + } + + [Fact] + public async Task HandleAsync_WhenContactCenterOwnsCall_SkipsDirectSoftPhoneProjection() + { + // Arrange + var store = new Mock(); + var providerVoiceEventService = new Mock(); + var hubContext = new Mock>(); + var clock = new Mock(); + providerVoiceEventService + .Setup(service => service.IngestAsync(It.Is(value => + value.ProviderCallId == "call-1" && + value.State == ContactCenterCallState.Ended), + It.IsAny())) + .ReturnsAsync(new CallSession + { + ItemId = "session-1", + ProviderCallId = "call-1", + ProviderName = "Asterisk", + }); + + var dispatcher = new AsteriskRealtimeVoiceEventDispatcher( + [providerVoiceEventService.Object], + store.Object, + hubContext.Object, + clock.Object, + NullLogger.Instance); + var voiceEvent = new AsteriskRealtimeVoiceEvent + { + ProviderName = "Asterisk", + CallId = "call-1", + EventType = "ChannelDestroyed", + State = CallState.Disconnected, + }; + + // Act + await dispatcher.HandleAsync(voiceEvent, TestContext.Current.CancellationToken); + + // Assert + store.Verify(value => value.FindByProviderCallIdAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + hubContext.VerifyGet(value => value.Clients, Times.Never); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs new file mode 100644 index 000000000..9c110d8a9 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.Asterisk.Services; +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Tests.Telephony; + +public sealed class AsteriskRealtimeVoiceEventMapperTests +{ + [Fact] + public void TryMap_WhenChannelDestroyedPayloadReceived_ReturnsDisconnectedVoiceEvent() + { + // Arrange + const string payload = + """ + { + "type": "ChannelDestroyed", + "timestamp": "2026-07-10T15:03:00.000Z", + "application": "crestapps-telephony", + "cause": 16, + "cause_txt": "Normal Clearing", + "channel": { + "id": "call-1", + "state": "Up", + "caller": { + "number": "+15550001000" + }, + "connected": { + "number": "+15550002000" + } + } + } + """; + + // Act + var mapped = AsteriskRealtimeVoiceEventMapper.TryMap("Asterisk", payload, out var voiceEvent); + + // Assert + Assert.True(mapped); + Assert.NotNull(voiceEvent); + Assert.Equal("call-1", voiceEvent.CallId); + Assert.Equal(CallState.Disconnected, voiceEvent.State); + Assert.Equal("+15550001000", voiceEvent.FromAddress); + Assert.Equal("+15550002000", voiceEvent.ToAddress); + Assert.Equal("ChannelDestroyed", voiceEvent.EventType); + Assert.Equal("Normal Clearing", voiceEvent.Metadata["causeText"]); + } +} From 38d5ffb7474d90d8e7af778f311513caa0760dc5 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 11:10:40 -0700 Subject: [PATCH 45/56] Harden telephony state synchronization Make provider events durable and terminal-safe, serialize assignment and reconciliation races, and improve Asterisk and DialPad recovery behavior. Add regression coverage and update Contact Center documentation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 1 + .../Models/CallSession.cs | 5 + .../Models/ContactCenterOutboxMessage.cs | 11 +- .../Models/QueueItem.cs | 10 + .../Services/ActivityAssignmentService.cs | 15 +- .../Services/ActivityQueueService.cs | 18 +- .../Services/ActivityReservationService.cs | 98 ++++++- .../Services/CallSessionManager.cs | 16 ++ .../Services/CallSessionStore.cs | 17 ++ .../Services/ContactCenterOutbox.cs | 134 +++++++--- .../Services/ContactCenterOutboxStore.cs | 11 + .../Services/DefaultBusinessHoursService.cs | 48 +++- .../DefaultContactCenterEventPublisher.cs | 6 +- .../Services/DialerAttemptService.cs | 38 ++- .../Services/ICallSessionManager.cs | 9 + .../Services/ICallSessionStore.cs | 9 + .../Services/IContactCenterOutbox.cs | 17 +- .../Services/IContactCenterOutboxStore.cs | 8 + .../Services/IInteractionManager.cs | 17 ++ .../Services/IInteractionStore.cs | 17 ++ ...ProviderCallStateSynchronizationService.cs | 8 + .../Services/InteractionManager.cs | 31 +++ .../Services/InteractionStore.cs | 34 +++ ...ProviderCallStateSynchronizationService.cs | 43 +++- .../Services/ProviderVoiceEventService.cs | 59 ++++- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 9 + .../contact-center/agents-queues-dialer.md | 10 +- .../docs/contact-center/voice-routing.md | 39 ++- src/CrestApps.Docs/docs/telephony/asterisk.md | 8 + src/CrestApps.Docs/docs/telephony/dialpad.md | 9 +- .../AsteriskRealtimeVoiceEventMapper.cs | 28 +- .../Services/AsteriskRealtimeVoiceListener.cs | 80 +++++- .../Services/AsteriskTelephonyProviderBase.cs | 100 +++++++- .../DefaultDialerEligibilityService.cs | 5 + .../Services/VoiceContactCenterCallRouter.cs | 76 ++++++ .../Controllers/DialPadWebhookController.cs | 27 +- .../Models/DialPadSettings.cs | 2 +- .../Services/DialPadWebhookService.cs | 21 +- .../ActivityQueueServiceTests.cs | 70 +++++ .../ActivityReservationServiceTests.cs | 110 +++++++- .../BusinessHoursServiceTests.cs | 55 ++++ .../ContactCenter/ContactCenterOutboxTests.cs | 84 +++++- ...DefaultContactCenterEventPublisherTests.cs | 2 + .../DialerAttemptServiceTests.cs | 32 ++- .../DialerEligibilityServiceTests.cs | 19 ++ .../ContactCenter/InboundVoiceServiceTests.cs | 52 ++++ ...derCallStateSynchronizationServiceTests.cs | 240 ++++++++++++++++++ .../ProviderVoiceEventServiceTests.cs | 175 ++++++++++++- .../DialPad/DialPadWebhookControllerTests.cs | 82 ++++++ .../DialPad/DialPadWebhookServiceTests.cs | 33 +++ .../AsteriskRealtimeVoiceEventMapperTests.cs | 38 +++ .../AsteriskTelephonyProviderTests.cs | 67 +++++ .../Doubles/StubHttpMessageHandler.cs | 8 +- 53 files changed, 2012 insertions(+), 149 deletions(-) create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookControllerTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index cedf8d101..7d70cc0ca 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1553,3 +1553,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-06: Fixed the default Debug solution build by re-enabling `CrestApps.OrchardCore.Reports.Abstractions` and `CrestApps.OrchardCore.Reports` in `CrestApps.OrchardCore.slnx`, which removed the 45 reporting-related errors from plain `dotnet build`. Load Inventory was also tightened back to the Contact Center design: the picker now exposes only **Manual** and **Dialer** sources, dialer inventory loads require a dialer profile, and the default batch loader applies the selected profile's dialing mode and campaign to the created activities while leaving them unassigned for later dialer reservation. Legacy hidden source registrations remain available so older inventory loads do not lose their source metadata. - 2026-07-06: Terminology was aligned again by renaming the Omnichannel Management UI/docs label from **Contact Lists** to **Load Inventory** while keeping the internal `OmnichannelActivityBatch` model/API stable for compatibility. The Contact Center plan also records the dialer workflow decision: technical call outcomes (no answer, busy, disconnected, rejected, failed, voicemail, and similar provider states) must be classified by the dialer/voice layer and then mapped at the Subject level into business dispositions or retry/callback actions; the dialer profile remains an execution policy, not a workflow owner. - 2026-07-09: **Reliability/build-quality review pass (Softphone + Contact Center + Asterisk + DialPad).** Ran a comprehensive review against the stated objectives (code quality, real-time event sync, inbound real-time processing, self-healing, dashboard call-state visibility, unit coverage, build quality). **Shipped:** (1) Fixed 5 `CA1873` analyzer warnings in `TelephonyHub.cs` that failed CI `-warnaserror` — the five completion-log `_logger.LogInformation` sites now guard on `_logger.IsEnabled(LogLevel.Information)`, matching the file's existing `LogHubActionStart` helper pattern; the full solution now builds clean with `-warnaserror` (0 warnings, 0 errors) and all 1219 tests pass. (2) Improved Asterisk dashboard call-state granularity: `AsteriskDiagnosticsService.SummarizeCallState` now reports **"In conference"** for bridged multi-party (3+) calls instead of collapsing them to "Connected" (Ringing/Offering/Offered/On hold/Connected/In conference are now distinguished, with Muted/On hold/bridge-type badges already rendered by `dashboard.js`). (3) Added a self-healing unit test (`HealForAvailabilityAsync_WhenRingingInteractionHasNoActiveReservation_RequeuesIt`) covering the critical path where an agent stuck with a stale **Ringing** interaction and no live reservation is reclaimed so future inbound calls can route — the exact "never stay falsely on a call" objective; 245 ContactCenter+Telephony tests pass. **Findings reported for maintainer decision (not auto-fixed because correct fixes need multi-node/optimistic-concurrency design + live validation):** (a) *[High]* `ActivityReservationService.ReserveAsync` is serialized only by the per-**queue** lock in `ActivityAssignmentService`, but the over-committed resource is the **agent**; two concurrent inbound calls on two queues that share one available agent can both reserve that agent. **Partially fixed this pass:** `ReserveAsync` now re-reads the agent and aborts (compare-and-set on `ActiveReservationId`) before booking, so it no longer double-books once the prior reservation is visible (+1 unit test). **Still recommended:** a per-agent distributed lock around the reserve/accept/release transition (plus an optimistic-concurrency/version guard) to fully close the multi-node TOCTOU window, since YesSql session identity-map caching + commit-at-scope-end make an in-session re-read alone insufficient across nodes. (b) *[Medium]* `ReserveAsync`/`AcceptAsync` vs `ReleaseAsync` (expiry/cancel) are not serialized, so an accepted (live) call can race an expiry pass and be requeued to a second agent; `ReleaseAsync` should re-validate the reservation is still `Pending` under the same per-agent lock before mutating. (c) *[High]* the **Asterisk** module is command-only — it registers no ARI WebSocket/event listener and never emits `ProviderVoiceEvent`/`CallStateChanged`, so remote answer/hangup/hold are never observed and call state cannot stay in sync with the PBX (unlike DialPad's signed webhook → `IProviderVoiceEventService.IngestAsync`). Recommend an in-module ARI event subscriber (hosted service) mapping `ChannelStateChange`/`ChannelHangupRequest`/`StasisEnd`/hold events to normalized provider voice events, mirroring the DialPad path. (d) *[Medium]* DialPad webhook accepts unsigned bodies when no signing secret is configured (`[AllowAnonymous]` + no-secret bypass) — recommend requiring a secret before enabling the webhook. Self-healing (`AgentWorkStateHealingService`), `AgentSessionService` heartbeat/expiry locking, `ProviderVoiceEventService` state mapping/idempotency, OAuth/PKCE + token encryption, and the DialPad JWT signature path all reviewed clean. +- 2026-07-11: **Softphone, Contact Center, Asterisk, and DialPad reliability hardening completed.** Closed the outstanding review findings and additional race/recovery defects: provider events now reject stale/nonterminal-after-terminal transitions and publish unique semantic idempotency keys; every event is durably enqueued before handler fan-out with per-handler completion checkpoints; inbound provider calls use provider-scoped distributed locks and provider+call-id lookups; agent reservation and reservation lifecycle transitions use distributed locks with accept-versus-expiry revalidation; terminal dial failures/permanent suppressions dequeue work; overnight business hours and fail-closed equal dialer windows are covered; overflow preserves total SLA age while tracking per-queue dwell time and preventing queue cycles; Asterisk listeners are independently supervised, reconnect with backoff, perform provider-scoped serialized reconciliation, recover hold/mute variables, reject unknown channel states, and map bridge-leave transitions; DialPad webhooks fail closed without a usable signing secret and include richer state in idempotency keys. Added regression coverage for outbox restart/partial-handler behavior, provider event ordering/idempotency, inbound duplicate delivery, reservation races/agent locks, dialer terminal behavior, overnight hours/overflow chains, provider restart reconciliation, Asterisk granular state, and DialPad authentication. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs index 2c277a931..b6fd06536 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallSession.cs @@ -97,6 +97,11 @@ public sealed class CallSession : CatalogItem, IModifiedUtcAwareModel /// public int ParticipantCount { 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 UTC time the call session was created. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs index 2e3187839..847ac6665 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterOutboxMessage.cs @@ -4,9 +4,9 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Models; /// -/// Represents a durable outbox entry that tracks an event whose handler dispatch failed and must be -/// retried. The interaction event itself remains the immutable audit record; this message carries only -/// the mutable retry state so a transient handler failure no longer silently drops a domain event. +/// 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 { @@ -40,6 +40,11 @@ public sealed class ContactCenterOutboxMessage : CatalogItem, IModifiedUtcAwareM /// public string LastError { 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs index 3e3d43219..3695372e1 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/QueueItem.cs @@ -45,6 +45,11 @@ public sealed class QueueItem : CatalogItem, IModifiedUtcAwareModel /// 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. /// @@ -55,6 +60,11 @@ public sealed class QueueItem : CatalogItem, IModifiedUtcAwareModel /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs index 44a9c5cce..3f6307e84 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs @@ -134,10 +134,11 @@ private async Task AssignNextCoreAsync(string queueId, Canc var agents = await _agentManager.ListAvailableForQueueAsync(queueId, cancellationToken); var decision = await _routingService.SelectAgentAsync(queue, topItem, agents, cancellationToken); - await PublishRoutingDecisionAsync(decision, cancellationToken); if (!decision.Succeeded || decision.Agent is null) { + await PublishRoutingDecisionAsync(decision, cancellationToken); + return null; } @@ -145,7 +146,17 @@ private async Task AssignNextCoreAsync(string queueId, Canc ? queue.ReservationTimeoutSeconds : 30; - return await _reservationService.ReserveAsync(topItem, decision.Agent, timeout, cancellationToken); + 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."; + } + + await PublishRoutingDecisionAsync(decision, cancellationToken); + + return reservation; } private Task PublishRoutingDecisionAsync(ActivityRoutingDecision decision, CancellationToken cancellationToken) diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs index eb8d85523..1b308394b 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs @@ -65,6 +65,7 @@ public async Task EnqueueAsync(string activityItemId, string queueId, item.Status = QueueItemStatus.Waiting; item.StickyAgentUserId = activity?.AssignedToId; item.EnqueuedUtc = _clock.UtcNow; + item.QueueEnteredUtc = item.EnqueuedUtc; await _queueItemManager.CreateAsync(item, cancellationToken: cancellationToken); @@ -131,17 +132,30 @@ public async Task OverflowDueAsync(ActivityQueue queue, CancellationToken c foreach (var item in waiting) { + var queueEnteredUtc = item.QueueEnteredUtc == default + ? item.EnqueuedUtc + : item.QueueEnteredUtc; var overflowDueByWait = queue.OverflowAfterSeconds > 0 - && (now - item.EnqueuedUtc).TotalSeconds >= queue.OverflowAfterSeconds; + && (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.EnqueuedUtc = now; + item.QueueEnteredUtc = now; await _queueItemManager.UpdateAsync(item, cancellationToken: cancellationToken); await _publisher.PublishAsync(new InteractionEvent diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs index 1c8ea37ef..07866ceaa 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -6,6 +6,7 @@ using CrestApps.OrchardCore.Telephony.Models; using Microsoft.Extensions.Logging; using OrchardCore; +using OrchardCore.Locking.Distributed; using OrchardCore.Modules; namespace CrestApps.OrchardCore.ContactCenter.Core.Services; @@ -15,6 +16,9 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// 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; @@ -24,6 +28,7 @@ public sealed class ActivityReservationService : IActivityReservationService private readonly IOmnichannelActivityManager _activityManager; private readonly IContactCenterEventPublisher _publisher; private readonly ITelephonyService _telephonyService; + private readonly IDistributedLock _distributedLock; private readonly IClock _clock; private readonly ILogger _logger; @@ -39,6 +44,7 @@ public sealed class ActivityReservationService : IActivityReservationService /// The CRM activity manager. /// The Contact Center event publisher. /// The optional telephony services used for voice-specific timeout actions. + /// The distributed lock used to serialize agent and reservation transitions. /// The clock used to stamp reservation times. /// The logger. public ActivityReservationService( @@ -51,6 +57,7 @@ public ActivityReservationService( IOmnichannelActivityManager activityManager, IContactCenterEventPublisher publisher, IEnumerable telephonyServices, + IDistributedLock distributedLock, IClock clock, ILogger logger) { @@ -63,6 +70,7 @@ public ActivityReservationService( _activityManager = activityManager; _publisher = publisher; _telephonyService = telephonyServices.FirstOrDefault(); + _distributedLock = distributedLock; _clock = clock; _logger = logger; } @@ -73,6 +81,18 @@ public async Task ReserveAsync(QueueItem queueItem, AgentPr ArgumentNullException.ThrowIfNull(queueItem); ArgumentNullException.ThrowIfNull(agent); + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetAgentReservationLockKey(agent.ItemId), + _lockTimeout, + _lockExpiration); + + if (!locked) + { + return null; + } + + await using var acquiredLock = locker; + var current = await _queueItemManager.FindByIdAsync(queueItem.ItemId, cancellationToken); if (current is null || current.Status != QueueItemStatus.Waiting) @@ -81,12 +101,6 @@ public async Task ReserveAsync(QueueItem queueItem, AgentPr } queueItem = current; - - // Re-read and re-validate the agent before booking them. Assignment is serialized only by a - // per-queue lock, but an agent can belong to several queues; if a concurrent assignment on - // another queue already reserved this agent, abort here instead of double-booking the seat. - // (This compare-and-set narrows the window; full multi-node safety additionally needs a - // per-agent distributed lock around this transition.) agent = await _agentManager.FindByIdAsync(agent.ItemId, cancellationToken) ?? agent; if (!string.IsNullOrWhiteSpace(agent.ActiveReservationId)) @@ -146,6 +160,18 @@ public async Task AcceptAsync(string reservationId, Cancell { 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) @@ -192,6 +218,18 @@ public async Task RejectAsync(string reservationId, Cancell { 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 || @@ -210,6 +248,18 @@ public async Task CancelAsync(string reservationId, Cancell { 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) @@ -225,11 +275,33 @@ public async Task CancelAsync(string reservationId, Cancell /// public async Task ExpireDueAsync(CancellationToken cancellationToken = default) { - var expired = await _reservationManager.ListExpiredAsync(_clock.UtcNow, cancellationToken); + var now = _clock.UtcNow; + var expired = await _reservationManager.ListExpiredAsync(now, cancellationToken); var count = 0; - foreach (var reservation in expired) + foreach (var candidate in expired) { + (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); count++; } @@ -457,4 +529,14 @@ private Task PublishAsync(string eventType, ActivityReservation reservation, Can SourceComponent = ContactCenterConstants.Components.Queues, }, cancellationToken); } + + private static string GetAgentReservationLockKey(string agentId) + { + return $"ContactCenterAgentReservation:{agentId}"; + } + + private static string GetReservationLockKey(string reservationId) + { + return $"ContactCenterReservation:{reservationId}"; + } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs index 567e5be63..65390d575 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionManager.cs @@ -40,6 +40,22 @@ public async Task FindByProviderCallIdAsync(string providerCallId, 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) { diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs index 3eb535d97..20d727906 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallSessionStore.cs @@ -32,6 +32,23 @@ public async Task FindByProviderCallIdAsync(string providerCallId, .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) { diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs index 47e44f683..8c179cc31 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutbox.cs @@ -6,9 +6,10 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// -/// Provides the default implementation. It runs the registered -/// instances, and on failure persists a -/// that a background task redelivers with exponential back-off. +/// 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 { @@ -53,34 +54,30 @@ public ContactCenterOutbox( _logger = logger; } + /// + 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 firstError = await RunHandlersAsync(interactionEvent, cancellationToken); + var message = await GetOrCreateMessageAsync(interactionEvent, cancellationToken); + var firstError = await RunHandlersAsync(interactionEvent, message, cancellationToken); if (firstError is null) { + await _outboxStore.DeleteAsync(message, cancellationToken); + return; } - var now = _clock.UtcNow; - - var message = new ContactCenterOutboxMessage - { - ItemId = IdGenerator.GenerateId(), - EventId = interactionEvent.ItemId, - EventType = interactionEvent.EventType, - Status = OutboxMessageStatus.Pending, - AttemptCount = 1, - NextAttemptUtc = now.Add(GetBackoff(1)), - LastError = firstError, - CreatedUtc = now, - ModifiedUtc = now, - }; - - await _outboxStore.CreateAsync(message, cancellationToken); + await ScheduleRetryAsync(message, firstError, cancellationToken); _logger.LogWarning( "Scheduled Contact Center event '{EventType}' ({EventId}) for retry after a handler failure: {Error}", @@ -114,7 +111,7 @@ public async Task DispatchDueAsync(CancellationToken cancellationToken = de continue; } - var firstError = await RunHandlersAsync(interactionEvent, cancellationToken); + var firstError = await RunHandlersAsync(interactionEvent, message, cancellationToken); if (firstError is null) { @@ -124,41 +121,68 @@ public async Task DispatchDueAsync(CancellationToken cancellationToken = de continue; } - message.AttemptCount++; - message.LastError = firstError; - message.ModifiedUtc = _clock.UtcNow; + await ScheduleRetryAsync(message, firstError, cancellationToken); + } - if (message.AttemptCount >= MaxAttempts) - { - message.Status = OutboxMessageStatus.DeadLettered; + return redelivered; + } - _logger.LogError( - "Dead-lettered Contact Center event '{EventType}' ({EventId}) after {Attempts} failed dispatch attempts: {Error}", - message.EventType, - message.EventId, - message.AttemptCount, - firstError); - } - else - { - message.NextAttemptUtc = _clock.UtcNow.Add(GetBackoff(message.AttemptCount)); - } + private async Task GetOrCreateMessageAsync( + InteractionEvent interactionEvent, + CancellationToken cancellationToken) + { + var existing = await _outboxStore.FindByEventIdAsync(interactionEvent.ItemId, cancellationToken); - await _outboxStore.UpdateAsync(message, cancellationToken); + if (existing is not null) + { + return existing; } - return redelivered; + var now = _clock.UtcNow; + var message = new ContactCenterOutboxMessage + { + ItemId = IdGenerator.GenerateId(), + EventId = interactionEvent.ItemId, + EventType = interactionEvent.EventType, + Status = OutboxMessageStatus.Pending, + NextAttemptUtc = now, + CreatedUtc = now, + ModifiedUtc = now, + }; + + await _outboxStore.CreateAsync(message, cancellationToken); + + return message; } - private async Task RunHandlersAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken) + private async Task RunHandlersAsync( + InteractionEvent interactionEvent, + ContactCenterOutboxMessage message, + CancellationToken cancellationToken) { string firstError = null; + var completedHandlerTypes = message.CompletedHandlerTypes.ToHashSet(StringComparer.Ordinal); + + var handlerIndex = 0; foreach (var handler in _handlers) { + var handlerType = $"{handler.GetType().AssemblyQualifiedName ?? handler.GetType().FullName ?? handler.GetType().Name}:{handlerIndex}"; + handlerIndex++; + + if (completedHandlerTypes.Contains(handlerType)) + { + continue; + } + try { await handler.HandleAsync(interactionEvent, cancellationToken); + + completedHandlerTypes.Add(handlerType); + message.CompletedHandlerTypes = completedHandlerTypes.ToArray(); + message.ModifiedUtc = _clock.UtcNow; + await _outboxStore.UpdateAsync(message, cancellationToken); } catch (Exception ex) { @@ -176,6 +200,34 @@ private async Task RunHandlersAsync(InteractionEvent interactionEvent, C return firstError; } + private async Task ScheduleRetryAsync( + ContactCenterOutboxMessage message, + string error, + CancellationToken cancellationToken) + { + message.AttemptCount++; + message.LastError = error; + message.ModifiedUtc = _clock.UtcNow; + + if (message.AttemptCount >= MaxAttempts) + { + message.Status = OutboxMessageStatus.DeadLettered; + + _logger.LogError( + "Dead-lettered Contact Center event '{EventType}' ({EventId}) after {Attempts} failed dispatch attempts: {Error}", + message.EventType, + message.EventId, + message.AttemptCount, + error); + } + else + { + message.NextAttemptUtc = _clock.UtcNow.Add(GetBackoff(message.AttemptCount)); + } + + await _outboxStore.UpdateAsync(message, cancellationToken); + } + private async Task DeadLetterAsync(ContactCenterOutboxMessage message, string reason, CancellationToken cancellationToken) { message.Status = OutboxMessageStatus.DeadLettered; diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs index 064199cb4..69510ba93 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterOutboxStore.cs @@ -20,6 +20,17 @@ public ContactCenterOutboxStore(ISession 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) { diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs index 408d840ae..07e7d77b4 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultBusinessHoursService.cs @@ -67,16 +67,56 @@ public static bool IsOpen(BusinessHoursCalendar calendar, DateTime utcInstant) return false; } - var window = calendar.WeeklySchedule?.FirstOrDefault(day => day.Day == local.DayOfWeek); + var schedule = calendar.WeeklySchedule; + var window = schedule?.FirstOrDefault(day => day.Day == local.DayOfWeek); + var minuteOfDay = (local.Hour * 60) + local.Minute; - if (window is null || !window.IsOpen || window.CloseMinute <= window.OpenMinute) + 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; } - var minuteOfDay = (local.Hour * 60) + local.Minute; + if (window.OpenMinute == window.CloseMinute) + { + return true; + } + + if (window.OpenMinute < window.CloseMinute) + { + return minuteOfDay >= window.OpenMinute && minuteOfDay < 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) diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs index bc383b77d..5c4196f74 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DefaultContactCenterEventPublisher.cs @@ -9,9 +9,8 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Provides the default implementation of . Events are -/// recorded in the durable interaction event history and then dispatched through , -/// which runs the registered handlers and guarantees at-least-once delivery by scheduling a durable retry -/// when a handler fails. +/// 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 { @@ -79,6 +78,7 @@ await _eventStore.ExistsByIdempotencyKeyAsync(interactionEvent.IdempotencyKey, c } await _eventStore.CreateAsync(interactionEvent, cancellationToken); + await _outbox.EnqueueAsync(interactionEvent, cancellationToken); if (ShellScope.Current is null) { diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs index 888898170..55bdb51f8 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerAttemptService.cs @@ -16,6 +16,8 @@ public sealed class DialerAttemptService : IDialerAttemptService { private readonly IDialerEligibilityService _eligibilityService; private readonly IActivityReservationService _reservationService; + private readonly IQueueItemManager _queueItemManager; + private readonly IActivityQueueService _queueService; private readonly IInteractionManager _interactionManager; private readonly IOmnichannelActivityManager _activityManager; private readonly IVoiceContactCenterCallRouter _voiceCallRouter; @@ -28,6 +30,8 @@ public sealed class DialerAttemptService : IDialerAttemptService /// /// The compliance gate evaluated before every attempt. /// The reservation service used to release failed or suppressed attempts. + /// The queue item manager used to resolve terminal dialer work. + /// The queue service used to remove terminal dialer work. /// The interaction manager used to record attempts. /// The CRM activity manager. /// The voice call router. @@ -37,6 +41,8 @@ public sealed class DialerAttemptService : IDialerAttemptService public DialerAttemptService( IDialerEligibilityService eligibilityService, IActivityReservationService reservationService, + IQueueItemManager queueItemManager, + IActivityQueueService queueService, IInteractionManager interactionManager, IOmnichannelActivityManager activityManager, IVoiceContactCenterCallRouter voiceCallRouter, @@ -46,6 +52,8 @@ public DialerAttemptService( { _eligibilityService = eligibilityService; _reservationService = reservationService; + _queueItemManager = queueItemManager; + _queueService = queueService; _interactionManager = interactionManager; _activityManager = activityManager; _voiceCallRouter = voiceCallRouter; @@ -64,7 +72,7 @@ public async Task TryDialAsync(DialerProfile profile, ActivityReservation if (activity is null) { - await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + await CancelReservationAsync(reservation, removeFromQueue: true, cancellationToken); return false; } @@ -159,7 +167,7 @@ public async Task TryDialAsync(DialerProfile profile, ActivityReservation activity.Status = ActivityStatus.Failed; await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); - await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + await CancelReservationAsync(reservation, removeFromQueue: true, cancellationToken); return false; } @@ -176,7 +184,7 @@ public async Task TryDialAsync(DialerProfile profile, ActivityReservation interaction.TechnicalMetadata["providerErrorCode"] = result.ErrorCode; await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); - await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + await CancelReservationAsync(reservation, removeFromQueue: true, cancellationToken); } await _publisher.PublishAsync(new InteractionEvent @@ -206,7 +214,7 @@ private async Task SuppressAsync( await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); } - await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + await CancelReservationAsync(reservation, removeFromQueue: status.HasValue, cancellationToken); if (_logger.IsEnabled(LogLevel.Information)) { @@ -239,6 +247,28 @@ private async Task SuppressAsync( await _publisher.PublishAsync(suppressionEvent, cancellationToken); } + private async Task CancelReservationAsync( + ActivityReservation reservation, + bool removeFromQueue, + CancellationToken cancellationToken) + { + await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + + if (!removeFromQueue || string.IsNullOrEmpty(reservation.QueueItemId)) + { + return; + } + + var queueItem = await _queueItemManager.FindByIdAsync(reservation.QueueItemId, cancellationToken); + + if (queueItem is null || queueItem.Status is QueueItemStatus.Completed or QueueItemStatus.Removed) + { + return; + } + + await _queueService.DequeueAsync(queueItem, QueueItemStatus.Removed, cancellationToken); + } + private static ActivityStatus? ResolveSuppressedStatus(DialerSuppressionReason reason) { return reason switch diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs index f3c7e99c7..228a296a1 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionManager.cs @@ -16,6 +16,15 @@ public interface ICallSessionManager : ICatalogManager /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs index 381a43edb..744ef065b 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallSessionStore.cs @@ -16,6 +16,15 @@ public interface ICallSessionStore : ICatalog /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs index cae21baca..21e8b4c93 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutbox.cs @@ -3,15 +3,22 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// -/// Dispatches Contact Center domain events to their handlers and guarantees at-least-once delivery: a -/// handler failure during inline dispatch is captured as a durable outbox message and retried with -/// exponential back-off until it succeeds or is dead-lettered. Handlers must therefore be idempotent. +/// 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 { /// - /// Runs every registered handler for the event inline. When one or more handlers fail, a durable - /// outbox message is scheduled so the event is retried later instead of being silently lost. + /// 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. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs index 848271d5b..801932280 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterOutboxStore.cs @@ -8,6 +8,14 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs index 31ba314db..c6f7dcf8e 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionManager.cs @@ -34,6 +34,15 @@ public interface IInteractionManager : ICatalogManager /// 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. /// @@ -76,4 +85,12 @@ public interface IInteractionManager : ICatalogManager /// The token to monitor for cancellation requests. /// The active provider-backed interactions. Task> ListActiveWithProviderCallIdAsync(CancellationToken cancellationToken = default); + + /// + /// Lists active interactions for the specified provider that still carry a provider call identifier. + /// + /// The provider technical name. + /// The token to monitor for cancellation requests. + /// The active provider-backed interactions. + Task> ListActiveWithProviderCallIdAsync(string providerName, 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 index 7c0027458..30c7cb68a 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionStore.cs @@ -34,6 +34,15 @@ public interface IInteractionStore : ICatalog /// 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. /// @@ -76,4 +85,12 @@ public interface IInteractionStore : ICatalog /// The token to monitor for cancellation requests. /// The active provider-backed interactions. Task> ListActiveWithProviderCallIdAsync(CancellationToken cancellationToken = default); + + /// + /// Lists active interactions for the specified provider that still carry a provider call identifier. + /// + /// The provider technical name. + /// The token to monitor for cancellation requests. + /// The active provider-backed interactions. + Task> ListActiveWithProviderCallIdAsync(string providerName, 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 index a4b6f8841..456a36789 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderCallStateSynchronizationService.cs @@ -22,4 +22,12 @@ public interface IProviderCallStateSynchronizationService /// 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/InteractionManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs index 8d1712a5a..df650c383 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionManager.cs @@ -68,6 +68,22 @@ public async Task FindByProviderInteractionIdAsync(string providerI 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) { @@ -125,4 +141,19 @@ public async Task> ListActiveWithProviderCallId return interactions; } + + /// + public async Task> ListActiveWithProviderCallIdAsync( + string providerName, + CancellationToken cancellationToken = default) + { + var interactions = await _store.ListActiveWithProviderCallIdAsync(providerName, cancellationToken); + + foreach (var interaction in interactions) + { + await LoadAsync(interaction, cancellationToken); + } + + return interactions; + } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs index 8282fa582..cae52b4c9 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionStore.cs @@ -58,6 +58,23 @@ public async Task FindByProviderInteractionIdAsync(string providerI .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) { @@ -128,4 +145,21 @@ public async Task> ListActiveWithProviderCallId .OrderByDescending(index => index.CreatedUtc) .ListAsync(cancellationToken)).ToArray(); } + + /// + public async Task> ListActiveWithProviderCallIdAsync( + string providerName, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + + return (await Session.Query( + index => index.ProviderName == providerName && + index.Status != InteractionStatus.Ended && + index.Status != InteractionStatus.Failed, + collection: ContactCenterConstants.CollectionName) + .Where(index => index.ProviderInteractionId != null && index.ProviderInteractionId != string.Empty) + .OrderByDescending(index => index.CreatedUtc) + .ListAsync(cancellationToken)).ToArray(); + } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs index 2ae215b41..45e63bd19 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs @@ -3,6 +3,7 @@ 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; @@ -13,10 +14,15 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// 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 readonly IInteractionManager _interactionManager; private readonly ICallSessionManager _callSessionManager; private readonly IProviderVoiceEventService _providerVoiceEventService; private readonly ITelephonyProviderResolver _telephonyProviderResolver; + private readonly IDistributedLock _distributedLock; private readonly IClock _clock; private readonly ILogger _logger; @@ -27,6 +33,7 @@ public sealed class ProviderCallStateSynchronizationService : IProviderCallState /// The call session manager. /// The provider voice-event ingestion 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( @@ -34,6 +41,7 @@ public ProviderCallStateSynchronizationService( ICallSessionManager callSessionManager, IProviderVoiceEventService providerVoiceEventService, ITelephonyProviderResolver telephonyProviderResolver, + IDistributedLock distributedLock, IClock clock, ILogger logger) { @@ -41,6 +49,7 @@ public ProviderCallStateSynchronizationService( _callSessionManager = callSessionManager; _providerVoiceEventService = providerVoiceEventService; _telephonyProviderResolver = telephonyProviderResolver; + _distributedLock = distributedLock; _clock = clock; _logger = logger; } @@ -89,14 +98,44 @@ public async Task RefreshInteractionAsync(Interaction interaction, var providerEvent = BuildProviderEvent(interaction, lookup); await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); - return await _interactionManager.FindByProviderInteractionIdAsync(interaction.ProviderInteractionId, cancellationToken) + return await _interactionManager.FindByProviderInteractionIdAsync( + interaction.ProviderName, + interaction.ProviderInteractionId, + cancellationToken) ?? interaction; } /// public async Task ReconcileActiveInteractionsAsync(CancellationToken cancellationToken = default) { - var interactions = await _interactionManager.ListActiveWithProviderCallIdAsync(cancellationToken); + 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(cancellationToken) + : await _interactionManager.ListActiveWithProviderCallIdAsync(providerName, cancellationToken); var refreshed = 0; foreach (var interaction in interactions) diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs index 2ae42718b..86e37e88c 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs @@ -73,7 +73,12 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc return null; } - var interaction = await _interactionManager.FindByProviderInteractionIdAsync(providerEvent.ProviderCallId, cancellationToken); + var interaction = !string.IsNullOrWhiteSpace(providerEvent.ProviderName) + ? await _interactionManager.FindByProviderInteractionIdAsync( + providerEvent.ProviderName, + providerEvent.ProviderCallId, + cancellationToken) + : await _interactionManager.FindByProviderInteractionIdAsync(providerEvent.ProviderCallId, cancellationToken); if (interaction is null) { @@ -89,6 +94,24 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc var now = providerEvent.OccurredUtc ?? _clock.UtcNow; var session = await EnsureSessionAsync(interaction, providerEvent, now, cancellationToken); + + 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, + providerEvent.ProviderCallId, + session.State, + providerEvent.State, + session.LastProviderEventUtc, + now); + } + + return session; + } + var previousState = session.State; var previousIsMuted = session.IsMuted; var previousRecordingState = session.RecordingState; @@ -97,6 +120,7 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc ApplyState(session, interaction, providerEvent.State, now); ApplyProviderDetails(session, interaction, providerEvent); + session.LastProviderEventUtc = now; await _callSessionManager.UpdateAsync(session, cancellationToken: cancellationToken); await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); @@ -124,7 +148,9 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc previousParticipantCount, session.ParticipantCount)) { - await PublishAsync(eventType, interaction.ItemId, session.AgentId, providerEvent.IdempotencyKey, cancellationToken); + var idempotencyKey = ResolveEventIdempotencyKey(providerEvent.IdempotencyKey, eventType); + + await PublishAsync(eventType, interaction.ItemId, session.AgentId, idempotencyKey, cancellationToken); } return session; @@ -136,7 +162,12 @@ private async Task EnsureSessionAsync( DateTime now, CancellationToken cancellationToken) { - var session = await _callSessionManager.FindByProviderCallIdAsync(providerEvent.ProviderCallId, 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) @@ -158,6 +189,7 @@ private async Task EnsureSessionAsync( 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); @@ -165,6 +197,16 @@ private async Task EnsureSessionAsync( return session; } + private static bool ShouldIgnoreEvent(CallSession session, ProviderVoiceEvent providerEvent, DateTime occurredUtc) + { + if (session.LastProviderEventUtc.HasValue && occurredUtc < session.LastProviderEventUtc.Value) + { + return true; + } + + return IsTerminalState(session.State) && !IsTerminalState(providerEvent.State); + } + private static void ApplyState(CallSession session, Interaction interaction, ContactCenterCallState state, DateTime now) { session.State = state; @@ -387,6 +429,17 @@ ContactCenterCallState.Canceled or ContactCenterCallState.Transferred; } + private static string ResolveEventIdempotencyKey(string providerEventKey, string eventType) + { + if (string.IsNullOrEmpty(providerEventKey) || + eventType == ContactCenterConstants.Events.CallSessionUpdated) + { + return providerEventKey; + } + + return $"{providerEventKey}:{eventType}"; + } + private async Task TryBridgeAnsweredOutboundAsync(CallSession session, Interaction interaction, CancellationToken cancellationToken) { if (session.Direction != InteractionDirection.Outbound || string.IsNullOrEmpty(session.AgentId)) diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index b1a1aba95..f7077893d 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -53,6 +53,15 @@ At a high level, the platform changes are: ### Contact Center +- 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. diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index e62cb744e..f1fdca9e8 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -64,13 +64,13 @@ Agent capacity is enforced during candidate selection. Each agent profile define ### Business hours and overflow -A queue can reference a reusable **business-hours calendar** (managed from **Interaction Center → Business hours**). A calendar defines a time zone, a weekly open window per day, and all-day holiday dates. While the calendar reports the queue closed, assignment pauses. The queue's **after-hours action** decides what happens to waiting items: *Hold in queue* keeps them until the queue reopens, and *Overflow* moves them to the configured overflow queue. +A queue can reference a reusable **business-hours calendar** (managed from **Interaction Center → Business hours**). A calendar defines a time zone, a weekly open window per day, and all-day holiday dates. Weekly windows can cross midnight, and equal opening/closing times represent an enabled 24-hour day. While the calendar reports the queue closed, assignment pauses. The queue's **after-hours action** decides what happens to waiting items: *Hold in queue* keeps them until the queue reopens, and *Overflow* moves them to the configured overflow queue. -Independently of business hours, a queue may set an **overflow queue** and an **overflow-after** threshold. Waiting items that exceed the threshold are moved to the overflow queue so long-waiting work can be picked up by a broader team. Overflow moves run each minute alongside reservation expiry and assignment. +Independently of business hours, a queue may set an **overflow queue** and an **overflow-after** threshold. Waiting items that exceed the threshold are moved to the overflow queue so long-waiting work can be picked up by a broader team. Contact Center preserves the original enqueue time for SLA aging while separately tracking when the item entered its current queue, so every overflow hop receives its configured dwell time and visited queues cannot form a routing cycle. Overflow moves run each minute alongside reservation expiry and assignment. A reservation locks the activity for one agent and can be accepted, rejected, canceled, or expired. The CRM activity moves through `Available → Reserved → Assigned`, mirrored on the queue item and agent presence. Canceled reservations always return the item to the queue. The **Reservation timeout (seconds)** setting controls how long an unanswered offer stays reserved, and **Unanswered offer action** controls what happens when that timeout expires: requeue the work, send the live voice call to voicemail, or reject the live voice call. Voicemail and reject are voice-only actions; when there is no live provider call to act on, the system safely falls back to requeueing the work instead of dropping it. A background task expires stale reservations and assigns waiting work every minute. -Assignment is concurrency-safe. Each queue's assignment runs under a per-queue distributed lock, so two nodes — or the reservation-expiry background task running alongside an inbound call — cannot double-assign the same item or reserve the same agent twice. +Assignment is concurrency-safe. Each queue's assignment runs under a per-queue distributed lock, agent reservation creation runs under a per-agent distributed lock across all queues, and reservation accept/reject/cancel/expiry transitions share a per-reservation lock. Expiry re-reads the reservation after acquiring the lock and releases it only while it is still pending, so an accept racing the expiry sweep cannot requeue a live call. ## Dialer @@ -95,10 +95,10 @@ Before every attempt, `IDialerEligibilityService` runs and records an auditable - **Destination present** and the **maximum attempt count** has not been reached. - **Retry cool-down** - a previous attempt must be older than `RetryDelayMinutes`. - **Do-not-call / communication preferences** - the contact's `DoNotCall` opt-out (when *Respect do-not-call and communication preferences* is enabled). -- **Calling window** - when *Enforce a calling window* is enabled, the contact is only dialed while their local time (from the contact's time zone, or the profile's default time zone) is within the configured start/end hours. +- **Calling window** - when *Enforce a calling window* is enabled, the contact is only dialed while their local time (from the contact's time zone, or the profile's default time zone) is within the configured start/end hours. Equal start/end hours fail closed instead of silently allowing calls all day. - **National do-not-call registries** - any registered `INationalDoNotCallRegistry` (for example the USA FTC or Canada DNCL registries) is scrubbed when *Respect do-not-call* is enabled. -Do-not-call and registry suppressions cancel the activity; calling-window and cool-down suppressions release the reservation and leave the activity available for a later cycle. Full calling-window calendars, abandonment caps, and answering-machine detection are hardened in a later compliance phase. +Do-not-call, registry, missing-destination, and maximum-attempt suppressions are terminal and remove the queue item so the dialer cannot retry them forever. Calling-window and cool-down suppressions release the reservation and leave the activity available for a later cycle. Terminal provider dial failures also remove the queue item, while retryable failures remain available according to the configured retry policy. Full calling-window calendars, abandonment caps, and answering-machine detection are hardened in a later compliance phase. ### Callback operations diff --git a/src/CrestApps.Docs/docs/contact-center/voice-routing.md b/src/CrestApps.Docs/docs/contact-center/voice-routing.md index 358011fb6..174f5d812 100644 --- a/src/CrestApps.Docs/docs/contact-center/voice-routing.md +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -67,11 +67,13 @@ The provider never pushes state directly to the browser. It always comes into Or `VoiceContactCenterCallRouter` takes the inbound event and: -1. resolves the dialed number (`ToAddress`) to the configured phone channel endpoint -2. resolves the matching subject flow and optional CRM contact -3. creates the `OmnichannelActivity` -4. creates the `Interaction` -5. resolves the entry-point plan and target queue +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. creates the `OmnichannelActivity` +6. creates the `Interaction` +7. resolves the entry-point plan and target queue At this stage: @@ -238,7 +240,11 @@ That contract carries the authoritative server-side facts Contact Center cares a - conference state - idempotency key -`ProviderVoiceEventService` ingests those events idempotently and updates the durable interaction/session projection. +`ProviderVoiceEventService` ingests those events idempotently and updates the durable interaction/session projection. Provider name and call id are queried together so identical call ids from two providers cannot collide. 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. + +### 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 @@ -310,6 +316,8 @@ This catches cases where: - 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. + ### 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. @@ -318,20 +326,23 @@ When an agent becomes available again or reconnects, Contact Center can re-check The current voice flow stays consistent because it combines these protections: -1. **Per-queue distributed locks** prevent double assignment. -2. **Reservations** make offers explicit and auditable. -3. **Provider call ids** let Contact Center correlate server truth back to local interactions. -4. **Idempotent provider-event ingestion** prevents duplicate webhook deliveries from corrupting state. -5. **Pre-accept provider refresh** stops agents from accepting already-ended calls. -6. **Ended-offer reconciliation** clears stale queue and agent state immediately. -7. **Tenant-startup and periodic reconciliation** repair drift after restarts or missed events. -8. **Server-driven soft-phone projection** keeps the browser as a mirror instead of a source of truth. +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. **Ordered, terminal-safe provider-event ingestion** prevents stale 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 - `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. +- 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. diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md index 2ec1624ce..a52fa3c29 100644 --- a/src/CrestApps.Docs/docs/telephony/asterisk.md +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -122,6 +122,14 @@ The provider uses ARI endpoints such as: Because all requests are issued server-side, the ARI password never reaches the browser. +## Real-time call state and recovery + +The module keeps a tenant-scoped ARI WebSocket listener for every configured Asterisk provider. Each listener is supervised independently, reconnects with exponential jitter after failure, and runs an immediate provider-scoped reconciliation 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 browser polling. 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, hold/unhold, mute-variable, state-change, hangup, and Stasis lifecycle events update the projection in real time. + +Periodic and startup 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. + ## Voicemail routing When an agent clicks **Send to voicemail**, Orchard now sends a provider-neutral metadata bag along diff --git a/src/CrestApps.Docs/docs/telephony/dialpad.md b/src/CrestApps.Docs/docs/telephony/dialpad.md index 6dda3f92d..d124d258f 100644 --- a/src/CrestApps.Docs/docs/telephony/dialpad.md +++ b/src/CrestApps.Docs/docs/telephony/dialpad.md @@ -40,7 +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** | 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. | +| **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. @@ -122,8 +122,11 @@ dialing and call transfer. 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, ended) update the interaction and call session. When no signing secret is - configured, unsigned JSON webhooks are accepted, which is only recommended for local testing. + 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. + +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 diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs index 4f5983f96..191bb0707 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs @@ -1,4 +1,6 @@ using System.Globalization; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using CrestApps.OrchardCore.Telephony.Models; @@ -47,7 +49,7 @@ public static bool TryMap(string providerName, string payload, out AsteriskRealt IsMuted = isMuted, IsOnHold = isOnHold, OccurredUtc = occurredUtc, - IdempotencyKey = BuildIdempotencyKey(providerName, eventType, callId, occurredUtc, metadata), + IdempotencyKey = BuildIdempotencyKey(providerName, payload), IsConference = TryReadParticipantCount(root, out var participantCount) ? participantCount > 2 : null, @@ -110,17 +112,11 @@ private static Dictionary BuildMetadata(JsonElement root, JsonEl return metadata; } - private static string BuildIdempotencyKey( - string providerName, - string eventType, - string callId, - DateTime? occurredUtc, - Dictionary metadata) + private static string BuildIdempotencyKey(string providerName, string payload) { - metadata.TryGetValue("bridgeId", out var bridgeId); - metadata.TryGetValue("asteriskId", out var asteriskId); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(payload)); - return $"{providerName}:{eventType}:{callId}:{occurredUtc?.ToString("O", CultureInfo.InvariantCulture) ?? string.Empty}:{bridgeId ?? string.Empty}:{asteriskId ?? string.Empty}"; + return $"{providerName}:{Convert.ToHexString(hash)}"; } private static bool TryResolveChannel(JsonElement root, out JsonElement channel) @@ -174,6 +170,18 @@ private static bool TryMapState( return true; } + if (string.Equals(eventType, "ChannelLeftBridge", StringComparison.OrdinalIgnoreCase)) + { + state = MapChannelState(ReadString(channel, "state"), isTerminalEvent: false); + + if (state == CallState.Idle) + { + state = CallState.Connected; + } + + return true; + } + if (string.Equals(eventType, "ChannelVarset", StringComparison.OrdinalIgnoreCase)) { var variable = ReadString(root, "variable"); diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs index 9ffe09371..2a7665988 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs @@ -1,5 +1,6 @@ using System.Net.WebSockets; using System.Text; +using CrestApps.OrchardCore.ContactCenter.Core.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell.Scope; @@ -76,18 +77,31 @@ public async ValueTask DisposeAsync() private async Task RunAsync(IReadOnlyList listeners, CancellationToken cancellationToken) { - while (!cancellationToken.IsCancellationRequested) + if (listeners.Count == 0) { - if (listeners.Count == 0) - { - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); - continue; - } + return; + } + + await Task.WhenAll(listeners.Select(listener => RunListenerAsync(listener, cancellationToken))); + } + + private async Task RunListenerAsync(AsteriskResolvedSettings settings, CancellationToken cancellationToken) + { + if (!AsteriskSettingsUtilities.HasRequiredConfiguration(settings)) + { + return; + } + + var failureCount = 0; + while (!cancellationToken.IsCancellationRequested) + { try { - await Task.WhenAll(listeners.Select(listener => ListenAsync(listener, cancellationToken))); + await ListenAsync(settings, cancellationToken); + failureCount = 0; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -95,23 +109,23 @@ private async Task RunAsync(IReadOnlyList listeners, C } catch (Exception ex) { - _logger.LogError(ex, "The Asterisk real-time voice listener failed unexpectedly."); + failureCount++; + + _logger.LogError( + ex, + "The Asterisk real-time voice listener for provider {ProviderName} failed unexpectedly.", + settings.ProviderName); } if (!cancellationToken.IsCancellationRequested) { - await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken); + await Task.Delay(GetReconnectDelay(failureCount), cancellationToken); } } } private async Task ListenAsync(AsteriskResolvedSettings settings, CancellationToken cancellationToken) { - if (!AsteriskSettingsUtilities.HasRequiredConfiguration(settings)) - { - return; - } - using var socket = new ClientWebSocket(); var eventsUri = AsteriskSettingsUtilities.CreateEventsUri(settings); @@ -132,6 +146,8 @@ private async Task ListenAsync(AsteriskResolvedSettings settings, CancellationTo settings.ProviderName); } + await ReconcileAsync(settings.ProviderName, cancellationToken); + var buffer = new byte[8 * 1024]; while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) @@ -166,6 +182,42 @@ private async Task ListenAsync(AsteriskResolvedSettings settings, CancellationTo } } + private static TimeSpan GetReconnectDelay(int failureCount) + { + var exponent = Math.Min(Math.Max(failureCount, 0), 5); + var seconds = Math.Min(Math.Pow(2, exponent), 30); + var jitter = 0.8 + (Random.Shared.NextDouble() * 0.4); + + return TimeSpan.FromSeconds(seconds * jitter); + } + + private async Task ReconcileAsync(string providerName, CancellationToken cancellationToken) + { + await ShellScope.UsingChildScopeAsync(async scope => + { + var service = scope.ServiceProvider + .GetServices() + .FirstOrDefault(); + + if (service is null) + { + return; + } + + try + { + await service.ReconcileProviderInteractionsAsync(providerName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Provider-state reconciliation failed after reconnecting the Asterisk real-time listener for provider {ProviderName}.", + providerName); + } + }); + } + private async Task DispatchAsync(string providerName, string payload, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(payload)) diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs index c37a55e4c..443ec1285 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs @@ -193,13 +193,44 @@ public async Task GetCallStateAsync(string callId, Ca using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); var root = document.RootElement; var stateText = ReadString(root, "state"); - var state = TryMapLookupState(stateText, out var mappedState) - ? mappedState - : CallState.Connected; + + if (!TryMapLookupState(stateText, out var state)) + { + _logger.LogWarning( + "Asterisk returned an unrecognized channel state '{State}' for provider {ProviderName} call {CallId}; reconciliation was skipped.", + stateText, + ProviderName, + callId); + + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["Asterisk returned an unrecognized call state."].Value, + }; + } + + var holdState = await GetChannelBooleanVariableAsync( + settings, + callId, + AsteriskConstants.HoldStateVariableName, + cancellationToken); + var muteState = await GetChannelBooleanVariableAsync( + settings, + callId, + AsteriskConstants.MuteStateVariableName, + cancellationToken); + var isOnHold = state == CallState.OnHold || holdState == true; + + if (isOnHold) + { + state = CallState.OnHold; + } + var call = BuildCall( callId, state, - isOnHold: state == CallState.OnHold, + isOnHold: isOnHold, + isMuted: muteState ?? false, direction: ResolveDirection(ReadString(root, "direction"))); call.From = ReadNestedString(root, "caller", "number"); @@ -207,6 +238,16 @@ public async Task GetCallStateAsync(string callId, Ca call.StartedUtc = _clock.UtcNow; call.Metadata["asteriskState"] = stateText ?? string.Empty; + if (holdState.HasValue) + { + call.Metadata["asteriskHoldState"] = holdState.Value; + } + + if (muteState.HasValue) + { + call.Metadata["asteriskMuteState"] = muteState.Value; + } + return new TelephonyCallLookupResult { Succeeded = true, @@ -622,6 +663,57 @@ private static bool TryMapLookupState(string state, out CallState mapped) return Enum.IsDefined(mapped); } + private async Task GetChannelBooleanVariableAsync( + AsteriskResolvedSettings settings, + string callId, + string variableName, + CancellationToken cancellationToken) + { + using var response = await SendAsync( + settings, + HttpMethod.Get, + $"channels/{Uri.EscapeDataString(callId)}/variable", + new Dictionary + { + ["variable"] = variableName, + }, + null, + cancellationToken); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return false; + } + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Asterisk could not query channel variable {VariableName} for provider {ProviderName} call {CallId}. Status code: {StatusCode}.", + variableName, + ProviderName, + callId, + response.StatusCode); + + return null; + } + + await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken); + using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + var value = ReadString(document.RootElement, "value"); + + if (bool.TryParse(value, out var parsed)) + { + return parsed; + } + + return value switch + { + "1" => true, + "0" => false, + _ => null, + }; + } + private static CallDirection ResolveDirection(string direction) { return string.Equals(direction?.Trim(), "inbound", StringComparison.OrdinalIgnoreCase) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/DefaultDialerEligibilityService.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/DefaultDialerEligibilityService.cs index fd38a1909..ef7c801a4 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/DefaultDialerEligibilityService.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/DefaultDialerEligibilityService.cs @@ -159,6 +159,11 @@ private bool IsWithinCallingWindow(DialerProfile profile, string contactTimeZone var localNow = TimeZoneInfo.ConvertTimeFromUtc(_clock.UtcNow, timeZone); var hour = localNow.Hour; + if (startHour == endHour) + { + return false; + } + if (startHour < endHour) { return hour >= startHour && hour < endHour; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs index 3415f5934..02f1858ae 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs @@ -8,6 +8,8 @@ using CrestApps.OrchardCore.Telephony; using CrestApps.OrchardCore.Telephony.Models; using OrchardCore.ContentManagement; +using OrchardCore.Environment.Shell.Scope; +using OrchardCore.Locking.Distributed; using OrchardCore.Modules; namespace CrestApps.OrchardCore.ContactCenter.Services; @@ -20,6 +22,8 @@ namespace CrestApps.OrchardCore.ContactCenter.Services; public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter, IInboundVoiceService { private const string ServiceAddressMetadataKey = "serviceAddress"; + private static readonly TimeSpan _inboundLockTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan _inboundLockExpiration = TimeSpan.FromMinutes(1); private readonly IOmnichannelChannelEndpointManager _channelEndpointManager; private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; @@ -35,6 +39,7 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter private readonly IIncomingCallDispatcher _incomingCallDispatcher; private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; private readonly IEntryPointResolver _entryPointResolver; + private readonly IDistributedLock _distributedLock; private readonly IClock _clock; /// @@ -54,6 +59,7 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter /// The dispatcher used to offer the ringing call to the agent. /// The voice provider resolver used for outbound voice calls. /// The entry point resolver used to route inbound calls by dialed number. + /// The distributed lock used to serialize inbound call creation by provider call id. /// The clock used to stamp times. public VoiceContactCenterCallRouter( IOmnichannelChannelEndpointManager channelEndpointManager, @@ -70,6 +76,7 @@ public VoiceContactCenterCallRouter( IIncomingCallDispatcher incomingCallDispatcher, IContactCenterVoiceProviderResolver voiceProviderResolver, IEntryPointResolver entryPointResolver, + IDistributedLock distributedLock, IClock clock) { _channelEndpointManager = channelEndpointManager; @@ -86,6 +93,7 @@ public VoiceContactCenterCallRouter( _incomingCallDispatcher = incomingCallDispatcher; _voiceProviderResolver = voiceProviderResolver; _entryPointResolver = entryPointResolver; + _distributedLock = distributedLock; _clock = clock; } @@ -139,7 +147,70 @@ public async Task RouteInboundAsync(InboundVoiceEvent { ArgumentNullException.ThrowIfNull(inboundEvent); + if (string.IsNullOrWhiteSpace(inboundEvent.ProviderCallId)) + { + return await RouteInboundCoreAsync(inboundEvent, cancellationToken); + } + + (var locker, var locked) = await _distributedLock.TryAcquireLockAsync( + GetInboundLockKey(inboundEvent), + _inboundLockTimeout, + _inboundLockExpiration); + + if (!locked) + { + throw new InvalidOperationException($"Inbound call '{inboundEvent.ProviderCallId}' is already being routed."); + } + + var releaseDeferred = false; + + try + { + if (ShellScope.Current is not null) + { + ShellScope.AddDeferredTask(_ => locker.DisposeAsync().AsTask()); + releaseDeferred = true; + } + + return await RouteInboundCoreAsync(inboundEvent, cancellationToken); + } + finally + { + if (!releaseDeferred && locker is not null) + { + await locker.DisposeAsync(); + } + } + } + + private async Task RouteInboundCoreAsync( + InboundVoiceEvent inboundEvent, + CancellationToken cancellationToken) + { var result = new InboundVoiceRoutingResult(); + Interaction existing = null; + + if (!string.IsNullOrWhiteSpace(inboundEvent.ProviderCallId)) + { + existing = !string.IsNullOrWhiteSpace(inboundEvent.ProviderName) + ? await _interactionManager.FindByProviderInteractionIdAsync( + inboundEvent.ProviderName, + inboundEvent.ProviderCallId, + cancellationToken) + : await _interactionManager.FindByProviderInteractionIdAsync(inboundEvent.ProviderCallId, cancellationToken); + } + + if (existing is not null) + { + result.ActivityItemId = existing.ActivityItemId; + result.InteractionId = existing.ItemId; + result.QueueId = existing.QueueId; + result.Routed = !string.IsNullOrEmpty(existing.AgentId); + result.Reason = "The provider call is already tracked by the Contact Center."; + + return result; + } + var now = inboundEvent.ReceivedUtc ?? _clock.UtcNow; var fromAddress = inboundEvent.FromAddress?.GetCleanedPhoneNumber(); var serviceAddress = inboundEvent.ToAddress?.GetCleanedPhoneNumber(); @@ -410,6 +481,11 @@ private async Task CreateActivityAsync( return interaction; } + private static string GetInboundLockKey(InboundVoiceEvent inboundEvent) + { + return $"ContactCenterInboundVoice:{inboundEvent.ProviderName}:{inboundEvent.ProviderCallId}"; + } + private async Task ResolveQueueAsync(OmnichannelChannelEndpoint endpoint, CancellationToken cancellationToken) { var queues = await _queueManager.ListEnabledAsync(cancellationToken); diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs index 4c85f3bfd..54fe515ee 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs @@ -4,6 +4,7 @@ using CrestApps.OrchardCore.DialPad.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using OrchardCore.Modules; @@ -62,7 +63,19 @@ public async Task Call() using var reader = new StreamReader(Request.Body); var body = await reader.ReadToEndAsync(HttpContext.RequestAborted); - var secret = UnprotectSecret(settings.WebhookSigningSecret); + if (string.IsNullOrEmpty(settings.WebhookSigningSecret)) + { + _logger.LogWarning("Rejected a DialPad webhook because no webhook signing secret is configured."); + + return Unauthorized(); + } + + if (!TryUnprotectSecret(settings.WebhookSigningSecret, out var secret)) + { + _logger.LogError("Rejected a DialPad webhook because the configured signing secret could not be unprotected."); + + return StatusCode(StatusCodes.Status503ServiceUnavailable); + } if (!DialPadJwtValidator.TryValidateAndExtract(body, secret, out var payloadJson)) { @@ -92,22 +105,20 @@ public async Task Call() return Ok(new { result = result.ToString() }); } - private string UnprotectSecret(string protectedSecret) + private bool TryUnprotectSecret(string protectedSecret, out string secret) { - if (string.IsNullOrEmpty(protectedSecret)) - { - return null; - } + secret = null; try { var protector = _dataProtectionProvider.CreateProtector(DialPadConstants.WebhookProtectorName); + secret = protector.Unprotect(protectedSecret); - return protector.Unprotect(protectedSecret); + return !string.IsNullOrEmpty(secret); } catch (CryptographicException) { - return null; + return false; } } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs index b0d8d0e75..151c5466c 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs @@ -77,7 +77,7 @@ public bool UseOAuth /// /// Gets or sets the protected secret DialPad uses to sign call-event webhooks (JWT HS256). The value - /// is stored encrypted using the data protection provider. When empty, unsigned webhooks are accepted. + /// is stored encrypted using the data protection provider. Inbound webhooks are rejected when empty. /// public string WebhookSigningSecret { get; set; } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs index db90c2f8e..d4b71beb3 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs @@ -1,3 +1,5 @@ +using System.Security.Cryptography; +using System.Text; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; using OrchardCore.Modules; @@ -55,7 +57,7 @@ public async Task ProcessAsync(DialPadCallEvent callEvent, FromAddress = callEvent.ExternalNumber, ToAddress = toAddress, OccurredUtc = occurredUtc, - IdempotencyKey = $"{callEvent.CallId}:{callEvent.State}:{callEvent.EventTimestamp}", + IdempotencyKey = BuildIdempotencyKey(callEvent), IsMuted = callEvent.IsMuted, RecordingState = TryMapRecordingState(callEvent.RecordingState, out var recordingState) ? recordingState @@ -94,6 +96,23 @@ await _voiceCallRouter.RouteInboundAsync(new InboundVoiceEvent return DialPadWebhookResult.Ignored; } + private static string BuildIdempotencyKey(DialPadCallEvent callEvent) + { + var value = string.Join( + '|', + callEvent.CallId, + callEvent.State, + callEvent.EventTimestamp, + callEvent.IsMuted, + callEvent.RecordingState, + callEvent.RecordingId, + callEvent.IsConference, + callEvent.ParticipantCount); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(value)); + + return $"{DialPadConstants.ProviderTechnicalName}:{Convert.ToHexString(hash)}"; + } + private static bool IsInbound(string direction) { return string.Equals(direction?.Trim(), "inbound", StringComparison.OrdinalIgnoreCase); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs index 04870f74c..5cad9936d 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs @@ -36,6 +36,8 @@ public async Task EnqueueAsync_CapturesAssignedUserAsStickyHint() Assert.Equal("user-7", item.StickyAgentUserId); Assert.Equal("q1", item.QueueId); Assert.Equal(QueueItemStatus.Waiting, item.Status); + Assert.Equal(_now, item.EnqueuedUtc); + Assert.Equal(_now, item.QueueEnteredUtc); } [Fact] @@ -99,9 +101,77 @@ public async Task OverflowDueAsync_MovesOnlyItemsWaitingPastThreshold() Assert.Equal(1, moved); Assert.Equal("q2", overdue.QueueId); Assert.Equal("q1", overdue.OverflowedFromQueueId); + Assert.Equal(_now.AddSeconds(-60), overdue.EnqueuedUtc); + Assert.Equal(_now, overdue.QueueEnteredUtc); + Assert.Contains("q1", overdue.OverflowHistory); Assert.Equal("q1", fresh.QueueId); } + [Fact] + public async Task OverflowDueAsync_WhenItemRecentlyEnteredCurrentQueue_DoesNotUseOriginalWaitForNextHop() + { + // Arrange + var item = new QueueItem + { + ItemId = "i1", + QueueId = "q2", + Status = QueueItemStatus.Waiting, + EnqueuedUtc = _now.AddMinutes(-10), + QueueEnteredUtc = _now.AddSeconds(-10), + OverflowHistory = ["q1"], + }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.ListWaitingAsync("q2", It.IsAny())).ReturnsAsync([item]); + var service = CreateService( + queueItemManager, + new Mock(), + new Mock(), + new Mock()); + var queue = new ActivityQueue { ItemId = "q2", OverflowQueueId = "q3", OverflowAfterSeconds = 30 }; + + // Act + var moved = await service.OverflowDueAsync(queue, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, moved); + Assert.Equal("q2", item.QueueId); + queueItemManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task OverflowDueAsync_WhenDestinationWasPreviouslyVisited_DoesNotCreateCycle() + { + // Arrange + var item = new QueueItem + { + ItemId = "i1", + QueueId = "q1", + Status = QueueItemStatus.Waiting, + EnqueuedUtc = _now.AddSeconds(-60), + OverflowHistory = ["q2"], + }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.ListWaitingAsync("q1", It.IsAny())).ReturnsAsync([item]); + var service = CreateService( + queueItemManager, + new Mock(), + new Mock(), + new Mock()); + var queue = new ActivityQueue { ItemId = "q1", OverflowQueueId = "q2", OverflowAfterSeconds = 30 }; + + // Act + var moved = await service.OverflowDueAsync(queue, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, moved); + Assert.Equal("q1", item.QueueId); + queueItemManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task OverflowDueAsync_WhenClosedAndAfterHoursOverflow_MovesAllWaitingItems() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs index 9c4915c9b..6df85ce0a 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -7,6 +7,7 @@ using CrestApps.OrchardCore.Telephony.Models; using Microsoft.Extensions.Logging; using Moq; +using OrchardCore.Locking.Distributed; using OrchardCore.Modules; namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; @@ -110,6 +111,46 @@ public async Task ReserveAsync_WhenAgentAlreadyHasActiveReservation_AbortsWithou reservationManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task ReserveAsync_WhenAgentReservationLockIsHeldByAnotherQueue_AbortsWithoutReserving() + { + // Arrange + var reservationManager = new Mock(); + var queueItemManager = new Mock(); + var agentManager = new Mock(); + var distributedLock = new Mock(); + distributedLock + .Setup(l => l.TryAcquireLockAsync("ContactCenterAgentReservation:a1", It.IsAny(), It.IsAny())) + .ReturnsAsync((null, false)); + var service = CreateService( + reservationManager, + queueItemManager, + agentManager, + new Mock(), + new Mock(), + new Mock(), + new Mock(), + new Mock(), + new Mock(), + distributedLock); + + // Act + var reservation = await service.ReserveAsync( + new QueueItem { ItemId = "qi-2", QueueId = "q2", ActivityItemId = "act-2" }, + new AgentProfile { ItemId = "a1", UserId = "u1" }, + 30, + TestContext.Current.CancellationToken); + + // Assert + Assert.Null(reservation); + queueItemManager.Verify( + manager => manager.FindByIdAsync(It.IsAny(), It.IsAny()), + Times.Never); + reservationManager.Verify( + manager => manager.CreateAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task ReserveAsync_WhenBreakWasGrantedAfterRoutingDecision_PreservesPendingBreak() { @@ -147,6 +188,7 @@ public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueu var reservation = new ActivityReservation { ItemId = "r1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; var reservationManager = new Mock(); reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); var queueItem = new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }; var queueItemManager = new Mock(); queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); @@ -173,6 +215,57 @@ public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueu Assert.Null(interaction.AgentId); } + [Fact] + public async Task ExpireDueAsync_WhenReservationWasAcceptedAfterExpiredListWasRead_DoesNotReleaseIt() + { + // Arrange + var candidate = new ActivityReservation + { + ItemId = "r1", + QueueItemId = "qi-1", + AgentId = "a1", + ActivityItemId = "act-1", + Status = ReservationStatus.Pending, + ExpiresUtc = _now.AddSeconds(-1), + }; + var accepted = new ActivityReservation + { + ItemId = "r1", + QueueItemId = "qi-1", + AgentId = "a1", + ActivityItemId = "act-1", + Status = ReservationStatus.Accepted, + ExpiresUtc = candidate.ExpiresUtc, + }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([candidate]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(accepted); + var queueItemManager = new Mock(); + var service = CreateService( + reservationManager, + queueItemManager, + new Mock(), + new Mock(), + new Mock(), + new Mock(), + new Mock(), + new Mock(), + new Mock()); + + // Act + var count = await service.ExpireDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, count); + Assert.Equal(ReservationStatus.Accepted, accepted.Status); + reservationManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + queueItemManager.Verify( + manager => manager.FindByIdAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task ExpireDueAsync_WhenRequeueing_ClearsStaleRingingAssignmentFromInteraction() { @@ -180,6 +273,7 @@ public async Task ExpireDueAsync_WhenRequeueing_ClearsStaleRingingAssignmentFrom var reservation = new ActivityReservation { ItemId = "r1", QueueId = "q1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; var reservationManager = new Mock(); reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); var queueItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", Status = QueueItemStatus.Reserved, ReservationId = "r1", AgentId = "a1" }; var queueItemManager = new Mock(); queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); @@ -212,6 +306,7 @@ public async Task ExpireDueAsync_WhenBreakIsPending_GrantsBreak() var reservation = new ActivityReservation { ItemId = "r1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; var reservationManager = new Mock(); reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); var queueItemManager = new Mock(); queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }); var agent = new AgentProfile { ItemId = "a1", RequestedPresenceStatus = AgentPresenceStatus.Break }; @@ -240,6 +335,7 @@ public async Task ExpireDueAsync_WhenAgentSignedOut_KeepsAgentOffline() var reservation = new ActivityReservation { ItemId = "r1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; var reservationManager = new Mock(); reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); var queueItemManager = new Mock(); queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }); var agent = new AgentProfile { ItemId = "a1", PresenceStatus = AgentPresenceStatus.Offline }; @@ -268,6 +364,7 @@ public async Task ExpireDueAsync_WhenQueueUsesVoicemail_RemovesItemAndSendsToVoi var reservation = new ActivityReservation { ItemId = "r1", QueueId = "q1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; var reservationManager = new Mock(); reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); var queueItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", Status = QueueItemStatus.Reserved, ReservationId = "r1", AgentId = "a1" }; var queueItemManager = new Mock(); queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); @@ -301,6 +398,7 @@ public async Task ExpireDueAsync_WhenQueueUsesReject_RemovesItemAndRejectsCall() var reservation = new ActivityReservation { ItemId = "r1", QueueId = "q1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; var reservationManager = new Mock(); reservationManager.Setup(m => m.ListExpiredAsync(_now, It.IsAny())).ReturnsAsync([reservation]); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); var queueItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", Status = QueueItemStatus.Reserved, ReservationId = "r1", AgentId = "a1" }; var queueItemManager = new Mock(); queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); @@ -364,11 +462,20 @@ private static ActivityReservationService CreateService( Mock interactionManager, Mock activityManager, Mock publisher, - Mock telephonyService) + Mock telephonyService, + Mock distributedLock = null) { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); + if (distributedLock is null) + { + distributedLock = new Mock(); + distributedLock + .Setup(l => l.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, true)); + } + return new ActivityReservationService( reservationManager.Object, queueItemManager.Object, @@ -379,6 +486,7 @@ private static ActivityReservationService CreateService( activityManager.Object, publisher.Object, [telephonyService.Object], + distributedLock.Object, clock.Object, new Mock>().Object); } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs index 970a652bd..1d7b410f8 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs @@ -90,6 +90,61 @@ public void IsOpen_RespectsTimeZone() Assert.False(closedLocalEarly); } + [Theory] + [InlineData(2026, 1, 5, 23, 0, true)] + [InlineData(2026, 1, 6, 2, 0, true)] + [InlineData(2026, 1, 6, 7, 0, false)] + public void IsOpen_WithOvernightWindow_EvaluatesBothSidesOfMidnight( + int year, + int month, + int day, + int hour, + int minute, + bool expected) + { + // Arrange + var calendar = new BusinessHoursCalendar + { + ItemId = "cal1", + TimeZoneId = "UTC", + Enabled = true, + WeeklySchedule = + [ + new BusinessHoursDay { Day = DayOfWeek.Monday, IsOpen = true, OpenMinute = 1320, CloseMinute = 360 }, + ], + }; + + // Act + var open = DefaultBusinessHoursService.IsOpen( + calendar, + new DateTime(year, month, day, hour, minute, 0, DateTimeKind.Utc)); + + // Assert + Assert.Equal(expected, open); + } + + [Fact] + public void IsOpen_WithEqualOpenAndCloseMinutes_TreatsDayAsOpenAllDay() + { + // Arrange + var calendar = new BusinessHoursCalendar + { + ItemId = "cal1", + TimeZoneId = "UTC", + Enabled = true, + WeeklySchedule = + [ + new BusinessHoursDay { Day = DayOfWeek.Monday, IsOpen = true, OpenMinute = 0, CloseMinute = 0 }, + ], + }; + + // Act + var open = DefaultBusinessHoursService.IsOpen(calendar, _mondayNoonUtc); + + // Assert + Assert.True(open); + } + [Fact] public async Task IsOpenAsync_WithEmptyCalendarId_ReturnsTrue() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs index 0f5912c0f..a502d8343 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs @@ -12,22 +12,52 @@ public sealed class ContactCenterOutboxTests private static readonly DateTime _now = new(2026, 6, 30, 12, 0, 0, DateTimeKind.Utc); [Fact] - public async Task DispatchAsync_WhenAllHandlersSucceed_DoesNotScheduleRetry() + public async Task EnqueueAsync_CreatesPendingMessageWithoutDispatchingHandlers() { // Arrange var handler = new Mock(); handler.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); var outboxStore = new Mock(); + ContactCenterOutboxMessage created = null; + outboxStore + .Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((message, _) => created = message) + .Returns(ValueTask.CompletedTask); + var outbox = CreateOutbox(outboxStore, new Mock(), [handler.Object]); var interactionEvent = new InteractionEvent { ItemId = "e1", EventType = ContactCenterConstants.Events.InteractionCreated }; + // Act + await outbox.EnqueueAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(created); + Assert.Equal("e1", created.EventId); + Assert.Equal(OutboxMessageStatus.Pending, created.Status); + Assert.Equal(0, created.AttemptCount); + Assert.Equal(_now, created.NextAttemptUtc); + handler.Verify(h => h.HandleAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DispatchAsync_WhenAllHandlersSucceed_RemovesPendingMessage() + { + // Arrange + var handler = new Mock(); + handler.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + var outboxStore = new Mock(); + var outbox = CreateOutbox(outboxStore, new Mock(), [handler.Object]); + var interactionEvent = new InteractionEvent { ItemId = "e1", EventType = ContactCenterConstants.Events.InteractionCreated }; + // Act await outbox.DispatchAsync(interactionEvent, TestContext.Current.CancellationToken); // Assert - outboxStore.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + outboxStore.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + outboxStore.Verify(s => s.DeleteAsync(It.Is(message => message.EventId == "e1"), It.IsAny()), Times.Once); } [Fact] @@ -88,6 +118,56 @@ public async Task DispatchDueAsync_WhenRetrySucceeds_DeletesTheMessage() outboxStore.Verify(s => s.DeleteAsync(message, It.IsAny()), Times.Once); } + [Fact] + public async Task DispatchDueAsync_AfterPartialFailure_RetriesOnlyIncompleteHandlers() + { + // Arrange + var completedHandler = new Mock(); + completedHandler + .Setup(handler => handler.HandleAsync(It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + var retryingHandler = new Mock(); + retryingHandler + .SetupSequence(handler => handler.HandleAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("transient")) + .Returns(Task.CompletedTask); + ContactCenterOutboxMessage pending = null; + var outboxStore = new Mock(); + outboxStore + .Setup(store => store.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((message, _) => pending = message) + .Returns(ValueTask.CompletedTask); + outboxStore + .Setup(store => store.ListDueAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(() => [pending]); + var interactionEvent = new InteractionEvent + { + ItemId = "e1", + EventType = ContactCenterConstants.Events.InteractionCreated, + }; + var eventStore = new Mock(); + eventStore + .Setup(store => store.FindByIdAsync("e1", It.IsAny())) + .ReturnsAsync(interactionEvent); + var outbox = CreateOutbox(outboxStore, eventStore, [completedHandler.Object, retryingHandler.Object]); + + // Act + await outbox.DispatchAsync(interactionEvent, TestContext.Current.CancellationToken); + var processed = await outbox.DispatchDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, processed); + completedHandler.Verify( + handler => handler.HandleAsync(interactionEvent, It.IsAny()), + Times.Once); + retryingHandler.Verify( + handler => handler.HandleAsync(interactionEvent, It.IsAny()), + Times.Exactly(2)); + outboxStore.Verify( + store => store.DeleteAsync(pending, It.IsAny()), + Times.Once); + } + [Fact] public async Task DispatchDueAsync_WhenRetryFails_IncrementsAttemptAndAppliesBackoff() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs index 3560f904a..b69a44593 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs @@ -30,6 +30,7 @@ public async Task PublishAsync_PersistsTheEvent_AndDispatchesThroughTheOutbox() // Assert store.Verify(s => s.CreateAsync(interactionEvent, It.IsAny()), Times.Once); + outbox.Verify(o => o.EnqueueAsync(interactionEvent, It.IsAny()), Times.Once); outbox.Verify(o => o.DispatchAsync(interactionEvent, It.IsAny()), Times.Once); } @@ -110,6 +111,7 @@ public async Task PublishAsync_WhenIdempotencyKeyAlreadyExists_SkipsPersistAndDi // Assert store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + outbox.Verify(o => o.EnqueueAsync(It.IsAny(), It.IsAny()), Times.Never); outbox.Verify(o => o.DispatchAsync(It.IsAny(), It.IsAny()), Times.Never); } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerAttemptServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerAttemptServiceTests.cs index 5798a27be..a43b8bef4 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerAttemptServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerAttemptServiceTests.cs @@ -20,6 +20,7 @@ public async Task TryDialAsync_WhenActivityMissing_CancelsReservationWithoutDial // Arrange var reservation = Reservation(); var reservationService = new Mock(); + var queueService = new Mock(); var activityManager = new Mock(); activityManager.Setup(m => m.FindByIdAsync("act1", It.IsAny())).ReturnsAsync((OmnichannelActivity)null); @@ -29,7 +30,8 @@ public async Task TryDialAsync_WhenActivityMissing_CancelsReservationWithoutDial reservationService, CreateInteractionManager(new Interaction { ItemId = "int1" }), activityManager, - voiceCallRouter); + voiceCallRouter, + queueService: queueService); // Act var started = await service.TryDialAsync(CreateProfile(), reservation, TestContext.Current.CancellationToken); @@ -37,6 +39,7 @@ public async Task TryDialAsync_WhenActivityMissing_CancelsReservationWithoutDial // Assert Assert.False(started); reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); + queueService.Verify(s => s.DequeueAsync(It.IsAny(), QueueItemStatus.Removed, It.IsAny()), Times.Once); voiceCallRouter.Verify(p => p.RouteOutboundAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -75,6 +78,7 @@ public async Task TryDialAsync_WhenProviderFails_CancelsReservationAndMarksInter // Arrange var reservation = Reservation(); var reservationService = new Mock(); + var queueService = new Mock(); var interaction = new Interaction { ItemId = "int1" }; var voiceCallRouter = CreateVoiceCallRouter(Failure("provider_failed", "Provider rejected the request.")); var service = CreateService( @@ -82,7 +86,8 @@ public async Task TryDialAsync_WhenProviderFails_CancelsReservationAndMarksInter reservationService, CreateInteractionManager(interaction), CreateActivityManager(new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }), - voiceCallRouter); + voiceCallRouter, + queueService: queueService); // Act var started = await service.TryDialAsync(CreateProfile(), reservation, TestContext.Current.CancellationToken); @@ -91,6 +96,7 @@ public async Task TryDialAsync_WhenProviderFails_CancelsReservationAndMarksInter Assert.False(started); Assert.Equal(InteractionStatus.Failed, interaction.Status); reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); + queueService.Verify(s => s.DequeueAsync(It.IsAny(), QueueItemStatus.Removed, It.IsAny()), Times.Once); } [Fact] @@ -101,6 +107,7 @@ public async Task TryDialAsync_WhenSuppressedDoNotCall_MarksCancelledReleasesRes var reservationService = new Mock(); var activity = new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }; var publisher = new Mock(); + var queueService = new Mock(); var voiceCallRouter = CreateVoiceCallRouter(Success("call1")); var gate = new Mock(); @@ -114,7 +121,8 @@ public async Task TryDialAsync_WhenSuppressedDoNotCall_MarksCancelledReleasesRes CreateInteractionManager(new Interaction { ItemId = "int1" }), CreateActivityManager(activity), voiceCallRouter, - publisher); + publisher, + queueService: queueService); // Act var started = await service.TryDialAsync(CreateProfile(), reservation, TestContext.Current.CancellationToken); @@ -123,6 +131,7 @@ public async Task TryDialAsync_WhenSuppressedDoNotCall_MarksCancelledReleasesRes Assert.False(started); Assert.Equal(ActivityStatus.Cancelled, activity.Status); reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); + queueService.Verify(s => s.DequeueAsync(It.IsAny(), QueueItemStatus.Removed, It.IsAny()), Times.Once); voiceCallRouter.Verify(p => p.RouteOutboundAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); publisher.Verify(p => p.PublishAsync(It.Is(e => e.EventType == ContactCenterConstants.Events.DialSuppressed), It.IsAny()), Times.Once); } @@ -136,6 +145,7 @@ public async Task TryDialAsync_WhenSuppressedOutsideCallingWindow_ReleasesReserv var activity = new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222", Status = ActivityStatus.Pending }; var activityManager = CreateActivityManager(activity); var publisher = new Mock(); + var queueService = new Mock(); var gate = new Mock(); gate @@ -148,7 +158,8 @@ public async Task TryDialAsync_WhenSuppressedOutsideCallingWindow_ReleasesReserv CreateInteractionManager(new Interaction { ItemId = "int1" }), activityManager, CreateVoiceCallRouter(Success("call1")), - publisher); + publisher, + queueService: queueService); // Act var started = await service.TryDialAsync(CreateProfile(), reservation, TestContext.Current.CancellationToken); @@ -158,12 +169,13 @@ public async Task TryDialAsync_WhenSuppressedOutsideCallingWindow_ReleasesReserv Assert.Equal(ActivityStatus.Pending, activity.Status); activityManager.Verify(m => m.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); + queueService.Verify(s => s.DequeueAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); publisher.Verify(p => p.PublishAsync(It.Is(e => e.EventType == ContactCenterConstants.Events.DialSuppressed), It.IsAny()), Times.Once); } private static ActivityReservation Reservation() { - return new ActivityReservation { ItemId = "r1", ActivityItemId = "act1", AgentId = "a1" }; + return new ActivityReservation { ItemId = "r1", ActivityItemId = "act1", QueueItemId = "qi1", AgentId = "a1" }; } private static DialerProfile CreateProfile() @@ -229,14 +241,22 @@ private static DialerAttemptService CreateService( Mock interactionManager, Mock activityManager, Mock voiceCallRouter, - Mock publisher = null) + Mock publisher = null, + Mock queueItemManager = null, + Mock queueService = null) { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); + queueItemManager ??= new Mock(); + queueItemManager + .Setup(m => m.FindByIdAsync("qi1", It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi1", Status = QueueItemStatus.Waiting }); return new DialerAttemptService( eligibilityService.Object, reservationService.Object, + queueItemManager.Object, + (queueService ?? new Mock()).Object, interactionManager.Object, activityManager.Object, voiceCallRouter.Object, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerEligibilityServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerEligibilityServiceTests.cs index 288641043..59ec394af 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerEligibilityServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerEligibilityServiceTests.cs @@ -111,6 +111,25 @@ public async Task EvaluateAsync_WhenOutsideCallingWindow_SuppressesWindow() Assert.Equal(DialerSuppressionReason.OutsideCallingWindow, result.Reason); } + [Fact] + public async Task EvaluateAsync_WhenCallingWindowStartAndEndAreEqual_SuppressesWindow() + { + // Arrange + var harness = new Harness(); + var activity = new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }; + var profile = Profile(); + profile.EnforceCallingWindow = true; + profile.CallingWindowStartHour = 9; + profile.CallingWindowEndHour = 9; + + // Act + var result = await harness.EvaluateAsync(profile, activity); + + // Assert + Assert.False(result.IsEligible); + Assert.Equal(DialerSuppressionReason.OutsideCallingWindow, result.Reason); + } + [Fact] public async Task EvaluateAsync_WhenOnNationalRegistry_SuppressesRegistry() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index 84d879f9b..7f6bd3271 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -11,6 +11,7 @@ using CrestApps.OrchardCore.Telephony.Models; using Moq; using OrchardCore.ContentManagement; +using OrchardCore.Locking.Distributed; using OrchardCore.Modules; namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; @@ -164,6 +165,47 @@ public async Task HandleInboundAsync_WhenNoAgentAvailable_QueuesWithoutOffering( harness.IncomingCallDispatcher.Verify(d => d.DispatchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task HandleInboundAsync_WhenProviderCallIsAlreadyTracked_ReturnsExistingInteractionWithoutDuplicatingWork() + { + // Arrange + var harness = new Harness(); + harness.InteractionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("TestProvider", "call-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "interaction-1", + ActivityItemId = "activity-1", + ProviderInteractionId = "call-1", + }); + var service = harness.CreateService(); + + // Act + var result = await service.HandleInboundAsync( + new InboundVoiceEvent + { + ProviderName = "TestProvider", + ProviderCallId = "call-1", + FromAddress = "+15551112222", + ToAddress = "+15553334444", + }, + TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Routed); + Assert.Equal("activity-1", result.ActivityItemId); + Assert.Equal("interaction-1", result.InteractionId); + harness.ActivityManager.Verify( + manager => manager.NewAsync(It.IsAny(), It.IsAny()), + Times.Never); + harness.InteractionManager.Verify( + manager => manager.NewAsync(It.IsAny(), It.IsAny()), + Times.Never); + harness.QueueService.Verify( + service => service.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task HandleInboundAsync_WhenEndpointSpecificQueueExists_PrefersItOverGenericQueue() { @@ -257,6 +299,15 @@ private sealed class Harness public Mock EntryPointResolver { get; } = new(); + public Mock DistributedLock { get; } = new(); + + public Harness() + { + DistributedLock + .Setup(l => l.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, true)); + } + public void SetupNoContext() { ChannelEndpointManager @@ -300,6 +351,7 @@ public VoiceContactCenterCallRouter CreateService() IncomingCallDispatcher.Object, VoiceProviderResolver.Object, EntryPointResolver.Object, + DistributedLock.Object, clock.Object); } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs new file mode 100644 index 000000000..b35c93453 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs @@ -0,0 +1,240 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ProviderCallStateSynchronizationServiceTests +{ + private static readonly DateTime _now = new(2026, 7, 11, 15, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task RefreshInteractionAsync_WhenProviderNoLongerHasCall_EndsLocalInteraction() + { + // Arrange + var interaction = CreateInteraction(); + ProviderVoiceEvent providerEvent = null; + var eventService = new Mock(); + eventService + .Setup(service => service.IngestAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => providerEvent = value) + .ReturnsAsync(new CallSession()); + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("provider-1", "call-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "interaction-1", + ProviderName = "provider-1", + ProviderInteractionId = "call-1", + Status = InteractionStatus.Ended, + }); + var service = CreateService( + interactionManager, + new Mock(), + eventService, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }); + + // Act + var refreshed = await service.RefreshInteractionAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(InteractionStatus.Ended, refreshed.Status); + Assert.NotNull(providerEvent); + Assert.Equal(ContactCenterCallState.Ended, providerEvent.State); + Assert.Equal("reconcile-missing:provider-1:call-1:ended", providerEvent.IdempotencyKey); + } + + [Fact] + public async Task RefreshInteractionAsync_WhenProviderReportsHeldAndMuted_PropagatesGranularState() + { + // Arrange + var interaction = CreateInteraction(); + ProviderVoiceEvent providerEvent = null; + var eventService = new Mock(); + eventService + .Setup(service => service.IngestAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => providerEvent = value) + .ReturnsAsync(new CallSession()); + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(new CallSession + { + InteractionId = "interaction-1", + ProviderCallId = "call-1", + State = ContactCenterCallState.Connected, + }); + var service = CreateService( + new Mock(), + callSessionManager, + eventService, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = true, + Call = new TelephonyCall + { + CallId = "call-1", + State = CallState.Connected, + IsOnHold = true, + IsMuted = true, + }, + }); + + // Act + await service.RefreshInteractionAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(providerEvent); + Assert.Equal(ContactCenterCallState.OnHold, providerEvent.State); + Assert.True(providerEvent.IsMuted); + Assert.Contains(":OnHold:True:True", providerEvent.IdempotencyKey, StringComparison.Ordinal); + } + + [Fact] + public async Task RefreshInteractionAsync_WhenProviderAndSessionMatch_DoesNotRepublishState() + { + // Arrange + var interaction = CreateInteraction(); + var eventService = new Mock(); + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(new CallSession + { + InteractionId = "interaction-1", + ProviderCallId = "call-1", + State = ContactCenterCallState.OnHold, + IsOnHold = true, + IsMuted = true, + }); + var service = CreateService( + new Mock(), + callSessionManager, + eventService, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = true, + Call = new TelephonyCall + { + CallId = "call-1", + State = CallState.OnHold, + IsOnHold = true, + IsMuted = true, + }, + }); + + // Act + var refreshed = await service.RefreshInteractionAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.Same(interaction, refreshed); + eventService.Verify( + service => service.IngestAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task TenantActivation_PerformsImmediateProviderReconciliation() + { + // Arrange + var synchronizationService = new Mock(); + synchronizationService + .Setup(service => service.ReconcileActiveInteractionsAsync(It.IsAny())) + .ReturnsAsync(1); + var tenantEvents = new ContactCenterVoiceTenantEvents( + synchronizationService.Object, + NullLogger.Instance); + + // Act + await tenantEvents.ActivatingAsync(); + + // Assert + synchronizationService.Verify( + service => service.ReconcileActiveInteractionsAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ReconcileProviderInteractionsAsync_OnlyLoadsInteractionsForReconnectingProvider() + { + // Arrange + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.ListActiveWithProviderCallIdAsync("provider-1", It.IsAny())) + .ReturnsAsync([]); + var service = CreateService( + interactionManager, + new Mock(), + new Mock(), + new TelephonyCallLookupResult()); + + // Act + var refreshed = await service.ReconcileProviderInteractionsAsync( + "provider-1", + TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, refreshed); + interactionManager.Verify( + manager => manager.ListActiveWithProviderCallIdAsync("provider-1", It.IsAny()), + Times.Once); + interactionManager.Verify( + manager => manager.ListActiveWithProviderCallIdAsync(It.IsAny()), + Times.Never); + } + + private static ProviderCallStateSynchronizationService CreateService( + Mock interactionManager, + Mock callSessionManager, + Mock eventService, + TelephonyCallLookupResult lookup) + { + var provider = new Mock(); + provider + .As() + .Setup(value => value.GetCallStateAsync("call-1", It.IsAny())) + .ReturnsAsync(lookup); + var resolver = new Mock(); + resolver.Setup(value => value.GetAsync("provider-1")).ReturnsAsync(provider.Object); + var distributedLock = new Mock(); + distributedLock + .Setup(value => value.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, true)); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(_now); + + return new ProviderCallStateSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + eventService.Object, + resolver.Object, + distributedLock.Object, + clock.Object, + NullLogger.Instance); + } + + private static Interaction CreateInteraction() + { + return new Interaction + { + ItemId = "interaction-1", + ProviderName = "provider-1", + ProviderInteractionId = "call-1", + Status = InteractionStatus.Connected, + }; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs index b442156a0..ac22df9f2 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs @@ -17,6 +17,7 @@ public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvent var interaction = new Interaction { ItemId = "interaction-1", + ProviderName = "ProviderA", ProviderInteractionId = "call-1", AgentId = "agent-1", Direction = InteractionDirection.Inbound, @@ -27,6 +28,7 @@ public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvent { ItemId = "session-1", InteractionId = "interaction-1", + ProviderName = "ProviderA", ProviderCallId = "call-1", AgentId = "agent-1", Direction = InteractionDirection.Inbound, @@ -39,11 +41,11 @@ public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvent var publishedEvents = new List(); var interactionManager = new Mock(); - interactionManager.Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + interactionManager.Setup(manager => manager.FindByProviderInteractionIdAsync("ProviderA", "call-1", It.IsAny())) .ReturnsAsync(interaction); var callSessionManager = new Mock(); - callSessionManager.Setup(manager => manager.FindByProviderCallIdAsync("call-1", It.IsAny())) + callSessionManager.Setup(manager => manager.FindByProviderCallIdAsync("ProviderA", "call-1", It.IsAny())) .ReturnsAsync(session); var eventStore = new Mock(); @@ -73,6 +75,7 @@ public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvent // Act await service.IngestAsync(new ProviderVoiceEvent { + ProviderName = "ProviderA", ProviderCallId = "call-1", State = ContactCenterCallState.Connected, IdempotencyKey = "key-1", @@ -94,6 +97,15 @@ await service.IngestAsync(new ProviderVoiceEvent Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallMuted); Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.RecordingStarted); Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallConferenceChanged); + Assert.Equal( + publishedEvents.Count, + publishedEvents.Select(value => value.IdempotencyKey).Distinct(StringComparer.Ordinal).Count()); + interactionManager.Verify( + manager => manager.FindByProviderInteractionIdAsync("ProviderA", "call-1", It.IsAny()), + Times.Once); + callSessionManager.Verify( + manager => manager.FindByProviderCallIdAsync("ProviderA", "call-1", It.IsAny()), + Times.Once); } [Fact] @@ -174,4 +186,163 @@ await service.IngestAsync(new ProviderVoiceEvent Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.RecordingStopped); Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallConferenceChanged); } + + [Fact] + public async Task IngestAsync_WithRealPublisher_PersistsEverySemanticEventWithAUniqueKey() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderInteractionId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Connected, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + State = ContactCenterCallState.Connected, + }; + var persistedEvents = new List(); + var persistedKeys = new HashSet(StringComparer.Ordinal); + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + .ReturnsAsync(interaction); + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByProviderCallIdAsync("call-1", It.IsAny())) + .ReturnsAsync(session); + var eventStore = new Mock(); + eventStore + .Setup(store => store.ExistsByIdempotencyKeyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((string key, CancellationToken _) => persistedKeys.Contains(key)); + eventStore + .Setup(store => store.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((interactionEvent, _) => + { + persistedEvents.Add(interactionEvent); + + if (!string.IsNullOrEmpty(interactionEvent.IdempotencyKey)) + { + persistedKeys.Add(interactionEvent.IdempotencyKey); + } + }) + .Returns(ValueTask.CompletedTask); + var outbox = new Mock(); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc)); + var publisher = new DefaultContactCenterEventPublisher( + eventStore.Object, + outbox.Object, + clock.Object, + NullLogger.Instance); + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + eventStore.Object, + publisher, + new Mock().Object, + clock.Object, + NullLogger.Instance); + var providerEvent = new ProviderVoiceEvent + { + ProviderCallId = "call-1", + State = ContactCenterCallState.OnHold, + IdempotencyKey = "provider-event-1", + }; + + // Act + await service.IngestAsync(providerEvent, TestContext.Current.CancellationToken); + await service.IngestAsync(providerEvent, TestContext.Current.CancellationToken); + + // Assert + Assert.Collection( + persistedEvents, + interactionEvent => + { + Assert.Equal(ContactCenterConstants.Events.CallSessionUpdated, interactionEvent.EventType); + Assert.Equal("provider-event-1", interactionEvent.IdempotencyKey); + }, + interactionEvent => + { + Assert.Equal(ContactCenterConstants.Events.CallHeld, interactionEvent.EventType); + Assert.Equal($"provider-event-1:{ContactCenterConstants.Events.CallHeld}", interactionEvent.IdempotencyKey); + }); + outbox.Verify(value => value.EnqueueAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + outbox.Verify(value => value.DispatchAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task IngestAsync_WhenEventIsOlderThanTerminalState_DoesNotReopenCall() + { + // Arrange + var endedUtc = new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc); + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderInteractionId = "call-1", + Status = InteractionStatus.Ended, + EndedUtc = endedUtc, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + EndedUtc = endedUtc, + LastProviderEventUtc = endedUtc, + }; + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + .ReturnsAsync(interaction); + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByProviderCallIdAsync("call-1", It.IsAny())) + .ReturnsAsync(session); + var eventStore = new Mock(); + eventStore + .Setup(store => store.ExistsByIdempotencyKeyAsync("late-connected", It.IsAny())) + .ReturnsAsync(false); + var publisher = new Mock(); + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + eventStore.Object, + publisher.Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + + // Act + await service.IngestAsync(new ProviderVoiceEvent + { + ProviderCallId = "call-1", + State = ContactCenterCallState.Connected, + IdempotencyKey = "late-connected", + OccurredUtc = endedUtc.AddSeconds(-5), + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ContactCenterCallState.Ended, session.State); + Assert.Equal(InteractionStatus.Ended, interaction.Status); + callSessionManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + interactionManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + publisher.Verify( + value => value.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookControllerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookControllerTests.cs new file mode 100644 index 000000000..f8424ca95 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookControllerTests.cs @@ -0,0 +1,82 @@ +using CrestApps.OrchardCore.DialPad.Controllers; +using CrestApps.OrchardCore.DialPad.Models; +using CrestApps.OrchardCore.DialPad.Services; +using CrestApps.OrchardCore.Tests.Telephony.Doubles; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.DialPad; + +public sealed class DialPadWebhookControllerTests +{ + [Fact] + public async Task Call_WhenSigningSecretIsMissing_RejectsWebhook() + { + // Arrange + var webhookService = new Mock(); + var controller = CreateController( + webhookService, + new DialPadSettings + { + IsEnabled = true, + WebhookSigningSecret = null, + }, + new EphemeralDataProtectionProvider()); + + // Act + var result = await controller.Call(); + + // Assert + Assert.IsType(result); + webhookService.Verify( + service => service.ProcessAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task Call_WhenSigningSecretCannotBeUnprotected_FailsClosed() + { + // Arrange + var webhookService = new Mock(); + var controller = CreateController( + webhookService, + new DialPadSettings + { + IsEnabled = true, + WebhookSigningSecret = "not-a-protected-secret", + }, + new EphemeralDataProtectionProvider()); + + // Act + var result = await controller.Call(); + + // Assert + var statusCodeResult = Assert.IsType(result); + Assert.Equal(StatusCodes.Status503ServiceUnavailable, statusCodeResult.StatusCode); + webhookService.Verify( + service => service.ProcessAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + private static DialPadWebhookController CreateController( + Mock webhookService, + DialPadSettings settings, + IDataProtectionProvider dataProtectionProvider) + { + var controller = new DialPadWebhookController( + webhookService.Object, + SiteServiceFactory.Create(settings), + dataProtectionProvider, + NullLogger.Instance); + controller.ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext(), + }; + controller.Request.Body = new MemoryStream("signed-payload"u8.ToArray()); + + return controller; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs index 9180c1539..50f82aff1 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs @@ -137,6 +137,39 @@ public async Task ProcessAsync_WithRicherProviderDetails_PassesNormalizedDetails Assert.Equal("connected", providerEvent.Metadata["dialPadState"]); } + [Fact] + public async Task ProcessAsync_WhenStateAttributesChange_UsesDifferentIdempotencyKeys() + { + // Arrange + var providerEvents = new List(); + var eventService = new Mock(); + eventService + .Setup(s => s.IngestAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => providerEvents.Add(value)) + .ReturnsAsync(new CallSession { ItemId = "cs1" }); + var service = CreateService(eventService, new Mock()); + + // Act + await service.ProcessAsync(new DialPadCallEvent + { + CallId = "c1", + State = "connected", + Direction = "inbound", + IsMuted = false, + }, TestContext.Current.CancellationToken); + await service.ProcessAsync(new DialPadCallEvent + { + CallId = "c1", + State = "connected", + Direction = "inbound", + IsMuted = true, + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, providerEvents.Count); + Assert.NotEqual(providerEvents[0].IdempotencyKey, providerEvents[1].IdempotencyKey); + } + private static DialPadWebhookService CreateService(Mock eventService, Mock router) { var clock = new Mock(); diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs index 9c110d8a9..8b2a84dea 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs @@ -43,4 +43,42 @@ public void TryMap_WhenChannelDestroyedPayloadReceived_ReturnsDisconnectedVoiceE Assert.Equal("ChannelDestroyed", voiceEvent.EventType); Assert.Equal("Normal Clearing", voiceEvent.Metadata["causeText"]); } + + [Fact] + public void TryMap_WhenChannelLeavesBridge_ReturnsUnheldVoiceEvent() + { + // Arrange + const string payload = + """ + { + "type": "ChannelLeftBridge", + "timestamp": "2026-07-10T15:03:00.000Z", + "application": "crestapps-telephony", + "bridge": { + "id": "bridge-1" + }, + "channel": { + "id": "call-1", + "state": "Up", + "caller": { + "number": "+15550001000" + }, + "connected": { + "number": "+15550002000" + } + } + } + """; + + // Act + var mapped = AsteriskRealtimeVoiceEventMapper.TryMap("Asterisk", payload, out var voiceEvent); + + // Assert + Assert.True(mapped); + Assert.NotNull(voiceEvent); + Assert.Equal(CallState.Connected, voiceEvent.State); + Assert.False(voiceEvent.IsOnHold); + Assert.Equal("ChannelLeftBridge", voiceEvent.EventType); + Assert.Equal("bridge-1", voiceEvent.Metadata["bridgeId"]); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs index 61805e2e8..f58002879 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs @@ -229,6 +229,73 @@ public async Task SendToVoicemailAsync_WhenConfigured_SetsMetadataAndContinuesTo Assert.Equal($"{BaseUrl}channels/call-1/continue?context=voicemail&extension=mike&priority=1", handler.Requests[2].RequestUri.AbsoluteUri); } + [Fact] + public async Task GetCallAsync_WhenChannelIsHeldAndMuted_RecoversGranularProviderState() + { + // Arrange + var handler = new StubHttpMessageHandler(request => + { + var body = request.RequestUri.AbsoluteUri switch + { + $"{BaseUrl}channels/call-1" => + """ + { + "id": "call-1", + "state": "Up", + "caller": { "number": "+15550001000" }, + "connected": { "number": "+15550002000" } + } + """, + $"{BaseUrl}channels/call-1/variable?variable=CRESTAPPS_STATE_ONHOLD" => """{"value":"true"}""", + $"{BaseUrl}channels/call-1/variable?variable=CRESTAPPS_STATE_MUTED" => """{"value":"true"}""", + _ => throw new InvalidOperationException($"Unexpected request: {request.RequestUri}"), + }; + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body), + }; + }); + var provider = CreateProvider(handler, out _, isEnabled: true); + + // Act + var result = await provider.GetCallStateAsync("call-1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.True(result.Found); + var call = result.Call; + Assert.NotNull(call); + Assert.Equal(CallState.OnHold, call.State); + Assert.True(call.IsMuted); + Assert.Equal(3, handler.Requests.Count); + } + + [Fact] + public async Task GetCallAsync_WhenAriReturnsUnknownChannelState_DoesNotAssumeConnected() + { + // Arrange + var handler = new StubHttpMessageHandler( + HttpStatusCode.OK, + """ + { + "id": "call-1", + "state": "Mystery", + "caller": { "number": "+15550001000" }, + "connected": { "number": "+15550002000" } + } + """); + var provider = CreateProvider(handler, out _, isEnabled: true); + + // Act + var result = await provider.GetCallStateAsync("call-1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + Assert.Null(result.Call); + Assert.Single(handler.Requests); + } + private static AsteriskTelephonyProvider CreateProvider( StubHttpMessageHandler handler, out IDataProtectionProvider dataProtectionProvider, diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs index 0dfe79ab7..a86b96db6 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/Doubles/StubHttpMessageHandler.cs @@ -9,6 +9,7 @@ internal sealed class StubHttpMessageHandler : HttpMessageHandler { private readonly HttpStatusCode _statusCode; private readonly string _responseBody; + private readonly Func _responseFactory; public StubHttpMessageHandler(HttpStatusCode statusCode, string responseBody = "") { @@ -16,6 +17,11 @@ public StubHttpMessageHandler(HttpStatusCode statusCode, string responseBody = " _responseBody = responseBody; } + public StubHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + public HttpRequestMessage LastRequest { get; private set; } public string LastRequestBody { get; private set; } @@ -39,7 +45,7 @@ protected override async Task SendAsync(HttpRequestMessage RequestBodies.Add(null); } - return new HttpResponseMessage(_statusCode) + return _responseFactory?.Invoke(request) ?? new HttpResponseMessage(_statusCode) { Content = new StringContent(_responseBody), }; From 6290e3cab6ec283f9455fb6a8ebd144826df0e04 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 14:32:10 -0700 Subject: [PATCH 46/56] Fix provider-authoritative soft phone recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 5 +- .../ITelephonyInteractionStore.cs | 31 ++ ...ephonyInteractionSynchronizationService.cs | 32 ++ .../Services/ProviderVoiceEventService.cs | 34 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 6 +- src/CrestApps.Docs/docs/telephony/asterisk.md | 6 +- src/CrestApps.Docs/docs/telephony/index.md | 39 +- .../AsteriskRealtimeVoiceEventDispatcher.cs | 15 + .../AsteriskRealtimeVoiceEventMapper.cs | 8 +- .../Services/AsteriskRealtimeVoiceListener.cs | 42 ++- .../Assets/js/soft-phone.js | 87 +++-- ...InteractionReconciliationBackgroundTask.cs | 33 ++ .../Hubs/TelephonyHub.cs | 92 +++-- .../DefaultTelephonyInteractionStore.cs | 54 +++ ...ephonyInteractionSynchronizationService.cs | 348 ++++++++++++++++++ .../TelephonyInteractionTenantEvents.cs | 39 ++ .../Startup.cs | 5 + .../wwwroot/scripts/soft-phone.js | 73 ++-- .../wwwroot/scripts/soft-phone.min.js | 2 +- .../InMemoryTelephonyProvider.cs | 129 ++++++- .../Infrastructure/TestTelephonyHub.cs | 116 +++++- .../SoftPhoneWidgetTests.cs | 76 +++- .../ProviderVoiceEventServiceTests.cs | 115 ++++++ ...teriskRealtimeVoiceEventDispatcherTests.cs | 2 + .../AsteriskRealtimeVoiceEventMapperTests.cs | 28 ++ ...yInteractionSynchronizationServiceTests.cs | 233 ++++++++++++ 26 files changed, 1456 insertions(+), 194 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Telephony/BackgroundTasks/TelephonyInteractionReconciliationBackgroundTask.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionTenantEvents.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 7d70cc0ca..e15a3f899 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1484,13 +1484,13 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 4 — Voice integration with Telephony (`Voice` feature: Voice Contact Center Call Router (`IVoiceContactCenterCallRouter`) for inbound and outbound voice routing, inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService` compatibility), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, outbound provider dispatch through `IContactCenterVoiceProvider`, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; **G1 backend shipped: provider delivery models (`AgentDeviceNative`/`ServerSideAcd`) + `ConnectToAgentAsync`, `CallSession` aggregate, normalized `ProviderVoiceEvent`/`IProviderVoiceEventService` ingestion, and the authoritative `IContactCenterCallCommandService` that accepts the reservation, bridges media, and advances interaction+call-session together. all G1 items shipped: the soft-phone JS accept-then-answer coordination now awaits the server accept and only answers the device when `RequiresDeviceAnswer` (asset rebuilt), per-provider signed webhook adapters emit `ProviderVoiceEvent`, and the blind/consultative transfer + conference taxonomy landed** — see "Design review" P0 #1, #2, #3 and P1 #9) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider. **G4 dialer safety shipped (2026-06-30):** each mode is now a dedicated `IDialerStrategy` (Predictive disabled in editor + rejected server-side + refused at runtime; Power hard-capped via `PowerDialerStrategy.MaxCallsPerAgent`); the new `IDialerEligibilityService` compliance gate runs before every attempt and audits `DialSuppressed` (destination, max-attempts, retry cool-down, contact do-not-call, calling window in the contact's time zone, and national DNC registries); single-attempt logic moved to `IDialerAttemptService`. Remaining: callback scheduling (`CallbackRequest`) + callback queues, and dialer run/attempt projections.) - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call work is represented by agent presence (`WrapUp`) plus the active/wrap-up interaction on the Agent Workspace, not by a separate domain aggregate.) -- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) +- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. **Local test harness shipped 2026-07-08:** `src/Startup/CrestApps.OrchardCore.Asterisk.Web` signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards normalized `InboundVoiceEvent` payloads using the real Asterisk channel ids so routing and agent offers can be exercised through the local PBX flow. The sample dashboard now also surfaces provider-tracked hold/mute state plus inferred party counts, moves live notifications beside the raw ARI payload drill-down to free more width for active-call tables, and lowers the default polling cadence to one second so state changes show up faster. The simulator now makes it explicit that **To address** drives inbound queue or entry-point routing while **Caller number seed** only changes caller identity generation. **2026-07-08 queue hardening:** queues now expose an unanswered-offer policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; when no live provider call exists the timeout safely falls back to requeue. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) - [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. **Reusable Reports framework shipped 2026-07-02:** new `CrestApps.OrchardCore.Reports` module (`IReport` registry + display-driver-extensible `ReportFilter` with a from/to range + uniform `ReportDocument` renderer + pluggable `IReportExportFormat`/CSV) surfaced under a single top-level **Reports** admin menu grouped by category. The five Contact Center reports were migrated into this framework (moved off Interaction Center) and a new **Omnichannel Reports** feature adds CRM reports — activity summary, campaign performance, and disposition breakdown. Remaining: SLA/adherence trend snapshots, operational alerts, and per-agent self-service report scoping.) -- [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. **2026-07-10 voice restart reconciliation:** providers can now expose per-call live-state lookup through `ITelephonyCallStateProvider`, Contact Center runs a reconciliation pass during tenant activation and on a periodic background task, and active provider-backed interactions are revalidated against current provider truth after restarts so stale queued or assigned voice work is cleared before routing continues. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, retention for call sessions/metrics/recordings, and full replay/bootstrap strategies for providers that need server-pushed event streams.) +- [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. **2026-07-10 voice restart reconciliation:** providers can now expose per-call live-state lookup through `ITelephonyCallStateProvider`, Contact Center runs a reconciliation pass during tenant activation and on a periodic background task, and active provider-backed interactions are revalidated against current provider truth after restarts so stale queued or assigned voice work is cleared before routing continues. **2026-07-11 plain Telephony recovery:** Telephony now performs the same provider-authoritative recovery during tenant activation, every minute, on Asterisk listener reconnect, and when the soft phone restores; confirmed missing provider calls delete orphaned in-progress Telephony records and disconnect clients, while transient lookup failures retain state for retry. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, retention for call sessions/metrics/recordings, and full replay/bootstrap strategies for providers that need server-pushed event streams.) - [~] Phase 14 — Advanced capabilities (**AI assist seam shipped 2026-07-01:** `IContactCenterAssistProvider` + `IContactCenterAssistService` orchestrate optional summarization + disposition-suggestion providers by order, decoupled from any specific AI provider. Remaining: concrete AI provider (summaries/disposition/sentiment) wired to the AI module, virtual-agent handoff, and AI routing recommendations.) ### Gap-closure backlog (from the 2026-06-30 design review) @@ -1508,6 +1508,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement ### Change log +- 2026-07-11: Eliminated zombie soft-phone calls by making provider events and provider-state lookup the only authorities for active call state. Telephony command responses no longer mutate the browser or broadcast `CallStateChanged`; active-call restoration validates the provider instead of trusting `TelephonyInteraction.Outcome`, and a new locked reconciliation service runs at tenant startup, every minute, after Asterisk listener reconnects, and during soft-phone reconnect/page restoration. Provider-confirmed missing calls delete orphaned in-progress Telephony records and disconnect connected clients, while transient lookup failures preserve the record for retry. Asterisk terminal projection now ignores duplicate terminal events and ambiguous down-state bridge-leave snapshots, and duplicate Contact Center events retain Contact Center ownership. - Codebase analysis completed for Telephony, Omnichannel Core, Omnichannel Management, SMS automation, SignalR docs, target bundle, solution structure, docs, and tests. - Plan reviewed and expanded to add inbound entry points/IVR, call recording, live monitoring (silent monitor/whisper/barge/take-over), outbound compliance hardening, quality management, a standard terminology/metrics glossary, scale-out/high-availability, data retention/privacy, a testing strategy, and a migration strategy. Phases renumbered to 0-14. - Phase 0 started: promoted the session plan to this durable repo-tracked document, added the copilot-instructions pointer, and created the public Contact Center docs landing page. diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs index 137885c01..47f64ebec 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs @@ -23,6 +23,14 @@ public interface ITelephonyInteractionStore /// A task that represents the asynchronous operation. Task UpdateAsync(TelephonyInteraction interaction, 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. /// @@ -42,6 +50,29 @@ public interface ITelephonyInteractionStore /// 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 that can be reconciled against their providers. + /// + /// The cancellation token. + /// The active interactions. + Task> ListActiveAsync(CancellationToken cancellationToken = default); + + /// + /// Lists all in-progress interactions for the specified provider. + /// + /// The technical provider name. + /// The cancellation token. + /// The active interactions for the provider. + Task> ListActiveAsync(string providerName, 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..4a71ddfcb --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs @@ -0,0 +1,32 @@ +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); + + /// + /// 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/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs index 86e37e88c..5e44c6b0b 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs @@ -60,19 +60,6 @@ public async Task IngestAsync(ProviderVoiceEvent providerEvent, Can return null; } - if (!string.IsNullOrEmpty(providerEvent.IdempotencyKey) && - await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, cancellationToken)) - { - if (_logger.IsEnabled(LogLevel.Debug)) - { - _logger.LogDebug( - "Skipping duplicate provider voice event with idempotency key '{IdempotencyKey}'.", - providerEvent.IdempotencyKey); - } - - return null; - } - var interaction = !string.IsNullOrWhiteSpace(providerEvent.ProviderName) ? await _interactionManager.FindByProviderInteractionIdAsync( providerEvent.ProviderName, @@ -92,6 +79,25 @@ await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, canc return null; } + if (!string.IsNullOrEmpty(providerEvent.IdempotencyKey) && + await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, cancellationToken)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Skipping duplicate provider voice event with idempotency key '{IdempotencyKey}'.", + providerEvent.IdempotencyKey); + } + + 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); @@ -204,7 +210,7 @@ private static bool ShouldIgnoreEvent(CallSession session, ProviderVoiceEvent pr return true; } - return IsTerminalState(session.State) && !IsTerminalState(providerEvent.State); + return IsTerminalState(session.State); } private static void ApplyState(CallSession session, Interaction interaction, ContactCenterCallState state, DateTime now) diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index f7077893d..759ef8b08 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -66,16 +66,18 @@ At a high level, the platform changes are: - 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 from persisted interaction history after a page reload or reconnect so agents can still see an already-connected call. +- 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. - 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 now advances an accepted server-side inbound offer out of `Ringing` immediately and keeps the keypad **Hangup** control hidden until the call is actually connected or held, preventing the stale post-answer `Ringing...` state that could not really be hung up. +- 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. - 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. diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md index a52fa3c29..b3c10bd79 100644 --- a/src/CrestApps.Docs/docs/telephony/asterisk.md +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -124,11 +124,11 @@ Because all requests are issued server-side, the ARI password never reaches the ## Real-time call state and recovery -The module keeps a tenant-scoped ARI WebSocket listener for every configured Asterisk provider. Each listener is supervised independently, reconnects with exponential jitter after failure, and runs an immediate provider-scoped reconciliation after connecting. A failed endpoint therefore does not stop healthy provider listeners, and reconnect recovery does not trigger overlapping full-provider sweeps. +The module keeps a tenant-scoped ARI WebSocket listener for every configured Asterisk provider. 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 browser polling. 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, hold/unhold, mute-variable, state-change, hangup, and Stasis lifecycle events update the projection in real time. +ARI events are normalized into provider-authoritative call states and projected through Orchard to connected soft-phone clients without browser polling. 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 startup 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. +Periodic and startup 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. ## Voicemail routing diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index 6d1fc25c4..b9f088f7a 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -10,11 +10,7 @@ description: Provider-agnostic soft phone, SignalR hub, and telephony provider m | **Feature Name** | Telephony | | **Feature ID** | `CrestApps.OrchardCore.Telephony` | -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 @@ -25,19 +21,14 @@ billing sense. 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`, `ITelephonyCallStateProvider`, `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`, `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 or Asterisk) implements `ITelephonyProvider` and registers itself as a @@ -74,7 +65,7 @@ routing hints or contextual data for scenarios such as voicemail routing. ## Provider event normalization -Modern soft-phone behavior depends on more than keypad actions. Provider integrations should normalize live provider events into the Contact Center voice-event pipeline so the server becomes the source of truth for: +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. 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 @@ -84,7 +75,7 @@ Modern soft-phone behavior depends on more than keypad actions. Provider integra 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`, Contact Center can query the provider's current server truth for an individual call during authoritative offer accept, tenant-startup recovery, and periodic reconciliation. That keeps queued and assigned work aligned with the telephony backend even across short Orchard Core restarts. +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. A 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. @@ -99,11 +90,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` (with its contextual cards), and `ReceiveError` events to -the connected client through the strongly-typed `ITelephonyClient` interface. It also exposes -`Answer`, `Reject`, and `Voicemail` operations for a ringing inbound call. +Every hub method runs in its own Orchard Core shell scope and is authorized against the `Use the telephony soft phone` permission. 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 provider-authoritative 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 @@ -162,9 +149,7 @@ The widget's footer is a tab bar that switches the panel between built-in and co - **Contributed tabs** – modules can add their own views through Display Management. For example, Contact Center adds a **Work** tab for queue/campaign sign-in and presence. -The history is read from the hub's `GetInteractions` method and is backed by the persisted -interaction store described below, so it survives page reloads and is available independently of the -provider. Inbound calls are now persisted as soon as they are offered, so the **Recent** tab shows inbound and outbound history instead of only calls placed from the keypad. When the latest interaction is still in progress, the soft phone now restores that active call on reconnect or page reload so agents do not lose the connected-call state just because the page refreshed. When Contact Center owns the voice interaction, normalized server-side call-session changes now also upsert that same telephony history and push a `CallStateChanged` event back through the Telephony hub, so provider-driven disconnects, hold/resume changes, mute/unmute updates, and other server-side lifecycle changes clear or update the live soft-phone state immediately. Provider-side Asterisk ARI events now do the same for plain Telephony calls, so a server-initiated disconnect updates the active soft phone and closes the persisted in-progress record before the next page reload can resurrect it. The widget also keeps the **Keypad** tab's natural height as the shared body height for **Recent** and contributed tabs such as Contact Center **Work**, so switching tabs does not resize the panel unless the user moves it. When a non-keypad tab needs more room than that shared height, it scrolls within the panel instead of clipping its contents. +The history is read from the hub's `GetInteractions` method and is backed by the persisted interaction store described below, so completed history survives page reloads independently of the provider. Inbound calls are persisted as soon as they are offered, so the **Recent** tab shows inbound and outbound history instead of only calls placed from the keypad. Active-call restoration no longer trusts an `InProgress` history record or assumes that it is connected: reconnect and page load call `GetActiveCall`, which validates the record against `ITelephonyCallStateProvider` before rendering it. Startup, periodic, and provider-reconnect sweeps apply the same provider-authoritative reconciliation, delete confirmed orphaned active records, and push a disconnected event so stale history cannot resurrect a zombie call. When Contact Center owns the voice interaction, normalized server-side call-session changes upsert that same telephony history and push `CallStateChanged`, so provider-driven disconnects, hold/resume changes, mute/unmute updates, and other server-side lifecycle changes clear or update the live soft-phone state immediately. Provider-side Asterisk ARI events do the same for plain Telephony calls. The widget also keeps the **Keypad** tab's natural height as the shared body height for **Recent** and contributed tabs such as Contact Center **Work**, so switching tabs does not resize the panel unless the user moves it. When a non-keypad tab needs more room than that shared height, it scrolls within the panel instead of clipping its contents. ## Incoming calls @@ -176,7 +161,7 @@ three actions: the provider advertises the `Voicemail` capability. - **Ignore** declines the ringing call on this device (`RejectAsync`). -The modal appears for a ringing **inbound** call even when the panel is closed. When Contact Center is using the soft phone for queue offers, the modal now restores the current ringing offer after a page refresh or reconnect, reopens immediately when the Contact Center hub reports a new queued offer, and keeps the ringing state visible until the offer is accepted, declined, or the authoritative reservation timeout expires. Those Contact Center offer actions now go through the authoritative reservation endpoints without sending an extra duplicate reject/answer device action, and the modal then hides itself once the call connects, ends, or times out. If the real-time revoke event arrives while the authoritative accept is still completing, the soft phone now preserves the accepted call instead of clearing it back to idle. When the authoritative accept indicates that the server-side voice flow already connected the media, the widget immediately advances its local call state out of `Ringing` so the keypad reflects the live call instead of a stale ringing shell, and the active call header shows the remote number for the side you are actually speaking to. For provider-only server-side queue flows that do not register a Contact Center voice provider, Contact Center now also answers the underlying telephony call during accept so the live call remains visible and hangup still works. The keypad **Hangup** control also stays hidden while a call is still only ringing and appears only once the live call is connected or held. +The modal appears for a ringing **inbound** call even when the panel is closed. When Contact Center is using the soft phone for queue offers, the modal restores the current ringing offer after a page refresh or reconnect, reopens immediately when the Contact Center hub reports a new queued offer, and keeps the ringing state visible until the offer is accepted, declined, or the authoritative reservation timeout expires. Those Contact Center offer actions go through the authoritative reservation endpoints without sending an extra duplicate reject/answer device action. If the real-time revoke event arrives while the authoritative accept is still completing, the soft phone preserves the accepted call instead of clearing it back to idle. The successful accept response does not mark the browser connected; the widget waits for the normalized provider state event or provider-authoritative lookup before changing the call state. For provider-only server-side queue flows that do not register a Contact Center voice provider, Contact Center also answers the underlying telephony call during accept so the provider can emit the resulting live state. The keypad **Hangup** control stays hidden while a call is still only ringing and appears only once the provider reports that the call is connected or held. ### Offering a call to a user diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs index 9528249ed..e962740f4 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventDispatcher.cs @@ -78,6 +78,21 @@ public async Task HandleAsync(AsteriskRealtimeVoiceEvent voiceEvent, Cancellatio return; } + if (interaction.Outcome != CallOutcome.InProgress) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Ignored Asterisk real-time event {EventType} for provider {ProviderName} call {CallId} because telephony interaction {InteractionId} is already terminal.", + voiceEvent.EventType, + voiceEvent.ProviderName, + voiceEvent.CallId, + interaction.InteractionId); + } + + return; + } + ApplyInteractionState(interaction, voiceEvent); await _telephonyInteractionStore.UpdateAsync(interaction, cancellationToken); await _hubContext.Clients.User(interaction.UserId).CallStateChanged(BuildTelephonyCall(interaction, voiceEvent)); diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs index 191bb0707..96eb03be2 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceEventMapper.cs @@ -172,11 +172,13 @@ private static bool TryMapState( if (string.Equals(eventType, "ChannelLeftBridge", StringComparison.OrdinalIgnoreCase)) { - state = MapChannelState(ReadString(channel, "state"), isTerminalEvent: false); + var channelState = ReadString(channel, "state"); + state = MapChannelState(channelState, isTerminalEvent: false); - if (state == CallState.Idle) + if (state == CallState.Idle || + string.Equals(channelState, "Down", StringComparison.OrdinalIgnoreCase)) { - state = CallState.Connected; + return false; } return true; diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs index 2a7665988..8efc26564 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs @@ -1,6 +1,7 @@ using System.Net.WebSockets; using System.Text; using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Telephony; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell.Scope; @@ -195,25 +196,42 @@ private async Task ReconcileAsync(string providerName, CancellationToken cancell { await ShellScope.UsingChildScopeAsync(async scope => { - var service = scope.ServiceProvider + var contactCenterSynchronizationService = scope.ServiceProvider .GetServices() .FirstOrDefault(); - if (service is null) + if (contactCenterSynchronizationService is not null) { - return; + try + { + await contactCenterSynchronizationService.ReconcileProviderInteractionsAsync(providerName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Contact Center provider-state reconciliation failed after reconnecting the Asterisk real-time listener for provider {ProviderName}.", + providerName); + } } - try - { - await service.ReconcileProviderInteractionsAsync(providerName, cancellationToken); - } - catch (Exception ex) + var telephonySynchronizationService = scope.ServiceProvider + .GetServices() + .FirstOrDefault(); + + if (telephonySynchronizationService is not null) { - _logger.LogError( - ex, - "Provider-state reconciliation failed after reconnecting the Asterisk real-time listener for provider {ProviderName}.", - providerName); + try + { + await telephonySynchronizationService.ReconcileProviderInteractionsAsync(providerName, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Telephony interaction reconciliation failed after reconnecting the Asterisk real-time listener for provider {ProviderName}.", + providerName); + } } }); } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js index b28e5a423..66613bb3d 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js @@ -607,7 +607,7 @@ // ---- Call operations ---- - function applyResult(result) { + function applyCommandResult(result) { if (!result) { return false; } @@ -620,17 +620,49 @@ showError(null); - if (result.call) { - currentCall = result.call; + return true; + } - if (normalizeState(currentCall.state) === 'Disconnected' || normalizeState(currentCall.state) === 'Failed') { - currentCall = null; - } + function applyActiveCallLookup(result) { + if (!result || result.succeeded === false) { + return null; + } + + currentCall = result.found === true ? result.call : null; + + if (currentCall && + (normalizeState(currentCall.state) === 'Disconnected' || normalizeState(currentCall.state) === 'Failed')) { + currentCall = null; + } + + if (!currentCall) { + incomingHandled = false; } render(); - return true; + return currentCall; + } + + function refreshActiveCall(attempts, retryDelay) { + if (!connection) { + return Promise.resolve(null); + } + + attempts = attempts || 1; + retryDelay = retryDelay || 0; + + return connection.invoke('GetActiveCall').then(function (result) { + if (result && result.succeeded && !result.found && attempts > 1) { + return new Promise(function (resolve) { + window.setTimeout(resolve, retryDelay); + }).then(function () { + return refreshActiveCall(attempts - 1, retryDelay); + }); + } + + return applyActiveCallLookup(result); + }); } function invoke(method, payload) { @@ -639,9 +671,14 @@ } return connection.invoke(method, payload).then(function (result) { - applyResult(result); + applyCommandResult(result); + + var attempts = method === 'Dial' && result && result.succeeded !== false ? 5 : 1; + var retryDelay = attempts > 1 ? 100 : 0; - return result; + return refreshActiveCall(attempts, retryDelay).then(function () { + return result; + }); }).catch(function (error) { showError(error && error.message ? error.message : String(error)); @@ -1054,10 +1091,6 @@ // answer is required. if (result.requiresDeviceAnswer !== false && id) { invoke('Answer', { callId: id }); - } else if (currentCall) { - currentCall.state = 'Connected'; - currentCall.isOnHold = false; - render(); } }).finally(function () { incomingAcceptPending = false; @@ -1250,31 +1283,7 @@ return Promise.resolve(); } - return connection.invoke('GetInteractions', 1).then(function (items) { - if (!items || !items.length) { - return null; - } - - var interaction = items[0]; - - if (!interaction || !isInProgress(interaction) || interaction.endedUtc) { - return null; - } - - currentCall = { - callId: interaction.callId, - from: interaction.from, - to: interaction.to, - direction: interaction.direction, - state: 'Connected', - providerName: interaction.providerName, - startedUtc: interaction.startedUtc - }; - - render(); - - return currentCall; - }).catch(function () { }); + return refreshActiveCall().catch(function () { }); } function formatTime(value) { @@ -1342,7 +1351,7 @@ connection.on('CallStateChanged', function (call) { currentCall = call; - if (normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed') { + if (!call || normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed') { currentCall = null; incomingHandled = false; } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/BackgroundTasks/TelephonyInteractionReconciliationBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.Telephony/BackgroundTasks/TelephonyInteractionReconciliationBackgroundTask.cs new file mode 100644 index 000000000..aaf5b4dfd --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Telephony/BackgroundTasks/TelephonyInteractionReconciliationBackgroundTask.cs @@ -0,0 +1,33 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.Telephony.BackgroundTasks; + +/// +/// Periodically reconciles in-progress telephony interactions with provider-authoritative state. +/// +[BackgroundTask( + Title = "Telephony Interaction Reconciliation", + Schedule = "* * * * *", + Description = "Reconciles in-progress soft-phone calls against the current provider state.", + LockTimeout = 5_000, + LockExpiration = 120_000)] +public sealed class TelephonyInteractionReconciliationBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var synchronizationService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + try + { + await synchronizationService.ReconcileActiveInteractionsAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while reconciling telephony interaction state."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs index b846b7f49..418a9db52 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Hubs/TelephonyHub.cs @@ -255,6 +255,57 @@ await ShellScope.UsingChildScopeAsync(async scope => return interactions; } + /// + /// Gets the current user's active call directly from the configured telephony provider. + /// + /// The provider-authoritative call lookup result. + public async Task GetActiveCall() + { + var result = new TelephonyCallLookupResult + { + Succeeded = false, + Found = false, + Error = S["Unable to determine the current call state."].Value, + }; + LogHubActionStart("GetActiveCall"); + + await ShellScope.UsingChildScopeAsync(async scope => + { + if (!await AuthorizeAsync(scope.ServiceProvider)) + { + LogHubActionUnauthorized("GetActiveCall"); + result.Error = S["You are not authorized to use the soft phone."].Value; + + return; + } + + var userId = Context.UserIdentifier; + + if (string.IsNullOrEmpty(userId)) + { + return; + } + + var synchronizationService = scope.ServiceProvider.GetRequiredService(); + result = await synchronizationService.GetActiveCallAsync(userId, Context.ConnectionAborted); + }); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Telephony hub action {Action} completed for user {UserId}. Succeeded={Succeeded}, Found={Found}, CallId={CallId}, CallState={CallState}, Error={Error}.", + "GetActiveCall", + Context.UserIdentifier ?? "(anonymous)", + result.Succeeded, + result.Found, + result.Call?.CallId ?? "(none)", + result.Call?.State.ToString() ?? "(none)", + result.Error ?? "(none)"); + } + + return result; + } + /// /// Gets the capabilities of the configured provider as a bit flag integer value. /// @@ -329,15 +380,10 @@ await ShellScope.UsingChildScopeAsync(async scope => if (result?.Call is not null) { - await RecordInteractionAsync(scope.ServiceProvider, result.Call); + await RecordInteractionAsync(scope.ServiceProvider, actionName, result.Call); } }); - if (result?.Call is not null) - { - await Clients.Caller.CallStateChanged(result.Call); - } - if (_logger.IsEnabled(LogLevel.Information)) { var completionRequest = BuildLogRequest(requestFactory); @@ -441,9 +487,11 @@ private static string BuildLogRequest(Func requestFactory) return requestFactory?.Invoke() ?? "(none)"; } - private async Task RecordInteractionAsync(IServiceProvider services, TelephonyCall call) + private async Task RecordInteractionAsync(IServiceProvider services, string actionName, TelephonyCall call) { - if (call is null || string.IsNullOrEmpty(call.CallId)) + if (!string.Equals(actionName, "Dial", StringComparison.Ordinal) || + call is null || + string.IsNullOrEmpty(call.CallId)) { return; } @@ -456,8 +504,8 @@ private async Task RecordInteractionAsync(IServiceProvider services, TelephonyCa return; } - var clock = services.GetService(); - var now = clock?.UtcNow ?? DateTime.UtcNow; + var clock = services.GetRequiredService(); + var now = clock.UtcNow; var userName = Context.GetHttpContext()?.User?.Identity?.Name; var existing = await store.FindByCallIdAsync(userId, call.CallId, Context.ConnectionAborted); @@ -488,28 +536,14 @@ private async Task RecordInteractionAsync(IServiceProvider services, TelephonyCa return; } - if (call.State == CallState.Disconnected) + if (!string.IsNullOrEmpty(call.To)) { - existing.Outcome = CallOutcome.Completed; - existing.EndedUtc = now; - existing.DurationSeconds = Math.Max(0, (now - existing.StartedUtc).TotalSeconds); + existing.To = call.To; } - else if (call.State == CallState.Failed) - { - existing.Outcome = CallOutcome.Failed; - existing.EndedUtc = now; - } - else - { - if (!string.IsNullOrEmpty(call.To)) - { - existing.To = call.To; - } - if (!string.IsNullOrEmpty(call.From)) - { - existing.From = call.From; - } + if (!string.IsNullOrEmpty(call.From)) + { + existing.From = call.From; } await store.UpdateAsync(existing, Context.ConnectionAborted); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs index 49ee1e24b..6b8f823f7 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/DefaultTelephonyInteractionStore.cs @@ -36,6 +36,16 @@ public Task UpdateAsync(TelephonyInteraction interaction, CancellationToken canc return _session.SaveAsync(interaction, cancellationToken: cancellationToken); } + /// + public Task DeleteAsync(TelephonyInteraction interaction, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interaction); + + _session.Delete(interaction); + + return Task.CompletedTask; + } + /// public async Task FindByCallIdAsync(string userId, string callId, CancellationToken cancellationToken = default) { @@ -62,6 +72,50 @@ public async Task FindByProviderCallIdAsync(string provide .FirstOrDefaultAsync(cancellationToken); } + /// + public async Task FindActiveByUserAsync(string userId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(userId)) + { + return null; + } + + return await _session + .Query(x => + x.UserId == userId && + x.Outcome == CallOutcome.InProgress) + .OrderByDescending(x => x.StartedUtc) + .FirstOrDefaultAsync(cancellationToken); + } + + /// + public async Task> ListActiveAsync(CancellationToken cancellationToken = default) + { + var interactions = await _session + .Query(x => x.Outcome == CallOutcome.InProgress) + .OrderByDescending(x => x.StartedUtc) + .ListAsync(cancellationToken); + + return interactions.ToList(); + } + + /// + public async Task> ListActiveAsync( + string providerName, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(providerName); + + var interactions = await _session + .Query(x => + x.ProviderName == providerName && + x.Outcome == CallOutcome.InProgress) + .OrderByDescending(x => x.StartedUtc) + .ListAsync(cancellationToken); + + return interactions.ToList(); + } + /// public async Task> GetRecentAsync(string userId, int count, CancellationToken cancellationToken = default) { diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs new file mode 100644 index 000000000..47aa5834a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs @@ -0,0 +1,348 @@ +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Telephony.Services; + +/// +/// Reconciles locally persisted telephony interactions with provider-authoritative call state. +/// +public sealed class TelephonyInteractionSynchronizationService : ITelephonyInteractionSynchronizationService +{ + private const string ReconciliationLockKey = "TelephonyInteractionStateReconciliation"; + private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(5); + private static readonly TimeSpan _lockExpiration = TimeSpan.FromMinutes(2); + private static readonly TimeSpan _newInteractionGracePeriod = TimeSpan.FromSeconds(15); + + private readonly ITelephonyInteractionStore _interactionStore; + private readonly ITelephonyProviderResolver _providerResolver; + private readonly IHubContext _hubContext; + private readonly IDistributedLock _distributedLock; + private readonly IClock _clock; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The telephony interaction store. + /// The telephony provider resolver. + /// The soft-phone hub context. + /// The distributed lock used to prevent overlapping reconciliation sweeps. + /// The clock used to stamp terminal interactions. + /// The logger. + public TelephonyInteractionSynchronizationService( + ITelephonyInteractionStore interactionStore, + ITelephonyProviderResolver providerResolver, + IHubContext hubContext, + IDistributedLock distributedLock, + IClock clock, + ILogger logger) + { + _interactionStore = interactionStore; + _providerResolver = providerResolver; + _hubContext = hubContext; + _distributedLock = distributedLock; + _clock = clock; + _logger = logger; + } + + /// + public async Task GetActiveCallAsync( + string userId, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(userId); + + var interaction = await _interactionStore.FindActiveByUserAsync(userId, cancellationToken); + + if (interaction is null) + { + return new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }; + } + + var (lookup, _) = await RefreshInteractionAsync(interaction, notifyUser: true, cancellationToken); + + return lookup; + } + + /// + 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 _interactionStore.ListActiveAsync(cancellationToken) + : await _interactionStore.ListActiveAsync(providerName, cancellationToken); + var changed = 0; + + foreach (var interaction in interactions) + { + var (_, interactionChanged) = await RefreshInteractionAsync( + interaction, + notifyUser: true, + cancellationToken); + + if (interactionChanged) + { + changed++; + } + } + + return changed; + } + + private async Task<(TelephonyCallLookupResult Lookup, bool Changed)> RefreshInteractionAsync( + TelephonyInteraction interaction, + bool notifyUser, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(interaction.ProviderName) || + string.IsNullOrWhiteSpace(interaction.CallId)) + { + await RemoveOrphanAsync( + interaction, + notifyUser, + "the interaction does not contain a complete provider identity", + cancellationToken); + + return (new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }, true); + } + + var provider = await _providerResolver.GetAsync(interaction.ProviderName); + + if (provider is not ITelephonyCallStateProvider stateProvider) + { + var error = $"Provider '{interaction.ProviderName}' does not support authoritative call-state lookup."; + + _logger.LogWarning( + "Unable to reconcile telephony interaction {InteractionId}: {ErrorMessage}", + interaction.InteractionId, + error); + + return (new TelephonyCallLookupResult + { + Succeeded = false, + Found = false, + Error = error, + }, false); + } + + var lookup = await stateProvider.GetCallStateAsync(interaction.CallId, cancellationToken); + + if (!lookup.Succeeded) + { + _logger.LogWarning( + "Unable to reconcile telephony interaction {InteractionId} with provider {ProviderName} call {CallId}: {ErrorMessage}", + interaction.InteractionId, + interaction.ProviderName, + interaction.CallId, + lookup.Error); + + return (lookup, false); + } + + if (!lookup.Found) + { + if (interaction.StartedUtc != default && + _clock.UtcNow - interaction.StartedUtc < _newInteractionGracePeriod) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Deferred removal of new telephony interaction {InteractionId} for provider {ProviderName} call {CallId} while the provider propagates the originated call.", + interaction.InteractionId, + interaction.ProviderName, + interaction.CallId); + } + + return (lookup, false); + } + + await RemoveOrphanAsync( + interaction, + notifyUser, + "the provider no longer reports the call", + cancellationToken); + + return (lookup, true); + } + + if (lookup.Call is null) + { + var error = $"Provider '{interaction.ProviderName}' returned an empty call state for '{interaction.CallId}'."; + + _logger.LogWarning( + "Unable to reconcile telephony interaction {InteractionId}: {ErrorMessage}", + interaction.InteractionId, + error); + + return (new TelephonyCallLookupResult + { + Succeeded = false, + Found = true, + Error = error, + }, false); + } + + var call = NormalizeCall(interaction, lookup.Call); + var changed = ApplyProviderState(interaction, call); + + if (changed) + { + await _interactionStore.UpdateAsync(interaction, cancellationToken); + } + + if (notifyUser) + { + await NotifyUserAsync(interaction.UserId, call); + } + + return (lookup, changed); + } + + private async Task RemoveOrphanAsync( + TelephonyInteraction interaction, + bool notifyUser, + string reason, + CancellationToken cancellationToken) + { + await _interactionStore.DeleteAsync(interaction, cancellationToken); + + var disconnectedCall = new TelephonyCall + { + CallId = interaction.CallId, + From = interaction.From, + To = interaction.To, + State = CallState.Disconnected, + Direction = interaction.Direction, + ProviderName = interaction.ProviderName, + StartedUtc = interaction.StartedUtc == default + ? null + : new DateTimeOffset(DateTime.SpecifyKind(interaction.StartedUtc, DateTimeKind.Utc)), + }; + + if (notifyUser) + { + await NotifyUserAsync(interaction.UserId, disconnectedCall); + } + + _logger.LogWarning( + "Removed orphaned in-progress telephony interaction {InteractionId} for provider {ProviderName} call {CallId} because {Reason}.", + interaction.InteractionId, + interaction.ProviderName, + interaction.CallId, + reason); + } + + private bool ApplyProviderState(TelephonyInteraction interaction, TelephonyCall call) + { + var changed = false; + + changed |= SetIfDifferent(interaction.ProviderName, call.ProviderName, value => interaction.ProviderName = value); + changed |= SetIfDifferent(interaction.From, call.From, value => interaction.From = value); + changed |= SetIfDifferent(interaction.To, call.To, value => interaction.To = value); + + if (call.State is CallState.Disconnected or CallState.Failed) + { + var endedUtc = _clock.UtcNow; + var outcome = call.State == CallState.Failed + ? CallOutcome.Failed + : CallOutcome.Completed; + + if (interaction.Outcome != outcome) + { + interaction.Outcome = outcome; + changed = true; + } + + if (interaction.EndedUtc != endedUtc) + { + interaction.EndedUtc = endedUtc; + changed = true; + } + + var durationSeconds = Math.Max(0, (endedUtc - interaction.StartedUtc).TotalSeconds); + + if (interaction.DurationSeconds != durationSeconds) + { + interaction.DurationSeconds = durationSeconds; + changed = true; + } + } + + return changed; + } + + private static TelephonyCall NormalizeCall(TelephonyInteraction interaction, TelephonyCall call) + { + call.CallId ??= interaction.CallId; + call.ProviderName ??= interaction.ProviderName; + call.From ??= interaction.From; + call.To ??= interaction.To; + call.Direction = interaction.Direction; + call.StartedUtc ??= interaction.StartedUtc == default + ? null + : new DateTimeOffset(DateTime.SpecifyKind(interaction.StartedUtc, DateTimeKind.Utc)); + + return call; + } + + private async Task NotifyUserAsync(string userId, TelephonyCall call) + { + if (string.IsNullOrEmpty(userId)) + { + return; + } + + await _hubContext.Clients.User(userId).CallStateChanged(call); + } + + private static bool SetIfDifferent(string currentValue, string providerValue, Action setter) + { + if (string.IsNullOrWhiteSpace(providerValue) || + string.Equals(currentValue, providerValue, StringComparison.Ordinal)) + { + return false; + } + + setter(providerValue); + + return true; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionTenantEvents.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionTenantEvents.cs new file mode 100644 index 000000000..79b42c26f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionTenantEvents.cs @@ -0,0 +1,39 @@ +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Telephony.Services; + +/// +/// Reconciles provider-authoritative telephony state when a tenant starts. +/// +public sealed class TelephonyInteractionTenantEvents : ModularTenantEvents +{ + private readonly ITelephonyInteractionSynchronizationService _synchronizationService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The telephony interaction synchronization service. + /// The logger. + public TelephonyInteractionTenantEvents( + ITelephonyInteractionSynchronizationService synchronizationService, + ILogger logger) + { + _synchronizationService = synchronizationService; + _logger = logger; + } + + /// + public override async Task ActivatingAsync() + { + try + { + await _synchronizationService.ReconcileActiveInteractionsAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while reconciling telephony interaction state during tenant activation."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs index bc3d3eb26..0a2e1c8e4 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Startup.cs @@ -1,4 +1,5 @@ using CrestApps.Core.SignalR.Services; +using CrestApps.OrchardCore.Telephony.BackgroundTasks; using CrestApps.OrchardCore.Telephony.Drivers; using CrestApps.OrchardCore.Telephony.Filters; using CrestApps.OrchardCore.Telephony.Hubs; @@ -11,6 +12,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; +using OrchardCore.BackgroundTasks; using OrchardCore.Data; using OrchardCore.Data.Migration; using OrchardCore.DisplayManagement.Handlers; @@ -37,6 +39,9 @@ public override void ConfigureServices(IServiceCollection services) services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services.AddIndexProvider(); services.AddDataMigration(); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js index 6e5010937..0429d81d1 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js @@ -516,7 +516,7 @@ // ---- Call operations ---- - function applyResult(result) { + function applyCommandResult(result) { if (!result) { return false; } @@ -525,22 +525,50 @@ return false; } showError(null); - if (result.call) { - currentCall = result.call; - if (normalizeState(currentCall.state) === 'Disconnected' || normalizeState(currentCall.state) === 'Failed') { - currentCall = null; - } + return true; + } + function applyActiveCallLookup(result) { + if (!result || result.succeeded === false) { + return null; + } + currentCall = result.found === true ? result.call : null; + if (currentCall && (normalizeState(currentCall.state) === 'Disconnected' || normalizeState(currentCall.state) === 'Failed')) { + currentCall = null; + } + if (!currentCall) { + incomingHandled = false; } render(); - return true; + return currentCall; + } + function refreshActiveCall(attempts, retryDelay) { + if (!connection) { + return Promise.resolve(null); + } + attempts = attempts || 1; + retryDelay = retryDelay || 0; + return connection.invoke('GetActiveCall').then(function (result) { + if (result && result.succeeded && !result.found && attempts > 1) { + return new Promise(function (resolve) { + window.setTimeout(resolve, retryDelay); + }).then(function () { + return refreshActiveCall(attempts - 1, retryDelay); + }); + } + return applyActiveCallLookup(result); + }); } function invoke(method, payload) { if (!connection) { return Promise.reject(new Error('Not connected.')); } return connection.invoke(method, payload).then(function (result) { - applyResult(result); - return result; + applyCommandResult(result); + var attempts = method === 'Dial' && result && result.succeeded !== false ? 5 : 1; + var retryDelay = attempts > 1 ? 100 : 0; + return refreshActiveCall(attempts, retryDelay).then(function () { + return result; + }); })["catch"](function (error) { showError(error && error.message ? error.message : String(error)); throw error; @@ -884,10 +912,6 @@ invoke('Answer', { callId: id }); - } else if (currentCall) { - currentCall.state = 'Connected'; - currentCall.isOnHold = false; - render(); } })["finally"](function () { incomingAcceptPending = false; @@ -1043,26 +1067,7 @@ if (!connection || currentCall) { return Promise.resolve(); } - return connection.invoke('GetInteractions', 1).then(function (items) { - if (!items || !items.length) { - return null; - } - var interaction = items[0]; - if (!interaction || !isInProgress(interaction) || interaction.endedUtc) { - return null; - } - currentCall = { - callId: interaction.callId, - from: interaction.from, - to: interaction.to, - direction: interaction.direction, - state: 'Connected', - providerName: interaction.providerName, - startedUtc: interaction.startedUtc - }; - render(); - return currentCall; - })["catch"](function () {}); + return refreshActiveCall()["catch"](function () {}); } function formatTime(value) { try { @@ -1110,7 +1115,7 @@ } connection.on('CallStateChanged', function (call) { currentCall = call; - if (normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed') { + if (!call || normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed') { currentCall = null; incomingHandled = false; } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js index cd286438c..3b81b3dd6 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js @@ -1 +1 @@ -!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function f(c,f){f=f||{};var h=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),y=h.strings||{},m=h.capabilities||0,g=(h.storageKey||"telephony-soft-phone")+"-layout",v=f.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,k=null,S=null,C=!1,L=!1,I=null,q=!1,E=!1,_=!1,A=!1,x=null,T="keypad",R=!1;function N(e){return(m&e)===e}function P(e,n){e&&(e.hidden=!n)}function U(e){b.status&&(b.status.textContent=e)}function V(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function M(e){b.views.forEach(function(n){P(n,n.getAttribute("data-telephony-view")===e)})}function H(){if(b.panel&&!b.panel.hidden&&b.keypadView){var e=b.keypadView.hidden,n=b.keypadView.style.position,t=b.keypadView.style.visibility,o=b.keypadView.style.pointerEvents,i=b.keypadView.style.inset;e&&(b.keypadView.hidden=!1,b.keypadView.style.position="absolute",b.keypadView.style.inset="0 auto auto 0",b.keypadView.style.visibility="hidden",b.keypadView.style.pointerEvents="none");var r=Math.ceil(b.keypadView.getBoundingClientRect().height||b.keypadView.scrollHeight||0);e&&(b.keypadView.hidden=e,b.keypadView.style.position=n,b.keypadView.style.inset=i,b.keypadView.style.visibility=t,b.keypadView.style.pointerEvents=o),r>0&&c.style.setProperty("--telephony-view-height",r+"px")}}function O(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===T})||(T=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function B(e){return"keypad"===e||"history"===e}function D(){return b.tabs.some(function(e){return!B(e.getAttribute("data-telephony-tab"))})}function F(e){return e?1===e.direction||"Inbound"===e.direction?e.from||e.to||"":e.to||e.from||"":""}function j(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function G(e){try{var n=j();Object.assign(n,e),localStorage.setItem(g,JSON.stringify(n))}catch(e){}}function J(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function Q(){var e=c.getBoundingClientRect(),n=e.width||56,t=e.height||56,o=Math.max(8,window.innerWidth-n-8),i=Math.max(8,window.innerHeight-t-8),r=8,a=8;if(b.panel&&!b.panel.hidden){var l=b.panel.getBoundingClientRect(),u=l.width||n,s=l.height||0;r=Math.min(o,Math.max(8,u-n+8)),a=Math.min(i,s+20)}return{minLeft:r,minTop:a,maxLeft:o,maxTop:i}}function z(e,n){var t=Q();return{left:p(e,t.minLeft,t.maxLeft),top:p(n,t.minTop,t.maxTop)}}function K(e){if(!e)return null;var n=Q(),t=Number(e.left),o=Number(e.top),i=Number(e.leftRatio),r=Number(e.topRatio);return Number.isFinite(i)&&(t=n.minLeft+Math.max(0,n.maxLeft-n.minLeft)*i),Number.isFinite(r)&&(o=n.minTop+Math.max(0,n.maxTop-n.minTop)*r),Number.isFinite(t)&&Number.isFinite(o)?z(t,o):null}function X(){var e=j();if(e.position){var n=K(e.position);if(n)return void J(n.left,n.top)}if(c.style.left){var t=c.getBoundingClientRect(),o=z(t.left,t.top);J(o.left,o.top)}}function Y(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();J(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var u=z(r+n,a+c);J(u.left,u.top)}}}function s(){var e,o,i,r,a,d;null!==t&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(d=c.getBoundingClientRect(),G({position:(e=d.left,o=d.top,i=Q(),r=Math.max(0,i.maxLeft-i.minLeft),a=Math.max(0,i.maxTop-i.minTop),{left:e,top:o,leftRatio:0===r?0:(e-i.minLeft)/r,topRatio:0===a?0:(o-i.minTop)/a})}),n.suppressClick&&(R=!0)))}}function W(){b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===T;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")})}function Z(e){G({activeTab:T=e}),$(),"history"===e&&_e()}function $(){!function(){var e=he()&&!C;if(P(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return me(),void(he()||(S=null));b.incomingCaller&&(b.incomingCaller.textContent=F(k)||y.incomingCall||"Incoming call");var n=S&&S.properties?S.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);P(b.incomingVoicemail,N(l)),function(){if(!b.incomingCards)return;var e=S&&S.cards?S.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=S&&S.heading||y.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(y.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ve(e.getAttribute("data-url"))})})}(),function(){if(me(),!S||!S.properties||!S.properties.expiresUtc)return;var e=Date.parse(S.properties.expiresUtc);if(!isFinite(e))return;var n=e-Date.now();if(n<=0)return void Se();I=window.setTimeout(function(){Se()},n+250)}()}(),O();var a=k?u(k.state):"Idle",p=s(a),f="Connected"===a,h=f||"OnHold"===a;b.toggleIcon&&(b.toggleIcon.className="fa-solid fa-phone");var m=A&&_&&q&&!E;if(A&&!_&&!p){var g=B(T);return b.unavailableText&&(b.unavailableText.textContent=y.notConfigured||"No telephony provider is configured."),P(b.unavailable,g),P(b.connectPanel,!1),M(g?null:T),P(b.footer,D()),W(),U(y.notReady||"Not Ready"),void H()}if(P(b.unavailable,!1),m&&!p){var v=B(T);return P(b.connectPanel,v),M(v?null:T),P(b.footer,D()),W(),U(y.notConnected||"Not connected"),void H()}P(b.connectPanel,!1),P(b.footer,!0),W(),M(T),U(k?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return y[n]||e}(a):y.idle||"Ready"),b.peer&&(b.peer.textContent=p&&k?F(k):""),P(b.dial,!p),P(b.hangup,h&&N(e)),P(b.hold,p&&"Connected"===a&&N(n)),P(b.resume,p&&"OnHold"===a&&N(t));var w=k&&k.isMuted;P(b.mute,f&&!w&&N(o)),P(b.unmute,f&&w&&N(o)),P(b.transfer,h&&N(i)),P(b.merge,f&&N(r)),b.number&&(b.number.disabled=p),H()}function ee(e,n){return w?w.invoke(e,n).then(function(e){return function(e){!!e&&(!1===e.succeeded?V(e.error||y.failed||"Call failed"):(V(null),e.call&&("Disconnected"!==u((k=e.call).state)&&"Failed"!==u(k.state)||(k=null)),$()))}(e),e}).catch(function(e){throw V(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function ne(){return k?k.callId:null}function te(){var e=ne();return e?{callId:e,metadata:k&&k.metadata?k.metadata:null}:null}function oe(){var e=b.number?b.number.value.trim():"";e?ee("Dial",{to:e}):V(y.invalidNumber||"Enter a phone number to call.")}function ie(e){e&&(Z("keypad"),fe(!0),b.number&&(b.number.value=e),ee("Dial",{to:e}))}function re(){var e=te();e&&ee("Hangup",e)}function ae(){var e=te();e&&ee("Hold",e)}function le(){var e=te();e&&ee("Resume",e)}function ce(){var e=te();e&&ee("Mute",e)}function ue(){var e=te();e&&ee("Unmute",e)}function se(){var e=ne();if(e){var n=window.prompt(y.transferPrompt||"Transfer to number");n&&ee("Transfer",{callId:e,to:n,mode:0})}}function de(){var e=ne();if(e){var n=window.prompt(y.mergePrompt||"Second call id to merge");n&&ee("Merge",{primaryCallId:e,secondaryCallId:n})}}function pe(e){var n=k?u(k.state):"Idle";"Connected"===n&&N(a)?ee("SendDigits",{callId:ne(),digits:e}):!s(n)&&b.number&&(b.number.value+=e)}function fe(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,G({open:n}),X(),$()}}function he(){if(!k)return!1;var e=1===k.direction||"Inbound"===k.direction;return"Ringing"===u(k.state)&&e}function ye(e){return e&&e.properties&&e.properties.reservationId||null}function me(){I&&(window.clearTimeout(I),I=null)}function ge(e){if(!S||!S.properties)return Promise.resolve(null);var n=S.properties[e];if(!n)return Promise.resolve(null);var t={"Content-Type":"application/json"};h.antiForgeryToken&&(t.RequestVerificationToken=h.antiForgeryToken);try{return fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:ne()})}).then(function(e){return e.ok?e.json().catch(function(){return{succeeded:!0}}):{succeeded:!1}}).catch(function(){return{succeeded:!1}})}catch(e){return Promise.resolve({succeeded:!1})}}function ve(e){var n=ne();e&&window.open(e,"_blank","noopener"),S&&S.properties&&S.properties.acceptUrl?(L=!0,ge("acceptUrl").then(function(e){if(!e||!1===e.succeeded)return V(y.offerUnavailable||"This call is no longer available."),S=null,k=null,void $();C=!0,S=null,fe(!0),$(),!1!==e.requiresDeviceAnswer&&n?ee("Answer",{callId:n}):k&&(k.state="Connected",k.isOnHold=!1,$())}).finally(function(){L=!1})):n&&(fe(!0),ee("Answer",{callId:n}))}function be(){var e=te();ge("declineUrl"),e&&ee("Voicemail",e)}function we(){var e=te();S&&S.properties&&S.properties.declineUrl?ge("declineUrl").then(function(e){e&&!1!==e.succeeded?Se():V(y.offerUnavailable||"This call is no longer available.")}):e?ee("Reject",e):Se()}function ke(e,n){e&&(k&&s(u(k.state))&&!he()||C&&function(e,n){if(!k||!e)return!1;var t=ye(S),o=ye(n);return!(k.callId!==e.callId||t&&o&&t!==o)}(e,n)||(k=e,S=n||null,C=!1,L=!1,Z("keypad"),$()))}function Se(e){e=e||{},me(),S=null,C=!1,e.preservePendingAccept||(L=!1),!e.preserveCurrentCall&&k&&he()&&(k=null),$()}function Ce(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(_=!!e.isAvailable,q=!!e.requiresAuthentication,E=!!e.isConnected,x=e.authenticationScheme||"oauth2",A=!0,$())}).catch(function(){}):Promise.resolve()}function Le(){return w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(m=e,$())}).catch(function(){}):Promise.resolve()}function Ie(){if(h.connectUrl){var e=h.connectUrl.indexOf("?")>=0?"&":"?",n=h.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function qe(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[x]||e.oauth2),t={scheme:x,connectUrl:h.connectUrl,startOAuth:Ie,refreshStatus:Ce};"function"==typeof n?n(t):Ie()}function Ee(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Ce()}function _e(){w?w.invoke("GetInteractions",50).then(function(e){Te(e||[])}).catch(function(){Te([])}):Te([])}function Ae(e){return 0===e.outcome||"InProgress"===e.outcome}function xe(){return!w||k?Promise.resolve():w.invoke("GetInteractions",1).then(function(e){if(!e||!e.length)return null;var n=e[0];return n&&Ae(n)&&!n.endedUtc?(k={callId:n.callId,from:n.from,to:n.to,direction:n.direction,state:"Connected",providerName:n.providerName,startedUtc:n.startedUtc},$(),k):null}).catch(function(){})}function Te(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=Ae(e),i=n?"↙":"↗",r=n?e.from||"":e.to||"",a=t?y.missed||"Missed":n?y.incoming||"Incoming":y.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&ie(n)})})):b.historyList.innerHTML='
'+d(y.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){R?R=!1:fe()}),b.close&&b.close.addEventListener("click",function(){fe(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){Z(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",oe),b.hangup&&b.hangup.addEventListener("click",re),b.hold&&b.hold.addEventListener("click",ae),b.resume&&b.resume.addEventListener("click",le),b.mute&&b.mute.addEventListener("click",ce),b.unmute&&b.unmute.addEventListener("click",ue),b.transfer&&b.transfer.addEventListener("click",se),b.merge&&b.merge.addEventListener("click",de),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ve(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",be),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",we),b.connect&&b.connect.addEventListener("click",qe),b.keys.forEach(function(e){e.addEventListener("click",function(){pe(e.getAttribute("data-telephony-key"))})}),Y(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),Y(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",Ee),window.addEventListener("resize",function(){X(),H()}),function(){var e,n=j();if("string"==typeof n.activeTab&&n.activeTab.length&&(T=n.activeTab),n.open&&b.panel&&(b.panel.hidden=!1),n.position&&("number"==typeof(e=Number(n.position.left))&&isFinite(e))){var t=K(n.position);t&&J(t.left,t.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=z(o,n.top);J(i.left,i.top)}}()}(),$(),c.style.visibility="";var Re=v&&h.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(h.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){k=e,"Disconnected"!==u(e.state)&&"Failed"!==u(e.state)||(k=null,C=!1),$()}),w.on("IncomingCall",function(e,n){ke(e,n||null)}),w.on("ReceiveError",function(e){V(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){U(y.disconnectedHub||"Disconnected")}),"function"==typeof w.onreconnected&&w.onreconnected(function(){return V(null),Promise.all([Le(),Ce()]).then(function(){return xe()}).then(function(){"history"===T&&_e(),$()}).catch(function(e){V(e&&e.message?e.message:String(e))})})),w.start().then(function(){return V(null),Promise.all([Le(),Ce()])}).then(function(){return xe()}).then(function(){"history"===T&&_e(),$()}).catch(function(e){V(e&&e.message?e.message:String(e))})):($(),Promise.resolve());return{element:c,config:h,dial:oe,dialNumber:ie,hangup:re,hold:ae,resume:le,mute:ce,unmute:ue,transfer:se,merge:de,pressKey:pe,togglePanel:fe,open:function(){fe(!0)},getCurrentCall:function(){return k},isIncomingAcceptPending:function(){return L},setIncomingOffer:ke,clearIncomingOffer:Se,showError:V,getConnection:function(){return w},started:Re}}function h(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=f(e))})}function y(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:f,initializeAll:h,getInstance:y,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=y();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",h):h()}(); +!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function f(c,f){f=f||{};var h=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),y=h.strings||{},m=h.capabilities||0,g=(h.storageKey||"telephony-soft-phone")+"-layout",v=f.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,k=null,S=null,C=!1,L=!1,q=null,E=!1,_=!1,I=!1,A=!1,T=null,x="keypad",P=!1;function R(e){return(m&e)===e}function N(e,n){e&&(e.hidden=!n)}function V(e){b.status&&(b.status.textContent=e)}function M(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function U(e){b.views.forEach(function(n){N(n,n.getAttribute("data-telephony-view")===e)})}function H(){if(b.panel&&!b.panel.hidden&&b.keypadView){var e=b.keypadView.hidden,n=b.keypadView.style.position,t=b.keypadView.style.visibility,o=b.keypadView.style.pointerEvents,i=b.keypadView.style.inset;e&&(b.keypadView.hidden=!1,b.keypadView.style.position="absolute",b.keypadView.style.inset="0 auto auto 0",b.keypadView.style.visibility="hidden",b.keypadView.style.pointerEvents="none");var r=Math.ceil(b.keypadView.getBoundingClientRect().height||b.keypadView.scrollHeight||0);e&&(b.keypadView.hidden=e,b.keypadView.style.position=n,b.keypadView.style.inset=i,b.keypadView.style.visibility=t,b.keypadView.style.pointerEvents=o),r>0&&c.style.setProperty("--telephony-view-height",r+"px")}}function O(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===x})||(x=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function D(e){return"keypad"===e||"history"===e}function B(){return b.tabs.some(function(e){return!D(e.getAttribute("data-telephony-tab"))})}function F(e){return e?1===e.direction||"Inbound"===e.direction?e.from||e.to||"":e.to||e.from||"":""}function j(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function G(e){try{var n=j();Object.assign(n,e),localStorage.setItem(g,JSON.stringify(n))}catch(e){}}function J(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function Q(){var e=c.getBoundingClientRect(),n=e.width||56,t=e.height||56,o=Math.max(8,window.innerWidth-n-8),i=Math.max(8,window.innerHeight-t-8),r=8,a=8;if(b.panel&&!b.panel.hidden){var l=b.panel.getBoundingClientRect(),u=l.width||n,s=l.height||0;r=Math.min(o,Math.max(8,u-n+8)),a=Math.min(i,s+20)}return{minLeft:r,minTop:a,maxLeft:o,maxTop:i}}function z(e,n){var t=Q();return{left:p(e,t.minLeft,t.maxLeft),top:p(n,t.minTop,t.maxTop)}}function K(e){if(!e)return null;var n=Q(),t=Number(e.left),o=Number(e.top),i=Number(e.leftRatio),r=Number(e.topRatio);return Number.isFinite(i)&&(t=n.minLeft+Math.max(0,n.maxLeft-n.minLeft)*i),Number.isFinite(r)&&(o=n.minTop+Math.max(0,n.maxTop-n.minTop)*r),Number.isFinite(t)&&Number.isFinite(o)?z(t,o):null}function X(){var e=j();if(e.position){var n=K(e.position);if(n)return void J(n.left,n.top)}if(c.style.left){var t=c.getBoundingClientRect(),o=z(t.left,t.top);J(o.left,o.top)}}function Y(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();J(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var u=z(r+n,a+c);J(u.left,u.top)}}}function s(){var e,o,i,r,a,d;null!==t&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(d=c.getBoundingClientRect(),G({position:(e=d.left,o=d.top,i=Q(),r=Math.max(0,i.maxLeft-i.minLeft),a=Math.max(0,i.maxTop-i.minTop),{left:e,top:o,leftRatio:0===r?0:(e-i.minLeft)/r,topRatio:0===a?0:(o-i.minTop)/a})}),n.suppressClick&&(P=!0)))}}function W(){b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===x;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")})}function Z(e){G({activeTab:x=e}),$(),"history"===e&&Ae()}function $(){!function(){var e=ye()&&!C;if(N(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return ge(),void(ye()||(S=null));b.incomingCaller&&(b.incomingCaller.textContent=F(k)||y.incomingCall||"Incoming call");var n=S&&S.properties?S.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);N(b.incomingVoicemail,R(l)),function(){if(!b.incomingCards)return;var e=S&&S.cards?S.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=S&&S.heading||y.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(y.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){be(e.getAttribute("data-url"))})})}(),function(){if(ge(),!S||!S.properties||!S.properties.expiresUtc)return;var e=Date.parse(S.properties.expiresUtc);if(!isFinite(e))return;var n=e-Date.now();if(n<=0)return void Ce();q=window.setTimeout(function(){Ce()},n+250)}()}(),O();var a=k?u(k.state):"Idle",p=s(a),f="Connected"===a,h=f||"OnHold"===a;b.toggleIcon&&(b.toggleIcon.className="fa-solid fa-phone");var m=A&&I&&E&&!_;if(A&&!I&&!p){var g=D(x);return b.unavailableText&&(b.unavailableText.textContent=y.notConfigured||"No telephony provider is configured."),N(b.unavailable,g),N(b.connectPanel,!1),U(g?null:x),N(b.footer,B()),W(),V(y.notReady||"Not Ready"),void H()}if(N(b.unavailable,!1),m&&!p){var v=D(x);return N(b.connectPanel,v),U(v?null:x),N(b.footer,B()),W(),V(y.notConnected||"Not connected"),void H()}N(b.connectPanel,!1),N(b.footer,!0),W(),U(x),V(k?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return y[n]||e}(a):y.idle||"Ready"),b.peer&&(b.peer.textContent=p&&k?F(k):""),N(b.dial,!p),N(b.hangup,h&&R(e)),N(b.hold,p&&"Connected"===a&&R(n)),N(b.resume,p&&"OnHold"===a&&R(t));var w=k&&k.isMuted;N(b.mute,f&&!w&&R(o)),N(b.unmute,f&&w&&R(o)),N(b.transfer,h&&R(i)),N(b.merge,f&&R(r)),b.number&&(b.number.disabled=p),H()}function ee(e,n){return w?(e=e||1,n=n||0,w.invoke("GetActiveCall").then(function(t){return t&&t.succeeded&&!t.found&&e>1?new Promise(function(e){window.setTimeout(e,n)}).then(function(){return ee(e-1,n)}):function(e){return e&&!1!==e.succeeded?(!(k=!0===e.found?e.call:null)||"Disconnected"!==u(k.state)&&"Failed"!==u(k.state)||(k=null),k||(C=!1),$(),k):null}(t)})):Promise.resolve(null)}function ne(e,n){return w?w.invoke(e,n).then(function(n){!function(e){!!e&&(!1===e.succeeded?M(e.error||y.failed||"Call failed"):M(null))}(n);var t="Dial"===e&&n&&!1!==n.succeeded?5:1;return ee(t,t>1?100:0).then(function(){return n})}).catch(function(e){throw M(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function te(){return k?k.callId:null}function oe(){var e=te();return e?{callId:e,metadata:k&&k.metadata?k.metadata:null}:null}function ie(){var e=b.number?b.number.value.trim():"";e?ne("Dial",{to:e}):M(y.invalidNumber||"Enter a phone number to call.")}function re(e){e&&(Z("keypad"),he(!0),b.number&&(b.number.value=e),ne("Dial",{to:e}))}function ae(){var e=oe();e&&ne("Hangup",e)}function le(){var e=oe();e&&ne("Hold",e)}function ce(){var e=oe();e&&ne("Resume",e)}function ue(){var e=oe();e&&ne("Mute",e)}function se(){var e=oe();e&&ne("Unmute",e)}function de(){var e=te();if(e){var n=window.prompt(y.transferPrompt||"Transfer to number");n&&ne("Transfer",{callId:e,to:n,mode:0})}}function pe(){var e=te();if(e){var n=window.prompt(y.mergePrompt||"Second call id to merge");n&&ne("Merge",{primaryCallId:e,secondaryCallId:n})}}function fe(e){var n=k?u(k.state):"Idle";"Connected"===n&&R(a)?ne("SendDigits",{callId:te(),digits:e}):!s(n)&&b.number&&(b.number.value+=e)}function he(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,G({open:n}),X(),$()}}function ye(){if(!k)return!1;var e=1===k.direction||"Inbound"===k.direction;return"Ringing"===u(k.state)&&e}function me(e){return e&&e.properties&&e.properties.reservationId||null}function ge(){q&&(window.clearTimeout(q),q=null)}function ve(e){if(!S||!S.properties)return Promise.resolve(null);var n=S.properties[e];if(!n)return Promise.resolve(null);var t={"Content-Type":"application/json"};h.antiForgeryToken&&(t.RequestVerificationToken=h.antiForgeryToken);try{return fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:te()})}).then(function(e){return e.ok?e.json().catch(function(){return{succeeded:!0}}):{succeeded:!1}}).catch(function(){return{succeeded:!1}})}catch(e){return Promise.resolve({succeeded:!1})}}function be(e){var n=te();e&&window.open(e,"_blank","noopener"),S&&S.properties&&S.properties.acceptUrl?(L=!0,ve("acceptUrl").then(function(e){if(!e||!1===e.succeeded)return M(y.offerUnavailable||"This call is no longer available."),S=null,k=null,void $();C=!0,S=null,he(!0),$(),!1!==e.requiresDeviceAnswer&&n&&ne("Answer",{callId:n})}).finally(function(){L=!1})):n&&(he(!0),ne("Answer",{callId:n}))}function we(){var e=oe();ve("declineUrl"),e&&ne("Voicemail",e)}function ke(){var e=oe();S&&S.properties&&S.properties.declineUrl?ve("declineUrl").then(function(e){e&&!1!==e.succeeded?Ce():M(y.offerUnavailable||"This call is no longer available.")}):e?ne("Reject",e):Ce()}function Se(e,n){e&&(k&&s(u(k.state))&&!ye()||C&&function(e,n){if(!k||!e)return!1;var t=me(S),o=me(n);return!(k.callId!==e.callId||t&&o&&t!==o)}(e,n)||(k=e,S=n||null,C=!1,L=!1,Z("keypad"),$()))}function Ce(e){e=e||{},ge(),S=null,C=!1,e.preservePendingAccept||(L=!1),!e.preserveCurrentCall&&k&&ye()&&(k=null),$()}function Le(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(I=!!e.isAvailable,E=!!e.requiresAuthentication,_=!!e.isConnected,T=e.authenticationScheme||"oauth2",A=!0,$())}).catch(function(){}):Promise.resolve()}function qe(){return w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(m=e,$())}).catch(function(){}):Promise.resolve()}function Ee(){if(h.connectUrl){var e=h.connectUrl.indexOf("?")>=0?"&":"?",n=h.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function _e(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[T]||e.oauth2),t={scheme:T,connectUrl:h.connectUrl,startOAuth:Ee,refreshStatus:Le};"function"==typeof n?n(t):Ee()}function Ie(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Le()}function Ae(){w?w.invoke("GetInteractions",50).then(function(e){xe(e||[])}).catch(function(){xe([])}):xe([])}function Te(){return!w||k?Promise.resolve():ee().catch(function(){})}function xe(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"↙":"↗",r=n?e.from||"":e.to||"",a=t?y.missed||"Missed":n?y.incoming||"Incoming":y.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&re(n)})})):b.historyList.innerHTML='
'+d(y.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){P?P=!1:he()}),b.close&&b.close.addEventListener("click",function(){he(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){Z(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",ie),b.hangup&&b.hangup.addEventListener("click",ae),b.hold&&b.hold.addEventListener("click",le),b.resume&&b.resume.addEventListener("click",ce),b.mute&&b.mute.addEventListener("click",ue),b.unmute&&b.unmute.addEventListener("click",se),b.transfer&&b.transfer.addEventListener("click",de),b.merge&&b.merge.addEventListener("click",pe),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){be(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",we),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",ke),b.connect&&b.connect.addEventListener("click",_e),b.keys.forEach(function(e){e.addEventListener("click",function(){fe(e.getAttribute("data-telephony-key"))})}),Y(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),Y(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",Ie),window.addEventListener("resize",function(){X(),H()}),function(){var e,n=j();if("string"==typeof n.activeTab&&n.activeTab.length&&(x=n.activeTab),n.open&&b.panel&&(b.panel.hidden=!1),n.position&&("number"==typeof(e=Number(n.position.left))&&isFinite(e))){var t=K(n.position);t&&J(t.left,t.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=z(o,n.top);J(i.left,i.top)}}()}(),$(),c.style.visibility="";var Pe=v&&h.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(h.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){k=e,e&&"Disconnected"!==u(e.state)&&"Failed"!==u(e.state)||(k=null,C=!1),$()}),w.on("IncomingCall",function(e,n){Se(e,n||null)}),w.on("ReceiveError",function(e){M(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){V(y.disconnectedHub||"Disconnected")}),"function"==typeof w.onreconnected&&w.onreconnected(function(){return M(null),Promise.all([qe(),Le()]).then(function(){return Te()}).then(function(){"history"===x&&Ae(),$()}).catch(function(e){M(e&&e.message?e.message:String(e))})})),w.start().then(function(){return M(null),Promise.all([qe(),Le()])}).then(function(){return Te()}).then(function(){"history"===x&&Ae(),$()}).catch(function(e){M(e&&e.message?e.message:String(e))})):($(),Promise.resolve());return{element:c,config:h,dial:ie,dialNumber:re,hangup:ae,hold:le,resume:ce,mute:ue,unmute:se,transfer:de,merge:pe,pressKey:fe,togglePanel:he,open:function(){he(!0)},getCurrentCall:function(){return k},isIncomingAcceptPending:function(){return L},setIncomingOffer:Se,clearIncomingOffer:Ce,showError:M,getConnection:function(){return w},started:Pe}}function h(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=f(e))})}function y(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:f,initializeAll:h,getInstance:y,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=y();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",h):h()}(); diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs index 2da73a991..53ba62028 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/InMemoryTelephonyProvider.cs @@ -8,9 +8,11 @@ namespace CrestApps.OrchardCore.Telephony.PlaywrightTests.Infrastructure; /// An in-memory telephony provider used by the Playwright harness to exercise the soft phone widget /// and the SignalR hub contract without a real provider. ///
-public sealed class InMemoryTelephonyProvider : ITelephonyProvider +public sealed class InMemoryTelephonyProvider : ITelephonyProvider, ITelephonyCallStateProvider { private readonly ConcurrentDictionary _calls = new(); + private TelephonyCall _latestCall; + private bool _latestCallPublished; private int _counter; public LocalizedString Name => new("InMemory", "InMemory"); @@ -46,6 +48,8 @@ public Task DialAsync(DialRequest request, CancellationToken ca }; _calls[call.CallId] = call; + _latestCall = call; + _latestCallPublished = false; return Task.FromResult(TelephonyResult.Success(call)); } @@ -57,41 +61,139 @@ public Task HangupAsync(CallReference call, CancellationToken c _calls.TryRemove(call.CallId, out _); } - return Task.FromResult(TelephonyResult.Success(new TelephonyCall { CallId = call?.CallId, State = CallState.Disconnected })); + _latestCall = new TelephonyCall + { + CallId = call?.CallId, + State = CallState.Disconnected, + ProviderName = Name.Name, + }; + + return Task.FromResult(TelephonyResult.Success(_latestCall)); } public Task HoldAsync(CallReference call, CancellationToken cancellationToken = default) - => Update(call?.CallId, c => { c.State = CallState.OnHold; c.IsOnHold = true; }); + { + return Update(call?.CallId, c => + { + c.State = CallState.OnHold; + c.IsOnHold = true; + }); + } public Task ResumeAsync(CallReference call, CancellationToken cancellationToken = default) - => Update(call?.CallId, c => { c.State = CallState.Connected; c.IsOnHold = false; }); + { + return Update(call?.CallId, c => + { + c.State = CallState.Connected; + c.IsOnHold = false; + }); + } public Task MuteAsync(CallReference call, CancellationToken cancellationToken = default) - => Update(call?.CallId, c => c.IsMuted = true); + { + return Update(call?.CallId, c => c.IsMuted = true); + } public Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default) - => Update(call?.CallId, c => c.IsMuted = false); + { + return Update(call?.CallId, c => c.IsMuted = false); + } public Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default) - => Update(request?.CallId, c => c.State = CallState.Connected); + { + return Update(request?.CallId, c => c.State = CallState.Connected); + } public Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default) - => Update(request?.PrimaryCallId, c => c.State = CallState.Connected); + { + return Update(request?.PrimaryCallId, c => c.State = CallState.Connected); + } public Task SendDigitsAsync(SendDigitsRequest request, CancellationToken cancellationToken = default) - => Task.FromResult(TelephonyResult.Success()); + { + return Task.FromResult(TelephonyResult.Success()); + } public Task AnswerAsync(CallReference call, CancellationToken cancellationToken = default) - => Update(call?.CallId, c => { c.State = CallState.Connected; c.Direction = CallDirection.Inbound; }); + { + return Update(call?.CallId, c => + { + c.State = CallState.Connected; + c.Direction = CallDirection.Inbound; + }); + } public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) - => HangupAsync(call, cancellationToken); + { + return HangupAsync(call, cancellationToken); + } public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) - => HangupAsync(call, cancellationToken); + { + return HangupAsync(call, cancellationToken); + } public Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) - => Task.FromResult(new TelephonyClientCredentials { ProviderName = "InMemory" }); + { + return Task.FromResult(new TelephonyClientCredentials { ProviderName = "InMemory" }); + } + + public Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(callId) || + !_latestCallPublished || + !_calls.TryGetValue(callId, out var call)) + { + return Task.FromResult(new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }); + } + + return Task.FromResult(new TelephonyCallLookupResult + { + Succeeded = true, + Found = true, + Call = call, + }); + } + + public TelephonyCall GetLatestCall() + { + return _latestCall; + } + + public TelephonyCall PublishLatestCallState() + { + _latestCallPublished = true; + + return _latestCall; + } + + public TelephonyCall DisconnectLatestCall() + { + var callId = _latestCall?.CallId; + + if (!string.IsNullOrEmpty(callId)) + { + _calls.TryRemove(callId, out _); + } + + _latestCall = new TelephonyCall + { + CallId = callId, + From = _latestCall?.From, + To = _latestCall?.To, + Direction = _latestCall?.Direction ?? CallDirection.Outbound, + State = CallState.Disconnected, + ProviderName = Name.Name, + StartedUtc = _latestCall?.StartedUtc, + }; + _latestCallPublished = true; + + return _latestCall; + } private Task Update(string callId, Action mutate) { @@ -101,6 +203,7 @@ private Task Update(string callId, Action mutate } mutate(call); + _latestCall = call; return Task.FromResult(TelephonyResult.Success(call)); } diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs index d21ec44f5..5c2955ed5 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs @@ -16,34 +16,122 @@ public TestTelephonyHub(InMemoryTelephonyProvider provider) _provider = provider; } - public Task Dial(DialRequest request) => _provider.DialAsync(request); + public Task Dial(DialRequest request) + { + return _provider.DialAsync(request); + } - public Task Hangup(CallReference call) => _provider.HangupAsync(call); + public Task Hangup(CallReference call) + { + return _provider.HangupAsync(call); + } - public Task Hold(CallReference call) => _provider.HoldAsync(call); + public Task Hold(CallReference call) + { + return _provider.HoldAsync(call); + } - public Task Resume(CallReference call) => _provider.ResumeAsync(call); + public Task Resume(CallReference call) + { + return _provider.ResumeAsync(call); + } - public Task Mute(CallReference call) => _provider.MuteAsync(call); + public Task Mute(CallReference call) + { + return _provider.MuteAsync(call); + } - public Task Unmute(CallReference call) => _provider.UnmuteAsync(call); + public Task Unmute(CallReference call) + { + return _provider.UnmuteAsync(call); + } - public Task Transfer(TransferRequest request) => _provider.TransferAsync(request); + public Task Transfer(TransferRequest request) + { + return _provider.TransferAsync(request); + } - public Task Merge(MergeRequest request) => _provider.MergeAsync(request); + public Task Merge(MergeRequest request) + { + return _provider.MergeAsync(request); + } - public Task SendDigits(SendDigitsRequest request) => _provider.SendDigitsAsync(request); + public Task SendDigits(SendDigitsRequest request) + { + return _provider.SendDigitsAsync(request); + } - public Task Answer(CallReference call) => _provider.AnswerAsync(call); + public Task Answer(CallReference call) + { + return _provider.AnswerAsync(call); + } - public Task Reject(CallReference call) => _provider.RejectAsync(call); + public Task Reject(CallReference call) + { + return _provider.RejectAsync(call); + } - public Task GetCredentials() => _provider.GetClientCredentialsAsync(); + public Task GetCredentials() + { + return _provider.GetClientCredentialsAsync(); + } public Task GetConnectionStatus() - => Task.FromResult(new TelephonyConnectionStatus { ProviderName = _provider.Name.Name, IsAvailable = true, RequiresAuthentication = false, IsConnected = true }); + { + return Task.FromResult(new TelephonyConnectionStatus + { + ProviderName = _provider.Name.Name, + IsAvailable = true, + RequiresAuthentication = false, + IsConnected = true, + }); + } + + public Task GetCapabilities() + { + return Task.FromResult((int)_provider.Capabilities); + } + + public Task GetActiveCall() + { + var call = _provider.GetLatestCall(); + + if (call is null) + { + return Task.FromResult(new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }); + } + + return _provider.GetCallStateAsync(call.CallId); + } - public Task GetCapabilities() => Task.FromResult((int)_provider.Capabilities); + public async Task PublishLatestCallState() + { + var call = _provider.PublishLatestCallState(); + + if (call is null) + { + return false; + } + + await Clients.Caller.CallStateChanged(call); + + return true; + } + + public async Task DisconnectLatestCall() + { + var call = _provider.DisconnectLatestCall(); + await Clients.Caller.CallStateChanged(call); + } + + public Task PublishCallState(TelephonyCall call) + { + return Clients.Caller.CallStateChanged(call); + } public Task> GetInteractions(int count) { diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs index 7a4021bf5..75e5d2611 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs @@ -58,6 +58,13 @@ public async Task Dial_ThenHangup_TransitionsSoftPhoneUi() // Act await page.ClickAsync("[data-telephony-dial]"); + // Assert - the command response alone does not change the call state. + Assert.Equal("Ready", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + Assert.True(await page.Locator("[data-telephony-dial]").IsVisibleAsync()); + + // Act - publish the provider-authoritative state. + await PublishLatestCallStateAsync(page); + // Assert await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); var status = await page.Locator("[data-telephony-status]").InnerTextAsync(); @@ -86,6 +93,7 @@ public async Task Dial_ThenHold_ShowsResumeControl() await page.ClickAsync("[data-telephony-toggle]"); await page.FillAsync("[data-telephony-number]", "+15551234567"); await page.ClickAsync("[data-telephony-dial]"); + await PublishLatestCallStateAsync(page); await page.Locator("[data-telephony-hold]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); // Act @@ -97,6 +105,31 @@ public async Task Dial_ThenHold_ShowsResumeControl() Assert.Equal("On hold", status.Trim()); } + [Fact] + public async Task RemoteProviderDisconnect_ImmediatelyClearsActiveCall() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + await page.FillAsync("[data-telephony-number]", "+15551234567"); + await page.ClickAsync("[data-telephony-dial]"); + await PublishLatestCallStateAsync(page); + await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + + // Act + await page.EvaluateAsync( + """ + () => window.telephonySoftPhone.getInstance().getConnection().invoke('DisconnectLatestCall') + """); + + // Assert + await page.Locator("[data-telephony-dial]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + Assert.True(await page.Locator("[data-telephony-hangup]").IsHiddenAsync()); + Assert.Equal("Ready", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + } + [Fact] public async Task RecentTab_ShowsCallHistory_AndHidesKeypad() { @@ -242,8 +275,9 @@ await page.EvaluateAsync( // Act await page.ClickAsync("[data-telephony-incoming-answer]"); + await PublishCallStateAsync(page, "call-inbound-1", "+15550001000"); - // Assert - once the authoritative accept completes, the call is live. + // Assert - once the provider event arrives, the call is live. await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); Assert.Equal("In call", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); Assert.Equal("+15550001000", (await page.Locator("[data-telephony-peer]").InnerTextAsync()).Trim()); @@ -297,6 +331,7 @@ await page.EvaluateAsync( window.__completeInboundAccept(); } """); + await PublishCallStateAsync(page, "call-inbound-2", "+15550001000"); // Assert await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); @@ -317,6 +352,45 @@ await page.WaitForFunctionAsync( """); } + private static async Task PublishLatestCallStateAsync(IPage page) + { + await page.EvaluateAsync( + """ + async () => { + const connection = window.telephonySoftPhone.getInstance().getConnection(); + + for (let attempt = 0; attempt < 20; attempt++) { + const published = await connection.invoke('PublishLatestCallState'); + + if (published) { + return; + } + + await new Promise(resolve => setTimeout(resolve, 25)); + } + + throw new Error('The test provider did not create a call.'); + } + """); + } + + private static async Task PublishCallStateAsync(IPage page, string callId, string from) + { + await page.EvaluateAsync( + """ + ([callId, from]) => window.telephonySoftPhone.getInstance().getConnection().invoke( + 'PublishCallState', + { + callId, + from, + direction: 1, + state: 3, + providerName: 'InMemory' + }) + """, + new[] { callId, from }); + } + private static async Task GetConfiguredHeightAsync(IPage page) { return await page.Locator("#telephony-soft-phone").EvaluateAsync( diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs index ac22df9f2..cd7718e1a 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs @@ -345,4 +345,119 @@ await service.IngestAsync(new ProviderVoiceEvent value => value.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); } + + [Fact] + public async Task IngestAsync_WhenSessionIsAlreadyTerminal_DoesNotRepublishLaterTerminalEvent() + { + // Arrange + var endedUtc = new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc); + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderInteractionId = "call-1", + Status = InteractionStatus.Ended, + EndedUtc = endedUtc, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + EndedUtc = endedUtc, + LastProviderEventUtc = endedUtc, + }; + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + .ReturnsAsync(interaction); + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByProviderCallIdAsync("call-1", It.IsAny())) + .ReturnsAsync(session); + var eventStore = new Mock(); + eventStore + .Setup(store => store.ExistsByIdempotencyKeyAsync("later-terminal", It.IsAny())) + .ReturnsAsync(false); + var publisher = new Mock(); + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + eventStore.Object, + publisher.Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + + // Act + var result = await service.IngestAsync(new ProviderVoiceEvent + { + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + IdempotencyKey = "later-terminal", + OccurredUtc = endedUtc.AddSeconds(1), + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Same(session, result); + callSessionManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + publisher.Verify( + value => value.PublishAsync(It.IsAny(), It.IsAny()), + Times.Never); + } + + [Fact] + public async Task IngestAsync_WhenEventIsDuplicate_ReturnsExistingSession() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderName = "ProviderA", + ProviderInteractionId = "call-1", + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderName = "ProviderA", + ProviderCallId = "call-1", + }; + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("ProviderA", "call-1", It.IsAny())) + .ReturnsAsync(interaction); + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByProviderCallIdAsync("ProviderA", "call-1", It.IsAny())) + .ReturnsAsync(session); + var eventStore = new Mock(); + eventStore + .Setup(store => store.ExistsByIdempotencyKeyAsync("duplicate", It.IsAny())) + .ReturnsAsync(true); + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + eventStore.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + + // Act + var result = await service.IngestAsync(new ProviderVoiceEvent + { + ProviderName = "ProviderA", + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + IdempotencyKey = "duplicate", + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Same(session, result); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs index 98b9560f7..7e3fb63fa 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventDispatcherTests.cs @@ -70,6 +70,8 @@ public async Task HandleAsync_WhenPlainTelephonyInteractionMatches_EndsInteracti // Act await dispatcher.HandleAsync(voiceEvent, TestContext.Current.CancellationToken); + voiceEvent.EventType = "ChannelDestroyed"; + await dispatcher.HandleAsync(voiceEvent, TestContext.Current.CancellationToken); // Assert Assert.Equal(CallOutcome.Completed, interaction.Outcome); diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs index 8b2a84dea..8c7fe32d2 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskRealtimeVoiceEventMapperTests.cs @@ -81,4 +81,32 @@ public void TryMap_WhenChannelLeavesBridge_ReturnsUnheldVoiceEvent() Assert.Equal("ChannelLeftBridge", voiceEvent.EventType); Assert.Equal("bridge-1", voiceEvent.Metadata["bridgeId"]); } + + [Fact] + public void TryMap_WhenDownChannelLeavesBridge_DoesNotEmitFalseConnectingState() + { + // Arrange + const string payload = + """ + { + "type": "ChannelLeftBridge", + "timestamp": "2026-07-10T15:03:00.000Z", + "application": "crestapps-telephony", + "bridge": { + "id": "bridge-1" + }, + "channel": { + "id": "call-1", + "state": "Down" + } + } + """; + + // Act + var mapped = AsteriskRealtimeVoiceEventMapper.TryMap("Asterisk", payload, out var voiceEvent); + + // Assert + Assert.False(mapped); + Assert.Null(voiceEvent); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs new file mode 100644 index 000000000..0fa7d7e50 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs @@ -0,0 +1,233 @@ +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Hubs; +using CrestApps.OrchardCore.Telephony.Models; +using CrestApps.OrchardCore.Telephony.Services; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Telephony; + +public sealed class TelephonyInteractionSynchronizationServiceTests +{ + private static readonly DateTime _now = new(2026, 7, 11, 15, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task GetActiveCallAsync_WhenProviderNoLongerHasCall_DeletesOrphanedInteraction() + { + // Arrange + var interaction = CreateInteraction(); + var store = new Mock(); + store + .Setup(value => value.FindActiveByUserAsync("user-1", It.IsAny())) + .ReturnsAsync(interaction); + store + .Setup(value => value.DeleteAsync(interaction, It.IsAny())) + .Returns(Task.CompletedTask); + var (hubContext, client) = CreateHubContext(); + client + .Setup(value => value.CallStateChanged(It.IsAny())) + .Returns(Task.CompletedTask); + var service = CreateService( + store, + hubContext, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }); + + // Act + var result = await service.GetActiveCallAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.False(result.Found); + store.Verify(value => value.DeleteAsync(interaction, It.IsAny()), Times.Once); + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.Disconnected)), + Times.Once); + } + + [Fact] + public async Task GetActiveCallAsync_WhenProviderLookupFails_PreservesInteraction() + { + // Arrange + var interaction = CreateInteraction(); + var store = new Mock(); + store + .Setup(value => value.FindActiveByUserAsync("user-1", It.IsAny())) + .ReturnsAsync(interaction); + var (hubContext, client) = CreateHubContext(); + var service = CreateService( + store, + hubContext, + new TelephonyCallLookupResult + { + Succeeded = false, + Found = false, + Error = "Provider unavailable.", + }); + + // Act + var result = await service.GetActiveCallAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + Assert.Equal("Provider unavailable.", result.Error); + store.Verify(value => value.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + client.Verify(value => value.CallStateChanged(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetActiveCallAsync_WhenNewCallIsStillPropagating_PreservesInteraction() + { + // Arrange + var interaction = CreateInteraction(); + interaction.StartedUtc = _now.AddSeconds(-5); + var store = new Mock(); + store + .Setup(value => value.FindActiveByUserAsync("user-1", It.IsAny())) + .ReturnsAsync(interaction); + var (hubContext, client) = CreateHubContext(); + var service = CreateService( + store, + hubContext, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }); + + // Act + var result = await service.GetActiveCallAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.False(result.Found); + store.Verify(value => value.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + client.Verify(value => value.CallStateChanged(It.IsAny()), Times.Never); + } + + [Fact] + public async Task ReconcileProviderInteractionsAsync_WhenCallIsMissing_NotifiesUserAndDeletesOrphan() + { + // Arrange + var interaction = CreateInteraction(); + var store = new Mock(); + store + .Setup(value => value.ListActiveAsync("provider-1", It.IsAny())) + .ReturnsAsync([interaction]); + store + .Setup(value => value.DeleteAsync(interaction, It.IsAny())) + .Returns(Task.CompletedTask); + var (hubContext, client) = CreateHubContext(); + client + .Setup(value => value.CallStateChanged(It.IsAny())) + .Returns(Task.CompletedTask); + var service = CreateService( + store, + hubContext, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }, + lockAcquired: true); + + // Act + var changed = await service.ReconcileProviderInteractionsAsync( + "provider-1", + TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, changed); + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.Disconnected)), + Times.Once); + store.Verify(value => value.DeleteAsync(interaction, It.IsAny()), Times.Once); + } + + [Fact] + public async Task TenantActivation_PerformsImmediateTelephonyReconciliation() + { + // Arrange + var synchronizationService = new Mock(); + synchronizationService + .Setup(value => value.ReconcileActiveInteractionsAsync(It.IsAny())) + .ReturnsAsync(1); + var tenantEvents = new TelephonyInteractionTenantEvents( + synchronizationService.Object, + NullLogger.Instance); + + // Act + await tenantEvents.ActivatingAsync(); + + // Assert + synchronizationService.Verify( + value => value.ReconcileActiveInteractionsAsync(It.IsAny()), + Times.Once); + } + + private static TelephonyInteractionSynchronizationService CreateService( + Mock store, + Mock> hubContext, + TelephonyCallLookupResult lookup, + bool lockAcquired = false) + { + var provider = new Mock(); + provider + .As() + .Setup(value => value.GetCallStateAsync("call-1", It.IsAny())) + .ReturnsAsync(lookup); + var resolver = new Mock(); + resolver.Setup(value => value.GetAsync("provider-1")).ReturnsAsync(provider.Object); + var distributedLock = new Mock(); + distributedLock + .Setup(value => value.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, lockAcquired)); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(_now); + + return new TelephonyInteractionSynchronizationService( + store.Object, + resolver.Object, + hubContext.Object, + distributedLock.Object, + clock.Object, + NullLogger.Instance); + } + + private static (Mock> HubContext, Mock Client) CreateHubContext() + { + var hubContext = new Mock>(); + var clients = new Mock>(); + var client = new Mock(); + hubContext.SetupGet(value => value.Clients).Returns(clients.Object); + clients.Setup(value => value.User("user-1")).Returns(client.Object); + + return (hubContext, client); + } + + private static TelephonyInteraction CreateInteraction() + { + return new TelephonyInteraction + { + InteractionId = "interaction-1", + CallId = "call-1", + ProviderName = "provider-1", + UserId = "user-1", + From = "+15550001000", + To = "+15550002000", + Direction = CallDirection.Outbound, + Outcome = CallOutcome.InProgress, + StartedUtc = _now.AddMinutes(-3), + }; + } +} From 983f9ea04530cd85ddd2ba8d2a71a286230043a2 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 15:47:41 -0700 Subject: [PATCH 47/56] Harden real-time telephony recovery Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 3 +- src/CrestApps.Docs/docs/ai/chat.md | 2 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 7 + src/CrestApps.Docs/docs/telephony/asterisk.md | 10 +- src/CrestApps.Docs/docs/telephony/index.md | 6 +- .../CrestApps.OrchardCore.AI.Chat/Assets.json | 14 ++ .../wwwroot/css/ai-chat.css.map | 1 + .../wwwroot/css/chat-widget.css.map | 1 + .../Services/AsteriskRealtimeVoiceListener.cs | 29 +++- .../Services/AsteriskTelephonyProviderBase.cs | 40 ++++- .../Assets/js/soft-phone.js | 86 ++++++++--- ...ephonyInteractionSynchronizationService.cs | 33 +++- .../wwwroot/scripts/soft-phone.js | 59 ++++--- .../wwwroot/scripts/soft-phone.min.js | 2 +- .../CrestApps.Aspire.AppHost/Program.cs | 9 +- .../Pages/Simulator.cshtml.cs | 52 +++---- .../AsteriskInboundSimulationCoordinator.cs | 18 ++- .../AsteriskStasisEventForwarderService.cs | 104 +++++++++++-- .../Services/InboundCallSimulatorService.cs | 145 +++++++++++++++++- .../Services/OrchardSignInClient.cs | 3 +- .../appsettings.json | 2 +- .../InMemoryTelephonyProvider.cs | 58 ++++++- .../Infrastructure/TestTelephonyHub.cs | 24 +++ .../SoftPhoneWidgetTests.cs | 134 +++++++++++++++- .../AsteriskTelephonyProviderTests.cs | 51 +++++- ...yInteractionSynchronizationServiceTests.cs | 84 +++++++++- 26 files changed, 836 insertions(+), 141 deletions(-) create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/ai-chat.css.map create mode 100644 src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/chat-widget.css.map diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index e15a3f899..ed6b7a991 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1484,7 +1484,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 4 — Voice integration with Telephony (`Voice` feature: Voice Contact Center Call Router (`IVoiceContactCenterCallRouter`) for inbound and outbound voice routing, inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService` compatibility), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, outbound provider dispatch through `IContactCenterVoiceProvider`, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; **G1 backend shipped: provider delivery models (`AgentDeviceNative`/`ServerSideAcd`) + `ConnectToAgentAsync`, `CallSession` aggregate, normalized `ProviderVoiceEvent`/`IProviderVoiceEventService` ingestion, and the authoritative `IContactCenterCallCommandService` that accepts the reservation, bridges media, and advances interaction+call-session together. all G1 items shipped: the soft-phone JS accept-then-answer coordination now awaits the server accept and only answers the device when `RequiresDeviceAnswer` (asset rebuilt), per-provider signed webhook adapters emit `ProviderVoiceEvent`, and the blind/consultative transfer + conference taxonomy landed** — see "Design review" P0 #1, #2, #3 and P1 #9) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider. **G4 dialer safety shipped (2026-06-30):** each mode is now a dedicated `IDialerStrategy` (Predictive disabled in editor + rejected server-side + refused at runtime; Power hard-capped via `PowerDialerStrategy.MaxCallsPerAgent`); the new `IDialerEligibilityService` compliance gate runs before every attempt and audits `DialSuppressed` (destination, max-attempts, retry cool-down, contact do-not-call, calling window in the contact's time zone, and national DNC registries); single-attempt logic moved to `IDialerAttemptService`. Remaining: callback scheduling (`CallbackRequest`) + callback queues, and dialer run/attempt projections.) - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call work is represented by agent presence (`WrapUp`) plus the active/wrap-up interaction on the Agent Workspace, not by a separate domain aggregate.) -- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) +- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. **2026-07-11 event-ordering hardening:** command-triggered active-call polling was removed, page-restoration lookups now yield to newer provider events, stale terminal events cannot clear a newer call id, and Asterisk lookup verifies the channel still exists after its multi-request snapshot. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. **Local test harness shipped 2026-07-08:** `src/Startup/CrestApps.OrchardCore.Asterisk.Web` signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards normalized `InboundVoiceEvent` payloads using the real Asterisk channel ids so routing and agent offers can be exercised through the local PBX flow. The sample dashboard now also surfaces provider-tracked hold/mute state plus inferred party counts, moves live notifications beside the raw ARI payload drill-down to free more width for active-call tables, and lowers the default polling cadence to one second so state changes show up faster. The simulator now makes it explicit that **To address** drives inbound queue or entry-point routing while **Caller number seed** only changes caller identity generation. **2026-07-08 queue hardening:** queues now expose an unanswered-offer policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; when no live provider call exists the timeout safely falls back to requeue. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) @@ -1521,6 +1521,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-08: Adjusted the Minimal API inbound voice ingress endpoint to explicitly disable antiforgery so the local Dialer Simulator can continue posting authenticated JSON `InboundVoiceEvent` requests without the 403 regression introduced during the endpoint conversion. - 2026-07-09: Hardened the soft-phone widget UX and reconnect flow. The Telephony widget now remembers the selected Keypad/Recent/Work tab across reloads, waits for the real provider status before showing the unconfigured warning, and keeps a stable body height across tabs. Contact Center Voice now re-checks already-waiting inbound voice queues after the soft phone reconnects, so a refresh or reconnect no longer leaves queued calls waiting indefinitely for the newly reconnected agent. - 2026-07-10: Added a dedicated Contact Center technical architecture guide for inbound voice routing, outbound dialer flow, provider-truth synchronization, and restart reconciliation, and removed the last explicit Telephony soft-phone feature-id compatibility alias so the WIP voice surface no longer carries that obsolete constant. +- 2026-07-11: Fixed the live Asterisk event path discovered during end-to-end testing. The detached ARI listener now creates explicit tenant shell scopes instead of relying on an absent ambient `ShellScope`, so provider-side disconnects continue through normalized server events to the soft phone without a refresh. Dial acknowledgements remain `Connecting`, pending commands are de-duplicated in the widget, and Telephony removes active records whose old provider is no longer registered or enabled instead of leaving an uncontrollable zombie interaction. The local inbound simulator concurrently drains Stasis events, bounds Orchard forwards, recognizes tenant-prefixed failed logins, and reconciles a missed `StasisStart` from an authoritative matching ARI channel snapshot before reporting a timeout. The Aspire host now passes `--framework net10.0` to all multi-target project resources so the complete local stack actually starts. - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). - 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent sign-in campaign and skill fields now use managed catalog data; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; agent sign-in and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. - 2026-06-29: The standalone agent sign-in admin navigation item was removed from the Contact Center menu. Telephony soft-phone extensibility now uses `DisplayDriver` zones for contributed tabs/views, and Contact Center contributes a **Work** tab for queue/campaign sign-in, sign-out, and presence so agent availability controls stay inside the soft phone instead of a separate navigation page. diff --git a/src/CrestApps.Docs/docs/ai/chat.md b/src/CrestApps.Docs/docs/ai/chat.md index 0e95a825a..e01bf23b2 100644 --- a/src/CrestApps.Docs/docs/ai/chat.md +++ b/src/CrestApps.Docs/docs/ai/chat.md @@ -144,6 +144,8 @@ The admin and frontend chat widgets restore their saved toggle and panel positio The admin session chat now shows the same compact **Supported formats** note above the input attachment bar that the widgets already expose, so users can see the exact upload extensions allowed for the current profile. Document extensions follow the profile's **Allow session document uploads** setting, and image extensions only appear when **Allow session image uploads** is enabled and a vision deployment is available. +The module packages the upstream CSS source maps for the main chat UI and widget styles alongside the compiled assets, so browser developer tools can resolve `ai-chat.css.map` and `chat-widget.css.map` without 404 warnings. + By default, session-document uploads are stored on the local file system through the shared AI Documents storage pipeline. If you want widget uploads stored in Azure Blob Storage instead, enable `CrestApps.OrchardCore.AI.Documents.Azure` and configure it as described in [AI Documents - Azure Blob Storage](./documents/azure-blob-storage.md). ### Citations and references diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 759ef8b08..aa894e010 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -67,6 +67,7 @@ At a high level, the platform changes are: - 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. @@ -78,6 +79,11 @@ At a high level, the platform changes are: - 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. +- The Aspire AppHost now launches every multi-target project resource with the explicit `net10.0` framework so Orchard Core, Asterisk Web, and the sample clients do not exit at startup with a framework-selection error. - 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. @@ -133,6 +139,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 diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md index b3c10bd79..968877881 100644 --- a/src/CrestApps.Docs/docs/telephony/asterisk.md +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -124,12 +124,14 @@ Because all requests are issued server-side, the ARI password never reaches the ## Real-time call state and recovery -The module keeps a tenant-scoped ARI WebSocket listener for every configured Asterisk provider. 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. +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 browser polling. 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. +ARI events are normalized into provider-authoritative call states and projected through Orchard to connected soft-phone clients without browser polling. Command acknowledgements do not update or re-query the browser call state; the corresponding ARI event drives the transition. Provider lookup also verifies that the channel still exists after reading hold and mute variables, preventing a channel destroyed during the multi-request lookup from being reported as connected. 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 startup 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 @@ -163,7 +165,7 @@ 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. +`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. The AppHost explicitly launches its multi-target project resources with `net10.0`, preventing `dotnet run` from exiting before the Orchard and Asterisk Web applications start. This makes the configuration-backed provider available immediately for local tenants as soon as: @@ -180,7 +182,7 @@ Visiting `http://localhost:8088/` returns **Not Found** by design because the co The default Aspire endpoint template uses `Local/{number}@default`, which loops the originated call back into the bundled demo dialplan. That local development path now 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 still stays inside Asterisk. -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 sample exposes two pages: **Asterisk Dashboard** for live ARI telemetry and **Inbound Simulator** for burst testing. The dashboard now treats the Asterisk Stasis event stream as the primary update path: when live events arrive, the server immediately refreshes ARI state and pushes the new snapshot to connected browsers over SignalR. A slower 15-second reconciliation refresh remains in place so the dashboard can recover from missed events or transient SignalR disconnects. The dashboard still 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. Live notifications now sit beside the raw ARI payload drill-down so the active call and bridge tables have more room. In the simulator, **To address** controls which Contact Center entry point or queue mapping the inbound call targets, while **Caller number seed** only changes the generated caller identities. +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 **Inbound Simulator** for burst testing. The dashboard now treats the Asterisk Stasis event stream as the primary update path: when live events arrive, the server immediately refreshes ARI state and pushes the new snapshot to connected browsers over SignalR. A slower 15-second reconciliation refresh remains in place so the dashboard can recover from missed events or transient SignalR disconnects. The dashboard still 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. 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, **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. Production deployments should supply their own ARI credentials, dialplan, endpoints, and media/network configuration. diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index b9f088f7a..52672b467 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -65,7 +65,7 @@ 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. Provider integrations should normalize live provider events into the Contact Center voice-event pipeline so the server becomes the source of truth for: +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 @@ -75,7 +75,7 @@ Modern soft-phone behavior depends on more than keypad actions. A command respon 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. A lookup failure does not silently delete the record because the provider did not authoritatively confirm that the call is gone. +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. @@ -149,7 +149,7 @@ The widget's footer is a tab bar that switches the panel between built-in and co - **Contributed tabs** – modules can add their own views through Display Management. For example, Contact Center adds a **Work** tab for queue/campaign sign-in and presence. -The history is read from the hub's `GetInteractions` method and is backed by the persisted interaction store described below, so completed history survives page reloads independently of the provider. Inbound calls are persisted as soon as they are offered, so the **Recent** tab shows inbound and outbound history instead of only calls placed from the keypad. Active-call restoration no longer trusts an `InProgress` history record or assumes that it is connected: reconnect and page load call `GetActiveCall`, which validates the record against `ITelephonyCallStateProvider` before rendering it. Startup, periodic, and provider-reconnect sweeps apply the same provider-authoritative reconciliation, delete confirmed orphaned active records, and push a disconnected event so stale history cannot resurrect a zombie call. When Contact Center owns the voice interaction, normalized server-side call-session changes upsert that same telephony history and push `CallStateChanged`, so provider-driven disconnects, hold/resume changes, mute/unmute updates, and other server-side lifecycle changes clear or update the live soft-phone state immediately. Provider-side Asterisk ARI events do the same for plain Telephony calls. The widget also keeps the **Keypad** tab's natural height as the shared body height for **Recent** and contributed tabs such as Contact Center **Work**, so switching tabs does not resize the panel unless the user moves it. When a non-keypad tab needs more room than that shared height, it scrolls within the panel instead of clipping its contents. +The history is read from the hub's `GetInteractions` method and is backed by the persisted interaction store described below, so completed history survives page reloads independently of the provider. Inbound calls are persisted as soon as they are offered, so the **Recent** tab shows inbound and outbound history instead of only calls placed from the keypad. Active-call restoration no longer trusts an `InProgress` history record or assumes that it is connected: reconnect and page load call `GetActiveCall`, which validates the record against `ITelephonyCallStateProvider` before rendering it. Button commands do not trigger browser state changes or short provider-polling loops; provider events remain authoritative, while startup, periodic, provider-reconnect, and page-restoration reconciliation repair missed events and delete confirmed orphaned active records. A provider event received while a page-restoration lookup is in flight takes precedence over that older lookup result, and a terminal event for an earlier call id cannot clear a newer active call. When Contact Center owns the voice interaction, normalized server-side call-session changes upsert that same telephony history and push `CallStateChanged`, so provider-driven disconnects, hold/resume changes, mute/unmute updates, and other server-side lifecycle changes clear or update the live soft-phone state immediately. Provider-side Asterisk ARI events do the same for plain Telephony calls. The widget also keeps the **Keypad** tab's natural height as the shared body height for **Recent** and contributed tabs such as Contact Center **Work**, so switching tabs does not resize the panel unless the user moves it. When a non-keypad tab needs more room than that shared height, it scrolls within the panel instead of clipping its contents. ## Incoming calls diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets.json b/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets.json index 8e285dc15..fa4297eb9 100644 --- a/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets.json +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/Assets.json @@ -69,6 +69,13 @@ ], "output": "wwwroot/css/ai-chat.min.css" }, + { + "copy": true, + "inputs": [ + "node_modules/@crestapps/ai-chat-ui/dist/ai-chat.css.map" + ], + "output": "wwwroot/css/ai-chat.css.map" + }, { "copy": true, "inputs": [ @@ -83,6 +90,13 @@ ], "output": "wwwroot/css/ai-chat-widget.min.css" }, + { + "copy": true, + "inputs": [ + "node_modules/@crestapps/ai-chat-ui/dist/chat-widget.css.map" + ], + "output": "wwwroot/css/chat-widget.css.map" + }, { "inputs": [ "Assets/css/speech-to-text.css" diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/ai-chat.css.map b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/ai-chat.css.map new file mode 100644 index 000000000..c4965799e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/ai-chat.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["ai-chat.css"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"ai-chat.css","sourcesContent":[".ai-chat-message-heading {\n font-size: 0.75rem;\n margin-bottom: 0.35rem;\n}\n\n.ai-chat-message-heading .ai-chat-msg-role {\n margin-bottom: 0;\n letter-spacing: 0.03em;\n}\n\n.ai-chat-message-heading-assistant .ai-chat-msg-role {\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.ai-chat-message-role-label {\n display: inline-block;\n text-transform: inherit;\n}\n\n.ai-chat-msg-role {\n font-weight: 600;\n margin-bottom: 0.35rem;\n text-transform: uppercase;\n}\n\n.ai-chat-msg-role-user {\n color: var(--bs-primary, #0d6efd);\n}\n\n.ai-chat-msg-role-assistant {\n color: var(--ai-chat-primary, var(--bs-success, #198754));\n}\n\n.ai-chat-messages .ai-chat-message-item {\n position: relative;\n padding: 0.25rem 0;\n}\n\n.ai-chat-messages .ai-chat-message-item + .ai-chat-message-item::before {\n content: '';\n display: block;\n width: 100%;\n margin: 0.5rem auto 0.75rem;\n border-top: 1px solid var(--bs-border-color, #dee2e6);\n}\n\n.ai-chat-messages .ai-chat-message-item p:last-child {\n margin-bottom: 0;\n}\n\n.ai-chat-messages .ai-chat-message-body {\n position: relative;\n padding: 0;\n overflow-wrap: break-word;\n word-break: break-word;\n min-width: 0;\n}\n\n.ai-chat-messages .ai-chat-message-body sup {\n font-size: 0.75em;\n line-height: 0;\n}\n\n.ai-chat-messages .ai-chat-citation-list {\n margin: 0.75rem 0 0;\n padding-left: 1.25rem;\n}\n\n.ai-chat-messages .ai-chat-citation-item + .ai-chat-citation-item {\n margin-top: 0.25rem;\n}\n\n.ai-chat-messages .ai-chat-citation-item a {\n word-break: break-word;\n}\n\n.ai-partial-transcript {\n font-style: italic;\n opacity: 0.7;\n}\n\n.ai-chat-messages .message-buttons-container {\n position: absolute;\n right: 0.1rem;\n bottom: 0;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n width: auto;\n max-width: max-content;\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.15s ease-in-out;\n background: rgba(var(--bs-body-bg-rgb, 255, 255, 255), 0.96);\n padding: 0.2rem 0.35rem;\n border-radius: 999px;\n box-shadow: 0 1px 3px rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.08);\n margin-bottom: 1rem;\n transform: translateY(50%);\n z-index: 1;\n}\n\n.ai-chat-messages .ai-chat-message-item:hover .message-buttons-container,\n.ai-chat-messages .ai-chat-message-item:focus-within .message-buttons-container {\n opacity: 1;\n pointer-events: auto;\n}\n\n.ai-chat-messages .message-buttons-container:has(.tts-playing) {\n opacity: 1;\n pointer-events: auto;\n}\n\n.ai-chat-messages .message-buttons-container > .button-message-toolbox {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 1.75rem;\n height: 1.75rem;\n margin: 0 !important;\n padding: 0 !important;\n border-radius: 50%;\n color: var(--bs-secondary-color, #6c757d) !important;\n text-decoration: none;\n}\n\n.ai-chat-messages .message-buttons-container .ai-chat-message-assistant-feedback {\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.ai-chat-messages .message-buttons-container > .button-message-toolbox:hover,\n.ai-chat-messages .message-buttons-container > .button-message-toolbox:focus {\n color: var(--bs-body-color, #212529) !important;\n text-decoration: none;\n}\n\n.ai-chat-messages .message-buttons-container > .button-message-toolbox.text-success,\n.ai-chat-messages .message-buttons-container > .button-message-toolbox.text-success:hover,\n.ai-chat-messages .message-buttons-container > .button-message-toolbox.text-success:focus {\n color: var(--bs-success, #198754) !important;\n text-decoration: none;\n}\n\n.ai-chat-messages .message-buttons-container > .button-message-toolbox.text-success svg {\n color: inherit !important;\n fill: currentColor;\n}\n\n.ai-chat-messages .message-buttons-container .tts-playing {\n color: var(--ai-chat-primary, var(--bs-success, #198754));\n}\n\n@keyframes spark-burst {\n 0% {\n transform: scale(1);\n opacity: 1;\n }\n 20% {\n transform: scale(1.8);\n opacity: 0.7;\n }\n 40% {\n transform: scale(0.9);\n opacity: 1;\n }\n 60% {\n transform: scale(1.3);\n }\n 100% {\n transform: scale(1);\n }\n}\n\n.spark-effect i,\n.spark-effect svg {\n display: inline-block;\n animation: spark-burst 0.5s ease-out;\n}\n\n.ai-chat-messages .ai-bot-icon {\n color: currentColor;\n display: inline-block;\n animation: none;\n}\n\n@keyframes ai-chat-streaming-effect {\n 0% { opacity: 1; transform: rotate(0deg); }\n 10% { opacity: 0.3; transform: rotate(0deg); }\n 20% { opacity: 1; transform: rotate(0deg); }\n 30% { opacity: 0.3; transform: rotate(0deg); }\n 40% { opacity: 1; transform: rotate(0deg); }\n 50% { opacity: 0.3; transform: rotate(0deg); }\n 60% { opacity: 1; transform: rotate(0deg); }\n 80% { opacity: 1; transform: rotate(360deg); }\n 100% { opacity: 1; transform: rotate(360deg); }\n}\n\n.ai-chat-messages .ai-streaming-icon {\n color: currentColor;\n display: inline-block;\n animation: ai-chat-streaming-effect 4s ease-in-out infinite;\n}\n\n.ai-chat-messages pre {\n position: relative;\n margin: 0;\n}\n\n.ai-chat-messages .ai-code-block {\n border: 1px solid var(--bs-border-color, #dee2e6);\n border-radius: 0.5rem;\n overflow: hidden;\n margin: 0.75rem 0;\n background: var(--bs-body-bg, #fff);\n}\n\n.ai-chat-messages .ai-code-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.5rem 1rem;\n font-size: 0.8rem;\n min-height: 2rem;\n}\n\n.ai-chat-messages .ai-code-lang {\n font-weight: 600;\n color: var(--bs-body-color, #212529);\n text-transform: capitalize;\n}\n\n.ai-chat-messages .ai-code-lang i {\n color: var(--bs-secondary-color, #6c757d);\n margin-right: 0.25rem;\n}\n\n.ai-chat-messages .ai-code-copy-btn {\n display: inline-flex;\n align-items: center;\n gap: 0.3rem;\n padding: 0.2rem;\n font-size: 0.8rem;\n background: transparent;\n border: none;\n cursor: pointer;\n color: var(--bs-secondary-color, #6c757d);\n transition: color 0.15s;\n white-space: nowrap;\n}\n\n.ai-chat-messages .ai-code-copy-btn:hover {\n color: var(--bs-body-color, #212529);\n}\n\n.ai-chat-messages .ai-code-block pre {\n margin: 0;\n background: transparent !important;\n}\n\n.ai-chat-messages .ai-code-block pre code.hljs {\n background: transparent !important;\n padding: 0.75rem 1rem 1rem;\n}\n\n.ai-chat-messages pre code.hljs {\n white-space: pre-wrap;\n word-wrap: break-word;\n overflow-wrap: break-word;\n border-radius: 0.375rem;\n}\n\n.ai-chat-doc-bar {\n border-bottom: 1px solid var(--bs-border-color, #dee2e6);\n}\n\n.ai-chat-drag-over {\n outline: 2px dashed var(--bs-primary, #0d6efd) !important;\n outline-offset: -2px;\n background-color: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.05) !important;\n}\n\n.ai-chat-notification {\n margin: 0.75rem 0 0.25rem;\n padding: 0.625rem 0.875rem;\n border-radius: 0.5rem;\n border: 1px solid var(--bs-border-color, #dee2e6);\n background-color: var(--bs-tertiary-bg, #f8f9fa);\n font-size: 0.875rem;\n line-height: 1.4;\n}\n\n.ai-chat-notification-content {\n display: flex;\n align-items: center;\n gap: 0.5rem;\n}\n\n.ai-chat-notification-icon {\n flex-shrink: 0;\n font-size: 1rem;\n opacity: 0.8;\n}\n\n.ai-chat-notification-text {\n flex: 1;\n}\n\n.ai-chat-notification-dismiss {\n flex-shrink: 0;\n opacity: 0.5;\n transition: opacity 0.15s;\n}\n\n.ai-chat-notification-dismiss:hover {\n opacity: 1;\n}\n\n.ai-chat-notification-actions {\n display: flex;\n gap: 0.375rem;\n margin-top: 0.5rem;\n padding-top: 0.5rem;\n border-top: 1px solid var(--bs-border-color, #dee2e6);\n}\n\n.ai-chat-notification-actions .btn {\n font-size: 0.8125rem;\n padding: 0.1875rem 0.625rem;\n}\n\n.ai-chat-notification-typing {\n border-color: var(--ai-chat-primary, var(--bs-success, #198754));\n background-color: rgba(var(--ai-chat-primary-rgb, var(--bs-success-rgb, 25, 135, 84)), 0.05);\n}\n\n.ai-chat-notification-typing .ai-chat-notification-icon {\n color: var(--ai-chat-primary, var(--bs-success, #198754));\n animation: ai-chat-typing-pulse 1.5s ease-in-out infinite;\n}\n\n@keyframes ai-chat-typing-pulse {\n 0%, 100% { opacity: 0.4; }\n 50% { opacity: 1; }\n}\n\n.ai-chat-notification-transfer {\n border-color: var(--bs-warning, #ffc107);\n background-color: rgba(var(--bs-warning-rgb, 255, 193, 7), 0.06);\n}\n\n.ai-chat-notification-transfer .ai-chat-notification-icon {\n color: var(--bs-warning, #ffc107);\n animation: ai-chat-transfer-pulse 2s ease-in-out infinite;\n}\n\n@keyframes ai-chat-transfer-pulse {\n 0%, 100% { transform: scale(1); opacity: 0.7; }\n 50% { transform: scale(1.1); opacity: 1; }\n}\n\n.ai-chat-notification-ended,\n.ai-chat-notification-session-ended,\n.ai-chat-notification-conversation-ended {\n border-color: var(--bs-secondary, #6c757d);\n background-color: rgba(var(--bs-secondary-rgb, 108, 117, 125), 0.05);\n}\n\n.ai-chat-notification-ended .ai-chat-notification-icon,\n.ai-chat-notification-session-ended .ai-chat-notification-icon,\n.ai-chat-notification-conversation-ended .ai-chat-notification-icon {\n color: var(--bs-secondary, #6c757d);\n}\n\n.ai-chat-notification-info,\n.ai-chat-notification-agent-connected {\n border-color: var(--bs-info, #0dcaf0);\n background-color: rgba(var(--bs-info-rgb, 13, 202, 240), 0.05);\n}\n\n.ai-chat-notification-info .ai-chat-notification-icon,\n.ai-chat-notification-agent-connected .ai-chat-notification-icon {\n color: var(--bs-info, #0dcaf0);\n}\n\n.ai-chat-notification-warning,\n.ai-chat-notification-agent-reconnecting {\n border-color: var(--bs-warning, #ffc107);\n background-color: rgba(var(--bs-warning-rgb, 255, 193, 7), 0.06);\n}\n\n.ai-chat-notification-warning .ai-chat-notification-icon,\n.ai-chat-notification-agent-reconnecting .ai-chat-notification-icon {\n color: var(--bs-warning, #ffc107);\n}\n\n.ai-chat-notification-error,\n.ai-chat-notification-connection-lost {\n border-color: var(--bs-danger, #dc3545);\n background-color: rgba(var(--bs-danger-rgb, 220, 53, 69), 0.05);\n}\n\n.ai-chat-notification-error .ai-chat-notification-icon,\n.ai-chat-notification-connection-lost .ai-chat-notification-icon {\n color: var(--bs-danger, #dc3545);\n}\n"]} diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/chat-widget.css.map b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/chat-widget.css.map new file mode 100644 index 000000000..a4343ecdb --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/wwwroot/css/chat-widget.css.map @@ -0,0 +1 @@ +{"version":3,"sources":["chat-widget.css"],"names":[],"mappings":"AAAA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA","file":"chat-widget.css","sourcesContent":["/* CrestApps AI Chat Widget */\n:root {\n --ai-widget-primary: var(--bs-secondary, #6c757d);\n --ai-widget-primary-rgb: var(--bs-secondary-rgb, 108, 117, 125);\n --ai-widget-width: 380px;\n --ai-widget-height: 520px;\n}\n\n.ai-chat-widget-toggle {\n position: fixed;\n bottom: 1.25rem;\n right: 1.25rem;\n z-index: 10000;\n width: 52px;\n height: 52px;\n border-radius: 50%;\n background: var(--ai-widget-primary);\n color: var(--bs-white, #fff);\n border: none;\n cursor: pointer;\n display: flex;\n align-items: center;\n justify-content: center;\n font-size: 1.5rem;\n box-shadow: 0 4px 12px rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.2);\n transition: transform 0.2s, box-shadow 0.2s;\n}\n\n.ai-chat-widget-toggle:hover {\n transform: scale(1.1);\n box-shadow: 0 6px 16px rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.3);\n}\n\n.ai-chat-widget-container {\n position: fixed;\n bottom: 5rem;\n right: 1.25rem;\n z-index: 10001;\n width: var(--ai-widget-width);\n height: var(--ai-widget-height);\n background: var(--bs-body-bg, #fff);\n color: var(--bs-body-color, #212529);\n border: 1px solid var(--bs-border-color, #dee2e6);\n border-radius: 12px;\n box-shadow: 0 8px 32px rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.18);\n display: none;\n flex-direction: column;\n overflow: hidden;\n}\n\n.ai-chat-widget-container.ai-chat-widget-resizable {\n resize: both;\n min-width: 320px;\n min-height: 420px;\n max-width: min(90vw, 960px);\n max-height: min(90vh, 960px);\n}\n\n.ai-chat-widget-container.open {\n display: flex;\n}\n\n.ai-chat-widget-header {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 0.75rem 1rem;\n background: var(--ai-widget-primary);\n color: var(--bs-white, #fff);\n}\n\n.ai-chat-widget-header.ai-chat-widget-drag-handle {\n cursor: move;\n user-select: none;\n touch-action: none;\n}\n\n.ai-chat-widget-header-actions {\n display: flex;\n align-items: center;\n gap: 0.15rem;\n}\n\n.ai-chat-widget-toggle.ai-chat-widget-draggable {\n touch-action: none;\n}\n\n.ai-chat-widget-container.ai-chat-widget-dragging,\n.ai-chat-widget-toggle.ai-chat-widget-dragging {\n transition: none;\n}\n\n.ai-chat-widget-toggle.ai-chat-widget-dragging:hover {\n transform: none;\n}\n\n.ai-chat-widget-header .title {\n font-weight: 600;\n font-size: 0.95rem;\n}\n\n.ai-chat-widget-header button {\n background: none;\n border: none;\n color: var(--bs-white, #fff);\n cursor: pointer;\n font-size: 1rem;\n padding: 0 0.25rem;\n opacity: 0.8;\n}\n\n.ai-chat-widget-header button:hover {\n opacity: 1;\n}\n\n.ai-chat-widget-profile-select {\n padding: 0.5rem 0.75rem;\n border-bottom: 1px solid var(--bs-border-color, #dee2e6);\n background: var(--bs-tertiary-bg, #f8f9fa);\n}\n\n.ai-chat-widget-profile-select select {\n width: 100%;\n padding: 0.35rem 0.5rem;\n color: var(--bs-body-color, #212529);\n background-color: var(--bs-body-bg, #fff);\n border: 1px solid var(--bs-border-color, #ced4da);\n border-radius: 6px;\n font-size: 0.85rem;\n}\n\n.ai-chat-widget-messages {\n flex: 1;\n overflow-y: auto;\n padding: 0.75rem;\n font-size: 0.9rem;\n}\n\n/* ── Widget message items (ai-chat.js Vue template) ── */\n.ai-chat-widget-messages .ai-chat-messages {\n padding: 0.5rem;\n}\n\n.ai-chat-widget-messages .ai-chat-message-item {\n position: relative;\n padding: 0.5rem 0;\n}\n\n.ai-chat-widget-messages .ai-chat-message-item + .ai-chat-message-item::before {\n content: '';\n display: block;\n width: 100%;\n margin-bottom: 0.5rem;\n border-top: 1px solid rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.08);\n}\n\n.ai-chat-widget-messages .ai-chat-message-heading {\n margin-bottom: 0.35rem;\n}\n\n.ai-chat-widget-messages .ai-chat-message-heading .ai-chat-msg-role {\n margin-bottom: 0;\n letter-spacing: 0.03em;\n}\n\n.ai-chat-widget-messages .ai-chat-message-heading-assistant .ai-chat-msg-role {\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.ai-chat-widget-messages .ai-chat-message-role-label {\n display: inline-block;\n text-transform: inherit;\n}\n\n.ai-chat-widget-messages .ai-chat-msg-role {\n font-weight: 600;\n margin-bottom: 2px;\n text-transform: uppercase;\n}\n\n.ai-chat-widget-messages .ai-chat-msg-role-user {\n color: var(--bs-primary, #0d6efd);\n}\n\n.ai-chat-widget-messages .ai-chat-msg-role-assistant {\n color: var(--ai-widget-primary, #6f42c1);\n}\n\n.ai-chat-widget-messages .ai-bot-icon {\n color: currentColor;\n display: inline-block;\n animation: none;\n}\n\n@keyframes ai-widget-streaming-effect {\n 0% { opacity: 1; transform: rotate(0deg); }\n 10% { opacity: 0.3; transform: rotate(0deg); }\n 20% { opacity: 1; transform: rotate(0deg); }\n 30% { opacity: 0.3; transform: rotate(0deg); }\n 40% { opacity: 1; transform: rotate(0deg); }\n 50% { opacity: 0.3; transform: rotate(0deg); }\n 60% { opacity: 1; transform: rotate(0deg); }\n 80% { opacity: 1; transform: rotate(360deg); }\n 100% { opacity: 1; transform: rotate(360deg); }\n}\n\n.ai-chat-widget-messages .ai-streaming-icon {\n color: currentColor;\n display: inline-block;\n animation: ai-widget-streaming-effect 4s ease-in-out infinite;\n}\n\n.ai-chat-widget-messages .ai-chat-message-body {\n position: relative;\n padding: 0;\n overflow-wrap: break-word;\n word-break: break-word;\n min-width: 0;\n}\n\n.ai-chat-widget-messages .ai-chat-message-body p:last-child {\n margin-bottom: 0;\n}\n\n/* ── Message action buttons (copy, thumbs up/down) ── */\n.ai-chat-widget-messages .message-buttons-container {\n position: absolute;\n right: 0.1rem;\n bottom: 0;\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n width: auto;\n max-width: max-content;\n opacity: 0;\n pointer-events: none;\n transition: opacity 0.15s ease-in-out;\n background: rgba(var(--bs-body-bg-rgb, 255, 255, 255), 0.96);\n padding: 0.2rem 0.35rem;\n border-radius: 999px;\n box-shadow: 0 1px 3px rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.08);\n margin-bottom: 1rem;\n transform: translateY(50%);\n z-index: 1;\n}\n\n.ai-chat-widget-messages .ai-chat-message-item:hover .message-buttons-container,\n.ai-chat-widget-messages .ai-chat-message-item:focus-within .message-buttons-container {\n opacity: 1;\n pointer-events: auto;\n}\n\n.ai-chat-widget-messages .message-buttons-container:has(.tts-playing) {\n opacity: 1;\n pointer-events: auto;\n}\n\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex: 0 0 auto;\n width: 1.75rem;\n height: 1.75rem;\n margin: 0 !important;\n padding: 0 !important;\n border-radius: 50%;\n color: var(--bs-secondary-color, #6c757d) !important;\n text-decoration: none;\n}\n\n.ai-chat-widget-messages .message-buttons-container .ai-chat-message-assistant-feedback {\n display: inline-flex;\n align-items: center;\n gap: 0.35rem;\n}\n\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox:hover,\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox:focus {\n color: var(--bs-body-color, #212529) !important;\n text-decoration: none;\n}\n\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox.text-success,\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox.text-success:hover,\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox.text-success:focus {\n color: var(--bs-success, #198754) !important;\n text-decoration: none;\n}\n\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox.text-success svg {\n color: inherit !important;\n fill: currentColor;\n}\n\n.ai-chat-widget-messages .message-buttons-container > .button-message-toolbox.tts-playing {\n color: var(--ai-widget-primary, #6f42c1) !important;\n opacity: 1;\n pointer-events: auto;\n}\n\n.ai-chat-widget-history-panel {\n position: absolute;\n top: 3.5rem;\n left: 0;\n right: 0;\n bottom: 0;\n background: var(--bs-body-bg, #fff);\n padding: 1rem;\n z-index: 10;\n display: none;\n overflow-y: auto;\n flex-direction: column;\n}\n\n.ai-chat-widget-history-panel.show {\n display: flex;\n}\n\n.ai-chat-widget-history-panel .history-header {\n display: flex;\n justify-content: space-between;\n align-items: center;\n margin-bottom: 0.75rem;\n flex-shrink: 0;\n}\n\n.ai-chat-widget-history-panel .history-header h5 {\n margin: 0;\n font-size: 1rem;\n}\n\n.ai-chat-widget-history-list {\n list-style: none;\n padding: 0;\n margin: 0;\n overflow-y: auto;\n flex: 1 1 auto;\n}\n\n.ai-chat-widget-history-list li {\n margin-bottom: 0;\n}\n\n.ai-chat-widget-history-list .chat-session-history-item {\n display: block;\n padding: 0.5rem 0.75rem;\n text-decoration: none;\n color: var(--bs-body-color, #212529);\n font-size: 0.85rem;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis;\n transition: background 0.15s;\n cursor: pointer;\n border-bottom: 1px solid var(--bs-border-color, #dee2e6);\n}\n\n.ai-chat-widget-history-list li:last-child .chat-session-history-item {\n border-bottom: none;\n}\n\n.ai-chat-widget-history-list .chat-session-history-item:hover {\n background: var(--bs-tertiary-bg, #f8f9fa);\n}\n\n/* ── Notifications ── */\n.ai-chat-widget-messages .ai-chat-notification {\n margin: 0.75rem 0.5rem 0;\n padding: 0.625rem 0.75rem;\n border: 1px solid rgba(var(--bs-body-color-rgb, 33, 37, 41), 0.12);\n border-radius: 0.5rem;\n background: rgba(var(--bs-tertiary-bg-rgb, 248, 249, 250), 0.98);\n}\n\n.ai-chat-widget-messages .ai-chat-notification-content {\n display: flex;\n align-items: flex-start;\n gap: 0.5rem;\n}\n\n.ai-chat-widget-messages .ai-chat-notification-icon {\n margin-top: 0.1rem;\n color: var(--bs-secondary-color, #6c757d);\n}\n\n.ai-chat-widget-messages .ai-chat-notification-text {\n flex: 1 1 auto;\n min-width: 0;\n}\n\n.ai-chat-widget-messages .ai-chat-notification-dismiss {\n margin-left: auto !important;\n color: var(--bs-secondary-color, #6c757d) !important;\n}\n\n.ai-chat-widget-messages .ai-chat-notification-actions {\n display: flex;\n gap: 0.5rem;\n margin-top: 0.5rem;\n}\n\n/* ── Chart containers inside widget ── */\n.ai-chat-widget-messages .chart-container {\n width: 100%;\n max-width: 100% !important;\n min-height: 280px;\n}\n\n/* ── Code blocks & inline code ── */\n.ai-chat-widget-messages .ai-chat-message-body pre {\n background: var(--bs-dark-bg-subtle, #2d2d2d);\n color: var(--bs-emphasis-color, #f8f8f2);\n padding: 0.5rem;\n border-radius: 4px;\n overflow-x: auto;\n font-size: 0.8rem;\n}\n\n.ai-chat-widget-messages .ai-chat-message-body code {\n font-size: 0.85em;\n}\n\n.ai-chat-widget-input {\n border-top: 1px solid var(--bs-border-color, #dee2e6);\n padding: 0.5rem 0.75rem;\n display: flex;\n flex-wrap: wrap;\n gap: 0.5rem;\n align-items: flex-end;\n}\n\n.ai-chat-widget-input .ai-chat-doc-bar {\n flex: 0 0 100%;\n margin: -0.5rem -0.75rem 0.5rem;\n padding: 0.5rem 0.75rem;\n max-height: 4.5rem;\n overflow-y: auto;\n}\n\n.ai-chat-widget-input textarea {\n flex: 1;\n min-width: 0;\n color: var(--bs-body-color, #212529);\n background-color: var(--bs-body-bg, #fff);\n border: 1px solid var(--bs-border-color, #ced4da);\n border-radius: 8px;\n padding: 0.4rem 0.6rem;\n font-size: 0.9rem;\n resize: none;\n max-height: 80px;\n min-height: 36px;\n line-height: 1.4;\n outline: none;\n}\n\n.ai-chat-widget-input textarea:focus {\n border-color: var(--ai-widget-primary);\n box-shadow: 0 0 0 2px rgba(var(--ai-widget-primary-rgb, 108, 117, 125), 0.15);\n}\n\n.ai-chat-widget-input button {\n background: var(--ai-widget-primary);\n color: var(--bs-white, #fff);\n border: none;\n border-radius: 8px;\n padding: 0.4rem 0.75rem;\n cursor: pointer;\n font-size: 0.9rem;\n white-space: nowrap;\n}\n\n.ai-chat-widget-input .btn {\n background: var(--bs-body-bg, #fff);\n color: var(--bs-body-color, #212529);\n border: 1px solid var(--bs-border-color, #ced4da);\n}\n\n.ai-chat-widget-input .btn.btn-link {\n background: transparent;\n color: var(--bs-secondary-color, #6c757d);\n border: none;\n padding: 0.4rem;\n}\n\n.ai-chat-widget-input .btn.btn-outline-dark {\n background: var(--bs-dark, #212529);\n color: var(--bs-white, #fff);\n border-color: var(--bs-dark, #212529);\n}\n\n.ai-chat-widget-input .btn.btn-outline-secondary {\n color: var(--bs-secondary-color, #6c757d);\n}\n\n.ai-chat-widget-input button:disabled {\n opacity: 0.5;\n cursor: not-allowed;\n}\n\n.ai-chat-widget-status {\n padding: 0.25rem 0.75rem;\n font-size: 0.8rem;\n color: var(--bs-secondary-color, #6c757d);\n display: none;\n}\n\n.ai-chat-widget-status.visible {\n display: block;\n}\n\n.ai-chat-widget-welcome {\n text-align: center;\n color: var(--bs-secondary-color, #6c757d);\n padding: 2rem 1rem;\n font-size: 0.9rem;\n}\n\n@media (max-width: 576px) {\n .ai-chat-widget-container {\n width: calc(100vw - 1rem);\n height: calc(100vh - 6rem);\n right: 0.5rem;\n bottom: 4.5rem;\n }\n}\n"]} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs index 8efc26564..7b5a9330a 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs @@ -4,19 +4,26 @@ using CrestApps.OrchardCore.Telephony; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using OrchardCore.Environment.Shell.Scope; +using OrchardCore.Environment.Shell; namespace CrestApps.OrchardCore.Asterisk.Services; internal sealed class AsteriskRealtimeVoiceListener : IAsyncDisposable { + private readonly IShellHost _shellHost; + private readonly ShellSettings _shellSettings; private readonly ILogger _logger; private readonly Lock _lock = new(); private CancellationTokenSource _listenerCancellationTokenSource; private Task _listenerTask; - public AsteriskRealtimeVoiceListener(ILogger logger) + public AsteriskRealtimeVoiceListener( + IShellHost shellHost, + ShellSettings shellSettings, + ILogger logger) { + _shellHost = shellHost; + _shellSettings = shellSettings; _logger = logger; } @@ -194,9 +201,9 @@ private static TimeSpan GetReconnectDelay(int failureCount) private async Task ReconcileAsync(string providerName, CancellationToken cancellationToken) { - await ShellScope.UsingChildScopeAsync(async scope => + await ExecuteInTenantScopeAsync(async serviceProvider => { - var contactCenterSynchronizationService = scope.ServiceProvider + var contactCenterSynchronizationService = serviceProvider .GetServices() .FirstOrDefault(); @@ -215,7 +222,7 @@ await ShellScope.UsingChildScopeAsync(async scope => } } - var telephonySynchronizationService = scope.ServiceProvider + var telephonySynchronizationService = serviceProvider .GetServices() .FirstOrDefault(); @@ -265,10 +272,18 @@ private async Task DispatchAsync(string providerName, string payload, Cancellati voiceEvent.State); } - await ShellScope.UsingChildScopeAsync(async scope => + await ExecuteInTenantScopeAsync(async serviceProvider => { - var dispatcher = scope.ServiceProvider.GetRequiredService(); + var dispatcher = serviceProvider.GetRequiredService(); await dispatcher.HandleAsync(voiceEvent, cancellationToken); }); } + + private async Task ExecuteInTenantScopeAsync(Func action) + { + var scope = await _shellHost.GetScopeAsync(_shellSettings); + await scope.UsingAsync( + shellScope => action(shellScope.ServiceProvider), + activateShell: false); + } } diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs index 443ec1285..4e73ecc0a 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs @@ -108,9 +108,7 @@ public async Task DialAsync(DialRequest request, CancellationTo CallId = callId ?? request.To, From = callerId, To = request.To, - State = AsteriskSettingsUtilities.IsImmediateConnectionEndpoint(endpoint) - ? CallState.Connected - : CallState.Connecting, + State = CallState.Connecting, Direction = CallDirection.Outbound, ProviderName = ProviderName, StartedUtc = _clock.UtcNow, @@ -219,6 +217,42 @@ public async Task GetCallStateAsync(string callId, Ca callId, AsteriskConstants.MuteStateVariableName, cancellationToken); + + using var verificationResponse = await SendAsync( + settings, + HttpMethod.Get, + $"channels/{Uri.EscapeDataString(callId)}", + null, + null, + cancellationToken); + + if (verificationResponse.StatusCode == HttpStatusCode.NotFound) + { + return new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }; + } + + if (!verificationResponse.IsSuccessStatusCode) + { + var responseBody = await ReadResponseBodyAsync(verificationResponse, cancellationToken); + + _logger.LogError( + "Asterisk could not verify a call-state lookup for provider {ProviderName}. CallId: {CallId}. Status code: {StatusCode}. Response: {ResponseBody}", + ProviderName, + callId, + verificationResponse.StatusCode, + responseBody); + + return new TelephonyCallLookupResult + { + Succeeded = false, + Error = S["Asterisk could not query the call state."].Value, + }; + } + var isOnHold = state == CallState.OnHold || holdState == true; if (isOnHold) diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js index 66613bb3d..2ea8e4f68 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js @@ -120,6 +120,7 @@ var connection = null; var currentCall = null; + var callStateRevision = 0; var incomingContext = null; var incomingHandled = false; var incomingAcceptPending = false; @@ -130,6 +131,7 @@ var connectionStatusResolved = false; var authenticationScheme = null; var activeTab = 'keypad'; + var activeCommand = null; var suppressToggleClick = false; function has(capability) { @@ -599,8 +601,27 @@ show(dom.merge, connected && has(CAPABILITIES.Merge)); if (dom.number) { - dom.number.disabled = active; - } + dom.number.disabled = active || !!activeCommand; + } + + [ + dom.dial, + dom.hangup, + dom.hold, + dom.resume, + dom.mute, + dom.unmute, + dom.transfer, + dom.merge + ].forEach(function (button) { + if (button) { + button.disabled = !!activeCommand; + } + }); + + dom.keys.forEach(function (button) { + button.disabled = active || !!activeCommand; + }); syncViewHeight(); } @@ -623,11 +644,15 @@ return true; } - function applyActiveCallLookup(result) { + function applyActiveCallLookup(result, expectedRevision) { if (!result || result.succeeded === false) { return null; } + if (expectedRevision !== callStateRevision) { + return currentCall; + } + currentCall = result.found === true ? result.call : null; if (currentCall && @@ -644,24 +669,15 @@ return currentCall; } - function refreshActiveCall(attempts, retryDelay) { + function refreshActiveCall() { if (!connection) { return Promise.resolve(null); } - attempts = attempts || 1; - retryDelay = retryDelay || 0; + var expectedRevision = callStateRevision; return connection.invoke('GetActiveCall').then(function (result) { - if (result && result.succeeded && !result.found && attempts > 1) { - return new Promise(function (resolve) { - window.setTimeout(resolve, retryDelay); - }).then(function () { - return refreshActiveCall(attempts - 1, retryDelay); - }); - } - - return applyActiveCallLookup(result); + return applyActiveCallLookup(result, expectedRevision); }); } @@ -670,19 +686,24 @@ return Promise.reject(new Error('Not connected.')); } + if (activeCommand) { + return Promise.resolve(null); + } + + activeCommand = method; + render(); + return connection.invoke(method, payload).then(function (result) { applyCommandResult(result); - var attempts = method === 'Dial' && result && result.succeeded !== false ? 5 : 1; - var retryDelay = attempts > 1 ? 100 : 0; - - return refreshActiveCall(attempts, retryDelay).then(function () { - return result; - }); + return result; }).catch(function (error) { showError(error && error.message ? error.message : String(error)); throw error; + }).finally(function () { + activeCommand = null; + render(); }); } @@ -1349,13 +1370,28 @@ } connection.on('CallStateChanged', function (call) { - currentCall = call; + callStateRevision++; + + var isTerminal = !call || + normalizeState(call.state) === 'Disconnected' || + normalizeState(call.state) === 'Failed'; + + if (isTerminal) { + if (!call || + !currentCall || + !call.callId || + !currentCall.callId || + call.callId === currentCall.callId) { + currentCall = null; + incomingHandled = false; + } - if (!call || normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed') { - currentCall = null; - incomingHandled = false; + render(); + + return; } + currentCall = call; render(); }); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs index 47aa5834a..e57b99593 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Services/TelephonyInteractionSynchronizationService.cs @@ -67,7 +67,11 @@ public async Task GetActiveCallAsync( }; } - var (lookup, _) = await RefreshInteractionAsync(interaction, notifyUser: true, cancellationToken); + var (lookup, _) = await RefreshInteractionAsync( + interaction, + notifyOrphanRemoval: true, + notifyProviderState: false, + cancellationToken); return lookup; } @@ -111,7 +115,8 @@ private async Task ReconcileAsync(string providerName, CancellationToken ca { var (_, interactionChanged) = await RefreshInteractionAsync( interaction, - notifyUser: true, + notifyOrphanRemoval: true, + notifyProviderState: true, cancellationToken); if (interactionChanged) @@ -125,7 +130,8 @@ private async Task ReconcileAsync(string providerName, CancellationToken ca private async Task<(TelephonyCallLookupResult Lookup, bool Changed)> RefreshInteractionAsync( TelephonyInteraction interaction, - bool notifyUser, + bool notifyOrphanRemoval, + bool notifyProviderState, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(interaction.ProviderName) || @@ -133,7 +139,7 @@ private async Task ReconcileAsync(string providerName, CancellationToken ca { await RemoveOrphanAsync( interaction, - notifyUser, + notifyOrphanRemoval, "the interaction does not contain a complete provider identity", cancellationToken); @@ -146,6 +152,21 @@ await RemoveOrphanAsync( var provider = await _providerResolver.GetAsync(interaction.ProviderName); + if (provider is null) + { + await RemoveOrphanAsync( + interaction, + notifyOrphanRemoval, + "the interaction's provider is no longer registered or enabled", + cancellationToken); + + return (new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }, true); + } + if (provider is not ITelephonyCallStateProvider stateProvider) { var error = $"Provider '{interaction.ProviderName}' does not support authoritative call-state lookup."; @@ -196,7 +217,7 @@ await RemoveOrphanAsync( await RemoveOrphanAsync( interaction, - notifyUser, + notifyOrphanRemoval, "the provider no longer reports the call", cancellationToken); @@ -228,7 +249,7 @@ await RemoveOrphanAsync( await _interactionStore.UpdateAsync(interaction, cancellationToken); } - if (notifyUser) + if (notifyProviderState) { await NotifyUserAsync(interaction.UserId, call); } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js index 0429d81d1..62621a349 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js @@ -116,6 +116,7 @@ }; var connection = null; var currentCall = null; + var callStateRevision = 0; var incomingContext = null; var incomingHandled = false; var incomingAcceptPending = false; @@ -126,6 +127,7 @@ var connectionStatusResolved = false; var authenticationScheme = null; var activeTab = 'keypad'; + var activeCommand = null; var suppressToggleClick = false; function has(capability) { return (capabilities & capability) === capability; @@ -509,8 +511,16 @@ show(dom.transfer, liveMedia && has(CAPABILITIES.Transfer)); show(dom.merge, connected && has(CAPABILITIES.Merge)); if (dom.number) { - dom.number.disabled = active; + dom.number.disabled = active || !!activeCommand; } + [dom.dial, dom.hangup, dom.hold, dom.resume, dom.mute, dom.unmute, dom.transfer, dom.merge].forEach(function (button) { + if (button) { + button.disabled = !!activeCommand; + } + }); + dom.keys.forEach(function (button) { + button.disabled = active || !!activeCommand; + }); syncViewHeight(); } @@ -527,10 +537,13 @@ showError(null); return true; } - function applyActiveCallLookup(result) { + function applyActiveCallLookup(result, expectedRevision) { if (!result || result.succeeded === false) { return null; } + if (expectedRevision !== callStateRevision) { + return currentCall; + } currentCall = result.found === true ? result.call : null; if (currentCall && (normalizeState(currentCall.state) === 'Disconnected' || normalizeState(currentCall.state) === 'Failed')) { currentCall = null; @@ -541,37 +554,33 @@ render(); return currentCall; } - function refreshActiveCall(attempts, retryDelay) { + function refreshActiveCall() { if (!connection) { return Promise.resolve(null); } - attempts = attempts || 1; - retryDelay = retryDelay || 0; + var expectedRevision = callStateRevision; return connection.invoke('GetActiveCall').then(function (result) { - if (result && result.succeeded && !result.found && attempts > 1) { - return new Promise(function (resolve) { - window.setTimeout(resolve, retryDelay); - }).then(function () { - return refreshActiveCall(attempts - 1, retryDelay); - }); - } - return applyActiveCallLookup(result); + return applyActiveCallLookup(result, expectedRevision); }); } function invoke(method, payload) { if (!connection) { return Promise.reject(new Error('Not connected.')); } + if (activeCommand) { + return Promise.resolve(null); + } + activeCommand = method; + render(); return connection.invoke(method, payload).then(function (result) { applyCommandResult(result); - var attempts = method === 'Dial' && result && result.succeeded !== false ? 5 : 1; - var retryDelay = attempts > 1 ? 100 : 0; - return refreshActiveCall(attempts, retryDelay).then(function () { - return result; - }); + return result; })["catch"](function (error) { showError(error && error.message ? error.message : String(error)); throw error; + })["finally"](function () { + activeCommand = null; + render(); }); } function currentCallId() { @@ -1114,11 +1123,17 @@ return; } connection.on('CallStateChanged', function (call) { - currentCall = call; - if (!call || normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed') { - currentCall = null; - incomingHandled = false; + callStateRevision++; + var isTerminal = !call || normalizeState(call.state) === 'Disconnected' || normalizeState(call.state) === 'Failed'; + if (isTerminal) { + if (!call || !currentCall || !call.callId || !currentCall.callId || call.callId === currentCall.callId) { + currentCall = null; + incomingHandled = false; + } + render(); + return; } + currentCall = call; render(); }); connection.on('IncomingCall', function (call, context) { diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js index 3b81b3dd6..28d08cc3a 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js @@ -1 +1 @@ -!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function f(c,f){f=f||{};var h=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),y=h.strings||{},m=h.capabilities||0,g=(h.storageKey||"telephony-soft-phone")+"-layout",v=f.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,k=null,S=null,C=!1,L=!1,q=null,E=!1,_=!1,I=!1,A=!1,T=null,x="keypad",P=!1;function R(e){return(m&e)===e}function N(e,n){e&&(e.hidden=!n)}function V(e){b.status&&(b.status.textContent=e)}function M(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function U(e){b.views.forEach(function(n){N(n,n.getAttribute("data-telephony-view")===e)})}function H(){if(b.panel&&!b.panel.hidden&&b.keypadView){var e=b.keypadView.hidden,n=b.keypadView.style.position,t=b.keypadView.style.visibility,o=b.keypadView.style.pointerEvents,i=b.keypadView.style.inset;e&&(b.keypadView.hidden=!1,b.keypadView.style.position="absolute",b.keypadView.style.inset="0 auto auto 0",b.keypadView.style.visibility="hidden",b.keypadView.style.pointerEvents="none");var r=Math.ceil(b.keypadView.getBoundingClientRect().height||b.keypadView.scrollHeight||0);e&&(b.keypadView.hidden=e,b.keypadView.style.position=n,b.keypadView.style.inset=i,b.keypadView.style.visibility=t,b.keypadView.style.pointerEvents=o),r>0&&c.style.setProperty("--telephony-view-height",r+"px")}}function O(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===x})||(x=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function D(e){return"keypad"===e||"history"===e}function B(){return b.tabs.some(function(e){return!D(e.getAttribute("data-telephony-tab"))})}function F(e){return e?1===e.direction||"Inbound"===e.direction?e.from||e.to||"":e.to||e.from||"":""}function j(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function G(e){try{var n=j();Object.assign(n,e),localStorage.setItem(g,JSON.stringify(n))}catch(e){}}function J(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function Q(){var e=c.getBoundingClientRect(),n=e.width||56,t=e.height||56,o=Math.max(8,window.innerWidth-n-8),i=Math.max(8,window.innerHeight-t-8),r=8,a=8;if(b.panel&&!b.panel.hidden){var l=b.panel.getBoundingClientRect(),u=l.width||n,s=l.height||0;r=Math.min(o,Math.max(8,u-n+8)),a=Math.min(i,s+20)}return{minLeft:r,minTop:a,maxLeft:o,maxTop:i}}function z(e,n){var t=Q();return{left:p(e,t.minLeft,t.maxLeft),top:p(n,t.minTop,t.maxTop)}}function K(e){if(!e)return null;var n=Q(),t=Number(e.left),o=Number(e.top),i=Number(e.leftRatio),r=Number(e.topRatio);return Number.isFinite(i)&&(t=n.minLeft+Math.max(0,n.maxLeft-n.minLeft)*i),Number.isFinite(r)&&(o=n.minTop+Math.max(0,n.maxTop-n.minTop)*r),Number.isFinite(t)&&Number.isFinite(o)?z(t,o):null}function X(){var e=j();if(e.position){var n=K(e.position);if(n)return void J(n.left,n.top)}if(c.style.left){var t=c.getBoundingClientRect(),o=z(t.left,t.top);J(o.left,o.top)}}function Y(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();J(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var u=z(r+n,a+c);J(u.left,u.top)}}}function s(){var e,o,i,r,a,d;null!==t&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(d=c.getBoundingClientRect(),G({position:(e=d.left,o=d.top,i=Q(),r=Math.max(0,i.maxLeft-i.minLeft),a=Math.max(0,i.maxTop-i.minTop),{left:e,top:o,leftRatio:0===r?0:(e-i.minLeft)/r,topRatio:0===a?0:(o-i.minTop)/a})}),n.suppressClick&&(P=!0)))}}function W(){b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===x;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")})}function Z(e){G({activeTab:x=e}),$(),"history"===e&&Ae()}function $(){!function(){var e=ye()&&!C;if(N(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return ge(),void(ye()||(S=null));b.incomingCaller&&(b.incomingCaller.textContent=F(k)||y.incomingCall||"Incoming call");var n=S&&S.properties?S.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);N(b.incomingVoicemail,R(l)),function(){if(!b.incomingCards)return;var e=S&&S.cards?S.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=S&&S.heading||y.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(y.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){be(e.getAttribute("data-url"))})})}(),function(){if(ge(),!S||!S.properties||!S.properties.expiresUtc)return;var e=Date.parse(S.properties.expiresUtc);if(!isFinite(e))return;var n=e-Date.now();if(n<=0)return void Ce();q=window.setTimeout(function(){Ce()},n+250)}()}(),O();var a=k?u(k.state):"Idle",p=s(a),f="Connected"===a,h=f||"OnHold"===a;b.toggleIcon&&(b.toggleIcon.className="fa-solid fa-phone");var m=A&&I&&E&&!_;if(A&&!I&&!p){var g=D(x);return b.unavailableText&&(b.unavailableText.textContent=y.notConfigured||"No telephony provider is configured."),N(b.unavailable,g),N(b.connectPanel,!1),U(g?null:x),N(b.footer,B()),W(),V(y.notReady||"Not Ready"),void H()}if(N(b.unavailable,!1),m&&!p){var v=D(x);return N(b.connectPanel,v),U(v?null:x),N(b.footer,B()),W(),V(y.notConnected||"Not connected"),void H()}N(b.connectPanel,!1),N(b.footer,!0),W(),U(x),V(k?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return y[n]||e}(a):y.idle||"Ready"),b.peer&&(b.peer.textContent=p&&k?F(k):""),N(b.dial,!p),N(b.hangup,h&&R(e)),N(b.hold,p&&"Connected"===a&&R(n)),N(b.resume,p&&"OnHold"===a&&R(t));var w=k&&k.isMuted;N(b.mute,f&&!w&&R(o)),N(b.unmute,f&&w&&R(o)),N(b.transfer,h&&R(i)),N(b.merge,f&&R(r)),b.number&&(b.number.disabled=p),H()}function ee(e,n){return w?(e=e||1,n=n||0,w.invoke("GetActiveCall").then(function(t){return t&&t.succeeded&&!t.found&&e>1?new Promise(function(e){window.setTimeout(e,n)}).then(function(){return ee(e-1,n)}):function(e){return e&&!1!==e.succeeded?(!(k=!0===e.found?e.call:null)||"Disconnected"!==u(k.state)&&"Failed"!==u(k.state)||(k=null),k||(C=!1),$(),k):null}(t)})):Promise.resolve(null)}function ne(e,n){return w?w.invoke(e,n).then(function(n){!function(e){!!e&&(!1===e.succeeded?M(e.error||y.failed||"Call failed"):M(null))}(n);var t="Dial"===e&&n&&!1!==n.succeeded?5:1;return ee(t,t>1?100:0).then(function(){return n})}).catch(function(e){throw M(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function te(){return k?k.callId:null}function oe(){var e=te();return e?{callId:e,metadata:k&&k.metadata?k.metadata:null}:null}function ie(){var e=b.number?b.number.value.trim():"";e?ne("Dial",{to:e}):M(y.invalidNumber||"Enter a phone number to call.")}function re(e){e&&(Z("keypad"),he(!0),b.number&&(b.number.value=e),ne("Dial",{to:e}))}function ae(){var e=oe();e&&ne("Hangup",e)}function le(){var e=oe();e&&ne("Hold",e)}function ce(){var e=oe();e&&ne("Resume",e)}function ue(){var e=oe();e&&ne("Mute",e)}function se(){var e=oe();e&&ne("Unmute",e)}function de(){var e=te();if(e){var n=window.prompt(y.transferPrompt||"Transfer to number");n&&ne("Transfer",{callId:e,to:n,mode:0})}}function pe(){var e=te();if(e){var n=window.prompt(y.mergePrompt||"Second call id to merge");n&&ne("Merge",{primaryCallId:e,secondaryCallId:n})}}function fe(e){var n=k?u(k.state):"Idle";"Connected"===n&&R(a)?ne("SendDigits",{callId:te(),digits:e}):!s(n)&&b.number&&(b.number.value+=e)}function he(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,G({open:n}),X(),$()}}function ye(){if(!k)return!1;var e=1===k.direction||"Inbound"===k.direction;return"Ringing"===u(k.state)&&e}function me(e){return e&&e.properties&&e.properties.reservationId||null}function ge(){q&&(window.clearTimeout(q),q=null)}function ve(e){if(!S||!S.properties)return Promise.resolve(null);var n=S.properties[e];if(!n)return Promise.resolve(null);var t={"Content-Type":"application/json"};h.antiForgeryToken&&(t.RequestVerificationToken=h.antiForgeryToken);try{return fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:te()})}).then(function(e){return e.ok?e.json().catch(function(){return{succeeded:!0}}):{succeeded:!1}}).catch(function(){return{succeeded:!1}})}catch(e){return Promise.resolve({succeeded:!1})}}function be(e){var n=te();e&&window.open(e,"_blank","noopener"),S&&S.properties&&S.properties.acceptUrl?(L=!0,ve("acceptUrl").then(function(e){if(!e||!1===e.succeeded)return M(y.offerUnavailable||"This call is no longer available."),S=null,k=null,void $();C=!0,S=null,he(!0),$(),!1!==e.requiresDeviceAnswer&&n&&ne("Answer",{callId:n})}).finally(function(){L=!1})):n&&(he(!0),ne("Answer",{callId:n}))}function we(){var e=oe();ve("declineUrl"),e&&ne("Voicemail",e)}function ke(){var e=oe();S&&S.properties&&S.properties.declineUrl?ve("declineUrl").then(function(e){e&&!1!==e.succeeded?Ce():M(y.offerUnavailable||"This call is no longer available.")}):e?ne("Reject",e):Ce()}function Se(e,n){e&&(k&&s(u(k.state))&&!ye()||C&&function(e,n){if(!k||!e)return!1;var t=me(S),o=me(n);return!(k.callId!==e.callId||t&&o&&t!==o)}(e,n)||(k=e,S=n||null,C=!1,L=!1,Z("keypad"),$()))}function Ce(e){e=e||{},ge(),S=null,C=!1,e.preservePendingAccept||(L=!1),!e.preserveCurrentCall&&k&&ye()&&(k=null),$()}function Le(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(I=!!e.isAvailable,E=!!e.requiresAuthentication,_=!!e.isConnected,T=e.authenticationScheme||"oauth2",A=!0,$())}).catch(function(){}):Promise.resolve()}function qe(){return w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(m=e,$())}).catch(function(){}):Promise.resolve()}function Ee(){if(h.connectUrl){var e=h.connectUrl.indexOf("?")>=0?"&":"?",n=h.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function _e(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[T]||e.oauth2),t={scheme:T,connectUrl:h.connectUrl,startOAuth:Ee,refreshStatus:Le};"function"==typeof n?n(t):Ee()}function Ie(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Le()}function Ae(){w?w.invoke("GetInteractions",50).then(function(e){xe(e||[])}).catch(function(){xe([])}):xe([])}function Te(){return!w||k?Promise.resolve():ee().catch(function(){})}function xe(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"↙":"↗",r=n?e.from||"":e.to||"",a=t?y.missed||"Missed":n?y.incoming||"Incoming":y.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&re(n)})})):b.historyList.innerHTML='
'+d(y.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){P?P=!1:he()}),b.close&&b.close.addEventListener("click",function(){he(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){Z(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",ie),b.hangup&&b.hangup.addEventListener("click",ae),b.hold&&b.hold.addEventListener("click",le),b.resume&&b.resume.addEventListener("click",ce),b.mute&&b.mute.addEventListener("click",ue),b.unmute&&b.unmute.addEventListener("click",se),b.transfer&&b.transfer.addEventListener("click",de),b.merge&&b.merge.addEventListener("click",pe),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){be(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",we),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",ke),b.connect&&b.connect.addEventListener("click",_e),b.keys.forEach(function(e){e.addEventListener("click",function(){fe(e.getAttribute("data-telephony-key"))})}),Y(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),Y(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",Ie),window.addEventListener("resize",function(){X(),H()}),function(){var e,n=j();if("string"==typeof n.activeTab&&n.activeTab.length&&(x=n.activeTab),n.open&&b.panel&&(b.panel.hidden=!1),n.position&&("number"==typeof(e=Number(n.position.left))&&isFinite(e))){var t=K(n.position);t&&J(t.left,t.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=z(o,n.top);J(i.left,i.top)}}()}(),$(),c.style.visibility="";var Pe=v&&h.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(h.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){k=e,e&&"Disconnected"!==u(e.state)&&"Failed"!==u(e.state)||(k=null,C=!1),$()}),w.on("IncomingCall",function(e,n){Se(e,n||null)}),w.on("ReceiveError",function(e){M(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){V(y.disconnectedHub||"Disconnected")}),"function"==typeof w.onreconnected&&w.onreconnected(function(){return M(null),Promise.all([qe(),Le()]).then(function(){return Te()}).then(function(){"history"===x&&Ae(),$()}).catch(function(e){M(e&&e.message?e.message:String(e))})})),w.start().then(function(){return M(null),Promise.all([qe(),Le()])}).then(function(){return Te()}).then(function(){"history"===x&&Ae(),$()}).catch(function(e){M(e&&e.message?e.message:String(e))})):($(),Promise.resolve());return{element:c,config:h,dial:ie,dialNumber:re,hangup:ae,hold:le,resume:ce,mute:ue,unmute:se,transfer:de,merge:pe,pressKey:fe,togglePanel:he,open:function(){he(!0)},getCurrentCall:function(){return k},isIncomingAcceptPending:function(){return L},setIncomingOffer:Se,clearIncomingOffer:Ce,showError:M,getConnection:function(){return w},started:Pe}}function h(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=f(e))})}function y(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:f,initializeAll:h,getInstance:y,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=y();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",h):h()}(); +!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function f(c,f){f=f||{};var h=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),y=h.strings||{},m=h.capabilities||0,g=(h.storageKey||"telephony-soft-phone")+"-layout",v=f.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,k=null,S=0,C=null,L=!1,E=!1,I=null,q=!1,_=!1,A=!1,x=!1,T=null,P="keypad",R=null,N=!1;function V(e){return(m&e)===e}function M(e,n){e&&(e.hidden=!n)}function U(e){b.status&&(b.status.textContent=e)}function H(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function O(e){b.views.forEach(function(n){M(n,n.getAttribute("data-telephony-view")===e)})}function B(){if(b.panel&&!b.panel.hidden&&b.keypadView){var e=b.keypadView.hidden,n=b.keypadView.style.position,t=b.keypadView.style.visibility,o=b.keypadView.style.pointerEvents,i=b.keypadView.style.inset;e&&(b.keypadView.hidden=!1,b.keypadView.style.position="absolute",b.keypadView.style.inset="0 auto auto 0",b.keypadView.style.visibility="hidden",b.keypadView.style.pointerEvents="none");var r=Math.ceil(b.keypadView.getBoundingClientRect().height||b.keypadView.scrollHeight||0);e&&(b.keypadView.hidden=e,b.keypadView.style.position=n,b.keypadView.style.inset=i,b.keypadView.style.visibility=t,b.keypadView.style.pointerEvents=o),r>0&&c.style.setProperty("--telephony-view-height",r+"px")}}function D(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===P})||(P=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function F(e){return"keypad"===e||"history"===e}function j(){return b.tabs.some(function(e){return!F(e.getAttribute("data-telephony-tab"))})}function G(e){return e?1===e.direction||"Inbound"===e.direction?e.from||e.to||"":e.to||e.from||"":""}function J(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function Q(e){try{var n=J();Object.assign(n,e),localStorage.setItem(g,JSON.stringify(n))}catch(e){}}function z(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function K(){var e=c.getBoundingClientRect(),n=e.width||56,t=e.height||56,o=Math.max(8,window.innerWidth-n-8),i=Math.max(8,window.innerHeight-t-8),r=8,a=8;if(b.panel&&!b.panel.hidden){var l=b.panel.getBoundingClientRect(),u=l.width||n,s=l.height||0;r=Math.min(o,Math.max(8,u-n+8)),a=Math.min(i,s+20)}return{minLeft:r,minTop:a,maxLeft:o,maxTop:i}}function X(e,n){var t=K();return{left:p(e,t.minLeft,t.maxLeft),top:p(n,t.minTop,t.maxTop)}}function Y(e){if(!e)return null;var n=K(),t=Number(e.left),o=Number(e.top),i=Number(e.leftRatio),r=Number(e.topRatio);return Number.isFinite(i)&&(t=n.minLeft+Math.max(0,n.maxLeft-n.minLeft)*i),Number.isFinite(r)&&(o=n.minTop+Math.max(0,n.maxTop-n.minTop)*r),Number.isFinite(t)&&Number.isFinite(o)?X(t,o):null}function W(){var e=J();if(e.position){var n=Y(e.position);if(n)return void z(n.left,n.top)}if(c.style.left){var t=c.getBoundingClientRect(),o=X(t.left,t.top);z(o.left,o.top)}}function Z(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();z(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var u=X(r+n,a+c);z(u.left,u.top)}}}function s(){var e,o,i,r,a,d;null!==t&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(d=c.getBoundingClientRect(),Q({position:(e=d.left,o=d.top,i=K(),r=Math.max(0,i.maxLeft-i.minLeft),a=Math.max(0,i.maxTop-i.minTop),{left:e,top:o,leftRatio:0===r?0:(e-i.minLeft)/r,topRatio:0===a?0:(o-i.minTop)/a})}),n.suppressClick&&(N=!0)))}}function $(){b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===P;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")})}function ee(e){Q({activeTab:P=e}),ne(),"history"===e&&Te()}function ne(){!function(){var e=ge()&&!L;if(M(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return be(),void(ge()||(C=null));b.incomingCaller&&(b.incomingCaller.textContent=G(k)||y.incomingCall||"Incoming call");var n=C&&C.properties?C.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);M(b.incomingVoicemail,V(l)),function(){if(!b.incomingCards)return;var e=C&&C.cards?C.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=C&&C.heading||y.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(y.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ke(e.getAttribute("data-url"))})})}(),function(){if(be(),!C||!C.properties||!C.properties.expiresUtc)return;var e=Date.parse(C.properties.expiresUtc);if(!isFinite(e))return;var n=e-Date.now();if(n<=0)return void Ee();I=window.setTimeout(function(){Ee()},n+250)}()}(),D();var a=k?u(k.state):"Idle",p=s(a),f="Connected"===a,h=f||"OnHold"===a;b.toggleIcon&&(b.toggleIcon.className="fa-solid fa-phone");var m=x&&A&&q&&!_;if(x&&!A&&!p){var g=F(P);return b.unavailableText&&(b.unavailableText.textContent=y.notConfigured||"No telephony provider is configured."),M(b.unavailable,g),M(b.connectPanel,!1),O(g?null:P),M(b.footer,j()),$(),U(y.notReady||"Not Ready"),void B()}if(M(b.unavailable,!1),m&&!p){var v=F(P);return M(b.connectPanel,v),O(v?null:P),M(b.footer,j()),$(),U(y.notConnected||"Not connected"),void B()}M(b.connectPanel,!1),M(b.footer,!0),$(),O(P),U(k?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return y[n]||e}(a):y.idle||"Ready"),b.peer&&(b.peer.textContent=p&&k?G(k):""),M(b.dial,!p),M(b.hangup,h&&V(e)),M(b.hold,p&&"Connected"===a&&V(n)),M(b.resume,p&&"OnHold"===a&&V(t));var w=k&&k.isMuted;M(b.mute,f&&!w&&V(o)),M(b.unmute,f&&w&&V(o)),M(b.transfer,h&&V(i)),M(b.merge,f&&V(r)),b.number&&(b.number.disabled=p||!!R),[b.dial,b.hangup,b.hold,b.resume,b.mute,b.unmute,b.transfer,b.merge].forEach(function(e){e&&(e.disabled=!!R)}),b.keys.forEach(function(e){e.disabled=p||!!R}),B()}function te(){if(!w)return Promise.resolve(null);var e=S;return w.invoke("GetActiveCall").then(function(n){return function(e,n){return e&&!1!==e.succeeded?(n!==S||(!(k=!0===e.found?e.call:null)||"Disconnected"!==u(k.state)&&"Failed"!==u(k.state)||(k=null),k||(L=!1),ne()),k):null}(n,e)})}function oe(e,n){return w?R?Promise.resolve(null):(R=e,ne(),w.invoke(e,n).then(function(e){return function(e){!!e&&(!1===e.succeeded?H(e.error||y.failed||"Call failed"):H(null))}(e),e}).catch(function(e){throw H(e&&e.message?e.message:String(e)),e}).finally(function(){R=null,ne()})):Promise.reject(new Error("Not connected."))}function ie(){return k?k.callId:null}function re(){var e=ie();return e?{callId:e,metadata:k&&k.metadata?k.metadata:null}:null}function ae(){var e=b.number?b.number.value.trim():"";e?oe("Dial",{to:e}):H(y.invalidNumber||"Enter a phone number to call.")}function le(e){e&&(ee("keypad"),me(!0),b.number&&(b.number.value=e),oe("Dial",{to:e}))}function ce(){var e=re();e&&oe("Hangup",e)}function ue(){var e=re();e&&oe("Hold",e)}function se(){var e=re();e&&oe("Resume",e)}function de(){var e=re();e&&oe("Mute",e)}function pe(){var e=re();e&&oe("Unmute",e)}function fe(){var e=ie();if(e){var n=window.prompt(y.transferPrompt||"Transfer to number");n&&oe("Transfer",{callId:e,to:n,mode:0})}}function he(){var e=ie();if(e){var n=window.prompt(y.mergePrompt||"Second call id to merge");n&&oe("Merge",{primaryCallId:e,secondaryCallId:n})}}function ye(e){var n=k?u(k.state):"Idle";"Connected"===n&&V(a)?oe("SendDigits",{callId:ie(),digits:e}):!s(n)&&b.number&&(b.number.value+=e)}function me(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,Q({open:n}),W(),ne()}}function ge(){if(!k)return!1;var e=1===k.direction||"Inbound"===k.direction;return"Ringing"===u(k.state)&&e}function ve(e){return e&&e.properties&&e.properties.reservationId||null}function be(){I&&(window.clearTimeout(I),I=null)}function we(e){if(!C||!C.properties)return Promise.resolve(null);var n=C.properties[e];if(!n)return Promise.resolve(null);var t={"Content-Type":"application/json"};h.antiForgeryToken&&(t.RequestVerificationToken=h.antiForgeryToken);try{return fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:ie()})}).then(function(e){return e.ok?e.json().catch(function(){return{succeeded:!0}}):{succeeded:!1}}).catch(function(){return{succeeded:!1}})}catch(e){return Promise.resolve({succeeded:!1})}}function ke(e){var n=ie();e&&window.open(e,"_blank","noopener"),C&&C.properties&&C.properties.acceptUrl?(E=!0,we("acceptUrl").then(function(e){if(!e||!1===e.succeeded)return H(y.offerUnavailable||"This call is no longer available."),C=null,k=null,void ne();L=!0,C=null,me(!0),ne(),!1!==e.requiresDeviceAnswer&&n&&oe("Answer",{callId:n})}).finally(function(){E=!1})):n&&(me(!0),oe("Answer",{callId:n}))}function Se(){var e=re();we("declineUrl"),e&&oe("Voicemail",e)}function Ce(){var e=re();C&&C.properties&&C.properties.declineUrl?we("declineUrl").then(function(e){e&&!1!==e.succeeded?Ee():H(y.offerUnavailable||"This call is no longer available.")}):e?oe("Reject",e):Ee()}function Le(e,n){e&&(k&&s(u(k.state))&&!ge()||L&&function(e,n){if(!k||!e)return!1;var t=ve(C),o=ve(n);return!(k.callId!==e.callId||t&&o&&t!==o)}(e,n)||(k=e,C=n||null,L=!1,E=!1,ee("keypad"),ne()))}function Ee(e){e=e||{},be(),C=null,L=!1,e.preservePendingAccept||(E=!1),!e.preserveCurrentCall&&k&&ge()&&(k=null),ne()}function Ie(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(A=!!e.isAvailable,q=!!e.requiresAuthentication,_=!!e.isConnected,T=e.authenticationScheme||"oauth2",x=!0,ne())}).catch(function(){}):Promise.resolve()}function qe(){return w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(m=e,ne())}).catch(function(){}):Promise.resolve()}function _e(){if(h.connectUrl){var e=h.connectUrl.indexOf("?")>=0?"&":"?",n=h.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function Ae(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[T]||e.oauth2),t={scheme:T,connectUrl:h.connectUrl,startOAuth:_e,refreshStatus:Ie};"function"==typeof n?n(t):_e()}function xe(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Ie()}function Te(){w?w.invoke("GetInteractions",50).then(function(e){Re(e||[])}).catch(function(){Re([])}):Re([])}function Pe(){return!w||k?Promise.resolve():te().catch(function(){})}function Re(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"↙":"↗",r=n?e.from||"":e.to||"",a=t?y.missed||"Missed":n?y.incoming||"Incoming":y.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&le(n)})})):b.historyList.innerHTML='
'+d(y.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){N?N=!1:me()}),b.close&&b.close.addEventListener("click",function(){me(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){ee(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",ae),b.hangup&&b.hangup.addEventListener("click",ce),b.hold&&b.hold.addEventListener("click",ue),b.resume&&b.resume.addEventListener("click",se),b.mute&&b.mute.addEventListener("click",de),b.unmute&&b.unmute.addEventListener("click",pe),b.transfer&&b.transfer.addEventListener("click",fe),b.merge&&b.merge.addEventListener("click",he),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ke(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",Se),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",Ce),b.connect&&b.connect.addEventListener("click",Ae),b.keys.forEach(function(e){e.addEventListener("click",function(){ye(e.getAttribute("data-telephony-key"))})}),Z(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),Z(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",xe),window.addEventListener("resize",function(){W(),B()}),function(){var e,n=J();if("string"==typeof n.activeTab&&n.activeTab.length&&(P=n.activeTab),n.open&&b.panel&&(b.panel.hidden=!1),n.position&&("number"==typeof(e=Number(n.position.left))&&isFinite(e))){var t=Y(n.position);t&&z(t.left,t.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=X(o,n.top);z(i.left,i.top)}}()}(),ne(),c.style.visibility="";var Ne=v&&h.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(h.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){if(S++,!e||"Disconnected"===u(e.state)||"Failed"===u(e.state))return e&&k&&e.callId&&k.callId&&e.callId!==k.callId||(k=null,L=!1),void ne();k=e,ne()}),w.on("IncomingCall",function(e,n){Le(e,n||null)}),w.on("ReceiveError",function(e){H(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){U(y.disconnectedHub||"Disconnected")}),"function"==typeof w.onreconnected&&w.onreconnected(function(){return H(null),Promise.all([qe(),Ie()]).then(function(){return Pe()}).then(function(){"history"===P&&Te(),ne()}).catch(function(e){H(e&&e.message?e.message:String(e))})})),w.start().then(function(){return H(null),Promise.all([qe(),Ie()])}).then(function(){return Pe()}).then(function(){"history"===P&&Te(),ne()}).catch(function(e){H(e&&e.message?e.message:String(e))})):(ne(),Promise.resolve());return{element:c,config:h,dial:ae,dialNumber:le,hangup:ce,hold:ue,resume:se,mute:de,unmute:pe,transfer:fe,merge:he,pressKey:ye,togglePanel:me,open:function(){me(!0)},getCurrentCall:function(){return k},isIncomingAcceptPending:function(){return E},setIncomingOffer:Le,clearIncomingOffer:Ee,showError:H,getConnection:function(){return w},started:Ne}}function h(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=f(e))})}function y(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:f,initializeAll:h,getInstance:y,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=y();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",h):h()}(); diff --git a/src/Startup/CrestApps.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Aspire.AppHost/Program.cs index 912259096..1fe723042 100644 --- a/src/Startup/CrestApps.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Aspire.AppHost/Program.cs @@ -27,7 +27,8 @@ // .WithReference(redis) // .WithReference(ollama) // .WaitFor(redis) -// .WaitFor(asterisk) + .WithArgs("--framework", "net10.0") + .WaitFor(asterisk) .WithHttpsEndpoint(5001, name: "HttpsOrchardCore") .WithEnvironment((options) => { @@ -77,25 +78,29 @@ }); builder.AddProject("McpClientSample") + .WithArgs("--framework", "net10.0") .WithReference(orchardCore) .WaitFor(orchardCore) .WithHttpsEndpoint(5002, name: "HttpsMcpClient") .WithEnvironment("Mcp__Endpoint", "https://localhost:5001/mcp"); builder.AddProject("A2AClientSample") + .WithArgs("--framework", "net10.0") .WithReference(orchardCore) .WaitFor(orchardCore) .WithHttpsEndpoint(5003, name: "HttpsA2AClient") .WithEnvironment("A2A__Endpoint", "https://localhost:5001"); builder.AddProject("AsteriskWeb") + .WithArgs("--framework", "net10.0") .WithReference(orchardCore) .WaitFor(orchardCore) + .WaitFor(asterisk) .WithHttpsEndpoint(5004, name: "HttpsAsteriskWeb") .WithEnvironment("AsteriskWeb__OrchardBaseUrl", "https://localhost:5001") .WithEnvironment("AsteriskWeb__LoginPath", "/Login") .WithEnvironment("AsteriskWeb__InboundPath", "/api/contact-center/voice/inbound") - .WithEnvironment("AsteriskWeb__ProviderName", "Asterisk") + .WithEnvironment("AsteriskWeb__ProviderName", "Default Asterisk") .WithEnvironment("AsteriskWeb__AsteriskDestination", "1000") .WithEnvironment("AsteriskWeb__AsteriskBaseUrl", "http://localhost:8088/ari/") .WithEnvironment("AsteriskWeb__AsteriskEndpointTemplate", "Local/{number}@default") diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs index ce5842522..e4d1dfc2f 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs @@ -53,7 +53,7 @@ public SimulatorModel( /// public void OnGet() { - ApplyDefaults(); + ApplyDefaults(preferConfiguredValues: true); } /// @@ -62,7 +62,7 @@ public void OnGet() /// The current page. public async Task OnPostAsync() { - ApplyDefaults(); + ApplyDefaults(preferConfiguredValues: false); if (!ModelState.IsValid) { @@ -85,38 +85,26 @@ public async Task OnPostAsync() return Page(); } - private void ApplyDefaults() + private void ApplyDefaults(bool preferConfiguredValues) { - Input.OrchardBaseUrl = string.IsNullOrWhiteSpace(Input.OrchardBaseUrl) - ? _options.OrchardBaseUrl ?? Input.OrchardBaseUrl - : Input.OrchardBaseUrl; - - Input.LoginPath = string.IsNullOrWhiteSpace(Input.LoginPath) - ? _options.LoginPath ?? Input.LoginPath - : Input.LoginPath; - - Input.InboundPath = string.IsNullOrWhiteSpace(Input.InboundPath) - ? _options.InboundPath ?? Input.InboundPath - : Input.InboundPath; - - Input.ProviderName = string.IsNullOrWhiteSpace(Input.ProviderName) - ? _options.ProviderName ?? Input.ProviderName - : Input.ProviderName; - - Input.AsteriskDestination = string.IsNullOrWhiteSpace(Input.AsteriskDestination) - ? _options.AsteriskDestination ?? Input.AsteriskDestination - : Input.AsteriskDestination; - - Input.ToAddress = string.IsNullOrWhiteSpace(Input.ToAddress) - ? _options.ToAddress ?? Input.ToAddress - : Input.ToAddress; + Input.OrchardBaseUrl = ResolveDefault(Input.OrchardBaseUrl, _options.OrchardBaseUrl, preferConfiguredValues); + Input.LoginPath = ResolveDefault(Input.LoginPath, _options.LoginPath, preferConfiguredValues); + Input.InboundPath = ResolveDefault(Input.InboundPath, _options.InboundPath, preferConfiguredValues); + Input.ProviderName = ResolveDefault(Input.ProviderName, _options.ProviderName, preferConfiguredValues); + Input.AsteriskDestination = ResolveDefault(Input.AsteriskDestination, _options.AsteriskDestination, preferConfiguredValues); + Input.ToAddress = ResolveDefault(Input.ToAddress, _options.ToAddress, preferConfiguredValues); + Input.CallerNumberSeed = ResolveDefault(Input.CallerNumberSeed, _options.CallerNumberSeed, preferConfiguredValues); + Input.CallerNamePrefix = ResolveDefault(Input.CallerNamePrefix, _options.CallerNamePrefix, preferConfiguredValues); + } - Input.CallerNumberSeed = string.IsNullOrWhiteSpace(Input.CallerNumberSeed) - ? _options.CallerNumberSeed ?? Input.CallerNumberSeed - : Input.CallerNumberSeed; + private static string ResolveDefault(string currentValue, string configuredValue, bool preferConfiguredValue) + { + if (!string.IsNullOrWhiteSpace(configuredValue) && + (preferConfiguredValue || string.IsNullOrWhiteSpace(currentValue))) + { + return configuredValue; + } - Input.CallerNamePrefix = string.IsNullOrWhiteSpace(Input.CallerNamePrefix) - ? _options.CallerNamePrefix ?? Input.CallerNamePrefix - : Input.CallerNamePrefix; + return currentValue; } } diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs index 45a562b77..0c3d9a47d 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs @@ -15,6 +15,7 @@ namespace CrestApps.OrchardCore.Asterisk.Web.Services; public sealed class AsteriskInboundSimulationCoordinator { private const string RequestVerificationTokenHeaderName = "RequestVerificationToken"; + private static readonly TimeSpan _forwardTimeout = TimeSpan.FromSeconds(30); private readonly ConcurrentDictionary _pending = new(StringComparer.Ordinal); private readonly ILogger _logger; @@ -107,6 +108,16 @@ public async Task TryDispatchAsync(string key, string channelId, Cancellat { pending.Completion.TrySetResult(await ForwardToOrchardAsync(pending, cancellationToken)); } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + "The Asterisk Stasis listener timed out while forwarding simulation {SimulationKey} to Orchard.", + key); + pending.Completion.TrySetResult(BuildFailureResult( + pending, + "Timed out forwarding the simulated call to Orchard.", + 504)); + } catch (Exception ex) { _logger.LogError(ex, "The Asterisk Stasis listener failed while forwarding simulation {SimulationKey} to Orchard.", key); @@ -199,8 +210,11 @@ private static async Task ForwardToOrchardAsync( request.Headers.TryAddWithoutValidation(RequestVerificationTokenHeaderName, pending.RequestVerificationToken); } - using var response = await pending.Client.SendAsync(request, cancellationToken); - var rawResponse = await response.Content.ReadAsStringAsync(cancellationToken); + using var timeoutCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCancellation.CancelAfter(_forwardTimeout); + + using var response = await pending.Client.SendAsync(request, timeoutCancellation.Token); + var rawResponse = await response.Content.ReadAsStringAsync(timeoutCancellation.Token); stopwatch.Stop(); var result = new InboundCallSimulationResult diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs index 62b25b646..73b385ba4 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs @@ -1,6 +1,7 @@ using System.Net.WebSockets; using System.Text; using System.Text.Json; +using System.Threading.Channels; using Microsoft.Extensions.Options; namespace CrestApps.OrchardCore.Asterisk.Web.Services; @@ -11,6 +12,9 @@ namespace CrestApps.OrchardCore.Asterisk.Web.Services; /// public sealed class AsteriskStasisEventForwarderService : BackgroundService { + private const int EventDispatchConcurrency = 8; + private const int EventQueueCapacity = 256; + private readonly AsteriskInboundSimulationCoordinator _coordinator; private readonly AsteriskDashboardBroadcastService _dashboardBroadcastService; private readonly AsteriskWebOptions _options; @@ -75,31 +79,93 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) private async Task ListenAsync(CancellationToken cancellationToken) { using var socket = new ClientWebSocket(); - await socket.ConnectAsync(AsteriskAriConnectionUtilities.CreateEventsUri(_options), cancellationToken); + var eventsUri = AsteriskAriConnectionUtilities.CreateEventsUri(_options); - var buffer = new byte[8 * 1024]; + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Connecting the inbound simulator Stasis listener to {EventsUri}.", + eventsUri); + } - while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) + await socket.ConnectAsync(eventsUri, cancellationToken); + + if (_logger.IsEnabled(LogLevel.Information)) { - using var message = new MemoryStream(); - WebSocketReceiveResult result; + _logger.LogInformation( + "Connected the inbound simulator Stasis listener for application {ApplicationName}.", + _options.AsteriskApplicationName); + } - do + var buffer = new byte[8 * 1024]; + var events = Channel.CreateBounded(new BoundedChannelOptions(EventQueueCapacity) + { + FullMode = BoundedChannelFullMode.Wait, + SingleReader = false, + SingleWriter = true, + }); + var dispatchers = Enumerable + .Range(0, EventDispatchConcurrency) + .Select(_ => DispatchEventsAsync(events.Reader, cancellationToken)) + .ToArray(); + + try + { + while (socket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested) { - result = await socket.ReceiveAsync(buffer, cancellationToken); + using var message = new MemoryStream(); + WebSocketReceiveResult result; - if (result.MessageType == WebSocketMessageType.Close) + do { - await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", cancellationToken); + result = await socket.ReceiveAsync(buffer, cancellationToken); + + if (result.MessageType == WebSocketMessageType.Close) + { + _logger.LogWarning( + "The inbound simulator Stasis listener received a close frame. Status={Status}, Description={Description}.", + result.CloseStatus, + result.CloseStatusDescription); + + await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closed", cancellationToken); + + return; + } - return; + message.Write(buffer, 0, result.Count); } + while (!result.EndOfMessage); - message.Write(buffer, 0, result.Count); + await events.Writer.WriteAsync( + Encoding.UTF8.GetString(message.ToArray()), + cancellationToken); } - while (!result.EndOfMessage); + } + finally + { + events.Writer.TryComplete(); + await Task.WhenAll(dispatchers); + } + } - await HandleEventAsync(Encoding.UTF8.GetString(message.ToArray()), cancellationToken); + private async Task DispatchEventsAsync( + ChannelReader events, + CancellationToken cancellationToken) + { + await foreach (var payload in events.ReadAllAsync(cancellationToken)) + { + try + { + await HandleEventAsync(payload, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "The inbound simulator failed to process an Asterisk Stasis event."); + } } } @@ -138,7 +204,17 @@ private async Task HandleEventAsync(string payload, CancellationToken cancellati return; } - await _coordinator.TryDispatchAsync(simulationKey, channelIdElement.GetString(), cancellationToken); + var dispatched = await _coordinator.TryDispatchAsync( + simulationKey, + channelIdElement.GetString(), + cancellationToken); + + if (!dispatched && _logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Ignored StasisStart for simulation {SimulationKey} because no pending simulator request matched it.", + simulationKey); + } } private static string TryGetSimulationKey(JsonElement root) diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/InboundCallSimulatorService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/InboundCallSimulatorService.cs index 5d231bdf1..8ed5815b8 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/InboundCallSimulatorService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/InboundCallSimulatorService.cs @@ -13,11 +13,15 @@ namespace CrestApps.OrchardCore.Asterisk.Web.Services; /// public sealed class InboundCallSimulatorService { + private static readonly TimeSpan _stasisReconciliationDelay = TimeSpan.FromMilliseconds(250); + private const int StasisReconciliationAttempts = 20; + private readonly OrchardSignInClient _signInClient; private readonly IHttpClientFactory _httpClientFactory; private readonly AsteriskInboundSimulationCoordinator _coordinator; private readonly AsteriskWebOptions _options; private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -27,18 +31,21 @@ public sealed class InboundCallSimulatorService /// The Stasis simulation coordinator. /// The configured sample app options. /// The time provider. + /// The logger. public InboundCallSimulatorService( OrchardSignInClient signInClient, IHttpClientFactory httpClientFactory, AsteriskInboundSimulationCoordinator coordinator, IOptions options, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) { _signInClient = signInClient; _httpClientFactory = httpClientFactory; _coordinator = coordinator; _options = options.Value; _timeProvider = timeProvider; + _logger = logger; } /// @@ -141,10 +148,140 @@ private async Task SendAsync( _coordinator.SetOriginatedChannel(simulationKey, origination.ChannelId); - return await _coordinator.WaitForCompletionAsync( + using var reconciliationCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var reconciliationTask = ReconcileStasisStartAsync( simulationKey, - TimeSpan.FromSeconds(Math.Max(10, _options.SimulationTimeoutSeconds)), - cancellationToken); + origination.ChannelId, + reconciliationCancellation.Token); + + try + { + return await _coordinator.WaitForCompletionAsync( + simulationKey, + TimeSpan.FromSeconds(Math.Max(10, _options.SimulationTimeoutSeconds)), + cancellationToken); + } + finally + { + await reconciliationCancellation.CancelAsync(); + + try + { + await reconciliationTask; + } + catch (OperationCanceledException) when (reconciliationCancellation.IsCancellationRequested) + { + } + } + } + + private async Task ReconcileStasisStartAsync( + string simulationKey, + string channelId, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(channelId)) + { + return; + } + + var client = _httpClientFactory.CreateClient(); + AsteriskAriConnectionUtilities.ApplyBasicAuthentication(client, _options); + + for (var attempt = 0; attempt < StasisReconciliationAttempts; attempt++) + { + await Task.Delay(_stasisReconciliationDelay, cancellationToken); + + HttpResponseMessage response; + + try + { + response = await client.GetAsync( + $"channels/{Uri.EscapeDataString(channelId)}", + cancellationToken); + } + catch (HttpRequestException ex) + { + _logger.LogWarning( + ex, + "Unable to query Asterisk while reconciling inbound simulation {SimulationKey}.", + simulationKey); + + return; + } + + using (response) + { + if (!response.IsSuccessStatusCode) + { + continue; + } + + var rawResponse = await response.Content.ReadAsStringAsync(cancellationToken); + + if (!IsMatchingStasisChannel(rawResponse, simulationKey)) + { + continue; + } + } + + if (await _coordinator.TryDispatchAsync(simulationKey, channelId, cancellationToken)) + { + _logger.LogWarning( + "Recovered inbound simulation {SimulationKey} from authoritative Asterisk channel state after its StasisStart event was missed.", + simulationKey); + } + + return; + } + } + + private bool IsMatchingStasisChannel(string rawResponse, string simulationKey) + { + if (string.IsNullOrWhiteSpace(rawResponse)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(rawResponse); + + if (!document.RootElement.TryGetProperty("dialplan", out var dialplan) || + !dialplan.TryGetProperty("app_name", out var applicationName) || + !string.Equals(applicationName.GetString(), "Stasis", StringComparison.OrdinalIgnoreCase) || + !dialplan.TryGetProperty("app_data", out var applicationData)) + { + return false; + } + + var appData = applicationData.GetString(); + + if (string.IsNullOrWhiteSpace(appData)) + { + return false; + } + + var applicationArguments = appData.Split( + ',', + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + return applicationArguments.Contains( + _options.AsteriskApplicationName, + StringComparer.Ordinal) && + applicationArguments.Contains( + $"sim:{simulationKey}", + StringComparer.Ordinal); + } + catch (JsonException ex) + { + _logger.LogWarning( + ex, + "Asterisk returned an invalid channel payload while reconciling inbound simulation {SimulationKey}.", + simulationKey); + + return false; + } } private async Task OriginateAsteriskAsync( diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/OrchardSignInClient.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/OrchardSignInClient.cs index 86b1dd7b5..49b6a14f8 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/OrchardSignInClient.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/OrchardSignInClient.cs @@ -187,7 +187,8 @@ private static bool LooksLikeLoginPage(Uri requestUri, string responseContent, s var normalizedLoginPath = NormalizePath(loginPath); var normalizedRequestPath = NormalizePath(requestUri.AbsolutePath); - return normalizedRequestPath == normalizedLoginPath && + return (normalizedRequestPath == normalizedLoginPath || + normalizedRequestPath.EndsWith(normalizedLoginPath, StringComparison.OrdinalIgnoreCase)) && responseContent.Contains("password", StringComparison.OrdinalIgnoreCase) && responseContent.Contains(" new("InMemory", "InMemory"); @@ -34,8 +38,17 @@ public TelephonyCapabilities Capabilities } } - public Task DialAsync(DialRequest request, CancellationToken cancellationToken = default) + public async Task DialAsync(DialRequest request, CancellationToken cancellationToken = default) { + Interlocked.Increment(ref _dialRequestCount); + + var delayMilliseconds = Volatile.Read(ref _dialDelayMilliseconds); + + if (delayMilliseconds > 0) + { + await Task.Delay(delayMilliseconds, cancellationToken); + } + var call = new TelephonyCall { CallId = $"call-{Interlocked.Increment(ref _counter)}", @@ -51,7 +64,7 @@ public Task DialAsync(DialRequest request, CancellationToken ca _latestCall = call; _latestCallPublished = false; - return Task.FromResult(TelephonyResult.Success(call)); + return TelephonyResult.Success(call); } public Task HangupAsync(CallReference call, CancellationToken cancellationToken = default) @@ -138,25 +151,36 @@ public Task GetClientCredentialsAsync(CancellationTo return Task.FromResult(new TelephonyClientCredentials { ProviderName = "InMemory" }); } - public Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default) + public async Task GetCallStateAsync( + string callId, + CancellationToken cancellationToken = default) { + Interlocked.Increment(ref _lookupRequestCount); + if (string.IsNullOrEmpty(callId) || !_latestCallPublished || !_calls.TryGetValue(callId, out var call)) { - return Task.FromResult(new TelephonyCallLookupResult + return new TelephonyCallLookupResult { Succeeded = true, Found = false, - }); + }; } - return Task.FromResult(new TelephonyCallLookupResult + var delayMilliseconds = Volatile.Read(ref _lookupDelayMilliseconds); + + if (delayMilliseconds > 0) + { + await Task.Delay(delayMilliseconds, cancellationToken); + } + + return new TelephonyCallLookupResult { Succeeded = true, Found = true, Call = call, - }); + }; } public TelephonyCall GetLatestCall() @@ -195,6 +219,26 @@ public TelephonyCall DisconnectLatestCall() return _latestCall; } + public int GetDialRequestCount() + { + return Volatile.Read(ref _dialRequestCount); + } + + public void SetDialDelay(int milliseconds) + { + Volatile.Write(ref _dialDelayMilliseconds, Math.Max(0, milliseconds)); + } + + public int GetCallLookupRequestCount() + { + return Volatile.Read(ref _lookupRequestCount); + } + + public void SetCallLookupDelay(int milliseconds) + { + Volatile.Write(ref _lookupDelayMilliseconds, Math.Max(0, milliseconds)); + } + private Task Update(string callId, Action mutate) { if (callId is null || !_calls.TryGetValue(callId, out var call)) diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs index 5c2955ed5..ad07e649a 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/TestTelephonyHub.cs @@ -92,6 +92,30 @@ public Task GetCapabilities() return Task.FromResult((int)_provider.Capabilities); } + public Task GetDialRequestCount() + { + return Task.FromResult(_provider.GetDialRequestCount()); + } + + public Task SetDialDelay(int milliseconds) + { + _provider.SetDialDelay(milliseconds); + + return Task.CompletedTask; + } + + public Task GetCallLookupRequestCount() + { + return Task.FromResult(_provider.GetCallLookupRequestCount()); + } + + public Task SetCallLookupDelay(int milliseconds) + { + _provider.SetCallLookupDelay(milliseconds); + + return Task.CompletedTask; + } + public Task GetActiveCall() { var call = _provider.GetLatestCall(); diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs index 75e5d2611..cfd4c6821 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs @@ -77,11 +77,55 @@ public async Task Dial_ThenHangup_TransitionsSoftPhoneUi() // Act - hang up await page.ClickAsync("[data-telephony-hangup]"); - // Assert - back to idle + // Assert - the command acknowledgement does not change the state. + Assert.Equal("In call", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + + // Act - publish the provider-authoritative terminal state. + await PublishLatestCallStateAsync(page); + + // Assert - back to idle after the provider event. await page.Locator("[data-telephony-dial]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); Assert.True(await page.Locator("[data-telephony-hangup]").IsHiddenAsync()); } + [Fact] + public async Task Dial_WhileCommandIsPending_SendsOnlyOneRequest() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + await page.FillAsync("[data-telephony-number]", "+15551234567"); + + var baselineCount = await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('GetDialRequestCount')"); + await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('SetDialDelay', 500)"); + + // Act + await page.ClickAsync("[data-telephony-dial]"); + await page.EvaluateAsync("() => document.querySelector('[data-telephony-dial]').click()"); + + // Assert + Assert.True(await page.Locator("[data-telephony-dial]").IsDisabledAsync()); + await page.WaitForTimeoutAsync(100); + + var pendingCount = await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('GetDialRequestCount')"); + + Assert.Equal(baselineCount + 1, pendingCount); + + await page.Locator("[data-telephony-dial]").WaitForAsync(new LocatorWaitForOptions + { + State = WaitForSelectorState.Visible, + }); + await page.WaitForFunctionAsync( + "() => !document.querySelector('[data-telephony-dial]').disabled"); + await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('SetDialDelay', 0)"); + } + [Fact] public async Task Dial_ThenHold_ShowsResumeControl() { @@ -98,6 +142,7 @@ public async Task Dial_ThenHold_ShowsResumeControl() // Act await page.ClickAsync("[data-telephony-hold]"); + await PublishLatestCallStateAsync(page); // Assert await page.Locator("[data-telephony-resume]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); @@ -130,6 +175,87 @@ await page.EvaluateAsync( Assert.Equal("Ready", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); } + [Fact] + public async Task StaleDisconnectForPreviousCall_DoesNotClearCurrentCall() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + await page.FillAsync("[data-telephony-number]", "+15551234567"); + await page.ClickAsync("[data-telephony-dial]"); + await PublishLatestCallStateAsync(page); + var previousCallId = await GetCurrentCallIdAsync(page); + await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('DisconnectLatestCall')"); + await page.Locator("[data-telephony-dial]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + + await page.FillAsync("[data-telephony-number]", "+15557654321"); + await page.ClickAsync("[data-telephony-dial]"); + await PublishLatestCallStateAsync(page); + var currentCallId = await GetCurrentCallIdAsync(page); + + // Act + await page.EvaluateAsync( + """ + ([callId]) => window.telephonySoftPhone.getInstance().getConnection().invoke( + 'PublishCallState', + { + callId, + direction: 0, + state: 5, + providerName: 'InMemory' + }) + """, + new[] { previousCallId }); + + // Assert + Assert.NotEqual(previousCallId, currentCallId); + Assert.Equal(currentCallId, await GetCurrentCallIdAsync(page)); + Assert.Equal("In call", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + Assert.True(await page.Locator("[data-telephony-hangup]").IsVisibleAsync()); + } + + [Fact] + public async Task ProviderEventDuringActiveCallRestoration_WinsOverStaleLookup() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + await page.FillAsync("[data-telephony-number]", "+15551234567"); + await page.ClickAsync("[data-telephony-dial]"); + await PublishLatestCallStateAsync(page); + var connection = "window.telephonySoftPhone.getInstance().getConnection()"; + var baselineLookupCount = await page.EvaluateAsync( + $"() => {connection}.invoke('GetCallLookupRequestCount')"); + await page.EvaluateAsync( + $"() => {connection}.invoke('SetCallLookupDelay', 500)"); + + await page.ReloadAsync(); + await WaitForConnectedAsync(page); + await page.WaitForFunctionAsync( + """ + async baseline => { + const connection = window.telephonySoftPhone.getInstance().getConnection(); + return await connection.invoke('GetCallLookupRequestCount') > baseline; + } + """, + baselineLookupCount); + + // Act + await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('DisconnectLatestCall')"); + + // Assert + await page.Locator("[data-telephony-dial]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + await page.WaitForTimeoutAsync(600); + Assert.Equal("Ready", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + Assert.True(await page.Locator("[data-telephony-hangup]").IsHiddenAsync()); + } + [Fact] public async Task RecentTab_ShowsCallHistory_AndHidesKeypad() { @@ -396,4 +522,10 @@ private static async Task GetConfiguredHeightAsync(IPage page) return await page.Locator("#telephony-soft-phone").EvaluateAsync( "element => element.style.getPropertyValue('--telephony-view-height').trim()"); } + + private static async Task GetCurrentCallIdAsync(IPage page) + { + return await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getCurrentCall().callId"); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs index f58002879..a8b784832 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs @@ -38,7 +38,7 @@ public async Task DialAsync_WhenConfigured_PostsToAsteriskAriWithBasicAuthentica } [Fact] - public async Task DialAsync_WhenUsingLocalEndpoint_ReturnsConnectedStateForLocalSimulation() + public async Task DialAsync_WhenUsingLocalEndpoint_ReturnsConnectingUntilProviderEventArrives() { // Arrange var handler = new StubHttpMessageHandler(HttpStatusCode.OK, "{\"id\":\"call-1\"}"); @@ -50,7 +50,7 @@ public async Task DialAsync_WhenUsingLocalEndpoint_ReturnsConnectedStateForLocal // Assert Assert.True(result.Succeeded); Assert.NotNull(result.Call); - Assert.Equal(CallState.Connected, result.Call.State); + Assert.Equal(CallState.Connecting, result.Call.State); Assert.Equal( $"{BaseUrl}channels?endpoint=Local%2F1000@default&timeout=30&app=crestapps-telephony&callerId=%2B15550000000", handler.LastRequest.RequestUri.AbsoluteUri); @@ -268,7 +268,52 @@ public async Task GetCallAsync_WhenChannelIsHeldAndMuted_RecoversGranularProvide Assert.NotNull(call); Assert.Equal(CallState.OnHold, call.State); Assert.True(call.IsMuted); - Assert.Equal(3, handler.Requests.Count); + Assert.Equal(4, handler.Requests.Count); + } + + [Fact] + public async Task GetCallAsync_WhenChannelDisappearsDuringLookup_ReturnsNotFound() + { + // Arrange + var channelRequestCount = 0; + var handler = new StubHttpMessageHandler(request => + { + if (request.RequestUri.AbsoluteUri == $"{BaseUrl}channels/call-1") + { + channelRequestCount++; + + if (channelRequestCount > 1) + { + return new HttpResponseMessage(HttpStatusCode.NotFound); + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent( + """ + { + "id": "call-1", + "state": "Up" + } + """), + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"value":"false"}"""), + }; + }); + var provider = CreateProvider(handler, out _, isEnabled: true); + + // Act + var result = await provider.GetCallStateAsync("call-1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.False(result.Found); + Assert.Null(result.Call); + Assert.Equal(4, handler.Requests.Count); } [Fact] diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs index 0fa7d7e50..79c3cd1a0 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/TelephonyInteractionSynchronizationServiceTests.cs @@ -53,6 +53,40 @@ public async Task GetActiveCallAsync_WhenProviderNoLongerHasCall_DeletesOrphaned Times.Once); } + [Fact] + public async Task GetActiveCallAsync_WhenProviderReturnsActiveCall_DoesNotPublishDuplicateStateEvent() + { + // Arrange + var interaction = CreateInteraction(); + var store = new Mock(); + store + .Setup(value => value.FindActiveByUserAsync("user-1", It.IsAny())) + .ReturnsAsync(interaction); + var (hubContext, client) = CreateHubContext(); + var service = CreateService( + store, + hubContext, + new TelephonyCallLookupResult + { + Succeeded = true, + Found = true, + Call = new TelephonyCall + { + CallId = "call-1", + State = CallState.Connected, + ProviderName = "provider-1", + }, + }); + + // Act + var result = await service.GetActiveCallAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.True(result.Found); + client.Verify(value => value.CallStateChanged(It.IsAny()), Times.Never); + } + [Fact] public async Task GetActiveCallAsync_WhenProviderLookupFails_PreservesInteraction() { @@ -83,6 +117,42 @@ public async Task GetActiveCallAsync_WhenProviderLookupFails_PreservesInteractio client.Verify(value => value.CallStateChanged(It.IsAny()), Times.Never); } + [Fact] + public async Task GetActiveCallAsync_WhenProviderIsNoLongerRegistered_DeletesOrphanedInteraction() + { + // Arrange + var interaction = CreateInteraction(); + var store = new Mock(); + store + .Setup(value => value.FindActiveByUserAsync("user-1", It.IsAny())) + .ReturnsAsync(interaction); + store + .Setup(value => value.DeleteAsync(interaction, It.IsAny())) + .Returns(Task.CompletedTask); + var (hubContext, client) = CreateHubContext(); + client + .Setup(value => value.CallStateChanged(It.IsAny())) + .Returns(Task.CompletedTask); + var service = CreateService( + store, + hubContext, + new TelephonyCallLookupResult(), + providerRegistered: false); + + // Act + var result = await service.GetActiveCallAsync("user-1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.False(result.Found); + store.Verify(value => value.DeleteAsync(interaction, It.IsAny()), Times.Once); + client.Verify( + value => value.CallStateChanged(It.Is(call => + call.CallId == "call-1" && + call.State == CallState.Disconnected)), + Times.Once); + } + [Fact] public async Task GetActiveCallAsync_WhenNewCallIsStillPropagating_PreservesInteraction() { @@ -179,7 +249,8 @@ private static TelephonyInteractionSynchronizationService CreateService( Mock store, Mock> hubContext, TelephonyCallLookupResult lookup, - bool lockAcquired = false) + bool lockAcquired = false, + bool providerRegistered = true) { var provider = new Mock(); provider @@ -187,7 +258,16 @@ private static TelephonyInteractionSynchronizationService CreateService( .Setup(value => value.GetCallStateAsync("call-1", It.IsAny())) .ReturnsAsync(lookup); var resolver = new Mock(); - resolver.Setup(value => value.GetAsync("provider-1")).ReturnsAsync(provider.Object); + + if (providerRegistered) + { + resolver.Setup(value => value.GetAsync("provider-1")).ReturnsAsync(provider.Object); + } + else + { + resolver.Setup(value => value.GetAsync("provider-1")).ReturnsAsync((ITelephonyProvider)null); + } + var distributedLock = new Mock(); distributedLock .Setup(value => value.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) From 22180ad68be1f59df940328d68dfee2b69298d82 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 16:23:25 -0700 Subject: [PATCH 48/56] Fix inbound telephony release synchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 4 +- .../Services/ProviderVoiceEventService.cs | 57 ++++++- ...roviderVoiceOfferSynchronizationService.cs | 31 +++- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 5 + .../docs/contact-center/voice-routing.md | 4 +- src/CrestApps.Docs/docs/telephony/asterisk.md | 4 +- .../Services/AsteriskTelephonyProviderBase.cs | 23 ++- ...ctCenterVoiceOfferReconciliationHandler.cs | 3 +- .../Models/InboundCallSimulationInputModel.cs | 2 +- .../Pages/Simulator.cshtml | 3 +- .../Pages/Simulator.cshtml.cs | 2 +- .../AsteriskDashboardBroadcastService.cs | 2 +- .../Services/AsteriskDiagnosticsService.cs | 29 +++- .../ProviderVoiceEventServiceTests.cs | 142 ++++++++++++++++++ ...erVoiceOfferSynchronizationServiceTests.cs | 139 +++++++++++++++++ .../AsteriskTelephonyProviderTests.cs | 19 +++ 16 files changed, 437 insertions(+), 32 deletions(-) diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index ed6b7a991..64be89f44 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1484,8 +1484,8 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 4 — Voice integration with Telephony (`Voice` feature: Voice Contact Center Call Router (`IVoiceContactCenterCallRouter`) for inbound and outbound voice routing, inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService` compatibility), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, outbound provider dispatch through `IContactCenterVoiceProvider`, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; **G1 backend shipped: provider delivery models (`AgentDeviceNative`/`ServerSideAcd`) + `ConnectToAgentAsync`, `CallSession` aggregate, normalized `ProviderVoiceEvent`/`IProviderVoiceEventService` ingestion, and the authoritative `IContactCenterCallCommandService` that accepts the reservation, bridges media, and advances interaction+call-session together. all G1 items shipped: the soft-phone JS accept-then-answer coordination now awaits the server accept and only answers the device when `RequiresDeviceAnswer` (asset rebuilt), per-provider signed webhook adapters emit `ProviderVoiceEvent`, and the blind/consultative transfer + conference taxonomy landed** — see "Design review" P0 #1, #2, #3 and P1 #9) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider. **G4 dialer safety shipped (2026-06-30):** each mode is now a dedicated `IDialerStrategy` (Predictive disabled in editor + rejected server-side + refused at runtime; Power hard-capped via `PowerDialerStrategy.MaxCallsPerAgent`); the new `IDialerEligibilityService` compliance gate runs before every attempt and audits `DialSuppressed` (destination, max-attempts, retry cool-down, contact do-not-call, calling window in the contact's time zone, and national DNC registries); single-attempt logic moved to `IDialerAttemptService`. Remaining: callback scheduling (`CallbackRequest`) + callback queues, and dialer run/attempt projections.) - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call work is represented by agent presence (`WrapUp`) plus the active/wrap-up interaction on the Agent Workspace, not by a separate domain aggregate.) -- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. **2026-07-11 event-ordering hardening:** command-triggered active-call polling was removed, page-restoration lookups now yield to newer provider events, stale terminal events cannot clear a newer call id, and Asterisk lookup verifies the channel still exists after its multi-request snapshot. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) -- [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. **Local test harness shipped 2026-07-08:** `src/Startup/CrestApps.OrchardCore.Asterisk.Web` signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards normalized `InboundVoiceEvent` payloads using the real Asterisk channel ids so routing and agent offers can be exercised through the local PBX flow. The sample dashboard now also surfaces provider-tracked hold/mute state plus inferred party counts, moves live notifications beside the raw ARI payload drill-down to free more width for active-call tables, and lowers the default polling cadence to one second so state changes show up faster. The simulator now makes it explicit that **To address** drives inbound queue or entry-point routing while **Caller number seed** only changes caller identity generation. **2026-07-08 queue hardening:** queues now expose an unanswered-offer policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; when no live provider call exists the timeout safely falls back to requeue. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) +- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. **2026-07-11 event-ordering hardening:** command-triggered active-call polling was removed, page-restoration lookups now yield to newer provider events, stale terminal events cannot clear a newer call id, and Asterisk lookup verifies the channel still exists after its multi-request snapshot. **2026-07-11 inbound terminal repair:** provider-event ingestion now recovers provider-name aliases by call id, canonicalizes the live provider identity, moves answered calls into wrap-up, and completes their assigned queue item so a missed provider-name match cannot leave the agent Busy or block later queue work. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) +- [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. **Local test harness shipped 2026-07-08:** `src/Startup/CrestApps.OrchardCore.Asterisk.Web` signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards normalized `InboundVoiceEvent` payloads using the real Asterisk channel ids so routing and agent offers can be exercised through the local PBX flow. The sample dashboard now also surfaces provider-tracked hold/mute state plus inferred party counts, moves live notifications beside the raw ARI payload drill-down to free more width for active-call tables, and lowers the default polling cadence to one second so state changes show up faster. The simulator now makes it explicit that **To address** drives inbound queue or entry-point routing while **Caller number seed** only changes caller identity generation. **2026-07-08 queue hardening:** queues now expose an unanswered-offer policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; when no live provider call exists the timeout safely falls back to requeue. **2026-07-11 Asterisk sample synchronization:** the simulator now enforces the configured provider identity instead of accepting a stale form alias, and the dashboard refreshes independent ARI resources plus per-channel hold/mute enrichment concurrently after a short live-event coalescing window. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs index 5e44c6b0b..56f3a8875 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs @@ -1,5 +1,6 @@ using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; using Microsoft.Extensions.Logging; using OrchardCore.Modules; @@ -13,6 +14,7 @@ public sealed class ProviderVoiceEventService : IProviderVoiceEventService 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; @@ -25,6 +27,7 @@ public sealed class ProviderVoiceEventService : IProviderVoiceEventService /// 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. @@ -34,6 +37,7 @@ public ProviderVoiceEventService( IInteractionManager interactionManager, ICallSessionManager callSessionManager, IContactCenterVoiceProviderResolver voiceProviderResolver, + ITelephonyProviderResolver telephonyProviderResolver, IInteractionEventStore eventStore, IContactCenterEventPublisher publisher, IAgentPresenceManager presenceManager, @@ -43,6 +47,7 @@ public ProviderVoiceEventService( _interactionManager = interactionManager; _callSessionManager = callSessionManager; _voiceProviderResolver = voiceProviderResolver; + _telephonyProviderResolver = telephonyProviderResolver; _eventStore = eventStore; _publisher = publisher; _presenceManager = presenceManager; @@ -60,12 +65,22 @@ public async Task IngestAsync(ProviderVoiceEvent providerEvent, Can return null; } - var interaction = !string.IsNullOrWhiteSpace(providerEvent.ProviderName) - ? await _interactionManager.FindByProviderInteractionIdAsync( + Interaction interaction = null; + var matchedByCallIdOnly = false; + + if (!string.IsNullOrWhiteSpace(providerEvent.ProviderName)) + { + interaction = await _interactionManager.FindByProviderInteractionIdAsync( providerEvent.ProviderName, providerEvent.ProviderCallId, - cancellationToken) - : await _interactionManager.FindByProviderInteractionIdAsync(providerEvent.ProviderCallId, cancellationToken); + cancellationToken); + } + + if (interaction is null) + { + interaction = await _interactionManager.FindByProviderInteractionIdAsync(providerEvent.ProviderCallId, cancellationToken); + matchedByCallIdOnly = interaction is not null; + } if (interaction is null) { @@ -79,6 +94,34 @@ public async Task IngestAsync(ProviderVoiceEvent providerEvent, Can 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, + providerEvent.ProviderName, + interaction.ProviderName); + + return null; + } + + 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, + providerEvent.ProviderName, + interaction.ProviderName); + + interaction.ProviderName = providerEvent.ProviderName; + } + if (!string.IsNullOrEmpty(providerEvent.IdempotencyKey) && await _eventStore.ExistsByIdempotencyKeyAsync(providerEvent.IdempotencyKey, cancellationToken)) { @@ -279,6 +322,12 @@ ContactCenterCallState.Canceled or private static void ApplyProviderDetails(CallSession session, Interaction interaction, ProviderVoiceEvent providerEvent) { + if (!string.IsNullOrWhiteSpace(providerEvent.ProviderName)) + { + session.ProviderName = providerEvent.ProviderName; + interaction.ProviderName = providerEvent.ProviderName; + } + if (!string.IsNullOrWhiteSpace(providerEvent.FromAddress)) { session.FromAddress = providerEvent.FromAddress; diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs index 74f801d6c..ddd4e6ba8 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs @@ -8,8 +8,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// -/// Releases stale routing state when provider truth reports that a queued or offered call ended before it -/// was actually answered. +/// Reconciles routing state when provider truth reports that a queued, offered, or assigned call ended. /// public sealed class ProviderVoiceOfferSynchronizationService : IProviderVoiceOfferSynchronizationService { @@ -65,19 +64,29 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok return; } - if (interaction.Status is not InteractionStatus.Ended and not InteractionStatus.Failed) + var session = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + + if (interaction.Status is not InteractionStatus.Ended and not InteractionStatus.Failed && + !IsTerminalState(session?.State)) { return; } - var session = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); + var wasAnswered = interaction.AnsweredUtc.HasValue || session?.AnsweredUtc.HasValue == true; + var queueItem = await _queueItemManager.FindByActivityIdAsync(interaction.ActivityItemId, cancellationToken); - if (interaction.AnsweredUtc.HasValue || session?.AnsweredUtc.HasValue == true) + if (wasAnswered) { + if (queueItem?.Status == QueueItemStatus.Assigned) + { + queueItem.Status = QueueItemStatus.Completed; + queueItem.DequeuedUtc = _clock.UtcNow; + await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + } + return; } - var queueItem = await _queueItemManager.FindByActivityIdAsync(interaction.ActivityItemId, cancellationToken); ActivityReservation reservation = null; if (!string.IsNullOrWhiteSpace(queueItem?.ReservationId)) @@ -149,4 +158,14 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok activity.ReservationExpiresUtc = null; await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); } + + private static bool IsTerminalState(ContactCenterCallState? state) + { + return state is ContactCenterCallState.Ended or + ContactCenterCallState.Failed or + ContactCenterCallState.NoAnswer or + ContactCenterCallState.Rejected or + ContactCenterCallState.Canceled or + ContactCenterCallState.Transferred; + } } diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index aa894e010..53a8d5304 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -91,6 +91,11 @@ At a high level, the platform changes are: - 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. ### Artificial Intelligence diff --git a/src/CrestApps.Docs/docs/contact-center/voice-routing.md b/src/CrestApps.Docs/docs/contact-center/voice-routing.md index 174f5d812..c9b9953dc 100644 --- a/src/CrestApps.Docs/docs/contact-center/voice-routing.md +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -240,7 +240,9 @@ That contract carries the authoritative server-side facts Contact Center cares a - conference state - idempotency key -`ProviderVoiceEventService` ingests those events idempotently and updates the durable interaction/session projection. Provider name and call id are queried together so identical call ids from two providers cannot collide. 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. +`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 diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md index 968877881..2a4dc667b 100644 --- a/src/CrestApps.Docs/docs/telephony/asterisk.md +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -126,7 +126,7 @@ Because all requests are issued server-side, the ARI password never reaches the 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 browser polling. Command acknowledgements do not update or re-query the browser call state; the corresponding ARI event drives the transition. Provider lookup also verifies that the channel still exists after reading hold and mute variables, preventing a channel destroyed during the multi-request lookup from being reported as connected. 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. +ARI events are normalized into provider-authoritative call states and projected through Orchard to connected soft-phone clients without 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 and mute variables, preventing a channel destroyed during the multi-request lookup from being reported as connected. 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 startup 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. @@ -182,7 +182,7 @@ Visiting `http://localhost:8088/` returns **Not Found** by design because the co The default Aspire endpoint template uses `Local/{number}@default`, which loops the originated call back into the bundled demo dialplan. That local development path now 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 still stays inside Asterisk. -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 **Inbound Simulator** for burst testing. The dashboard now treats the Asterisk Stasis event stream as the primary update path: when live events arrive, the server immediately refreshes ARI state and pushes the new snapshot to connected browsers over SignalR. A slower 15-second reconciliation refresh remains in place so the dashboard can recover from missed events or transient SignalR disconnects. The dashboard still 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. 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, **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. +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 **Inbound Simulator** for burst testing. The dashboard treats the Asterisk Stasis event stream as the primary update path: when live events arrive, the server coalesces only a short event burst, reads the independent ARI diagnostics endpoints concurrently, enriches all active channels concurrently, and pushes the new snapshot to connected browsers over SignalR. A slower 15-second reconciliation refresh remains in place so the dashboard can recover from missed events or transient SignalR disconnects. The dashboard still 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. 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. Production deployments should supply their own ARI credentials, dialplan, endpoints, and media/network configuration. diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs index 4e73ecc0a..c84b118b4 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs @@ -302,7 +302,8 @@ public async Task GetCallStateAsync(string callId, Ca } public Task HangupAsync(CallReference call, CancellationToken cancellationToken = default) - => ExecuteCallActionAsync( + { + return ExecuteCallActionAsync( call?.CallId, HttpMethod.Delete, "channels/{callId}", @@ -311,7 +312,9 @@ public Task HangupAsync(CallReference call, CancellationToken c () => BuildCall(call?.CallId, CallState.Disconnected, metadata: call?.Metadata), S["The call could not be ended."].Value, S["A call id is required to end the call."].Value, - cancellationToken); + cancellationToken, + succeedWhenChannelIsMissing: true); + } public Task HoldAsync(CallReference call, CancellationToken cancellationToken = default) => ExecuteCallActionAsync( @@ -635,7 +638,8 @@ private async Task ExecuteCallActionAsync( Func onSuccess, string errorMessage, string missingCallIdMessage, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + bool succeedWhenChannelIsMissing = false) { if (string.IsNullOrWhiteSpace(callId)) { @@ -657,6 +661,19 @@ private async Task ExecuteCallActionAsync( if (!response.IsSuccessStatusCode) { + if (succeedWhenChannelIsMissing && response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Asterisk call action for provider {ProviderName} reached the requested terminal state because channel {CallId} no longer exists.", + ProviderName, + callId); + } + + return TelephonyResult.Success(onSuccess?.Invoke()); + } + var responseBody = await ReadResponseBodyAsync(response, cancellationToken); _logger.LogError( diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs index 337b4eb30..b8afc9182 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterVoiceOfferReconciliationHandler.cs @@ -4,8 +4,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Handlers; /// -/// Clears stale queue and reservation state when provider truth reports that an offered voice call ended -/// before it was actually answered. +/// Reconciles queue and reservation state when provider truth reports that a voice call ended. /// public sealed class ContactCenterVoiceOfferReconciliationHandler : IContactCenterEventHandler { diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationInputModel.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationInputModel.cs index 6efcff87a..fa0e4e0b1 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationInputModel.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationInputModel.cs @@ -49,7 +49,7 @@ public sealed class InboundCallSimulationInputModel /// [Required] [Display(Name = "Provider name")] - public string ProviderName { get; set; } = "Asterisk"; + public string ProviderName { get; set; } = "Default Asterisk"; /// /// Gets or sets the Asterisk extension or destination to dial when the simulator uses the loopback path. diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml index d0dbece61..52b2c5310 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml @@ -51,7 +51,8 @@
- + +
The simulator uses the configured Asterisk provider identity so ARI events and Contact Center interactions remain correlated.
diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs index e4d1dfc2f..0eb60ce91 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs @@ -90,7 +90,7 @@ private void ApplyDefaults(bool preferConfiguredValues) Input.OrchardBaseUrl = ResolveDefault(Input.OrchardBaseUrl, _options.OrchardBaseUrl, preferConfiguredValues); Input.LoginPath = ResolveDefault(Input.LoginPath, _options.LoginPath, preferConfiguredValues); Input.InboundPath = ResolveDefault(Input.InboundPath, _options.InboundPath, preferConfiguredValues); - Input.ProviderName = ResolveDefault(Input.ProviderName, _options.ProviderName, preferConfiguredValues); + Input.ProviderName = ResolveDefault(Input.ProviderName, _options.ProviderName, preferConfiguredValue: true); Input.AsteriskDestination = ResolveDefault(Input.AsteriskDestination, _options.AsteriskDestination, preferConfiguredValues); Input.ToAddress = ResolveDefault(Input.ToAddress, _options.ToAddress, preferConfiguredValues); Input.CallerNumberSeed = ResolveDefault(Input.CallerNumberSeed, _options.CallerNumberSeed, preferConfiguredValues); diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs index 5e4495442..6c99606ec 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs @@ -9,7 +9,7 @@ namespace CrestApps.OrchardCore.Asterisk.Web.Services; ///
public sealed class AsteriskDashboardBroadcastService : BackgroundService { - private static readonly TimeSpan EventCoalescingDelay = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan EventCoalescingDelay = TimeSpan.FromMilliseconds(50); private readonly AsteriskDiagnosticsService _asteriskDiagnosticsService; private readonly IHubContext _hubContext; diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs index 7998be983..8e011a025 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs @@ -101,9 +101,15 @@ private async Task LoadSnapshotAsync(CancellationTo try { - snapshot.InfoJson = await ReadJsonAsync(client, "asterisk/info", cancellationToken); - (snapshot.ChannelsJson, snapshot.Channels) = await ReadChannelsAsync(client, cancellationToken); - (snapshot.BridgesJson, snapshot.Bridges) = await ReadBridgesAsync(client, cancellationToken); + var infoTask = ReadJsonAsync(client, "asterisk/info", cancellationToken); + var channelsTask = ReadChannelsAsync(client, cancellationToken); + var bridgesTask = ReadBridgesAsync(client, cancellationToken); + + await Task.WhenAll(infoTask, channelsTask, bridgesTask); + + snapshot.InfoJson = await infoTask; + (snapshot.ChannelsJson, snapshot.Channels) = await channelsTask; + (snapshot.BridgesJson, snapshot.Bridges) = await bridgesTask; await EnrichChannelsAsync(client, snapshot.Channels, snapshot.Bridges, cancellationToken); snapshot.Calls = BuildCalls(snapshot.Channels, snapshot.Bridges); snapshot.ChannelCount = snapshot.Channels.Count; @@ -322,22 +328,29 @@ private static async Task EnrichChannelsAsync( .GroupBy(entry => entry.ChannelId, StringComparer.OrdinalIgnoreCase) .ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase); - foreach (var channel in channels) + var enrichmentTasks = channels.Select(async channel => { if (string.IsNullOrWhiteSpace(channel.Id)) { - continue; + return; } - channel.IsOnHold = await ReadBooleanVariableAsync(client, channel.Id, HoldStateVariableName, cancellationToken); - channel.IsMuted = await ReadBooleanVariableAsync(client, channel.Id, MuteStateVariableName, cancellationToken); + var holdTask = ReadBooleanVariableAsync(client, channel.Id, HoldStateVariableName, cancellationToken); + var muteTask = ReadBooleanVariableAsync(client, channel.Id, MuteStateVariableName, cancellationToken); + + await Task.WhenAll(holdTask, muteTask); + + channel.IsOnHold = await holdTask; + channel.IsMuted = await muteTask; if (bridgeMembership.TryGetValue(channel.Id, out var membership)) { channel.BridgeId = membership.Id; channel.BridgeType = membership.BridgeType; } - } + }); + + await Task.WhenAll(enrichmentTasks); } private static int GetPartyCount( diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs index cd7718e1a..9f1e0381b 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs @@ -2,6 +2,7 @@ using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; using Microsoft.Extensions.Logging.Abstractions; using Moq; using OrchardCore.Modules; @@ -10,6 +11,141 @@ namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; public sealed class ProviderVoiceEventServiceTests { + [Fact] + public async Task IngestAsync_WhenProviderNameChanged_FallsBackByCallIdAndCanonicalizesStoredProvider() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderName = "Asterisk", + ProviderInteractionId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Connected, + AnsweredUtc = new DateTime(2026, 7, 10, 14, 59, 0, DateTimeKind.Utc), + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderName = "Asterisk", + ProviderCallId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + State = ContactCenterCallState.Connected, + AnsweredUtc = interaction.AnsweredUtc, + }; + + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("Default Asterisk", "call-1", It.IsAny())) + .ReturnsAsync((Interaction)null); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("call-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByProviderCallIdAsync("Default Asterisk", "call-1", It.IsAny())) + .ReturnsAsync((CallSession)null); + callSessionManager + .Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(session); + + var eventStore = new Mock(); + eventStore + .Setup(store => store.ExistsByIdempotencyKeyAsync("ended-1", It.IsAny())) + .ReturnsAsync(false); + + var presenceManager = new Mock(); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + new Mock().Object, + eventStore.Object, + new Mock().Object, + presenceManager.Object, + clock.Object, + NullLogger.Instance); + + // Act + await service.IngestAsync(new ProviderVoiceEvent + { + ProviderName = "Default Asterisk", + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + IdempotencyKey = "ended-1", + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("Default Asterisk", interaction.ProviderName); + Assert.Equal("Default Asterisk", session.ProviderName); + Assert.Equal(InteractionStatus.Ended, interaction.Status); + Assert.Equal(ContactCenterCallState.Ended, session.State); + presenceManager.Verify( + manager => manager.StartWrapUpAsync("agent-1", It.IsAny()), + Times.Once); + } + + [Fact] + public async Task IngestAsync_WhenCallIdBelongsToAnotherActiveProvider_DoesNotCanonicalizeInteraction() + { + // Arrange + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderName = "ProviderA", + ProviderInteractionId = "shared-call-id", + Status = InteractionStatus.Connected, + }; + + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("ProviderB", "shared-call-id", It.IsAny())) + .ReturnsAsync((Interaction)null); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("shared-call-id", It.IsAny())) + .ReturnsAsync(interaction); + + var telephonyProviderResolver = new Mock(); + telephonyProviderResolver + .Setup(resolver => resolver.GetAsync("ProviderA")) + .ReturnsAsync(new Mock().Object); + + var callSessionManager = new Mock(); + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + telephonyProviderResolver.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + NullLogger.Instance); + + // Act + var result = await service.IngestAsync(new ProviderVoiceEvent + { + ProviderName = "ProviderB", + ProviderCallId = "shared-call-id", + State = ContactCenterCallState.Ended, + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(result); + Assert.Equal("ProviderA", interaction.ProviderName); + Assert.Equal(InteractionStatus.Connected, interaction.Status); + callSessionManager.Verify( + manager => manager.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvents() { @@ -66,6 +202,7 @@ public async Task IngestAsync_WithMuteAndRecordingChanges_PublishesDetailedEvent interactionManager.Object, callSessionManager.Object, voiceProviderResolver.Object, + new Mock().Object, eventStore.Object, publisher.Object, presenceManager.Object, @@ -162,6 +299,7 @@ public async Task IngestAsync_WhenCallResumesAndRecordingStops_PublishesResumeAn interactionManager.Object, callSessionManager.Object, voiceProviderResolver.Object, + new Mock().Object, eventStore.Object, publisher.Object, presenceManager.Object, @@ -246,6 +384,7 @@ public async Task IngestAsync_WithRealPublisher_PersistsEverySemanticEventWithAU interactionManager.Object, callSessionManager.Object, new Mock().Object, + new Mock().Object, eventStore.Object, publisher, new Mock().Object, @@ -317,6 +456,7 @@ public async Task IngestAsync_WhenEventIsOlderThanTerminalState_DoesNotReopenCal interactionManager.Object, callSessionManager.Object, new Mock().Object, + new Mock().Object, eventStore.Object, publisher.Object, new Mock().Object, @@ -384,6 +524,7 @@ public async Task IngestAsync_WhenSessionIsAlreadyTerminal_DoesNotRepublishLater interactionManager.Object, callSessionManager.Object, new Mock().Object, + new Mock().Object, eventStore.Object, publisher.Object, new Mock().Object, @@ -442,6 +583,7 @@ public async Task IngestAsync_WhenEventIsDuplicate_ReturnsExistingSession() interactionManager.Object, callSessionManager.Object, new Mock().Object, + new Mock().Object, eventStore.Object, new Mock().Object, new Mock().Object, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs index 435487fd8..03302006c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs @@ -122,4 +122,143 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue It.IsAny()), Times.Once); } + + [Fact] + public async Task ReconcileEndedOfferAsync_WhenAnsweredCallEnded_CompletesAssignedQueueItem() + { + // Arrange + var interaction = new Interaction + { + ItemId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + Status = InteractionStatus.Ended, + AnsweredUtc = new DateTime(2026, 7, 10, 11, 59, 0, DateTimeKind.Utc), + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + State = ContactCenterCallState.Ended, + AnsweredUtc = interaction.AnsweredUtc, + }; + var queueItem = new QueueItem + { + ItemId = "queue-1", + ActivityItemId = "act1", + ReservationId = "res-1", + Status = QueueItemStatus.Assigned, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(m => m.FindByInteractionIdAsync("int1", It.IsAny())).ReturnsAsync(session); + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + + var reservationManager = new Mock(); + var agentManager = new Mock(); + var activityManager = new Mock(); + var service = new ProviderVoiceOfferSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + queueItemManager.Object, + reservationManager.Object, + agentManager.Object, + activityManager.Object, + clock.Object, + new Mock>().Object); + + // Act + await service.ReconcileEndedOfferAsync("int1", TestContext.Current.CancellationToken); + + // Assert + queueItemManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == QueueItemStatus.Completed && value.DequeuedUtc.HasValue), + null, + It.IsAny()), + Times.Once); + reservationManager.Verify( + m => m.UpdateAsync(It.IsAny(), null, It.IsAny()), + Times.Never); + agentManager.Verify( + m => m.UpdateAsync(It.IsAny(), null, It.IsAny()), + Times.Never); + activityManager.Verify( + m => m.UpdateAsync(It.IsAny(), null, It.IsAny()), + Times.Never); + } + + [Fact] + public async Task ReconcileEndedOfferAsync_WhenAnsweredCallTransferred_CompletesAssignedQueueItem() + { + // Arrange + var answeredUtc = new DateTime(2026, 7, 10, 11, 59, 0, DateTimeKind.Utc); + var interaction = new Interaction + { + ItemId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + Status = InteractionStatus.Transferring, + AnsweredUtc = answeredUtc, + }; + var session = new CallSession + { + ItemId = "session-1", + InteractionId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + State = ContactCenterCallState.Transferred, + AnsweredUtc = answeredUtc, + }; + var queueItem = new QueueItem + { + ItemId = "queue-1", + ActivityItemId = "act1", + ReservationId = "res-1", + Status = QueueItemStatus.Assigned, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(m => m.FindByInteractionIdAsync("int1", It.IsAny())).ReturnsAsync(session); + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceOfferSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + queueItemManager.Object, + new Mock().Object, + new Mock().Object, + new Mock().Object, + clock.Object, + new Mock>().Object); + + // Act + await service.ReconcileEndedOfferAsync("int1", TestContext.Current.CancellationToken); + + // Assert + queueItemManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == QueueItemStatus.Completed && value.DequeuedUtc.HasValue), + null, + It.IsAny()), + Times.Once); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs index a8b784832..ec7f4f2b1 100644 --- a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs @@ -124,6 +124,25 @@ public async Task RejectAsync_WhenConfigured_DeletesChannel() Assert.Equal(HttpMethod.Delete, handler.LastRequest.Method); } + [Fact] + public async Task HangupAsync_WhenChannelAlreadyMissing_ReturnsDisconnectedSuccess() + { + // Arrange + var handler = new StubHttpMessageHandler(HttpStatusCode.NotFound, """{"message":"Channel not found"}"""); + var provider = CreateProvider(handler, out _, isEnabled: true); + + // Act + var result = await provider.HangupAsync( + new CallReference { CallId = "call-1" }, + TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + Assert.NotNull(result.Call); + Assert.Equal(CallState.Disconnected, result.Call.State); + Assert.Equal($"{BaseUrl}channels/call-1", handler.LastRequest.RequestUri.AbsoluteUri); + } + [Fact] public void Capabilities_WhenUsingLocalLoopback_KeepAdvancedActionsEnabled() { From 9b9df601d078e6b5bc7f4b1aad36f6ea71cf10a0 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 16:48:27 -0700 Subject: [PATCH 49/56] Fix recurring inbound call synchronization Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 1 + .../Services/AgentWorkStateHealingService.cs | 30 ++++++ ...ProviderCallStateSynchronizationService.cs | 36 +++++-- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 3 + .../docs/contact-center/voice-routing.md | 4 + .../Assets/js/soft-phone.js | 4 + .../wwwroot/scripts/soft-phone.js | 4 + .../wwwroot/scripts/soft-phone.min.js | 2 +- .../AsteriskAriConnectionUtilities.cs | 1 + .../SoftPhoneWidgetTests.cs | 36 +++++++ .../AgentWorkStateHealingServiceTests.cs | 102 +++++++++++++++++- ...derCallStateSynchronizationServiceTests.cs | 58 ++++++++++ 12 files changed, 267 insertions(+), 14 deletions(-) diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 64be89f44..d40176c44 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1508,6 +1508,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement ### Change log +- 2026-07-11: Corrected the recurring inbound zombie path found in live logs. Agent reset/sign-in healing now reconciles provider-backed work before touching routing state, retries stale provider aliases through the tenant's current default provider, terminalizes calls the provider no longer reports, and preserves calls the provider confirms are still active instead of resetting them to `Created` and requeueing answered work. The soft phone now performs an authoritative active-call refresh when a terminal event arrives for a different call id than its stale browser state. The Asterisk development dashboard event stream now subscribes to all visible ARI resources so channel and bridge changes request immediate SignalR snapshots. - 2026-07-11: Eliminated zombie soft-phone calls by making provider events and provider-state lookup the only authorities for active call state. Telephony command responses no longer mutate the browser or broadcast `CallStateChanged`; active-call restoration validates the provider instead of trusting `TelephonyInteraction.Outcome`, and a new locked reconciliation service runs at tenant startup, every minute, after Asterisk listener reconnects, and during soft-phone reconnect/page restoration. Provider-confirmed missing calls delete orphaned in-progress Telephony records and disconnect connected clients, while transient lookup failures preserve the record for retry. Asterisk terminal projection now ignores duplicate terminal events and ambiguous down-state bridge-leave snapshots, and duplicate Contact Center events retain Contact Center ownership. - Codebase analysis completed for Telephony, Omnichannel Core, Omnichannel Management, SMS automation, SignalR docs, target bundle, solution structure, docs, and tests. - Plan reviewed and expanded to add inbound entry points/IVR, call recording, live monitoring (silent monitor/whisper/barge/take-over), outbound compliance hardening, quality management, a standard terminology/metrics glossary, scale-out/high-availability, data retention/privacy, a testing strategy, and a migration strategy. Phases renumbered to 0-14. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs index 5aefe0439..3d97e45cc 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs @@ -2,6 +2,7 @@ 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; @@ -19,6 +20,7 @@ public sealed class AgentWorkStateHealingService : IAgentWorkStateHealingService private readonly IQueueItemManager _queueItemManager; private readonly IInteractionManager _interactionManager; private readonly IOmnichannelActivityManager _activityManager; + private readonly IServiceProvider _serviceProvider; private readonly IClock _clock; private readonly ILogger _logger; @@ -31,6 +33,7 @@ public sealed class AgentWorkStateHealingService : IAgentWorkStateHealingService /// The queue item manager. /// The interaction manager. /// The activity manager. + /// The service provider used to lazily resolve provider synchronization without a presence-manager cycle. /// The clock. /// The logger. public AgentWorkStateHealingService( @@ -40,6 +43,7 @@ public AgentWorkStateHealingService( IQueueItemManager queueItemManager, IInteractionManager interactionManager, IOmnichannelActivityManager activityManager, + IServiceProvider serviceProvider, IClock clock, ILogger logger) { @@ -49,6 +53,7 @@ public AgentWorkStateHealingService( _queueItemManager = queueItemManager; _interactionManager = interactionManager; _activityManager = activityManager; + _serviceProvider = serviceProvider; _clock = clock; _logger = logger; } @@ -164,6 +169,21 @@ private async Task HealActiveInteractionAsync(AgentProfile agent, bool rele return 0; } + var isProviderBacked = !string.IsNullOrWhiteSpace(interaction.ProviderName) && + !string.IsNullOrWhiteSpace(interaction.ProviderInteractionId); + + if (isProviderBacked) + { + var previousStatus = interaction.Status; + var synchronizationService = _serviceProvider.GetRequiredService(); + 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) { _logger.LogWarning( @@ -183,6 +203,16 @@ interaction.Status is not (InteractionStatus.Connected or InteractionStatus.Held 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, + agent.ItemId); + + 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, diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs index 45e63bd19..7b8e5f081 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs @@ -64,7 +64,24 @@ public async Task RefreshInteractionAsync(Interaction interaction, return interaction; } - var provider = await _telephonyProviderResolver.GetAsync(interaction.ProviderName); + 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, + providerName, + interaction.ProviderName); + } + } if (provider is not ITelephonyCallStateProvider stateProvider) { @@ -95,11 +112,11 @@ public async Task RefreshInteractionAsync(Interaction interaction, return interaction; } - var providerEvent = BuildProviderEvent(interaction, lookup); + var providerEvent = BuildProviderEvent(interaction, providerName, lookup); await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); return await _interactionManager.FindByProviderInteractionIdAsync( - interaction.ProviderName, + providerName, interaction.ProviderInteractionId, cancellationToken) ?? interaction; @@ -152,17 +169,20 @@ private async Task ReconcileAsync(string providerName, CancellationToken ca return refreshed; } - private ProviderVoiceEvent BuildProviderEvent(Interaction interaction, TelephonyCallLookupResult lookup) + private ProviderVoiceEvent BuildProviderEvent( + Interaction interaction, + string providerName, + TelephonyCallLookupResult lookup) { if (!lookup.Found) { return new ProviderVoiceEvent { - ProviderName = interaction.ProviderName, + ProviderName = providerName, ProviderCallId = interaction.ProviderInteractionId, State = ContactCenterCallState.Ended, OccurredUtc = _clock.UtcNow, - IdempotencyKey = $"reconcile-missing:{interaction.ProviderName}:{interaction.ProviderInteractionId}:ended", + IdempotencyKey = $"reconcile-missing:{providerName}:{interaction.ProviderInteractionId}:ended", }; } @@ -181,13 +201,13 @@ private ProviderVoiceEvent BuildProviderEvent(Interaction interaction, Telephony return new ProviderVoiceEvent { - ProviderName = interaction.ProviderName, + ProviderName = providerName, ProviderCallId = interaction.ProviderInteractionId, State = state, FromAddress = call.From, ToAddress = call.To, OccurredUtc = _clock.UtcNow, - IdempotencyKey = $"reconcile:{interaction.ProviderName}:{interaction.ProviderInteractionId}:{state}:{call.IsMuted}:{call.IsOnHold}", + IdempotencyKey = $"reconcile:{providerName}:{interaction.ProviderInteractionId}:{state}:{call.IsMuted}:{call.IsOnHold}", IsMuted = call.IsMuted, Metadata = call.Metadata? .Where(entry => !string.IsNullOrWhiteSpace(entry.Key)) diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 53a8d5304..ae888d3f3 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -96,6 +96,9 @@ At a high level, the platform changes are: - 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. +- 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. ### Artificial Intelligence diff --git a/src/CrestApps.Docs/docs/contact-center/voice-routing.md b/src/CrestApps.Docs/docs/contact-center/voice-routing.md index c9b9953dc..a357c1d94 100644 --- a/src/CrestApps.Docs/docs/contact-center/voice-routing.md +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -150,6 +150,10 @@ After the provider actually changes call state, the provider webhook or provider - 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 diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js index 2ea8e4f68..80033da96 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js @@ -1384,6 +1384,10 @@ call.callId === currentCall.callId) { currentCall = null; incomingHandled = false; + } else { + refreshActiveCall().catch(function (error) { + showError(error && error.message ? error.message : String(error)); + }); } render(); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js index 62621a349..679b705d3 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js @@ -1129,6 +1129,10 @@ if (!call || !currentCall || !call.callId || !currentCall.callId || call.callId === currentCall.callId) { currentCall = null; incomingHandled = false; + } else { + refreshActiveCall()["catch"](function (error) { + showError(error && error.message ? error.message : String(error)); + }); } render(); return; diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js index 28d08cc3a..9c1ea8882 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js @@ -1 +1 @@ -!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function f(c,f){f=f||{};var h=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),y=h.strings||{},m=h.capabilities||0,g=(h.storageKey||"telephony-soft-phone")+"-layout",v=f.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,k=null,S=0,C=null,L=!1,E=!1,I=null,q=!1,_=!1,A=!1,x=!1,T=null,P="keypad",R=null,N=!1;function V(e){return(m&e)===e}function M(e,n){e&&(e.hidden=!n)}function U(e){b.status&&(b.status.textContent=e)}function H(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function O(e){b.views.forEach(function(n){M(n,n.getAttribute("data-telephony-view")===e)})}function B(){if(b.panel&&!b.panel.hidden&&b.keypadView){var e=b.keypadView.hidden,n=b.keypadView.style.position,t=b.keypadView.style.visibility,o=b.keypadView.style.pointerEvents,i=b.keypadView.style.inset;e&&(b.keypadView.hidden=!1,b.keypadView.style.position="absolute",b.keypadView.style.inset="0 auto auto 0",b.keypadView.style.visibility="hidden",b.keypadView.style.pointerEvents="none");var r=Math.ceil(b.keypadView.getBoundingClientRect().height||b.keypadView.scrollHeight||0);e&&(b.keypadView.hidden=e,b.keypadView.style.position=n,b.keypadView.style.inset=i,b.keypadView.style.visibility=t,b.keypadView.style.pointerEvents=o),r>0&&c.style.setProperty("--telephony-view-height",r+"px")}}function D(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===P})||(P=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function F(e){return"keypad"===e||"history"===e}function j(){return b.tabs.some(function(e){return!F(e.getAttribute("data-telephony-tab"))})}function G(e){return e?1===e.direction||"Inbound"===e.direction?e.from||e.to||"":e.to||e.from||"":""}function J(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function Q(e){try{var n=J();Object.assign(n,e),localStorage.setItem(g,JSON.stringify(n))}catch(e){}}function z(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function K(){var e=c.getBoundingClientRect(),n=e.width||56,t=e.height||56,o=Math.max(8,window.innerWidth-n-8),i=Math.max(8,window.innerHeight-t-8),r=8,a=8;if(b.panel&&!b.panel.hidden){var l=b.panel.getBoundingClientRect(),u=l.width||n,s=l.height||0;r=Math.min(o,Math.max(8,u-n+8)),a=Math.min(i,s+20)}return{minLeft:r,minTop:a,maxLeft:o,maxTop:i}}function X(e,n){var t=K();return{left:p(e,t.minLeft,t.maxLeft),top:p(n,t.minTop,t.maxTop)}}function Y(e){if(!e)return null;var n=K(),t=Number(e.left),o=Number(e.top),i=Number(e.leftRatio),r=Number(e.topRatio);return Number.isFinite(i)&&(t=n.minLeft+Math.max(0,n.maxLeft-n.minLeft)*i),Number.isFinite(r)&&(o=n.minTop+Math.max(0,n.maxTop-n.minTop)*r),Number.isFinite(t)&&Number.isFinite(o)?X(t,o):null}function W(){var e=J();if(e.position){var n=Y(e.position);if(n)return void z(n.left,n.top)}if(c.style.left){var t=c.getBoundingClientRect(),o=X(t.left,t.top);z(o.left,o.top)}}function Z(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();z(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var u=X(r+n,a+c);z(u.left,u.top)}}}function s(){var e,o,i,r,a,d;null!==t&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(d=c.getBoundingClientRect(),Q({position:(e=d.left,o=d.top,i=K(),r=Math.max(0,i.maxLeft-i.minLeft),a=Math.max(0,i.maxTop-i.minTop),{left:e,top:o,leftRatio:0===r?0:(e-i.minLeft)/r,topRatio:0===a?0:(o-i.minTop)/a})}),n.suppressClick&&(N=!0)))}}function $(){b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===P;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")})}function ee(e){Q({activeTab:P=e}),ne(),"history"===e&&Te()}function ne(){!function(){var e=ge()&&!L;if(M(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return be(),void(ge()||(C=null));b.incomingCaller&&(b.incomingCaller.textContent=G(k)||y.incomingCall||"Incoming call");var n=C&&C.properties?C.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);M(b.incomingVoicemail,V(l)),function(){if(!b.incomingCards)return;var e=C&&C.cards?C.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=C&&C.heading||y.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(y.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ke(e.getAttribute("data-url"))})})}(),function(){if(be(),!C||!C.properties||!C.properties.expiresUtc)return;var e=Date.parse(C.properties.expiresUtc);if(!isFinite(e))return;var n=e-Date.now();if(n<=0)return void Ee();I=window.setTimeout(function(){Ee()},n+250)}()}(),D();var a=k?u(k.state):"Idle",p=s(a),f="Connected"===a,h=f||"OnHold"===a;b.toggleIcon&&(b.toggleIcon.className="fa-solid fa-phone");var m=x&&A&&q&&!_;if(x&&!A&&!p){var g=F(P);return b.unavailableText&&(b.unavailableText.textContent=y.notConfigured||"No telephony provider is configured."),M(b.unavailable,g),M(b.connectPanel,!1),O(g?null:P),M(b.footer,j()),$(),U(y.notReady||"Not Ready"),void B()}if(M(b.unavailable,!1),m&&!p){var v=F(P);return M(b.connectPanel,v),O(v?null:P),M(b.footer,j()),$(),U(y.notConnected||"Not connected"),void B()}M(b.connectPanel,!1),M(b.footer,!0),$(),O(P),U(k?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return y[n]||e}(a):y.idle||"Ready"),b.peer&&(b.peer.textContent=p&&k?G(k):""),M(b.dial,!p),M(b.hangup,h&&V(e)),M(b.hold,p&&"Connected"===a&&V(n)),M(b.resume,p&&"OnHold"===a&&V(t));var w=k&&k.isMuted;M(b.mute,f&&!w&&V(o)),M(b.unmute,f&&w&&V(o)),M(b.transfer,h&&V(i)),M(b.merge,f&&V(r)),b.number&&(b.number.disabled=p||!!R),[b.dial,b.hangup,b.hold,b.resume,b.mute,b.unmute,b.transfer,b.merge].forEach(function(e){e&&(e.disabled=!!R)}),b.keys.forEach(function(e){e.disabled=p||!!R}),B()}function te(){if(!w)return Promise.resolve(null);var e=S;return w.invoke("GetActiveCall").then(function(n){return function(e,n){return e&&!1!==e.succeeded?(n!==S||(!(k=!0===e.found?e.call:null)||"Disconnected"!==u(k.state)&&"Failed"!==u(k.state)||(k=null),k||(L=!1),ne()),k):null}(n,e)})}function oe(e,n){return w?R?Promise.resolve(null):(R=e,ne(),w.invoke(e,n).then(function(e){return function(e){!!e&&(!1===e.succeeded?H(e.error||y.failed||"Call failed"):H(null))}(e),e}).catch(function(e){throw H(e&&e.message?e.message:String(e)),e}).finally(function(){R=null,ne()})):Promise.reject(new Error("Not connected."))}function ie(){return k?k.callId:null}function re(){var e=ie();return e?{callId:e,metadata:k&&k.metadata?k.metadata:null}:null}function ae(){var e=b.number?b.number.value.trim():"";e?oe("Dial",{to:e}):H(y.invalidNumber||"Enter a phone number to call.")}function le(e){e&&(ee("keypad"),me(!0),b.number&&(b.number.value=e),oe("Dial",{to:e}))}function ce(){var e=re();e&&oe("Hangup",e)}function ue(){var e=re();e&&oe("Hold",e)}function se(){var e=re();e&&oe("Resume",e)}function de(){var e=re();e&&oe("Mute",e)}function pe(){var e=re();e&&oe("Unmute",e)}function fe(){var e=ie();if(e){var n=window.prompt(y.transferPrompt||"Transfer to number");n&&oe("Transfer",{callId:e,to:n,mode:0})}}function he(){var e=ie();if(e){var n=window.prompt(y.mergePrompt||"Second call id to merge");n&&oe("Merge",{primaryCallId:e,secondaryCallId:n})}}function ye(e){var n=k?u(k.state):"Idle";"Connected"===n&&V(a)?oe("SendDigits",{callId:ie(),digits:e}):!s(n)&&b.number&&(b.number.value+=e)}function me(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,Q({open:n}),W(),ne()}}function ge(){if(!k)return!1;var e=1===k.direction||"Inbound"===k.direction;return"Ringing"===u(k.state)&&e}function ve(e){return e&&e.properties&&e.properties.reservationId||null}function be(){I&&(window.clearTimeout(I),I=null)}function we(e){if(!C||!C.properties)return Promise.resolve(null);var n=C.properties[e];if(!n)return Promise.resolve(null);var t={"Content-Type":"application/json"};h.antiForgeryToken&&(t.RequestVerificationToken=h.antiForgeryToken);try{return fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:ie()})}).then(function(e){return e.ok?e.json().catch(function(){return{succeeded:!0}}):{succeeded:!1}}).catch(function(){return{succeeded:!1}})}catch(e){return Promise.resolve({succeeded:!1})}}function ke(e){var n=ie();e&&window.open(e,"_blank","noopener"),C&&C.properties&&C.properties.acceptUrl?(E=!0,we("acceptUrl").then(function(e){if(!e||!1===e.succeeded)return H(y.offerUnavailable||"This call is no longer available."),C=null,k=null,void ne();L=!0,C=null,me(!0),ne(),!1!==e.requiresDeviceAnswer&&n&&oe("Answer",{callId:n})}).finally(function(){E=!1})):n&&(me(!0),oe("Answer",{callId:n}))}function Se(){var e=re();we("declineUrl"),e&&oe("Voicemail",e)}function Ce(){var e=re();C&&C.properties&&C.properties.declineUrl?we("declineUrl").then(function(e){e&&!1!==e.succeeded?Ee():H(y.offerUnavailable||"This call is no longer available.")}):e?oe("Reject",e):Ee()}function Le(e,n){e&&(k&&s(u(k.state))&&!ge()||L&&function(e,n){if(!k||!e)return!1;var t=ve(C),o=ve(n);return!(k.callId!==e.callId||t&&o&&t!==o)}(e,n)||(k=e,C=n||null,L=!1,E=!1,ee("keypad"),ne()))}function Ee(e){e=e||{},be(),C=null,L=!1,e.preservePendingAccept||(E=!1),!e.preserveCurrentCall&&k&&ge()&&(k=null),ne()}function Ie(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(A=!!e.isAvailable,q=!!e.requiresAuthentication,_=!!e.isConnected,T=e.authenticationScheme||"oauth2",x=!0,ne())}).catch(function(){}):Promise.resolve()}function qe(){return w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(m=e,ne())}).catch(function(){}):Promise.resolve()}function _e(){if(h.connectUrl){var e=h.connectUrl.indexOf("?")>=0?"&":"?",n=h.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function Ae(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[T]||e.oauth2),t={scheme:T,connectUrl:h.connectUrl,startOAuth:_e,refreshStatus:Ie};"function"==typeof n?n(t):_e()}function xe(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Ie()}function Te(){w?w.invoke("GetInteractions",50).then(function(e){Re(e||[])}).catch(function(){Re([])}):Re([])}function Pe(){return!w||k?Promise.resolve():te().catch(function(){})}function Re(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"↙":"↗",r=n?e.from||"":e.to||"",a=t?y.missed||"Missed":n?y.incoming||"Incoming":y.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&le(n)})})):b.historyList.innerHTML='
'+d(y.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){N?N=!1:me()}),b.close&&b.close.addEventListener("click",function(){me(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){ee(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",ae),b.hangup&&b.hangup.addEventListener("click",ce),b.hold&&b.hold.addEventListener("click",ue),b.resume&&b.resume.addEventListener("click",se),b.mute&&b.mute.addEventListener("click",de),b.unmute&&b.unmute.addEventListener("click",pe),b.transfer&&b.transfer.addEventListener("click",fe),b.merge&&b.merge.addEventListener("click",he),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ke(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",Se),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",Ce),b.connect&&b.connect.addEventListener("click",Ae),b.keys.forEach(function(e){e.addEventListener("click",function(){ye(e.getAttribute("data-telephony-key"))})}),Z(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),Z(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",xe),window.addEventListener("resize",function(){W(),B()}),function(){var e,n=J();if("string"==typeof n.activeTab&&n.activeTab.length&&(P=n.activeTab),n.open&&b.panel&&(b.panel.hidden=!1),n.position&&("number"==typeof(e=Number(n.position.left))&&isFinite(e))){var t=Y(n.position);t&&z(t.left,t.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=X(o,n.top);z(i.left,i.top)}}()}(),ne(),c.style.visibility="";var Ne=v&&h.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(h.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){if(S++,!e||"Disconnected"===u(e.state)||"Failed"===u(e.state))return e&&k&&e.callId&&k.callId&&e.callId!==k.callId||(k=null,L=!1),void ne();k=e,ne()}),w.on("IncomingCall",function(e,n){Le(e,n||null)}),w.on("ReceiveError",function(e){H(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){U(y.disconnectedHub||"Disconnected")}),"function"==typeof w.onreconnected&&w.onreconnected(function(){return H(null),Promise.all([qe(),Ie()]).then(function(){return Pe()}).then(function(){"history"===P&&Te(),ne()}).catch(function(e){H(e&&e.message?e.message:String(e))})})),w.start().then(function(){return H(null),Promise.all([qe(),Ie()])}).then(function(){return Pe()}).then(function(){"history"===P&&Te(),ne()}).catch(function(e){H(e&&e.message?e.message:String(e))})):(ne(),Promise.resolve());return{element:c,config:h,dial:ae,dialNumber:le,hangup:ce,hold:ue,resume:se,mute:de,unmute:pe,transfer:fe,merge:he,pressKey:ye,togglePanel:me,open:function(){me(!0)},getCurrentCall:function(){return k},isIncomingAcceptPending:function(){return E},setIncomingOffer:Le,clearIncomingOffer:Ee,showError:H,getConnection:function(){return w},started:Ne}}function h(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=f(e))})}function y(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:f,initializeAll:h,getInstance:y,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=y();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",h):h()}(); +!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function f(c,f){f=f||{};var h=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),y=h.strings||{},m=h.capabilities||0,g=(h.storageKey||"telephony-soft-phone")+"-layout",v=f.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,k=null,S=0,C=null,L=!1,E=!1,I=null,q=!1,_=!1,A=!1,x=!1,T=null,P="keypad",R=null,N=!1;function V(e){return(m&e)===e}function M(e,n){e&&(e.hidden=!n)}function U(e){b.status&&(b.status.textContent=e)}function H(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function O(e){b.views.forEach(function(n){M(n,n.getAttribute("data-telephony-view")===e)})}function B(){if(b.panel&&!b.panel.hidden&&b.keypadView){var e=b.keypadView.hidden,n=b.keypadView.style.position,t=b.keypadView.style.visibility,o=b.keypadView.style.pointerEvents,i=b.keypadView.style.inset;e&&(b.keypadView.hidden=!1,b.keypadView.style.position="absolute",b.keypadView.style.inset="0 auto auto 0",b.keypadView.style.visibility="hidden",b.keypadView.style.pointerEvents="none");var r=Math.ceil(b.keypadView.getBoundingClientRect().height||b.keypadView.scrollHeight||0);e&&(b.keypadView.hidden=e,b.keypadView.style.position=n,b.keypadView.style.inset=i,b.keypadView.style.visibility=t,b.keypadView.style.pointerEvents=o),r>0&&c.style.setProperty("--telephony-view-height",r+"px")}}function D(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===P})||(P=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function F(e){return"keypad"===e||"history"===e}function j(){return b.tabs.some(function(e){return!F(e.getAttribute("data-telephony-tab"))})}function G(e){return e?1===e.direction||"Inbound"===e.direction?e.from||e.to||"":e.to||e.from||"":""}function J(){try{return JSON.parse(localStorage.getItem(g))||{}}catch(e){return{}}}function Q(e){try{var n=J();Object.assign(n,e),localStorage.setItem(g,JSON.stringify(n))}catch(e){}}function z(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function K(){var e=c.getBoundingClientRect(),n=e.width||56,t=e.height||56,o=Math.max(8,window.innerWidth-n-8),i=Math.max(8,window.innerHeight-t-8),r=8,a=8;if(b.panel&&!b.panel.hidden){var l=b.panel.getBoundingClientRect(),u=l.width||n,s=l.height||0;r=Math.min(o,Math.max(8,u-n+8)),a=Math.min(i,s+20)}return{minLeft:r,minTop:a,maxLeft:o,maxTop:i}}function X(e,n){var t=K();return{left:p(e,t.minLeft,t.maxLeft),top:p(n,t.minTop,t.maxTop)}}function Y(e){if(!e)return null;var n=K(),t=Number(e.left),o=Number(e.top),i=Number(e.leftRatio),r=Number(e.topRatio);return Number.isFinite(i)&&(t=n.minLeft+Math.max(0,n.maxLeft-n.minLeft)*i),Number.isFinite(r)&&(o=n.minTop+Math.max(0,n.maxTop-n.minTop)*r),Number.isFinite(t)&&Number.isFinite(o)?X(t,o):null}function W(){var e=J();if(e.position){var n=Y(e.position);if(n)return void z(n.left,n.top)}if(c.style.left){var t=c.getBoundingClientRect(),o=X(t.left,t.top);z(o.left,o.top)}}function Z(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();z(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var u=X(r+n,a+c);z(u.left,u.top)}}}function s(){var e,o,i,r,a,d;null!==t&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(d=c.getBoundingClientRect(),Q({position:(e=d.left,o=d.top,i=K(),r=Math.max(0,i.maxLeft-i.minLeft),a=Math.max(0,i.maxTop-i.minTop),{left:e,top:o,leftRatio:0===r?0:(e-i.minLeft)/r,topRatio:0===a?0:(o-i.minTop)/a})}),n.suppressClick&&(N=!0)))}}function $(){b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===P;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")})}function ee(e){Q({activeTab:P=e}),ne(),"history"===e&&Te()}function ne(){!function(){var e=ge()&&!L;if(M(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return be(),void(ge()||(C=null));b.incomingCaller&&(b.incomingCaller.textContent=G(k)||y.incomingCall||"Incoming call");var n=C&&C.properties?C.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);M(b.incomingVoicemail,V(l)),function(){if(!b.incomingCards)return;var e=C&&C.cards?C.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=C&&C.heading||y.matchedRecords;t&&(n+='
'+d(t)+"
");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
'+d(e.title||"")+"
";e.subtitle&&(t+='
'+d(e.subtitle)+"
");e.description&&(t+='
'+d(e.description)+"
");e.badges&&e.badges.length&&(t+='
',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(y.open||"Open")+""}return'
'+n+'
'+t+"
"+(o?'
'+o+"
":"")+"
"}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ke(e.getAttribute("data-url"))})})}(),function(){if(be(),!C||!C.properties||!C.properties.expiresUtc)return;var e=Date.parse(C.properties.expiresUtc);if(!isFinite(e))return;var n=e-Date.now();if(n<=0)return void Ee();I=window.setTimeout(function(){Ee()},n+250)}()}(),D();var a=k?u(k.state):"Idle",p=s(a),f="Connected"===a,h=f||"OnHold"===a;b.toggleIcon&&(b.toggleIcon.className="fa-solid fa-phone");var m=x&&A&&q&&!_;if(x&&!A&&!p){var g=F(P);return b.unavailableText&&(b.unavailableText.textContent=y.notConfigured||"No telephony provider is configured."),M(b.unavailable,g),M(b.connectPanel,!1),O(g?null:P),M(b.footer,j()),$(),U(y.notReady||"Not Ready"),void B()}if(M(b.unavailable,!1),m&&!p){var v=F(P);return M(b.connectPanel,v),O(v?null:P),M(b.footer,j()),$(),U(y.notConnected||"Not connected"),void B()}M(b.connectPanel,!1),M(b.footer,!0),$(),O(P),U(k?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return y[n]||e}(a):y.idle||"Ready"),b.peer&&(b.peer.textContent=p&&k?G(k):""),M(b.dial,!p),M(b.hangup,h&&V(e)),M(b.hold,p&&"Connected"===a&&V(n)),M(b.resume,p&&"OnHold"===a&&V(t));var w=k&&k.isMuted;M(b.mute,f&&!w&&V(o)),M(b.unmute,f&&w&&V(o)),M(b.transfer,h&&V(i)),M(b.merge,f&&V(r)),b.number&&(b.number.disabled=p||!!R),[b.dial,b.hangup,b.hold,b.resume,b.mute,b.unmute,b.transfer,b.merge].forEach(function(e){e&&(e.disabled=!!R)}),b.keys.forEach(function(e){e.disabled=p||!!R}),B()}function te(){if(!w)return Promise.resolve(null);var e=S;return w.invoke("GetActiveCall").then(function(n){return function(e,n){return e&&!1!==e.succeeded?(n!==S||(!(k=!0===e.found?e.call:null)||"Disconnected"!==u(k.state)&&"Failed"!==u(k.state)||(k=null),k||(L=!1),ne()),k):null}(n,e)})}function oe(e,n){return w?R?Promise.resolve(null):(R=e,ne(),w.invoke(e,n).then(function(e){return function(e){!!e&&(!1===e.succeeded?H(e.error||y.failed||"Call failed"):H(null))}(e),e}).catch(function(e){throw H(e&&e.message?e.message:String(e)),e}).finally(function(){R=null,ne()})):Promise.reject(new Error("Not connected."))}function ie(){return k?k.callId:null}function re(){var e=ie();return e?{callId:e,metadata:k&&k.metadata?k.metadata:null}:null}function ae(){var e=b.number?b.number.value.trim():"";e?oe("Dial",{to:e}):H(y.invalidNumber||"Enter a phone number to call.")}function le(e){e&&(ee("keypad"),me(!0),b.number&&(b.number.value=e),oe("Dial",{to:e}))}function ce(){var e=re();e&&oe("Hangup",e)}function ue(){var e=re();e&&oe("Hold",e)}function se(){var e=re();e&&oe("Resume",e)}function de(){var e=re();e&&oe("Mute",e)}function pe(){var e=re();e&&oe("Unmute",e)}function fe(){var e=ie();if(e){var n=window.prompt(y.transferPrompt||"Transfer to number");n&&oe("Transfer",{callId:e,to:n,mode:0})}}function he(){var e=ie();if(e){var n=window.prompt(y.mergePrompt||"Second call id to merge");n&&oe("Merge",{primaryCallId:e,secondaryCallId:n})}}function ye(e){var n=k?u(k.state):"Idle";"Connected"===n&&V(a)?oe("SendDigits",{callId:ie(),digits:e}):!s(n)&&b.number&&(b.number.value+=e)}function me(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,Q({open:n}),W(),ne()}}function ge(){if(!k)return!1;var e=1===k.direction||"Inbound"===k.direction;return"Ringing"===u(k.state)&&e}function ve(e){return e&&e.properties&&e.properties.reservationId||null}function be(){I&&(window.clearTimeout(I),I=null)}function we(e){if(!C||!C.properties)return Promise.resolve(null);var n=C.properties[e];if(!n)return Promise.resolve(null);var t={"Content-Type":"application/json"};h.antiForgeryToken&&(t.RequestVerificationToken=h.antiForgeryToken);try{return fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:ie()})}).then(function(e){return e.ok?e.json().catch(function(){return{succeeded:!0}}):{succeeded:!1}}).catch(function(){return{succeeded:!1}})}catch(e){return Promise.resolve({succeeded:!1})}}function ke(e){var n=ie();e&&window.open(e,"_blank","noopener"),C&&C.properties&&C.properties.acceptUrl?(E=!0,we("acceptUrl").then(function(e){if(!e||!1===e.succeeded)return H(y.offerUnavailable||"This call is no longer available."),C=null,k=null,void ne();L=!0,C=null,me(!0),ne(),!1!==e.requiresDeviceAnswer&&n&&oe("Answer",{callId:n})}).finally(function(){E=!1})):n&&(me(!0),oe("Answer",{callId:n}))}function Se(){var e=re();we("declineUrl"),e&&oe("Voicemail",e)}function Ce(){var e=re();C&&C.properties&&C.properties.declineUrl?we("declineUrl").then(function(e){e&&!1!==e.succeeded?Ee():H(y.offerUnavailable||"This call is no longer available.")}):e?oe("Reject",e):Ee()}function Le(e,n){e&&(k&&s(u(k.state))&&!ge()||L&&function(e,n){if(!k||!e)return!1;var t=ve(C),o=ve(n);return!(k.callId!==e.callId||t&&o&&t!==o)}(e,n)||(k=e,C=n||null,L=!1,E=!1,ee("keypad"),ne()))}function Ee(e){e=e||{},be(),C=null,L=!1,e.preservePendingAccept||(E=!1),!e.preserveCurrentCall&&k&&ge()&&(k=null),ne()}function Ie(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(A=!!e.isAvailable,q=!!e.requiresAuthentication,_=!!e.isConnected,T=e.authenticationScheme||"oauth2",x=!0,ne())}).catch(function(){}):Promise.resolve()}function qe(){return w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(m=e,ne())}).catch(function(){}):Promise.resolve()}function _e(){if(h.connectUrl){var e=h.connectUrl.indexOf("?")>=0?"&":"?",n=h.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function Ae(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[T]||e.oauth2),t={scheme:T,connectUrl:h.connectUrl,startOAuth:_e,refreshStatus:Ie};"function"==typeof n?n(t):_e()}function xe(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&Ie()}function Te(){w?w.invoke("GetInteractions",50).then(function(e){Re(e||[])}).catch(function(){Re([])}):Re([])}function Pe(){return!w||k?Promise.resolve():te().catch(function(){})}function Re(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"↙":"↗",r=n?e.from||"":e.to||"",a=t?y.missed||"Missed":n?y.incoming||"Incoming":y.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&le(n)})})):b.historyList.innerHTML='
'+d(y.noInteractions||"No recent calls.")+"
")}b.toggle&&b.toggle.addEventListener("click",function(){N?N=!1:me()}),b.close&&b.close.addEventListener("click",function(){me(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){ee(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",ae),b.hangup&&b.hangup.addEventListener("click",ce),b.hold&&b.hold.addEventListener("click",ue),b.resume&&b.resume.addEventListener("click",se),b.mute&&b.mute.addEventListener("click",de),b.unmute&&b.unmute.addEventListener("click",pe),b.transfer&&b.transfer.addEventListener("click",fe),b.merge&&b.merge.addEventListener("click",he),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ke(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",Se),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",Ce),b.connect&&b.connect.addEventListener("click",Ae),b.keys.forEach(function(e){e.addEventListener("click",function(){ye(e.getAttribute("data-telephony-key"))})}),Z(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),Z(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",xe),window.addEventListener("resize",function(){W(),B()}),function(){var e,n=J();if("string"==typeof n.activeTab&&n.activeTab.length&&(P=n.activeTab),n.open&&b.panel&&(b.panel.hidden=!1),n.position&&("number"==typeof(e=Number(n.position.left))&&isFinite(e))){var t=Y(n.position);t&&z(t.left,t.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=X(o,n.top);z(i.left,i.top)}}()}(),ne(),c.style.visibility="";var Ne=v&&h.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(h.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){if(S++,!e||"Disconnected"===u(e.state)||"Failed"===u(e.state))return e&&k&&e.callId&&k.callId&&e.callId!==k.callId?te().catch(function(e){H(e&&e.message?e.message:String(e))}):(k=null,L=!1),void ne();k=e,ne()}),w.on("IncomingCall",function(e,n){Le(e,n||null)}),w.on("ReceiveError",function(e){H(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){U(y.disconnectedHub||"Disconnected")}),"function"==typeof w.onreconnected&&w.onreconnected(function(){return H(null),Promise.all([qe(),Ie()]).then(function(){return Pe()}).then(function(){"history"===P&&Te(),ne()}).catch(function(e){H(e&&e.message?e.message:String(e))})})),w.start().then(function(){return H(null),Promise.all([qe(),Ie()])}).then(function(){return Pe()}).then(function(){"history"===P&&Te(),ne()}).catch(function(e){H(e&&e.message?e.message:String(e))})):(ne(),Promise.resolve());return{element:c,config:h,dial:ae,dialNumber:le,hangup:ce,hold:ue,resume:se,mute:de,unmute:pe,transfer:fe,merge:he,pressKey:ye,togglePanel:me,open:function(){me(!0)},getCurrentCall:function(){return k},isIncomingAcceptPending:function(){return E},setIncomingOffer:Le,clearIncomingOffer:Ee,showError:H,getConnection:function(){return w},started:Ne}}function h(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=f(e))})}function y(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:f,initializeAll:h,getInstance:y,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=y();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",h):h()}(); diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskAriConnectionUtilities.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskAriConnectionUtilities.cs index b60c67f12..49a847817 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskAriConnectionUtilities.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskAriConnectionUtilities.cs @@ -53,6 +53,7 @@ public static Uri CreateEventsUri(AsteriskWebOptions options) { ["app"] = options.AsteriskApplicationName, ["api_key"] = $"{options.AsteriskUserName}:{options.AsteriskPassword}", + ["subscribeAll"] = bool.TrueString.ToLowerInvariant(), }).TrimStart('?'); return builder.Uri; diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs index cfd4c6821..525d73cab 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs @@ -217,6 +217,42 @@ await page.EvaluateAsync( Assert.True(await page.Locator("[data-telephony-hangup]").IsVisibleAsync()); } + [Fact] + public async Task TerminalEventForProviderCall_WhenClientHoldsDifferentStaleCall_RefreshesAndClearsState() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + await page.ClickAsync("[data-telephony-toggle]"); + await page.EvaluateAsync( + """ + async () => { + const connection = window.telephonySoftPhone.getInstance().getConnection(); + await connection.invoke('Dial', { to: '+15551234567' }); + await connection.invoke( + 'PublishCallState', + { + callId: 'stale-contact-center-call', + direction: 1, + state: 3, + providerName: 'InMemory' + }); + } + """); + await page.Locator("[data-telephony-hangup]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + + // Act + await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getConnection().invoke('DisconnectLatestCall')"); + + // Assert + await page.Locator("[data-telephony-dial]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + Assert.Null(await page.EvaluateAsync( + "() => window.telephonySoftPhone.getInstance().getCurrentCall()")); + Assert.Equal("Ready", (await page.Locator("[data-telephony-status]").InnerTextAsync()).Trim()); + } + [Fact] public async Task ProviderEventDuringActiveCallRestoration_WinsOverStaleLookup() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs index 038f6d2c8..206c62944 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs @@ -77,7 +77,7 @@ public async Task HealForAvailabilityAsync_WhenPendingReservationIsStale_Cancels } [Fact] - public async Task HealForAvailabilityAsync_WhenAvailableAgentHasConnectedInteraction_RequeuesIt() + public async Task HealForAvailabilityAsync_WhenAvailableAgentHasProviderConfirmedConnectedInteraction_PreservesIt() { // Arrange var agentManager = new Mock(); @@ -124,6 +124,12 @@ public async Task HealForAvailabilityAsync_WhenAvailableAgentHasConnectedInterac var interactionManager = new Mock(); interactionManager.Setup(manager => manager.FindActiveByAgentAsync("a1", It.IsAny())) .ReturnsAsync(interaction); + interaction.ProviderName = "provider-1"; + interaction.ProviderInteractionId = "call-1"; + var synchronizationService = new Mock(); + synchronizationService + .Setup(service => service.RefreshInteractionAsync(interaction, It.IsAny())) + .ReturnsAsync(interaction); var activityManager = new Mock(); var activity = new OmnichannelActivity @@ -136,7 +142,89 @@ public async Task HealForAvailabilityAsync_WhenAvailableAgentHasConnectedInterac activityManager.Setup(manager => manager.FindByIdAsync("act-1", It.IsAny())) .ReturnsAsync(activity); - var service = CreateService(agentManager, reservationManager, reservationService, queueItemManager, interactionManager, activityManager); + var service = CreateService( + agentManager, + reservationManager, + reservationService, + queueItemManager, + interactionManager, + activityManager, + synchronizationService); + + // Act + var healed = await service.HealForAvailabilityAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, healed); + Assert.Equal(QueueItemStatus.Assigned, queueItem.Status); + Assert.Equal("a1", queueItem.AgentId); + Assert.Equal("r1", queueItem.ReservationId); + Assert.Equal(InteractionStatus.Connected, interaction.Status); + Assert.Equal("a1", interaction.AgentId); + Assert.Equal(ActivityAssignmentStatus.Assigned, activity.AssignmentStatus); + Assert.Equal("u1", activity.AssignedToId); + Assert.Equal("r1", activity.ReservationId); + } + + [Fact] + public async Task HealForAvailabilityAsync_WhenConnectedInteractionHasNoProvider_RequeuesIt() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }); + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("a1", It.IsAny())) + .ReturnsAsync((ActivityReservation)null); + var queueItem = new QueueItem + { + ItemId = "qi-1", + ActivityItemId = "act-1", + QueueId = "q1", + ReservationId = "r1", + AgentId = "a1", + Status = QueueItemStatus.Assigned, + }; + var queueItemManager = new Mock(); + queueItemManager.Setup(manager => manager.FindByActivityIdAsync("act-1", It.IsAny())) + .ReturnsAsync(queueItem); + var interaction = new Interaction + { + ItemId = "i1", + ActivityItemId = "act-1", + QueueId = "q1", + AgentId = "a1", + Status = InteractionStatus.Connected, + }; + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindActiveByAgentAsync("a1", It.IsAny())) + .ReturnsAsync(interaction); + var activity = new OmnichannelActivity + { + ItemId = "act-1", + AssignmentStatus = ActivityAssignmentStatus.Assigned, + AssignedToId = "u1", + ReservationId = "r1", + }; + var activityManager = new Mock(); + activityManager.Setup(manager => manager.FindByIdAsync("act-1", It.IsAny())) + .ReturnsAsync(activity); + var service = CreateService( + agentManager, + reservationManager, + new Mock(), + queueItemManager, + interactionManager, + activityManager); // Act var healed = await service.HealForAvailabilityAsync("a1", TestContext.Current.CancellationToken); @@ -145,12 +233,10 @@ public async Task HealForAvailabilityAsync_WhenAvailableAgentHasConnectedInterac Assert.Equal(1, healed); Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); Assert.Null(queueItem.AgentId); - Assert.Null(queueItem.ReservationId); Assert.Equal(InteractionStatus.Created, interaction.Status); Assert.Null(interaction.AgentId); Assert.Equal(ActivityAssignmentStatus.Available, activity.AssignmentStatus); Assert.Null(activity.AssignedToId); - Assert.Null(activity.ReservationId); } [Fact] @@ -301,10 +387,15 @@ private static AgentWorkStateHealingService CreateService( Mock reservationService, Mock queueItemManager, Mock interactionManager, - Mock activityManager = null) + Mock activityManager = null, + Mock synchronizationService = null) { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); + var serviceProvider = new Mock(); + serviceProvider + .Setup(provider => provider.GetService(typeof(IProviderCallStateSynchronizationService))) + .Returns(synchronizationService?.Object ?? Mock.Of()); return new AgentWorkStateHealingService( agentManager.Object, @@ -313,6 +404,7 @@ private static AgentWorkStateHealingService CreateService( queueItemManager.Object, interactionManager.Object, activityManager?.Object ?? Mock.Of(), + serviceProvider.Object, clock.Object, NullLogger.Instance); } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs index b35c93453..9f21df663 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs @@ -56,6 +56,54 @@ public async Task RefreshInteractionAsync_WhenProviderNoLongerHasCall_EndsLocalI Assert.Equal("reconcile-missing:provider-1:call-1:ended", providerEvent.IdempotencyKey); } + [Fact] + public async Task RefreshInteractionAsync_WhenStoredProviderIsMissing_ReconcilesThroughDefaultProvider() + { + // Arrange + var interaction = CreateInteraction(); + interaction.ProviderName = "stale-provider"; + ProviderVoiceEvent providerEvent = null; + var eventService = new Mock(); + eventService + .Setup(service => service.IngestAsync(It.IsAny(), It.IsAny())) + .Callback((value, _) => providerEvent = value) + .ReturnsAsync(new CallSession()); + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("Default Asterisk", "call-1", It.IsAny())) + .ReturnsAsync(new Interaction + { + ItemId = "interaction-1", + ProviderName = "Default Asterisk", + ProviderInteractionId = "call-1", + Status = InteractionStatus.Ended, + }); + var provider = new Mock(); + provider.SetupGet(value => value.Name) + .Returns(new Microsoft.Extensions.Localization.LocalizedString("Default Asterisk", "Default Asterisk")); + provider + .As() + .Setup(value => value.GetCallStateAsync("call-1", It.IsAny())) + .ReturnsAsync(new TelephonyCallLookupResult + { + Succeeded = true, + Found = false, + }); + var resolver = new Mock(); + resolver.Setup(value => value.GetAsync("stale-provider")).ReturnsAsync((ITelephonyProvider)null); + resolver.Setup(value => value.GetAsync(null)).ReturnsAsync(provider.Object); + var service = CreateService(interactionManager, new Mock(), eventService, resolver); + + // Act + var refreshed = await service.RefreshInteractionAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(InteractionStatus.Ended, refreshed.Status); + Assert.NotNull(providerEvent); + Assert.Equal("Default Asterisk", providerEvent.ProviderName); + Assert.Equal("reconcile-missing:Default Asterisk:call-1:ended", providerEvent.IdempotencyKey); + } + [Fact] public async Task RefreshInteractionAsync_WhenProviderReportsHeldAndMuted_PropagatesGranularState() { @@ -210,6 +258,16 @@ private static ProviderCallStateSynchronizationService CreateService( .ReturnsAsync(lookup); var resolver = new Mock(); resolver.Setup(value => value.GetAsync("provider-1")).ReturnsAsync(provider.Object); + + return CreateService(interactionManager, callSessionManager, eventService, resolver); + } + + private static ProviderCallStateSynchronizationService CreateService( + Mock interactionManager, + Mock callSessionManager, + Mock eventService, + Mock resolver) + { var distributedLock = new Mock(); distributedLock .Setup(value => value.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) From 4014064812ef7c4be86f5f93b730afb81a697f66 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 22:21:49 -0700 Subject: [PATCH 50/56] Self-heal zombie inbound offers via provider truth Inbound calls whose provider channel no longer exists could keep being offered and accepted, leaving the agent stuck (unable to disconnect, unable to receive new calls). Make the telephony provider the source of truth for offer validity and cleanup: - ProviderVoiceEventService seeds a newly created call session with the interaction's pre-event (non-terminal) state instead of the incoming provider state, so a first-observed terminal event (e.g. a reconcile of an already-gone queued call) still records a non-terminal to terminal transition, publishes CallEnded, and runs ended-offer cleanup instead of silently leaving the interaction queued. - VoiceContactCenterCallRouter.OfferNextAsync refreshes each queued interaction from provider truth in a bounded loop and, when the provider confirms the call is gone, removes it from the queue and releases the reservation and agent via ReconcileEndedOfferAsync before offering the next call, so a dead call is never offered. - ContactCenterCallCommandService accept-media failures now re-check provider truth and reconcile a confirmed-gone call instead of relying on a CancelAsync no-op that left an accepted reservation stuck. Adds 3 unit tests (1,279 total pass); clean -warnaserror build. Updates voice-routing docs, v2.0.0 changelog, and the Contact Center plan. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 1 + .../ContactCenterCallCommandService.cs | 44 ++++++--- .../Services/ProviderVoiceEventService.cs | 21 +++- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 3 + .../docs/contact-center/voice-routing.md | 19 ++-- .../Services/VoiceContactCenterCallRouter.cs | 98 ++++++++++++------- .../ContactCenterCallCommandServiceTests.cs | 37 +++++++ .../ContactCenter/InboundVoiceServiceTests.cs | 62 ++++++++++++ .../ProviderVoiceEventServiceTests.cs | 73 ++++++++++++++ 9 files changed, 305 insertions(+), 53 deletions(-) diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index d40176c44..56ab78d53 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1508,6 +1508,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement ### Change log +- 2026-07-11: Closed the remaining "zombie inbound offer" gap where a queued call whose Asterisk channel no longer existed kept being offered and accepted, leaving the agent stuck (unable to disconnect, unable to receive new calls). Three provider-truth guards were added and unit-tested: (1) `VoiceContactCenterCallRouter.OfferNextAsync` now refreshes each queued interaction against provider truth in a bounded loop and, when the provider confirms the call is gone, removes it from the queue and releases the reservation/agent via `ReconcileEndedOfferAsync` before offering the next call — a dead call is never offered; (2) `ContactCenterCallCommandService` accept-media failures now re-check provider truth and reconcile a confirmed-gone call (removing the offer and releasing the agent) instead of leaving an accepted reservation stuck on a Cancel no-op; (3) `ProviderVoiceEventService` now seeds a freshly created call session with the interaction's pre-event state instead of the incoming terminal state, so a first-observed terminal event (e.g. a reconcile of an already-gone queued call) still records a non-terminal→terminal transition, publishes `CallEnded`, and runs ended-offer cleanup instead of silently leaving the interaction queued. +3 unit tests (1,279 total pass, clean `-warnaserror` build). Confirmed the Asterisk dashboard is already event-driven end to end (ARI `subscribeAll` forwarder → 50ms-coalesced SignalR snapshot → client `dashboardSnapshot` handler with reconciliation-poll fallback); the observed lag is provider emission/network, not a code defect. - 2026-07-11: Corrected the recurring inbound zombie path found in live logs. Agent reset/sign-in healing now reconciles provider-backed work before touching routing state, retries stale provider aliases through the tenant's current default provider, terminalizes calls the provider no longer reports, and preserves calls the provider confirms are still active instead of resetting them to `Created` and requeueing answered work. The soft phone now performs an authoritative active-call refresh when a terminal event arrives for a different call id than its stale browser state. The Asterisk development dashboard event stream now subscribes to all visible ARI resources so channel and bridge changes request immediate SignalR snapshots. - 2026-07-11: Eliminated zombie soft-phone calls by making provider events and provider-state lookup the only authorities for active call state. Telephony command responses no longer mutate the browser or broadcast `CallStateChanged`; active-call restoration validates the provider instead of trusting `TelephonyInteraction.Outcome`, and a new locked reconciliation service runs at tenant startup, every minute, after Asterisk listener reconnects, and during soft-phone reconnect/page restoration. Provider-confirmed missing calls delete orphaned in-progress Telephony records and disconnect connected clients, while transient lookup failures preserve the record for retry. Asterisk terminal projection now ignores duplicate terminal events and ambiguous down-state bridge-leave snapshots, and duplicate Contact Center events retain Contact Center ownership. - Codebase analysis completed for Telephony, Omnichannel Core, Omnichannel Management, SMS automation, SignalR docs, target bundle, solution structure, docs, and tests. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs index 608cd53e7..c5ea2332b 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterCallCommandService.cs @@ -23,6 +23,7 @@ public sealed class ContactCenterCallCommandService : IContactCenterCallCommandS private readonly ICallSessionManager _callSessionManager; private readonly IInboundVoiceService _inboundVoiceService; private readonly IProviderCallStateSynchronizationService _providerCallStateSynchronizationService; + private readonly IProviderVoiceOfferSynchronizationService _offerSynchronizationService; private readonly IContactCenterEventPublisher _publisher; private readonly IClock _clock; private readonly ILogger _logger; @@ -39,6 +40,7 @@ public sealed class ContactCenterCallCommandService : IContactCenterCallCommandS /// The call session manager used to project the voice session. /// The inbound voice service used to re-offer a declined call. /// The provider call-state synchronization service. + /// The offer synchronization service used to release a queued call whose media can no longer be connected because it no longer exists on the provider. /// The Contact Center event publisher. /// The clock used to stamp times. /// The logger instance. @@ -52,6 +54,7 @@ public ContactCenterCallCommandService( ICallSessionManager callSessionManager, IInboundVoiceService inboundVoiceService, IProviderCallStateSynchronizationService providerCallStateSynchronizationService, + IProviderVoiceOfferSynchronizationService offerSynchronizationService, IContactCenterEventPublisher publisher, IClock clock, ILogger logger) @@ -65,6 +68,7 @@ public ContactCenterCallCommandService( _callSessionManager = callSessionManager; _inboundVoiceService = inboundVoiceService; _providerCallStateSynchronizationService = providerCallStateSynchronizationService; + _offerSynchronizationService = offerSynchronizationService; _publisher = publisher; _clock = clock; _logger = logger; @@ -138,12 +142,7 @@ provider is not null && connectResult.ErrorCode, connectResult.ErrorMessage); - await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); - - if (!string.IsNullOrEmpty(reservation.QueueId)) - { - await _inboundVoiceService.OfferNextAsync(reservation.QueueId, cancellationToken); - } + await HandleMediaConnectFailureAsync(reservation, interaction, cancellationToken); return CallCommandResult.Failure(connectResult.ErrorMessage ?? "The provider could not connect the call to the agent."); } @@ -168,12 +167,7 @@ provider is not null && reservation.AgentId, answerResult.Error); - await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); - - if (!string.IsNullOrEmpty(reservation.QueueId)) - { - await _inboundVoiceService.OfferNextAsync(reservation.QueueId, cancellationToken); - } + await HandleMediaConnectFailureAsync(reservation, interaction, cancellationToken); return CallCommandResult.Failure(answerResult.Error ?? "The provider could not answer the call."); } @@ -257,6 +251,32 @@ public async Task DeclineInboundOfferAsync(string reservation return CallCommandResult.Success("The offer was declined and re-offered.", requiresDeviceAnswer: false); } + private async Task HandleMediaConnectFailureAsync( + ActivityReservation reservation, + Interaction interaction, + CancellationToken cancellationToken) + { + // The provider could not connect or answer the accepted call. Ask the provider for the authoritative + // call state: if the call no longer exists, remove it from the queue and release both the reservation + // and the agent so the dead call is never re-offered and the agent can receive new calls. Only when + // the call still exists on the provider do we treat this as a transient failure and re-offer it. + var refreshed = await _providerCallStateSynchronizationService.RefreshInteractionAsync(interaction, cancellationToken); + + if (refreshed.Status is InteractionStatus.Ended or InteractionStatus.Failed) + { + await _offerSynchronizationService.ReconcileEndedOfferAsync(refreshed.ItemId, cancellationToken); + } + else + { + await _reservationService.CancelAsync(reservation.ItemId, cancellationToken); + } + + if (!string.IsNullOrEmpty(reservation.QueueId)) + { + await _inboundVoiceService.OfferNextAsync(reservation.QueueId, cancellationToken); + } + } + private async Task FindAuthorizedPendingReservationAsync( string reservationId, string agentUserId, diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs index 56f3a8875..f5a86a657 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceEventService.cs @@ -234,7 +234,14 @@ private async Task EnsureSessionAsync( session.QueueId = interaction.QueueId; session.FromAddress = providerEvent.FromAddress ?? interaction.CustomerAddress; session.ToAddress = providerEvent.ToAddress; - session.State = providerEvent.State; + + // 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.State = ResolveInitialSessionState(interaction); session.RecordingState = interaction.RecordingState; session.RecordingReference = interaction.RecordingReference; session.CreatedUtc = now; @@ -394,6 +401,18 @@ private static InteractionStatus MapInteractionStatus(ContactCenterCallState sta }; } + private static ContactCenterCallState ResolveInitialSessionState(Interaction interaction) + { + return interaction.Status switch + { + InteractionStatus.Connected => ContactCenterCallState.Connected, + InteractionStatus.Held => ContactCenterCallState.OnHold, + InteractionStatus.Transferring => ContactCenterCallState.Connected, + InteractionStatus.Conferenced => ContactCenterCallState.Connected, + _ => ContactCenterCallState.Ringing, + }; + } + private static List ResolveEventTypes( ContactCenterCallState previousState, ContactCenterCallState currentState, diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index ae888d3f3..09bca9144 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -98,6 +98,9 @@ At a high level, the platform changes are: - 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. ### Artificial Intelligence diff --git a/src/CrestApps.Docs/docs/contact-center/voice-routing.md b/src/CrestApps.Docs/docs/contact-center/voice-routing.md index a357c1d94..73ef23c8b 100644 --- a/src/CrestApps.Docs/docs/contact-center/voice-routing.md +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -105,9 +105,13 @@ This is the single-writer boundary for queue assignment. `VoiceContactCenterCallRouter.OfferNextAsync()` turns the pending reservation into a ringing offer: 1. loads the reserved agent and linked interaction -2. moves the interaction to `Ringing` -3. builds a telephony `TelephonyCall` -4. dispatches it through `IIncomingCallDispatcher` +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: @@ -127,9 +131,10 @@ The workspace and the soft-phone incoming modal both call the same authoritative 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. leaves device-native providers in `Ringing` until the provider later reports `Connected` -7. creates or updates the `CallSession` -8. publishes Contact Center events such as `OfferAccepted` +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**. @@ -275,6 +280,8 @@ If provider truth says a ringing call ended before it was actually answered, `Pr That prevents abandoned or already-ended calls from being re-offered as ghost work. +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: diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs index 02f1858ae..ea81ee444 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs @@ -22,6 +22,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Services; public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter, IInboundVoiceService { private const string ServiceAddressMetadataKey = "serviceAddress"; + private const int MaxOfferAttempts = 25; private static readonly TimeSpan _inboundLockTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan _inboundLockExpiration = TimeSpan.FromMinutes(1); @@ -39,6 +40,8 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter private readonly IIncomingCallDispatcher _incomingCallDispatcher; private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; private readonly IEntryPointResolver _entryPointResolver; + private readonly IProviderCallStateSynchronizationService _providerCallStateSynchronizationService; + private readonly IProviderVoiceOfferSynchronizationService _offerSynchronizationService; private readonly IDistributedLock _distributedLock; private readonly IClock _clock; @@ -59,6 +62,8 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter /// The dispatcher used to offer the ringing call to the agent. /// The voice provider resolver used for outbound voice calls. /// The entry point resolver used to route inbound calls by dialed number. + /// The provider call-state synchronization service used to confirm a queued call still exists before it is offered. + /// The offer synchronization service used to remove queued calls that no longer exist on the provider. /// The distributed lock used to serialize inbound call creation by provider call id. /// The clock used to stamp times. public VoiceContactCenterCallRouter( @@ -76,6 +81,8 @@ public VoiceContactCenterCallRouter( IIncomingCallDispatcher incomingCallDispatcher, IContactCenterVoiceProviderResolver voiceProviderResolver, IEntryPointResolver entryPointResolver, + IProviderCallStateSynchronizationService providerCallStateSynchronizationService, + IProviderVoiceOfferSynchronizationService offerSynchronizationService, IDistributedLock distributedLock, IClock clock) { @@ -93,6 +100,8 @@ public VoiceContactCenterCallRouter( _incomingCallDispatcher = incomingCallDispatcher; _voiceProviderResolver = voiceProviderResolver; _entryPointResolver = entryPointResolver; + _providerCallStateSynchronizationService = providerCallStateSynchronizationService; + _offerSynchronizationService = offerSynchronizationService; _distributedLock = distributedLock; _clock = clock; } @@ -283,51 +292,72 @@ public async Task OfferNextAsync(string queueId, CancellationToken cance { ArgumentException.ThrowIfNullOrEmpty(queueId); - var reservation = await _assignmentService.AssignNextAsync(queueId, cancellationToken); - - if (reservation is null) + // Offer the next viable queued call. The telephony provider is the source of truth, so any queued + // call whose provider channel no longer exists is removed instead of being offered. Skipping such + // "zombie" calls prevents an agent from being reserved for a call they can never answer or hang up, + // which would otherwise leave them stuck and unable to receive new inbound calls. The loop is bounded + // to avoid spinning if reservations keep failing for unrelated reasons. + for (var attempt = 0; attempt < MaxOfferAttempts; attempt++) { - return null; - } + var reservation = await _assignmentService.AssignNextAsync(queueId, cancellationToken); - var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + if (reservation is null) + { + return null; + } - if (agent is null || string.IsNullOrEmpty(agent.UserId)) - { - await _reservationService.RejectAsync(reservation.ItemId, cancellationToken); + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); - return null; - } + if (agent is null || string.IsNullOrEmpty(agent.UserId)) + { + await _reservationService.RejectAsync(reservation.ItemId, cancellationToken); - var interaction = await _interactionManager.FindByActivityIdAsync(reservation.ActivityItemId, cancellationToken); + return null; + } - if (interaction is null) - { - await _reservationService.RejectAsync(reservation.ItemId, cancellationToken); + var interaction = await _interactionManager.FindByActivityIdAsync(reservation.ActivityItemId, cancellationToken); - return null; - } + if (interaction is null) + { + await _reservationService.RejectAsync(reservation.ItemId, cancellationToken); + + return null; + } - interaction.Status = InteractionStatus.Ringing; - interaction.AgentId = agent.ItemId; - interaction.QueueId = reservation.QueueId; - await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + interaction = await _providerCallStateSynchronizationService.RefreshInteractionAsync(interaction, cancellationToken); - var call = new TelephonyCall - { - CallId = interaction.ProviderInteractionId, - From = interaction.CustomerAddress, - To = ResolveServiceAddress(interaction), - State = CallState.Ringing, - Direction = CallDirection.Inbound, - ProviderName = interaction.ProviderName, - StartedUtc = _clock.UtcNow, - Metadata = BuildCallMetadata(interaction), - }; + if (interaction.Status is InteractionStatus.Ended or InteractionStatus.Failed) + { + // Provider truth reports the call no longer exists. Remove it from the queue and release the + // reservation and agent so the dead call is never offered again, then try the next call. + await _offerSynchronizationService.ReconcileEndedOfferAsync(interaction.ItemId, cancellationToken); + + continue; + } - await _incomingCallDispatcher.DispatchAsync(agent.UserId, call, cancellationToken); + interaction.Status = InteractionStatus.Ringing; + interaction.AgentId = agent.ItemId; + interaction.QueueId = reservation.QueueId; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + var call = new TelephonyCall + { + CallId = interaction.ProviderInteractionId, + From = interaction.CustomerAddress, + To = ResolveServiceAddress(interaction), + State = CallState.Ringing, + Direction = CallDirection.Inbound, + ProviderName = interaction.ProviderName, + StartedUtc = _clock.UtcNow, + Metadata = BuildCallMetadata(interaction), + }; + + await _incomingCallDispatcher.DispatchAsync(agent.UserId, call, cancellationToken); + + return agent.UserId; + } - return agent.UserId; + return null; } private static string ResolveServiceAddress(Core.Models.Interaction interaction) diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs index 37e93978f..96b2c273c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterCallCommandServiceTests.cs @@ -125,6 +125,40 @@ public async Task AcceptInboundOfferAsync_WhenTelephonyFallbackAnswerFails_Retur Times.Once); } + [Fact] + public async Task AcceptInboundOfferAsync_WhenAnswerFailsAndProviderConfirmsCallGone_RemovesOfferInsteadOfCanceling() + { + // Arrange + var harness = new Harness(); + harness.SetupAcceptedReservation(); + harness.SetupInteraction(); + harness.SetupTelephonyProviderAnswer(TelephonyResult.Failed("The channel does not exist.")); + + // The pre-answer refresh still sees the call as active so the agent accepts and the answer is + // attempted; the answer then fails and the follow-up refresh reports the call as terminal, so the + // offer must be reconciled (removed and the agent released) rather than merely canceling a pending + // reservation. + var endedInteraction = new Interaction { ItemId = "int1", Status = InteractionStatus.Ended }; + harness.ProviderCallStateSynchronizationService + .SetupSequence(s => s.RefreshInteractionAsync(harness.Interaction, It.IsAny())) + .ReturnsAsync(harness.Interaction) + .ReturnsAsync(endedInteraction); + + var service = harness.CreateService(); + + // Act + var result = await service.AcceptInboundOfferAsync("r1", "u1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + harness.OfferSynchronizationService.Verify( + s => s.ReconcileEndedOfferAsync("int1", It.IsAny()), + Times.Once); + harness.ReservationService.Verify( + s => s.CancelAsync("r1", It.IsAny()), + Times.Never); + } + [Fact] public async Task AcceptInboundOfferAsync_WhenProviderConnectFails_DoesNotConnectInteraction() { @@ -274,6 +308,8 @@ private sealed class Harness public Mock ProviderCallStateSynchronizationService { get; } = new(); + public Mock OfferSynchronizationService { get; } = new(); + public Mock Publisher { get; } = new(); public Mock Provider { get; } = new(); @@ -382,6 +418,7 @@ public ContactCenterCallCommandService CreateService() CallSessionManager.Object, InboundVoiceService.Object, ProviderCallStateSynchronizationService.Object, + OfferSynchronizationService.Object, Publisher.Object, clock.Object, logger.Object); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index 7f6bd3271..ceeba3d5e 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -269,6 +269,58 @@ public async Task OfferNextAsync_WhenReservedAgentCannotBeLoaded_ReleasesReserva harness.ReservationService.Verify(s => s.RejectAsync("r1", It.IsAny()), Times.Once); } + [Fact] + public async Task OfferNextAsync_WhenQueuedCallNoLongerExistsOnProvider_RemovesItAndOffersNextCall() + { + // Arrange + var harness = new Harness(); + + harness.AssignmentService + .SetupSequence(m => m.AssignNextAsync("q1", It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r-dead", AgentId = "a-dead", ActivityItemId = "act-dead", QueueId = "q1" }) + .ReturnsAsync(new ActivityReservation { ItemId = "r-live", AgentId = "a-live", ActivityItemId = "act-live", QueueId = "q1" }); + + harness.AgentManager + .Setup(m => m.FindByIdAsync("a-dead", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a-dead", UserId = "user-dead" }); + + harness.AgentManager + .Setup(m => m.FindByIdAsync("a-live", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a-live", UserId = "user-live" }); + + harness.InteractionManager + .Setup(m => m.FindByActivityIdAsync("act-dead", It.IsAny())) + .ReturnsAsync(new Interaction { ItemId = "int-dead", ActivityItemId = "act-dead", ProviderInteractionId = "call-dead" }); + + harness.InteractionManager + .Setup(m => m.FindByActivityIdAsync("act-live", It.IsAny())) + .ReturnsAsync(new Interaction { ItemId = "int-live", ActivityItemId = "act-live", ProviderInteractionId = "call-live" }); + + // The provider reports the first queued call as terminal (gone), and the second call as still active. + harness.ProviderCallStateSynchronizationService + .Setup(s => s.RefreshInteractionAsync(It.Is(i => i.ItemId == "int-dead"), It.IsAny())) + .ReturnsAsync(new Interaction { ItemId = "int-dead", ActivityItemId = "act-dead", ProviderInteractionId = "call-dead", Status = InteractionStatus.Ended }); + + harness.ProviderCallStateSynchronizationService + .Setup(s => s.RefreshInteractionAsync(It.Is(i => i.ItemId == "int-live"), It.IsAny())) + .ReturnsAsync(new Interaction { ItemId = "int-live", ActivityItemId = "act-live", ProviderInteractionId = "call-live", Status = InteractionStatus.Ringing }); + + var service = harness.CreateService(); + + // Act + var agentUserId = await service.OfferNextAsync("q1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("user-live", agentUserId); + harness.OfferSynchronizationService.Verify(s => s.ReconcileEndedOfferAsync("int-dead", It.IsAny()), Times.Once); + harness.IncomingCallDispatcher.Verify( + d => d.DispatchAsync("user-dead", It.IsAny(), It.IsAny()), + Times.Never); + harness.IncomingCallDispatcher.Verify( + d => d.DispatchAsync("user-live", It.Is(c => c.CallId == "call-live"), It.IsAny()), + Times.Once); + } + private sealed class Harness { public Mock ChannelEndpointManager { get; } = new(); @@ -299,6 +351,10 @@ private sealed class Harness public Mock EntryPointResolver { get; } = new(); + public Mock ProviderCallStateSynchronizationService { get; } = new(); + + public Mock OfferSynchronizationService { get; } = new(); + public Mock DistributedLock { get; } = new(); public Harness() @@ -306,6 +362,10 @@ public Harness() DistributedLock .Setup(l => l.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((null, true)); + + ProviderCallStateSynchronizationService + .Setup(s => s.RefreshInteractionAsync(It.IsAny(), It.IsAny())) + .Returns((Interaction interaction, CancellationToken _) => Task.FromResult(interaction)); } public void SetupNoContext() @@ -351,6 +411,8 @@ public VoiceContactCenterCallRouter CreateService() IncomingCallDispatcher.Object, VoiceProviderResolver.Object, EntryPointResolver.Object, + ProviderCallStateSynchronizationService.Object, + OfferSynchronizationService.Object, DistributedLock.Object, clock.Object); } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs index 9f1e0381b..f2ba2fbab 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceEventServiceTests.cs @@ -602,4 +602,77 @@ public async Task IngestAsync_WhenEventIsDuplicate_ReturnsExistingSession() // Assert Assert.Same(session, result); } + + [Fact] + public async Task IngestAsync_WhenFirstObservedEventForNewSessionIsTerminal_PublishesCallEnded() + { + // Arrange + // Reconciliation discovers that a queued (never answered) call no longer exists on the provider and + // emits a terminal event before any session was ever created. The service must still record a real + // non-terminal -> terminal transition so CallEnded is published and the offer cleanup runs. + var interaction = new Interaction + { + ItemId = "interaction-1", + ProviderName = "ProviderA", + ProviderInteractionId = "call-1", + AgentId = "agent-1", + Direction = InteractionDirection.Inbound, + Status = InteractionStatus.Ringing, + }; + var publishedEvents = new List(); + + var interactionManager = new Mock(); + interactionManager + .Setup(manager => manager.FindByProviderInteractionIdAsync("ProviderA", "call-1", It.IsAny())) + .ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByProviderCallIdAsync("ProviderA", "call-1", It.IsAny())) + .ReturnsAsync((CallSession)null); + callSessionManager + .Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync((CallSession)null); + callSessionManager + .Setup(manager => manager.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new CallSession()); + + var eventStore = new Mock(); + eventStore + .Setup(store => store.ExistsByIdempotencyKeyAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(false); + + var publisher = new Mock(); + publisher + .Setup(value => value.PublishAsync(It.IsAny(), It.IsAny())) + .Callback((interactionEvent, _) => publishedEvents.Add(interactionEvent)) + .Returns(Task.CompletedTask); + + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(new DateTime(2026, 7, 10, 15, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceEventService( + interactionManager.Object, + callSessionManager.Object, + new Mock().Object, + new Mock().Object, + eventStore.Object, + publisher.Object, + new Mock().Object, + clock.Object, + NullLogger.Instance); + + // Act + await service.IngestAsync(new ProviderVoiceEvent + { + ProviderName = "ProviderA", + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + IdempotencyKey = "ended-new-1", + }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(InteractionStatus.Ended, interaction.Status); + Assert.Contains(publishedEvents, value => value.EventType == ContactCenterConstants.Events.CallEnded); + } } From 126afd44c51be96e779bd2f8d92d334ce470cae3 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 10 Jul 2026 23:16:25 -0700 Subject: [PATCH 51/56] Harden Contact Center telephony self-healing and event pipeline Fixes recurring inbound-queue zombies, stuck soft-phone "In Call" state, and dropped real-time events by repairing the event-delivery pipeline and self-healing triggers so the telephony provider stays the source of truth. - Asterisk live listener isolates each event dispatch (log+skip on malformed payloads, unroutable events, or transient tenant-scope failures during shell reload) instead of tearing down the WebSocket and dropping subsequent events; it now also subscribes to all application events (subscribeAll). - Ended-offer reconciliation cancels every non-terminal reservation bound to an activity and clears stale agent reservation pointers, even for answered calls. - Work-state healer never requeues a provider-backed ringing interaction; live ringing is released only when provider truth confirms it ended. - Manual presence changes self-heal against provider truth so an agent stuck Busy after a provider-ended call can return to Available immediately, while a genuinely live provider-backed call is preserved. - Adds ListActiveByActivityAsync across reservation store/manager. - 8 new unit tests; docs and changelog updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e704d92f-38e9-4d41-9a89-17dfd6e6fc95 --- .github/contact-center/PLAN.md | 2 +- .../Services/ActivityReservationManager.cs | 13 ++ .../Services/ActivityReservationStore.cs | 14 ++ .../Services/AgentPresenceManagerService.cs | 10 + .../Services/AgentWorkStateHealingService.cs | 18 ++ .../Services/IActivityReservationManager.cs | 8 + .../Services/IActivityReservationStore.cs | 8 + ...roviderVoiceOfferSynchronizationService.cs | 64 ++++-- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 + .../docs/contact-center/voice-routing.md | 10 +- .../Services/AsteriskRealtimeVoiceListener.cs | 48 ++++- .../Services/AsteriskSettingsUtilities.cs | 1 + .../AgentPresenceManagerServiceTests.cs | 82 ++++++++ .../AgentWorkStateHealingServiceTests.cs | 77 ++++++++ ...erVoiceOfferSynchronizationServiceTests.cs | 182 +++++++++++++++++- .../AsteriskSettingsUtilitiesTests.cs | 48 +++++ 16 files changed, 566 insertions(+), 23 deletions(-) create mode 100644 tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskSettingsUtilitiesTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 56ab78d53..f4ba69f59 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1484,7 +1484,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 4 — Voice integration with Telephony (`Voice` feature: Voice Contact Center Call Router (`IVoiceContactCenterCallRouter`) for inbound and outbound voice routing, inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService` compatibility), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, outbound provider dispatch through `IContactCenterVoiceProvider`, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; **G1 backend shipped: provider delivery models (`AgentDeviceNative`/`ServerSideAcd`) + `ConnectToAgentAsync`, `CallSession` aggregate, normalized `ProviderVoiceEvent`/`IProviderVoiceEventService` ingestion, and the authoritative `IContactCenterCallCommandService` that accepts the reservation, bridges media, and advances interaction+call-session together. all G1 items shipped: the soft-phone JS accept-then-answer coordination now awaits the server accept and only answers the device when `RequiresDeviceAnswer` (asset rebuilt), per-provider signed webhook adapters emit `ProviderVoiceEvent`, and the blind/consultative transfer + conference taxonomy landed** — see "Design review" P0 #1, #2, #3 and P1 #9) - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider. **G4 dialer safety shipped (2026-06-30):** each mode is now a dedicated `IDialerStrategy` (Predictive disabled in editor + rejected server-side + refused at runtime; Power hard-capped via `PowerDialerStrategy.MaxCallsPerAgent`); the new `IDialerEligibilityService` compliance gate runs before every attempt and audits `DialSuppressed` (destination, max-attempts, retry cool-down, contact do-not-call, calling window in the contact's time zone, and national DNC registries); single-attempt logic moved to `IDialerAttemptService`. Remaining: callback scheduling (`CallbackRequest`) + callback queues, and dialer run/attempt projections.) - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call work is represented by agent presence (`WrapUp`) plus the active/wrap-up interaction on the Agent Workspace, not by a separate domain aggregate.) -- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. **2026-07-11 event-ordering hardening:** command-triggered active-call polling was removed, page-restoration lookups now yield to newer provider events, stale terminal events cannot clear a newer call id, and Asterisk lookup verifies the channel still exists after its multi-request snapshot. **2026-07-11 inbound terminal repair:** provider-event ingestion now recovers provider-name aliases by call id, canonicalizes the live provider identity, moves answered calls into wrap-up, and completes their assigned queue item so a missed provider-name match cannot leave the agent Busy or block later queue work. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) +- [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. **agent desktop + supervisor dashboard shipped 2026-07-01** (see G5), and **supervisor live-engagement buttons + after-call presence release shipped 2026-07-01**. **2026-07-08 hardening:** when voice is enabled, signing in to a queue or returning to `Available` now immediately offers any already-waiting inbound voice work, the soft-phone incoming-call modal now resolves reservation lifecycle posts correctly from its offer URLs instead of waiting on workspace-only form posts, queue sign-out now clears queue/campaign membership cleanly, Orchard logout now defers the same Contact Center sign-out path until the Orchard logout succeeds, expired reservations keep signed-out agents offline instead of bouncing them back to `Available`, and the soft phone restores an in-progress call after reconnect or page reload from the persisted interaction history. **2026-07-08 soft-phone sign-out fix:** queue sign-in/sign-out now also synchronizes the live `AgentSession` membership used by the real-time layer, and Contact Center domain events are dispatched through deferred outbox fan-out after persistence so slow workflow or notifier handlers cannot leave the soft-phone sign-out postback hanging. **2026-07-09 soft-phone UX hardening:** the widget now preserves the selected footer tab across page reloads, measures the Keypad view and reuses that natural height across Keypad/Recent/Work, scrolls non-keypad tabs inside the shared panel height when needed, suppresses the transient no-provider warning until the real connection status is known, and re-checks already-waiting inbound voice work once the soft phone reconnects so queued calls are offered after a refresh. **2026-07-09 queue sign-in follow-up:** both the soft-phone queue sign-in controller and the availability handler now resolve queued-voice re-offer services lazily instead of constructing the full voice graph during handler/controller activation, which removes the new sign-in loading regression while preserving the already-waiting inbound-call recovery path. **2026-07-09 inbound offer recovery fix:** when a timed-out voice reservation is re-queued, the stale ringing interaction assignment is now cleared before the work returns to the queue so the agent does not stay falsely at capacity and block the next offer. **2026-07-09 stale-offer repair:** queued-voice reconnect recovery now also clears orphaned ringing interactions that no longer have an active reservation, cancels stale pending reservations whose queue item or interaction no longer reflects a live ringing offer, and ignores unassigned `Created` interactions for capacity, so abandoned offer fragments cannot keep future inbound calls from routing. **2026-07-09 pending-offer restore:** the soft phone now restores the currently ringing inbound offer from the active reservation after refresh or reconnect, reuses the same incoming-call context/lifecycle URLs, and keeps the modal visible until accept, decline, or authoritative timeout. **2026-07-09 hub-driven soft-phone sign-in:** queue sign-in, sign-out, queued-voice recovery, and pending-offer restoration in the soft-phone Work tab now flow through `ContactCenterHub` instead of a page reload, queue-group membership is updated on the live connection immediately, and the incoming modal suppresses duplicate or superseded ringing offers once the agent is already handling the call. **2026-07-09 centralized self-healing:** sign-in, sign-out, and queued-voice availability recovery now share an invariant-based healer that clears stale `ActiveReservationId` pointers, cancels ghost pending reservations, and re-queues active assigned interactions that survive restarts or partial failures in an impossible state, so orphaned voice work is reclaimed before it can block future inbound routing. **2026-07-09 live-offer + Asterisk accept follow-up:** queued voice offers now trigger an immediate soft-phone modal refresh from the Contact Center hub's live `OfferReceived`/`OfferRevoked` events, server-side provider-only voice offers now answer the underlying telephony call during authoritative accept so Asterisk-backed calls stay visible and controllable after the agent accepts them, inbound telephony offers are persisted into Recent history as soon as they are dispatched, and an accepted call is no longer cleared back to `Ready` if the hub's revoke event races with the accept response. **2026-07-10 call-session soft-phone sync:** assigned-agent Contact Center call-session events now upsert the Telephony interaction history and push `CallStateChanged` through the Telephony hub, so server-side disconnects and other normalized call-state transitions clear or update the soft phone immediately instead of leaving it stuck on the last client-initiated state. **2026-07-10 richer provider-event normalization:** normalized provider events now also carry mute, recording lifecycle, and conference topology details, and Contact Center emits more detailed real-time-friendly events (`CallHeld`, `CallResumed`, `CallMuted`, `CallUnmuted`, `Recording*`, `CallConferenceChanged`) so present and future providers can keep the soft phone and agent desktop synchronized from richer server truth. **2026-07-10 provider-truth offer reconciliation:** device-native offer accept now revalidates the provider's live call state before it accepts, no longer marks the interaction connected before the provider says it is connected, and a new provider-ended-offer reconciler clears stale queue, reservation, and agent state as soon as a pre-connect call ends. **2026-07-10 Asterisk tenant-stream sync:** the Asterisk module now runs a tenant-scoped ARI event listener through Orchard shell events, maps server-side channel changes into normalized voice events, and also closes plain Telephony soft-phone interactions in real time when Asterisk disconnects them outside the browser. **2026-07-11 provider-authoritative soft-phone state:** Telephony command responses no longer mutate browser state or broadcast `CallStateChanged`; provider events and provider-state lookups now drive all active call transitions, including server-side accept, hold, mute, and hangup recovery. **2026-07-11 event-ordering hardening:** command-triggered active-call polling was removed, page-restoration lookups now yield to newer provider events, stale terminal events cannot clear a newer call id, and Asterisk lookup verifies the channel still exists after its multi-request snapshot. **2026-07-11 inbound terminal repair:** provider-event ingestion now recovers provider-name aliases by call id, canonicalizes the live provider identity, moves answered calls into wrap-up, and completes their assigned queue item so a missed provider-name match cannot leave the agent Busy or block later queue work. **2026-07-11 event-pipeline + self-healing hardening:** the Asterisk live listener now isolates every event dispatch (a malformed payload, unroutable event, or transient tenant-scope failure while the shell reloads is logged and skipped instead of tearing down the WebSocket and dropping subsequent real-time events) and subscribes to all application events (`subscribeAll`) so every channel change reaches provider-truth ingest; ended-offer reconciliation now cancels **every** non-terminal reservation bound to the activity (not just the queue-referenced one) and clears stale agent reservation pointers even for answered calls; the healer never requeues a provider-backed **ringing** interaction (it is left under provider control and released only when provider truth confirms it ended); and manual presence changes self-heal against provider truth so an agent parked as **Busy** after a call the provider already ended can return to **Available** immediately while a genuinely live call is still preserved. Covered by new unit tests in `ProviderVoiceOfferSynchronizationServiceTests`, `AgentWorkStateHealingServiceTests`, `AgentPresenceManagerServiceTests`, and `AsteriskSettingsUtilitiesTests`. Remaining: reason-code deployment-plan step, browser coverage for the core agent/supervisor flows, and broader provider adoption of the tenant-safe live-stream pattern where a backend requires server-side sockets.) - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. **Local test harness shipped 2026-07-08:** `src/Startup/CrestApps.OrchardCore.Asterisk.Web` signs in to Orchard, originates one or more Asterisk channels into the configured Stasis application, waits for the matching `StasisStart` events, and then forwards normalized `InboundVoiceEvent` payloads using the real Asterisk channel ids so routing and agent offers can be exercised through the local PBX flow. The sample dashboard now also surfaces provider-tracked hold/mute state plus inferred party counts, moves live notifications beside the raw ARI payload drill-down to free more width for active-call tables, and lowers the default polling cadence to one second so state changes show up faster. The simulator now makes it explicit that **To address** drives inbound queue or entry-point routing while **Caller number seed** only changes caller identity generation. **2026-07-08 queue hardening:** queues now expose an unanswered-offer policy so timed-out voice offers can requeue, send the live call to voicemail, or reject the live call; when no live provider call exists the timeout safely falls back to requeue. **2026-07-11 Asterisk sample synchronization:** the simulator now enforces the configured provider identity instead of accepting a stale form alias, and the dashboard refreshes independent ARI resources plus per-channel hold/mute enrichment concurrently after a short live-event coalescing window. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs index 0848b3d7b..377433b1a 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationManager.cs @@ -51,4 +51,17 @@ public async Task FindPendingByAgentAsync(string agentId, C return reservation; } + + /// + 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/ActivityReservationStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs index 11f5d4d5b..d3fdda93a 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationStore.cs @@ -42,4 +42,18 @@ public async Task FindPendingByAgentAsync(string agentId, C collection: ContactCenterConstants.CollectionName) .FirstOrDefaultAsync(cancellationToken); } + + /// + 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/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs index a54a8eaef..171ea3687 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -161,6 +161,16 @@ public async Task SetPresenceAsync(string userId, AgentPresenceSta 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); + profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken) ?? profile; + } if (status == AgentPresenceStatus.RequestBreak) { diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs index 3d97e45cc..922aea7be 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentWorkStateHealingService.cs @@ -186,6 +186,24 @@ private async Task HealActiveInteractionAsync(AgentProfile agent, bool rele 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, + agent.ItemId); + } + + return 0; + } + _logger.LogWarning( "Clearing stale ringing interaction '{InteractionId}' for agent '{AgentId}' because no active reservation remains.", interaction.ItemId, diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs index ead11003a..240155cdb 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationManager.cs @@ -23,4 +23,12 @@ public interface IActivityReservationManager : ICatalogManagerThe token to monitor for cancellation requests. /// The pending reservation, or when none exists. Task FindPendingByAgentAsync(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/IActivityReservationStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs index f2d34dc74..8aa884f35 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IActivityReservationStore.cs @@ -23,4 +23,12 @@ public interface IActivityReservationStore : ICatalog /// 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 (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/ProviderVoiceOfferSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs index ddd4e6ba8..838edd783 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs @@ -75,6 +75,21 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok var wasAnswered = interaction.AnsweredUtc.HasValue || session?.AnsweredUtc.HasValue == true; var queueItem = await _queueItemManager.FindByActivityIdAsync(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 canceledReservationIds = new HashSet(StringComparer.Ordinal); + string reservationAgentId = null; + + foreach (var reservation in reservations) + { + reservationAgentId ??= reservation.AgentId; + reservation.Status = ReservationStatus.Canceled; + await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); + canceledReservationIds.Add(reservation.ItemId); + } + if (wasAnswered) { if (queueItem?.Status == QueueItemStatus.Assigned) @@ -84,14 +99,14 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); } - return; - } - - ActivityReservation reservation = null; + // The wrap-up cascade owns the agent's presence transition for answered calls, but still clear a + // dangling reservation pointer so the agent is not blocked from receiving the next offer. + await ClearStaleReservationPointerAsync( + session?.AgentId ?? interaction.AgentId ?? reservationAgentId, + canceledReservationIds, + cancellationToken); - if (!string.IsNullOrWhiteSpace(queueItem?.ReservationId)) - { - reservation = await _reservationManager.FindByIdAsync(queueItem.ReservationId, cancellationToken); + return; } if (_logger.IsEnabled(LogLevel.Warning)) @@ -109,13 +124,7 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); } - if (reservation is not null && reservation.Status is ReservationStatus.Pending or ReservationStatus.Accepted) - { - reservation.Status = ReservationStatus.Canceled; - await _reservationManager.UpdateAsync(reservation, cancellationToken: cancellationToken); - } - - var agentId = reservation?.AgentId ?? session?.AgentId ?? interaction.AgentId; + var agentId = reservationAgentId ?? session?.AgentId ?? interaction.AgentId; if (!string.IsNullOrWhiteSpace(agentId)) { @@ -123,8 +132,8 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok if (agent is not null) { - if (!string.IsNullOrWhiteSpace(reservation?.ItemId) && - string.Equals(agent.ActiveReservationId, reservation.ItemId, StringComparison.Ordinal)) + if (!string.IsNullOrWhiteSpace(agent.ActiveReservationId) && + canceledReservationIds.Contains(agent.ActiveReservationId)) { agent.ActiveReservationId = null; } @@ -159,6 +168,29 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); } + private async Task ClearStaleReservationPointerAsync( + string agentId, + HashSet canceledReservationIds, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(agentId) || canceledReservationIds.Count == 0) + { + return; + } + + var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); + + if (agent is null || + string.IsNullOrWhiteSpace(agent.ActiveReservationId) || + !canceledReservationIds.Contains(agent.ActiveReservationId)) + { + return; + } + + agent.ActiveReservationId = null; + await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); + } + private static bool IsTerminalState(ContactCenterCallState? state) { return state is ContactCenterCallState.Ended or diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 09bca9144..15be8c0ea 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -102,6 +102,10 @@ At a high level, the platform changes are: - 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. ### Artificial Intelligence diff --git a/src/CrestApps.Docs/docs/contact-center/voice-routing.md b/src/CrestApps.Docs/docs/contact-center/voice-routing.md index 73ef23c8b..96757d45e 100644 --- a/src/CrestApps.Docs/docs/contact-center/voice-routing.md +++ b/src/CrestApps.Docs/docs/contact-center/voice-routing.md @@ -274,11 +274,11 @@ That lookup is used for: If provider truth says a ringing call ended before it was actually answered, `ProviderVoiceOfferSynchronizationService` clears the stale routing state: - queue item -- reservation +- **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. +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. @@ -331,10 +331,16 @@ This catches cases where: 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. 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: diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs index 7b5a9330a..affead88d 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskRealtimeVoiceListener.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell; +using OrchardCore.Environment.Shell.Scope; namespace CrestApps.OrchardCore.Asterisk.Services; @@ -186,7 +187,24 @@ private async Task ListenAsync(AsteriskResolvedSettings settings, CancellationTo var payload = Encoding.UTF8.GetString(message.ToArray()); - await DispatchAsync(settings.ProviderName, payload, cancellationToken); + // A single malformed or unroutable event, or a transient tenant-scope failure while the shell is + // reloading, must never tear down the live event stream. Isolate each dispatch so the socket keeps + // receiving; any missed state change is still reconciled by the periodic provider-truth sweep. + try + { + await DispatchAsync(settings.ProviderName, payload, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to dispatch an Asterisk real-time payload for provider {ProviderName}; the listener will continue processing subsequent events.", + settings.ProviderName); + } } } @@ -281,7 +299,33 @@ await ExecuteInTenantScopeAsync(async serviceProvider => private async Task ExecuteInTenantScopeAsync(Func action) { - var scope = await _shellHost.GetScopeAsync(_shellSettings); + // The listener is a tenant singleton whose captured shell settings can point at a shell that is being + // reloaded or disposed. Acquiring a scope or resolving services from a half-built shell throws + // ArgumentNullException for a null service provider, so guard every step and skip gracefully rather + // than letting the failure bubble up and tear down the WebSocket receive loop. + ShellScope scope; + + try + { + scope = await _shellHost.GetScopeAsync(_shellSettings); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Skipped an Asterisk real-time dispatch because a tenant scope could not be acquired; the shell may be reloading."); + + return; + } + + if (scope?.ServiceProvider is null) + { + _logger.LogWarning( + "Skipped an Asterisk real-time dispatch because the tenant scope service provider was unavailable; the shell may be reloading."); + + return; + } + await scope.UsingAsync( shellScope => action(shellScope.ServiceProvider), activateShell: false); diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs index f1cad8846..30eb3864a 100644 --- a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs @@ -169,6 +169,7 @@ public static Uri CreateEventsUri(AsteriskResolvedSettings settings) { ["app"] = settings.ApplicationName, ["api_key"] = $"{settings.UserName}:{settings.Password}", + ["subscribeAll"] = bool.TrueString.ToLowerInvariant(), }).TrimStart('?'); return builder.Uri; diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index a27134248..e0c9dcb97 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -300,6 +300,88 @@ public async Task SignOutAsync_WhenProfileExists_HealsStaleWorkBeforeSigningOut( healer.Verify(manager => manager.HealForResetAsync("a1", It.IsAny()), Times.Once); } + [Fact] + public async Task SetPresenceAsync_WhenStuckBusyWithoutLiveCall_HealsThenBecomesAvailable() + { + // Arrange + var stuck = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Busy, + ActiveReservationId = "r1", + }; + var healed = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Available, + ActiveReservationId = null, + }; + + var agentManager = new Mock(); + agentManager.SetupSequence(m => m.FindByUserIdAsync("u1", It.IsAny())) + .ReturnsAsync(stuck) + .ReturnsAsync(healed); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(healed); + + var healer = new Mock(); + healer.Setup(h => h.HealForResetAsync("a1", It.IsAny())).ReturnsAsync(1); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, [], [healer.Object], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); + + // Act + var profile = await service.SetPresenceAsync("u1", AgentPresenceStatus.Available, "Ready", TestContext.Current.CancellationToken); + + // Assert + healer.Verify(h => h.HealForResetAsync("a1", It.IsAny()), Times.Once); + Assert.Equal(AgentPresenceStatus.Available, profile.PresenceStatus); + Assert.Null(profile.RequestedPresenceStatus); + } + + [Fact] + public async Task SetPresenceAsync_WhenBusyWithLiveCall_DefersAvailabilityAfterHealing() + { + // Arrange + var stuck = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Busy, + ActiveReservationId = "r1", + }; + var stillBusy = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Busy, + ActiveReservationId = "r1", + }; + + var agentManager = new Mock(); + agentManager.SetupSequence(m => m.FindByUserIdAsync("u1", It.IsAny())) + .ReturnsAsync(stuck) + .ReturnsAsync(stillBusy); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(stillBusy); + + var healer = new Mock(); + healer.Setup(h => h.HealForResetAsync("a1", It.IsAny())).ReturnsAsync(0); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, [], [healer.Object], new Mock().Object, CreateDistributedLock().Object, clock.Object, new Mock>().Object); + + // Act + var profile = await service.SetPresenceAsync("u1", AgentPresenceStatus.Available, null, TestContext.Current.CancellationToken); + + // Assert + healer.Verify(h => h.HealForResetAsync("a1", It.IsAny()), Times.Once); + Assert.Equal(AgentPresenceStatus.Busy, profile.PresenceStatus); + Assert.Equal(AgentPresenceStatus.Available, profile.RequestedPresenceStatus); + } + private static Mock CreateDistributedLock() { var distributedLock = new Mock(); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs index 206c62944..2eb48bb47 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentWorkStateHealingServiceTests.cs @@ -381,6 +381,83 @@ public async Task HealForResetAsync_WhenPendingReservationExists_CancelsItEvenWh reservationService.Verify(manager => manager.CancelAsync("r1", It.IsAny()), Times.Once); } + [Fact] + public async Task HealForAvailabilityAsync_WhenRingingInteractionIsProviderBackedAndStillLive_DoesNotRequeue() + { + // Arrange + var agentManager = new Mock(); + agentManager.SetupSequence(manager => manager.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + PresenceStatus = AgentPresenceStatus.Available, + }); + + var reservationManager = new Mock(); + reservationManager.Setup(manager => manager.FindPendingByAgentAsync("a1", It.IsAny())) + .ReturnsAsync((ActivityReservation)null); + + var queueItem = new QueueItem + { + ItemId = "qi-1", + ActivityItemId = "act-1", + QueueId = "q1", + ReservationId = "r1", + AgentId = "a1", + Status = QueueItemStatus.Reserved, + }; + + var queueItemManager = new Mock(); + queueItemManager.Setup(manager => manager.FindByActivityIdAsync("act-1", It.IsAny())) + .ReturnsAsync(queueItem); + + var interaction = new Interaction + { + ItemId = "i1", + ActivityItemId = "act-1", + QueueId = "q1", + AgentId = "a1", + Status = InteractionStatus.Ringing, + ProviderName = "provider-1", + ProviderInteractionId = "call-1", + }; + + var interactionManager = new Mock(); + interactionManager.Setup(manager => manager.FindActiveByAgentAsync("a1", It.IsAny())) + .ReturnsAsync(interaction); + + var synchronizationService = new Mock(); + synchronizationService + .Setup(service => service.RefreshInteractionAsync(interaction, It.IsAny())) + .ReturnsAsync(interaction); + + var activityManager = new Mock(); + + var service = CreateService( + agentManager, + reservationManager, + new Mock(), + queueItemManager, + interactionManager, + activityManager, + synchronizationService); + + // Act + var healed = await service.HealForAvailabilityAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, healed); + Assert.Equal(QueueItemStatus.Reserved, queueItem.Status); + Assert.Equal("a1", queueItem.AgentId); + Assert.Equal(InteractionStatus.Ringing, interaction.Status); + Assert.Equal("a1", interaction.AgentId); + } + private static AgentWorkStateHealingService CreateService( Mock agentManager, Mock reservationManager, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs index 03302006c..125797f59 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs @@ -68,7 +68,7 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); var reservationManager = new Mock(); - reservationManager.Setup(m => m.FindByIdAsync("res-1", It.IsAny())).ReturnsAsync(reservation); + reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync([reservation]); var agentManager = new Mock(); agentManager.Setup(m => m.FindByIdAsync("agent-1", It.IsAny())).ReturnsAsync(agent); @@ -165,6 +165,7 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallEnded_CompletesAssign clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync([]); var agentManager = new Mock(); var activityManager = new Mock(); var service = new ProviderVoiceOfferSynchronizationService( @@ -240,11 +241,14 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallTransferred_Completes var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync([]); + var service = new ProviderVoiceOfferSynchronizationService( interactionManager.Object, callSessionManager.Object, queueItemManager.Object, - new Mock().Object, + reservationManager.Object, new Mock().Object, new Mock().Object, clock.Object, @@ -261,4 +265,178 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallTransferred_Completes It.IsAny()), Times.Once); } + + [Fact] + public async Task ReconcileEndedOfferAsync_WhenMultipleReservationsExist_CancelsAllOfThemAndReleasesAgent() + { + // Arrange + var interaction = new Interaction + { + ItemId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + Status = InteractionStatus.Ended, + }; + var queueItem = new QueueItem + { + ItemId = "queue-1", + ActivityItemId = "act1", + ReservationId = "res-2", + Status = QueueItemStatus.Assigned, + }; + var reservations = new List + { + new() { ItemId = "res-1", AgentId = "agent-1", ActivityItemId = "act1", Status = ReservationStatus.Pending }, + new() { ItemId = "res-2", AgentId = "agent-1", ActivityItemId = "act1", Status = ReservationStatus.Accepted }, + new() { ItemId = "res-3", AgentId = "agent-1", ActivityItemId = "act1", Status = ReservationStatus.Accepted }, + }; + var agent = new AgentProfile + { + ItemId = "agent-1", + ActiveReservationId = "res-2", + PresenceStatus = AgentPresenceStatus.Busy, + QueueIds = ["queue-1"], + }; + var activity = new OmnichannelActivity + { + ItemId = "act1", + AssignmentStatus = ActivityAssignmentStatus.Assigned, + AssignedToId = "user-1", + ReservationId = "res-2", + }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(m => m.FindByInteractionIdAsync("int1", It.IsAny())).ReturnsAsync((CallSession)null); + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); + + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync(reservations); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("agent-1", It.IsAny())).ReturnsAsync(agent); + + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act1", It.IsAny())).ReturnsAsync(activity); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceOfferSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + queueItemManager.Object, + reservationManager.Object, + agentManager.Object, + activityManager.Object, + clock.Object, + new Mock>().Object); + + // Act + await service.ReconcileEndedOfferAsync("int1", TestContext.Current.CancellationToken); + + // Assert + reservationManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == ReservationStatus.Canceled), + null, + It.IsAny()), + Times.Exactly(3)); + agentManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.ActiveReservationId == null && value.PresenceStatus == AgentPresenceStatus.Available), + null, + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ReconcileEndedOfferAsync_WhenAnsweredCallHasLingeringReservation_CancelsItAndClearsAgentPointer() + { + // Arrange + var answeredUtc = new DateTime(2026, 7, 10, 11, 59, 0, DateTimeKind.Utc); + var interaction = new Interaction + { + ItemId = "int1", + ActivityItemId = "act1", + AgentId = "agent-1", + Status = InteractionStatus.Ended, + AnsweredUtc = answeredUtc, + }; + var queueItem = new QueueItem + { + ItemId = "queue-1", + ActivityItemId = "act1", + ReservationId = "res-1", + Status = QueueItemStatus.Assigned, + }; + var reservation = new ActivityReservation + { + ItemId = "res-1", + AgentId = "agent-1", + ActivityItemId = "act1", + Status = ReservationStatus.Accepted, + }; + var agent = new AgentProfile + { + ItemId = "agent-1", + ActiveReservationId = "res-1", + PresenceStatus = AgentPresenceStatus.WrapUp, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var callSessionManager = new Mock(); + callSessionManager.Setup(m => m.FindByInteractionIdAsync("int1", It.IsAny())).ReturnsAsync((CallSession)null); + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); + + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync([reservation]); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("agent-1", It.IsAny())).ReturnsAsync(agent); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + + var service = new ProviderVoiceOfferSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + queueItemManager.Object, + reservationManager.Object, + agentManager.Object, + new Mock().Object, + clock.Object, + new Mock>().Object); + + // Act + await service.ReconcileEndedOfferAsync("int1", TestContext.Current.CancellationToken); + + // Assert + queueItemManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == QueueItemStatus.Completed && value.DequeuedUtc.HasValue), + null, + It.IsAny()), + Times.Once); + reservationManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == ReservationStatus.Canceled), + null, + It.IsAny()), + Times.Once); + agentManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.ActiveReservationId == null && value.PresenceStatus == AgentPresenceStatus.WrapUp), + null, + It.IsAny()), + Times.Once); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskSettingsUtilitiesTests.cs b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskSettingsUtilitiesTests.cs new file mode 100644 index 000000000..63e1b2de7 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskSettingsUtilitiesTests.cs @@ -0,0 +1,48 @@ +using CrestApps.OrchardCore.Asterisk.Services; + +namespace CrestApps.OrchardCore.Tests.Telephony; + +public sealed class AsteriskSettingsUtilitiesTests +{ + [Theory] + [InlineData("http://asterisk:8088/ari", "ws")] + [InlineData("https://asterisk:8089/ari", "wss")] + public void CreateEventsUri_BuildsSubscribeAllEventStream(string baseUrl, string expectedScheme) + { + // Arrange + var settings = new AsteriskResolvedSettings + { + BaseUrl = baseUrl, + UserName = "user", + Password = "secret", + ApplicationName = "contact-center", + }; + + // Act + var uri = AsteriskSettingsUtilities.CreateEventsUri(settings); + + // Assert + Assert.NotNull(uri); + Assert.Equal(expectedScheme, uri.Scheme); + Assert.EndsWith("/ari/events", uri.AbsolutePath); + Assert.Contains("app=contact-center", uri.Query); + Assert.Contains("subscribeAll=true", uri.Query); + } + + [Fact] + public void CreateEventsUri_WhenBaseUrlMissing_ReturnsNull() + { + // Arrange + var settings = new AsteriskResolvedSettings + { + BaseUrl = null, + ApplicationName = "contact-center", + }; + + // Act + var uri = AsteriskSettingsUtilities.CreateEventsUri(settings); + + // Assert + Assert.Null(uri); + } +} From 7e5329b964e4c453c73b73083fcfbe58fa4f53a6 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 11 Jul 2026 08:40:22 -0700 Subject: [PATCH 52/56] Add omnichannel activity management enhancements Add permission-gated activity purge, configurable follow-up ownership, and national/E.164 phone search. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0683772-e324-4658-9aff-1eda6766ce4c --- .../Indexes/OmnichannelContactPhoneIndex.cs | 44 ++++ .../Models/BulkManageActivityFilter.cs | 4 +- .../Models/NewActivityActionMetadata.cs | 7 +- .../Models/OmnichannelActivityBatch.cs | 4 +- .../Models/PhoneNumberMatchType.cs | 11 +- .../SubjectActionOwnerAssignmentType.cs | 17 ++ ...ubjectActionOwnerAssignmentTypeResolver.cs | 25 ++ .../Models/TryAgainActionMetadata.cs | 7 +- .../OmnichannelConstants.cs | 5 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 3 + .../docs/omnichannel/management.md | 36 ++- .../Controllers/ActivitiesController.cs | 53 +++- ...OrchardCore.Omnichannel.Managements.csproj | 1 + .../BulkManageActivityFilterDisplayDriver.cs | 6 +- .../NewActivitySubjectActionDisplayDriver.cs | 22 +- .../OmnichannelActivityBatchDisplayDriver.cs | 6 +- .../TryAgainSubjectActionDisplayDriver.cs | 30 ++- .../BulkManageActivityFilterHandler.cs | 90 ++++--- .../OmnichannelContactPhoneIndexProvider.cs | 137 +++++++++++ .../Manifest.cs | 1 + .../OmnichannelContactsMigrations.cs | 124 +++++++++- .../Services/ActivityPurgeHelper.cs | 18 ++ .../DefaultContactActivityBatchLoader.cs | 72 ++++-- .../Services/DefaultSubjectActionExecutor.cs | 84 +++++-- ...actPhoneContentsAdminListFilterProvider.cs | 72 ++++++ .../Services/PermissionProvider.cs | 1 + .../Services/PhoneNumberSearchTerm.cs | 72 ++++++ .../Startup.cs | 4 + .../BulkManageActivityFilterViewModel.cs | 2 +- .../NewActivitySubjectActionViewModel.cs | 5 + .../OmnichannelActivityBatchViewModel.cs | 2 +- .../TryAgainSubjectActionViewModel.cs | 5 + ...BulkManageActivityFilterFields.Edit.cshtml | 5 +- ...eduledActivity.Buttons.SummaryAdmin.cshtml | 13 + ...NewActivitySubjectActionFields.Edit.cshtml | 41 +++- ...OmnichannelActivityBatchFields.Edit.cshtml | 4 +- .../TryAgainSubjectActionFields.Edit.cshtml | 41 +++- .../ContactIndexProviderCollectionTests.cs | 16 ++ .../DefaultSubjectActionExecutorTests.cs | 226 ++++++++++++++++++ ...nichannelContactPhoneIndexProviderTests.cs | 148 ++++++++++++ .../Services/ActivityPurgeHelperTests.cs | 33 +++ ...oneContentsAdminListFilterProviderTests.cs | 61 +++++ .../Services/PhoneNumberSearchTermTests.cs | 72 ++++++ .../OmnichannelPermissionsTests.cs | 52 ++++ 44 files changed, 1582 insertions(+), 100 deletions(-) create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentType.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentTypeResolver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PhoneNumberSearchTerm.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultSubjectActionExecutorTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/PhoneNumberSearchTermTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/OmnichannelPermissionsTests.cs diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs new file mode 100644 index 000000000..ece67c353 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs @@ -0,0 +1,44 @@ +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.Omnichannel.Core.Indexes; + +/// +/// Represents searchable phone numbers for an omnichannel contact content item version. +/// +public sealed class OmnichannelContactPhoneIndex : MapIndex +{ + /// + /// Gets or sets the content item identifier. + /// + public string ContentItemId { get; set; } + + /// + /// Gets or sets whether the indexed content item version is published. + /// + public bool Published { get; set; } + + /// + /// Gets or sets whether the indexed content item version is the latest version. + /// + public bool Latest { get; set; } + + /// + /// Gets or sets the primary cell phone number in E.164 format. + /// + public string E164PrimaryCellPhoneNumber { get; set; } + + /// + /// Gets or sets the primary cell phone number in national format. + /// + public string NationalPrimaryCellPhoneNumber { get; set; } + + /// + /// Gets or sets the primary home phone number in E.164 format. + /// + public string E164PrimaryHomePhoneNumber { get; set; } + + /// + /// Gets or sets the primary home phone number in national format. + /// + public string NationalPrimaryHomePhoneNumber { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs index fea6777a7..04a2a8762 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs @@ -96,14 +96,14 @@ public sealed class BulkManageActivityFilter : Entity /// /// Gets or sets the phone number to search for in contact records. - /// The value should be in E.164 format (e.g., +17025551234). + /// A leading plus sign searches E.164 values; otherwise, the national number is searched. /// public string PhoneNumber { get; set; } /// /// Gets or sets the match type for the phone number filter. /// - public PhoneNumberMatchType PhoneNumberMatchType { get; set; } + public PhoneNumberMatchType PhoneNumberMatchType { get; set; } = PhoneNumberMatchType.Contains; /// /// Gets or sets the time zone identifiers to filter contacts by. diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/NewActivityActionMetadata.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/NewActivityActionMetadata.cs index 5cb18b1f1..8c3562eb8 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/NewActivityActionMetadata.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/NewActivityActionMetadata.cs @@ -19,9 +19,14 @@ public sealed class NewActivityActionMetadata /// public ActivityUrgencyLevel? UrgencyLevel { get; set; } + /// + /// Gets or sets how the owner of the new activity is selected. + /// + public SubjectActionOwnerAssignmentType AssignmentType { get; set; } + /// /// Gets or sets the normalized username to assign the new activity to. - /// When null, the activity is assigned to the user who completed the original activity. + /// Used when is . /// public string NormalizedUserName { get; set; } diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs index c4a76d4db..53f4e223c 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs @@ -137,14 +137,14 @@ public sealed class OmnichannelActivityBatch : CatalogItem, IDisplayTextAwareMod /// /// Gets or sets the phone number to filter leads by. - /// The value should be in E.164 format (e.g., +17025551234). + /// A leading plus sign searches E.164 values; otherwise, the national number is searched. /// public string PhoneNumber { get; set; } /// /// Gets or sets the match type for the phone number filter. /// - public PhoneNumberMatchType PhoneNumberMatchType { get; set; } + public PhoneNumberMatchType PhoneNumberMatchType { get; set; } = PhoneNumberMatchType.Contains; /// /// Gets or sets the time zone identifiers to filter leads by. diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberMatchType.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberMatchType.cs index 7def61e4c..901912aa2 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberMatchType.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/PhoneNumberMatchType.cs @@ -8,15 +8,20 @@ public enum PhoneNumberMatchType /// /// Match phone numbers that exactly equal the given value. /// - Exact, + Exact = 0, /// /// Match phone numbers that start with the given value. /// - BeginsWith, + BeginsWith = 1, /// /// Match phone numbers that end with the given value. /// - EndsWith, + EndsWith = 2, + + /// + /// Match phone numbers that contain the given value. + /// + Contains = 3, } diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentType.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentType.cs new file mode 100644 index 000000000..a2bbe73cc --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentType.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Identifies how the owner of a follow-up subject action is selected. +/// +public enum SubjectActionOwnerAssignmentType +{ + /// + /// Assigns the follow-up activity to the user who completed the current activity. + /// + SameOwner, + + /// + /// Assigns the follow-up activity to a configured Orchard user. + /// + SpecificOwner, +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentTypeResolver.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentTypeResolver.cs new file mode 100644 index 000000000..c83f80b54 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/SubjectActionOwnerAssignmentTypeResolver.cs @@ -0,0 +1,25 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Resolves the effective owner assignment type for current and legacy subject action metadata. +/// +public static class SubjectActionOwnerAssignmentTypeResolver +{ + /// + /// Resolves the effective assignment type, treating a legacy configured username as a specific owner. + /// + /// The explicitly configured assignment type. + /// The configured normalized username, if any. + public static SubjectActionOwnerAssignmentType Resolve( + SubjectActionOwnerAssignmentType assignmentType, + string normalizedUserName) + { + if (assignmentType == SubjectActionOwnerAssignmentType.SpecificOwner || + !string.IsNullOrWhiteSpace(normalizedUserName)) + { + return SubjectActionOwnerAssignmentType.SpecificOwner; + } + + return SubjectActionOwnerAssignmentType.SameOwner; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/TryAgainActionMetadata.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/TryAgainActionMetadata.cs index 06382d59f..d5050e668 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/TryAgainActionMetadata.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/TryAgainActionMetadata.cs @@ -18,9 +18,14 @@ public sealed class TryAgainActionMetadata /// public ActivityUrgencyLevel? UrgencyLevel { get; set; } + /// + /// Gets or sets how the owner of the retry activity is selected. + /// + public SubjectActionOwnerAssignmentType AssignmentType { get; set; } + /// /// Gets or sets the normalized username to assign the retry activity to. - /// When null, the activity is assigned to the user who completed the original activity. + /// Used when is . /// public string NormalizedUserName { get; set; } diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs index 1db1a7535..796b1f126 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs @@ -146,6 +146,11 @@ public static class Permissions /// public readonly static Permission ManageActivities = new("ManageActivities", "Manage activities"); + /// + /// Gets the permission to purge an activity. + /// + public readonly static Permission PurgeActivity = new("PurgeActivity", "Purge activity", [ManageActivities]); + /// /// Gets the permission to manage activity batches. /// diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 15be8c0ea..2ac5518a3 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -510,6 +510,9 @@ At a high level, the platform changes are: - A new **Manage Activities** page (`Admin/omnichannel/manage-activities`) supports page-size selection, separate Contact and Activity filter groups, urgency icons, and bulk actions against selected or filtered activities. - 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. 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. - 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. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 25f3f4084..26968dfda 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -68,8 +68,13 @@ Each subject can have multiple actions per disposition, and each action has its | Type | Description | |------|-------------| | **Finish** | Completes the task. No additional actions are taken. | -| **Try Again** | Creates a retry activity with the same details and an incremented attempt count. Configurable parameters: max attempts, urgency level, assigned user, default schedule hours. | -| **New Activity** | Creates a brand new activity, optionally targeting a different subject type. The new activity resolves its campaign, interaction type, and channel settings from the target subject flow. | +| **Try Again** | Creates a retry activity with the same details and an incremented attempt count. Configurable parameters include max attempts, urgency level, owner assignment, and default schedule hours. | +| **New Activity** | Creates a brand new activity, optionally targeting a different subject type. The new activity resolves its campaign, interaction type, and channel settings from the target subject flow and supports configurable owner assignment. | + +Actions that create follow-up activities expose an **Assignment type**: + +- **Same owner** assigns the follow-up activity to the user who completes the current activity. +- **Specific owner** displays a required user selector and assigns the follow-up activity to that selected user. **Communication preferences:** Every action type can optionally set Do-Not-Call, Do-Not-SMS, Do-Not-Email, and Do-Not-Chat flags on the contact when executed. @@ -183,7 +188,7 @@ After saving the subject flow, click **Manage Flow** from the `Subject Flows` li 1. Click **Add Action**. 2. Select an action type (**Finish**, **Try Again**, or **New Activity**). -3. Choose a disposition and configure the action parameters. +3. Choose a disposition and configure the action parameters. For **Try Again** and **New Activity**, choose **Same owner** or **Specific owner**. 4. Repeat to add multiple actions per disposition or for different dispositions. **Example setup:** @@ -208,7 +213,7 @@ Subjects without any actions show a **Missing flow** badge in the Subject Flows - Select subject type - For **Dialer** inventory loads, select the required dialer profile that controls the dialing mode, queue, and campaign assignment. - Assign users when the selected source requires assignment. - - Optionally set lead created range, phone number, time zone, and last activity filters + - Optionally set contact created range, phone number, time zone, and last activity filters 4. Click `Load`. The inventory load runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the channel and channel endpoint. Manual inventory loads assign each created activity to a selected user. Dialer inventory loads require a phone subject flow, leave activities unassigned with assignment status `Available`, and apply the selected dialer profile so the created activities inherit the profile's dialing mode and campaign before dialers reserve them later. @@ -242,6 +247,27 @@ Navigate to **Interaction Center** -> **Activities** to review scheduled omnicha The scheduled activities list now includes a **Time zone** filter alongside the existing urgency, subject, channel, and attempt filters so agents can narrow work to leads in call-safe regions. Activity summary rows also display the contact's current local time when a lead time zone is stored, and the tooltip shows the full local date/time plus the IANA time zone id so agents can confirm whether the lead is ahead of or behind their own day before opening or completing the activity. +Users with the **Purge activity** permission see a **Purge** button on each scheduled activity in a contact profile. Purging is irreversible, changes the activity status to `Purged`, and clears any reservation state. The same permission is required for the bulk **Purge** action on the Manage Activities page; **Manage activities** implies **Purge activity**. + +### Phone number search + +Phone filters in **Load Inventory**, **Manage Activities**, and Content Admin search the primary **Cell** and **Home** contact methods. + +- Input that does not begin with `+` is reduced to digits and matched against the national number, so values such as `702499`, `(702) 499`, or `702-499` are accepted. +- Input whose trimmed value begins with `+` is matched against the E.164 value. The plus sign is a literal format indicator, not a wildcard. +- **Contains** is the default match mode. **Exact match**, **Begins with**, and **Ends with** are also available in Load Inventory and Manage Activities. + +Content Admin supports these named search terms: + +| Term | Match behavior | Example | +|------|----------------|---------| +| `phone:` | Contains | `phone:702499` | +| `phone-exact:` | Exact match | `phone-exact:7024993350` | +| `phone-starts:` | Begins with | `phone-starts:+1702` | +| `phone-ends:` | Ends with | `phone-ends:3350` | + +National-number searches can match contacts from more than one country. Use a leading `+` when the country calling code must be part of the search. + ## Bulk Activity Management The **Manage Activities** page provides a centralized interface for managing active omnichannel inventory across manual, automated, and dialer-oriented activities. It targets editable work states such as `NotStarted`, `Scheduled`, `Pending`, `AwaitingAgentResponse`, `Failed`, and `Cancelled` so managers can clean up, re-route, or reclassify queued work without opening each activity one by one. @@ -261,6 +287,8 @@ The filter panel groups fields into **Contact filters** and **Activity filters** | Filter | Type | Description | |--------|------|-------------| | Contact status | Select | Filter by published or unpublished contacts | +| Phone number | Text | Search primary Cell and Home numbers using national-number fragments or a leading `+` for E.164 | +| Phone match type | Select | Contains, exact match, begins with, or ends with | #### Activity Filters diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index bb6dd9d1d..d87cf4726 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -562,6 +562,42 @@ public async Task CompleteAsync(string id) return View(model); } + /// + /// Purges a scheduled activity from a contact profile. + /// + /// The contact content item identifier. + /// The activity identifier. + [HttpPost] + [ValidateAntiForgeryToken] + [Admin("omnichannel/activities/purge/{contentItemId}/{id}")] + public async Task Purge(string contentItemId, string id) + { + var contact = await _contentManager.GetAsync(contentItemId, VersionOptions.Published); + + if (contact is null) + { + return NotFound(); + } + + var activity = await _omnichannelActivityManager.FindByIdAsync(id); + + if (!IsContactScheduledActivity(activity, contact.ContentItemId)) + { + return NotFound(); + } + + if (!await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.PurgeActivity, activity)) + { + return Forbid(); + } + + ActivityPurgeHelper.Purge(activity); + await _omnichannelActivityManager.UpdateAsync(activity); + await _notifier.SuccessAsync(H["The activity has been purged successfully."]); + + return RedirectToAction(nameof(List), new { contentItemId = contact.ContentItemId }); + } + /// /// Displays the bulk manage activities page. /// @@ -694,7 +730,11 @@ public async Task ManageActivitiesBulkActionPost( PagerParameters pagerParameters, [FromServices] IDisplayManager filterDisplayManager) { - if (!await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.ManageActivities)) + var requiredPermission = viewModel.BulkAction == BulkActivityAction.Purge + ? OmnichannelConstants.Permissions.PurgeActivity + : OmnichannelConstants.Permissions.ManageActivities; + + if (!await _authorizationService.AuthorizeAsync(User, requiredPermission)) { return Forbid(); } @@ -883,8 +923,7 @@ private async Task BulkPurgeAsync(List activities) foreach (var activity in activities) { - activity.Status = ActivityStatus.Purged; - ClearReservationState(activity); + ActivityPurgeHelper.Purge(activity); await _omnichannelActivityManager.UpdateAsync(activity); processedCount++; } @@ -1095,6 +1134,14 @@ private static bool IsBulkManageableActivity(OmnichannelActivity activity) activity.Status is ActivityStatus.NotStated or ActivityStatus.Scheduled or ActivityStatus.Pending or ActivityStatus.AwaitingAgentResponse or ActivityStatus.Failed or ActivityStatus.Cancelled; } + private static bool IsContactScheduledActivity(OmnichannelActivity activity, string contactContentItemId) + { + return activity is not null && + activity.Status == ActivityStatus.NotStated && + activity.InteractionType == ActivityInteractionType.Manual && + string.Equals(activity.ContactContentItemId, contactContentItemId, StringComparison.Ordinal); + } + private static void ResetAssignment(OmnichannelActivity activity) { activity.AssignedToId = null; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj index ab35b3c85..ca4051416 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs index 461088d6a..23ce30696 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs @@ -138,6 +138,7 @@ public override IDisplayResult Edit(BulkManageActivityFilter filter, BuildEditor model.PhoneNumberMatchTypes = [ + new(S["Contains"], nameof(PhoneNumberMatchType.Contains)), new(S["Exact match"], nameof(PhoneNumberMatchType.Exact)), new(S["Begins with"], nameof(PhoneNumberMatchType.BeginsWith)), new(S["Ends with"], nameof(PhoneNumberMatchType.EndsWith)), @@ -202,9 +203,10 @@ public override async Task UpdateAsync(BulkManageActivityFilter filter.DoNotCallFrom = null; filter.DoNotCallTo = null; - if (!string.IsNullOrEmpty(filter.PhoneNumber) && !filter.PhoneNumber.StartsWith('+')) + if (!string.IsNullOrWhiteSpace(filter.PhoneNumber) && + !PhoneNumberSearchTerm.TryParse(filter.PhoneNumber, out _)) { - context.Updater.ModelState.AddModelError(Prefix, nameof(model.PhoneNumber), S["Phone number must be in E.164 format (e.g., +17025551234 for US/Canada)."]); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.PhoneNumber), S["Phone number must contain at least one digit."]); } if (!string.IsNullOrEmpty(model.ContactIsPublished) && bool.TryParse(model.ContactIsPublished, out var isPublished)) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs index b03f14247..e16ef5cfa 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs @@ -48,6 +48,7 @@ public override IDisplayResult Edit(SubjectAction action, BuildEditorContext con { model.SubjectContentType = metadata.SubjectContentType; model.UrgencyLevel = metadata.UrgencyLevel; + model.AssignmentType = SubjectActionOwnerAssignmentTypeResolver.Resolve(metadata.AssignmentType, metadata.NormalizedUserName); model.NormalizedUserName = metadata.NormalizedUserName; model.DefaultScheduleHours = metadata.DefaultScheduleHours; @@ -98,17 +99,36 @@ public override async Task UpdateAsync(SubjectAction action, Upd await context.Updater.TryUpdateModelAsync(model, Prefix); + var normalizedUserName = model.NormalizedUserName?.Trim(); + if (!string.IsNullOrWhiteSpace(model.SubjectContentType) && await _subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(model.SubjectContentType) is null) { context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubjectContentType), S["The selected subject must be configured under Subject Flows before it can be used by a New Activity action."]); } + if (model.AssignmentType == SubjectActionOwnerAssignmentType.SpecificOwner) + { + if (string.IsNullOrWhiteSpace(normalizedUserName)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.NormalizedUserName), S["A user is required when the assignment type is Specific owner."]); + } + else if (await _session.Query(x => x.NormalizedUserName == normalizedUserName).FirstOrDefaultAsync() is null) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.NormalizedUserName), S["The selected user does not exist."]); + } + } + else + { + normalizedUserName = null; + } + action.Put(new NewActivityActionMetadata { SubjectContentType = model.SubjectContentType, UrgencyLevel = model.UrgencyLevel, - NormalizedUserName = model.NormalizedUserName?.Trim(), + AssignmentType = model.AssignmentType, + NormalizedUserName = normalizedUserName, DefaultScheduleHours = model.DefaultScheduleHours, }); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs index 4f80a15af..0d859377b 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs @@ -181,6 +181,7 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC model.PhoneNumberMatchTypes = [ + new(S["Contains"], nameof(PhoneNumberMatchType.Contains)), new(S["Exact match"], nameof(PhoneNumberMatchType.Exact)), new(S["Begins with"], nameof(PhoneNumberMatchType.BeginsWith)), new(S["Ends with"], nameof(PhoneNumberMatchType.EndsWith)), @@ -319,9 +320,10 @@ public override async Task UpdateAsync(OmnichannelActivityBatch context.Updater.ModelState.AddModelError(Prefix, nameof(model.ScheduleAt), S["Schedule at field is required."]); } - if (!string.IsNullOrEmpty(model.PhoneNumber) && !model.PhoneNumber.TrimStart().StartsWith('+')) + if (!string.IsNullOrWhiteSpace(model.PhoneNumber) && + !PhoneNumberSearchTerm.TryParse(model.PhoneNumber, out _)) { - context.Updater.ModelState.AddModelError(Prefix, nameof(model.PhoneNumber), S["Phone number must be in E.164 format (e.g., +17025551234 for US/Canada)."]); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.PhoneNumber), S["Phone number must contain at least one digit."]); } batch.DisplayText = model.DisplayText?.Trim(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/TryAgainSubjectActionDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/TryAgainSubjectActionDisplayDriver.cs index 30daed0d6..e50d2440f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/TryAgainSubjectActionDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/TryAgainSubjectActionDisplayDriver.cs @@ -3,8 +3,10 @@ using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels; using CrestApps.OrchardCore.Users; +using Microsoft.Extensions.Localization; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; using OrchardCore.Users.Indexes; using OrchardCore.Users.Models; using YesSql; @@ -16,12 +18,16 @@ internal sealed class TryAgainSubjectActionDisplayDriver : DisplayDriver stringLocalizer) { _session = session; _displayNameProvider = displayNameProvider; + S = stringLocalizer; } public override IDisplayResult Edit(SubjectAction action, BuildEditorContext context) @@ -37,6 +43,7 @@ public override IDisplayResult Edit(SubjectAction action, BuildEditorContext con { model.MaxAttempt = metadata.MaxAttempt; model.UrgencyLevel = metadata.UrgencyLevel; + model.AssignmentType = SubjectActionOwnerAssignmentTypeResolver.Resolve(metadata.AssignmentType, metadata.NormalizedUserName); model.NormalizedUserName = metadata.NormalizedUserName; model.DefaultScheduleHours = metadata.DefaultScheduleHours; @@ -76,11 +83,30 @@ public override async Task UpdateAsync(SubjectAction action, Upd await context.Updater.TryUpdateModelAsync(model, Prefix); + var normalizedUserName = model.NormalizedUserName?.Trim(); + + if (model.AssignmentType == SubjectActionOwnerAssignmentType.SpecificOwner) + { + if (string.IsNullOrWhiteSpace(normalizedUserName)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.NormalizedUserName), S["A user is required when the assignment type is Specific owner."]); + } + else if (await _session.Query(x => x.NormalizedUserName == normalizedUserName).FirstOrDefaultAsync() is null) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.NormalizedUserName), S["The selected user does not exist."]); + } + } + else + { + normalizedUserName = null; + } + action.Put(new TryAgainActionMetadata { MaxAttempt = model.MaxAttempt, UrgencyLevel = model.UrgencyLevel, - NormalizedUserName = model.NormalizedUserName?.Trim(), + AssignmentType = model.AssignmentType, + NormalizedUserName = normalizedUserName, DefaultScheduleHours = model.DefaultScheduleHours, }); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs index 56ed674cf..615ec7bf8 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs @@ -1,6 +1,7 @@ using CrestApps.OrchardCore.Omnichannel.Core.Indexes; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; using YesSql; namespace CrestApps.OrchardCore.Omnichannel.Managements.Handlers; @@ -13,6 +14,7 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Handlers; public sealed class BulkManageActivityFilterHandler : IBulkManageActivityFilterHandler { private const string ContactAlias = "oci"; + private const string ContactPhoneAlias = "ocpi"; private const string DncAlias = "dnc"; /// @@ -149,8 +151,49 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, var dialect = context.Dialect; var actAlias = context.ActivityTableAlias; - // JOIN the contact index table to filter by phone number and/or timezone. - if (hasPhoneFilter || hasTimeZoneFilter) + if (hasPhoneFilter) + { + var actContactCol = nameof(OmnichannelActivityIndex.ContactContentItemId); + var phoneTable = context.TableNameConvention.GetIndexTable(typeof(OmnichannelContactPhoneIndex)); + var contactItemIdCol = nameof(OmnichannelContactPhoneIndex.ContentItemId); + + builder.Join( + JoinType.Inner, + phoneTable, + ContactPhoneAlias, + contactItemIdCol, + actAlias, + actContactCol, + context.Schema, + ContactPhoneAlias, + actAlias); + + var latestCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactPhoneIndex.Latest))}"; + builder.Parameters["@PhoneLatest"] = true; + builder.WhereAnd($"{latestCol} = @PhoneLatest"); + + if (!PhoneNumberSearchTerm.TryParse(filter.PhoneNumber, out var searchTerm)) + { + builder.WhereAnd("1 = 0"); + } + else + { + var cellColumnName = searchTerm.IsE164 + ? nameof(OmnichannelContactPhoneIndex.E164PrimaryCellPhoneNumber) + : nameof(OmnichannelContactPhoneIndex.NationalPrimaryCellPhoneNumber); + var homeColumnName = searchTerm.IsE164 + ? nameof(OmnichannelContactPhoneIndex.E164PrimaryHomePhoneNumber) + : nameof(OmnichannelContactPhoneIndex.NationalPrimaryHomePhoneNumber); + var cellCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(cellColumnName)}"; + var homeCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(homeColumnName)}"; + var comparison = filter.PhoneNumberMatchType == PhoneNumberMatchType.Exact ? "=" : "LIKE"; + + builder.Parameters["@PhonePattern"] = searchTerm.GetPattern(filter.PhoneNumberMatchType); + builder.WhereAnd($"({cellCol} {comparison} @PhonePattern OR {homeCol} {comparison} @PhonePattern)"); + } + } + + if (hasTimeZoneFilter) { var actContactCol = nameof(OmnichannelActivityIndex.ContactContentItemId); var contactTable = context.TableNameConvention.GetIndexTable(typeof(OmnichannelContactIndex)); @@ -167,44 +210,17 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, ContactAlias, actAlias); - if (hasPhoneFilter) - { - var cellCol = $"{dialect.QuoteForAliasName(ContactAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.NormalizedPrimaryCellPhoneNumber))}"; - var homeCol = $"{dialect.QuoteForAliasName(ContactAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.NormalizedPrimaryHomePhoneNumber))}"; + var tzCol = $"{dialect.QuoteForAliasName(ContactAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.TimeZoneId))}"; + var placeholders = new string[filter.TimeZoneIds.Length]; - switch (filter.PhoneNumberMatchType) - { - case PhoneNumberMatchType.Exact: - builder.Parameters["@PhoneNumber"] = filter.PhoneNumber; - builder.WhereAnd($"({cellCol} = @PhoneNumber OR {homeCol} = @PhoneNumber)"); - break; - - case PhoneNumberMatchType.EndsWith: - builder.Parameters["@PhonePattern"] = $"%{filter.PhoneNumber}"; - builder.WhereAnd($"({cellCol} LIKE @PhonePattern OR {homeCol} LIKE @PhonePattern)"); - break; - - default: // BeginsWith - builder.Parameters["@PhonePattern"] = $"{filter.PhoneNumber}%"; - builder.WhereAnd($"({cellCol} LIKE @PhonePattern OR {homeCol} LIKE @PhonePattern)"); - break; - } - } - - if (hasTimeZoneFilter) + for (var i = 0; i < filter.TimeZoneIds.Length; i++) { - var tzCol = $"{dialect.QuoteForAliasName(ContactAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.TimeZoneId))}"; - var placeholders = new string[filter.TimeZoneIds.Length]; - - for (var i = 0; i < filter.TimeZoneIds.Length; i++) - { - var paramName = $"@TZ{i}"; - placeholders[i] = paramName; - builder.Parameters[paramName] = filter.TimeZoneIds[i]; - } - - builder.WhereAnd($"{tzCol} IN ({string.Join(", ", placeholders)})"); + var paramName = $"@TZ{i}"; + placeholders[i] = paramName; + builder.Parameters[paramName] = filter.TimeZoneIds[i]; } + + builder.WhereAnd($"{tzCol} IN ({string.Join(", ", placeholders)})"); } // JOIN the DNC preference index for do-not-call date range filtering. diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs new file mode 100644 index 000000000..6ae999cb7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs @@ -0,0 +1,137 @@ +using CrestApps.OrchardCore.ContentFields.Fields; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.PhoneNumbers; +using OrchardCore.ContentManagement; +using OrchardCore.Flows.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Indexes; + +internal sealed class OmnichannelContactPhoneIndexProvider : IndexProvider +{ + private readonly IPhoneNumberService _phoneNumberService; + + public OmnichannelContactPhoneIndexProvider(IPhoneNumberService phoneNumberService) + { + _phoneNumberService = phoneNumberService; + } + + public override void Describe(DescribeContext context) + { + context + .For() + .Map(CreateIndex); + } + + internal OmnichannelContactPhoneIndex CreateIndex(ContentItem contact) + { + if ((!contact.Published && !contact.Latest) || + !contact.TryGet(out _) || + !contact.TryGet(OmnichannelConstants.NamedParts.ContactMethods, out var bagPart) || + bagPart.ContentItems is null || + bagPart.ContentItems.Count == 0) + { + return null; + } + + var index = new OmnichannelContactPhoneIndex + { + ContentItemId = contact.ContentItemId, + Published = contact.Published, + Latest = contact.Latest, + }; + + foreach (var contactMethod in bagPart.ContentItems) + { + if (!string.Equals(contactMethod.ContentType, OmnichannelConstants.ContentTypes.PhoneNumber, StringComparison.Ordinal) || + !contactMethod.TryGet(out var phonePart) || + string.IsNullOrWhiteSpace(phonePart.Number?.PhoneNumber)) + { + continue; + } + + var e164Number = NormalizeToE164(phonePart.Number); + var nationalNumber = NormalizeNationalNumber(phonePart.Number, e164Number); + + if (string.Equals(phonePart.Type?.Text, "Cell", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(index.E164PrimaryCellPhoneNumber)) + { + index.E164PrimaryCellPhoneNumber = e164Number; + index.NationalPrimaryCellPhoneNumber = nationalNumber; + } + else if (string.Equals(phonePart.Type?.Text, "Home", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(index.E164PrimaryHomePhoneNumber)) + { + index.E164PrimaryHomePhoneNumber = e164Number; + index.NationalPrimaryHomePhoneNumber = nationalNumber; + } + } + + return index; + } + + private string NormalizeToE164(PhoneField field) + { + var phoneNumber = field.PhoneNumber?.Trim(); + + if (_phoneNumberService.TryFormatToE164(phoneNumber, field.CountryCode, out var e164Number)) + { + return Truncate(e164Number, 50); + } + + if (PhoneNumberSearchTerm.TryParse(phoneNumber, out var searchTerm) && searchTerm.IsE164) + { + return Truncate(searchTerm.Value, 50); + } + + return null; + } + + private string NormalizeNationalNumber(PhoneField field, string e164Number) + { + var nationalNumber = PhoneNumberSearchTerm.NormalizeDigits(field.NationalNumber); + + if (!string.IsNullOrEmpty(nationalNumber)) + { + return Truncate(nationalNumber, 50); + } + + var digits = PhoneNumberSearchTerm.NormalizeDigits(e164Number ?? field.PhoneNumber); + + if (string.IsNullOrEmpty(digits) || string.IsNullOrEmpty(e164Number)) + { + return Truncate(digits, 50); + } + + var regionCode = string.IsNullOrWhiteSpace(field.CountryCode) + ? _phoneNumberService.GetRegionCode(e164Number) + : field.CountryCode; + + if (string.IsNullOrWhiteSpace(regionCode)) + { + return Truncate(digits, 50); + } + + var countryCode = _phoneNumberService.GetCountryCode(regionCode); + var countryCodeText = countryCode > 0 ? countryCode.ToString() : null; + + if (!string.IsNullOrEmpty(countryCodeText) && + digits.Length > countryCodeText.Length && + digits.StartsWith(countryCodeText, StringComparison.Ordinal)) + { + digits = digits.Substring(countryCodeText.Length); + } + + return Truncate(digits, 50); + } + + private static string Truncate(string value, int maxLength) + { + return string.IsNullOrEmpty(value) + ? null + : value.Substring(0, Math.Min(maxLength, value.Length)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs index 500dc55b4..3f27bb5cc 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs @@ -26,6 +26,7 @@ PhoneNumberVerificationsConstants.Features.PhoneNumbers, "CrestApps.OrchardCore.Resources", "OrchardCore.ContentTypes", + "OrchardCore.Contents", "OrchardCore.Flows", "OrchardCore.Users", TimeZonesConstants.Features.Area, diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs index 424c8a082..7091511ee 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs @@ -68,7 +68,9 @@ await SchemaBuilder.CreateMapIndexTableAsync(table => t .Column("TimeZoneId", column => column.WithLength(64)) ); - return 6; + await CreatePhoneIndexTableAsync(); + + return 7; } /// @@ -164,6 +166,76 @@ public async Task UpdateFrom5Async() return 6; } + /// + /// Adds the version-aware contact phone search index. + /// + public async Task UpdateFrom6Async() + { + await CreatePhoneIndexTableAsync(); + ShellScope.AddDeferredTask(ReindexContactSearchVersionsAsync); + + return 7; + } + + private async Task CreatePhoneIndexTableAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ContentItemId", column => column.WithLength(26)) + .Column("Published") + .Column("Latest") + .Column("E164PrimaryCellPhoneNumber", column => column.WithLength(50)) + .Column("NationalPrimaryCellPhoneNumber", column => column.WithLength(50)) + .Column("E164PrimaryHomePhoneNumber", column => column.WithLength(50)) + .Column("NationalPrimaryHomePhoneNumber", column => column.WithLength(50)) + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCPhone_DocumentId", + "DocumentId", + "ContentItemId") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCPhone_ContentItemLatest", + "ContentItemId", + "Latest") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCPhone_E164Cell", + "E164PrimaryCellPhoneNumber", + "Published", + "Latest") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCPhone_NationalCell", + "NationalPrimaryCellPhoneNumber", + "Published", + "Latest") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCPhone_E164Home", + "E164PrimaryHomePhoneNumber", + "Published", + "Latest") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCPhone_NationalHome", + "NationalPrimaryHomePhoneNumber", + "Published", + "Latest") + ); + } + private async Task EnsureDefaultContactIndexTableAsync() { await RemoveLegacyCollectionContactIndexTableAsync(); @@ -364,6 +436,56 @@ private static async Task ReindexPublishedContactsAsync(ShellScope scope) } } + private static async Task ReindexContactSearchVersionsAsync(ShellScope scope) + { + var contentDefinitionManager = scope.ServiceProvider.GetRequiredService(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var store = scope.ServiceProvider.GetRequiredService(); + var contentTypes = await GetContentTypesWithOmnichannelContactPartAsync(contentDefinitionManager); + + if (contentTypes.Length == 0) + { + return; + } + + var documentId = 0L; + var reindexedCount = 0; + + while (true) + { + await using var session = store.CreateSession(); + + var batch = await session.Query(index => + (index.Published || index.Latest) && + index.ContentType.IsIn(contentTypes) && + index.DocumentId > documentId) + .OrderBy(index => index.DocumentId) + .Take(ReindexBatchSize) + .ListAsync(); + + if (!batch.Any()) + { + break; + } + + foreach (var contentItem in batch) + { + documentId = Math.Max(documentId, contentItem.Id); + await session.SaveAsync(contentItem); + reindexedCount++; + } + + await session.SaveChangesAsync(); + } + + if (logger.IsEnabled(LogLevel.Information)) + { + logger.LogInformation( + "Reindexed {ReindexedCount} published or latest omnichannel contact content item version(s) for phone search.", + reindexedCount); + } + } + private static async Task GetContentTypesWithOmnichannelContactPartAsync(IContentDefinitionManager contentDefinitionManager) { var typeDefinitions = await contentDefinitionManager.ListTypeDefinitionsAsync(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs new file mode 100644 index 000000000..1ed21d44c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs @@ -0,0 +1,18 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +internal static class ActivityPurgeHelper +{ + internal static void Purge(OmnichannelActivity activity) + { + ArgumentNullException.ThrowIfNull(activity); + + activity.Status = ActivityStatus.Purged; + activity.ReservationId = null; + activity.ReservedById = null; + activity.ReservedByUsername = null; + activity.ReservedUtc = null; + activity.ReservationExpiresUtc = null; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs index d7758c528..751d76002 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs @@ -208,21 +208,22 @@ public virtual async Task LoadAsync(ActivityBatchLoadContext context, Cancellati if (hasPhoneFilter) { - var phoneQuery = batch.PhoneNumberMatchType switch + if (!PhoneNumberSearchTerm.TryParse(batch.PhoneNumber, out var searchTerm)) { - PhoneNumberMatchType.Exact => readonlySession.QueryIndex(index => - index.NormalizedPrimaryCellPhoneNumber == batch.PhoneNumber || - index.NormalizedPrimaryHomePhoneNumber == batch.PhoneNumber), - PhoneNumberMatchType.EndsWith => readonlySession.QueryIndex(index => - index.NormalizedPrimaryCellPhoneNumber.EndsWith(batch.PhoneNumber) || - index.NormalizedPrimaryHomePhoneNumber.EndsWith(batch.PhoneNumber)), - _ => readonlySession.QueryIndex(index => - index.NormalizedPrimaryCellPhoneNumber.StartsWith(batch.PhoneNumber) || - index.NormalizedPrimaryHomePhoneNumber.StartsWith(batch.PhoneNumber)), - }; - - var phoneContacts = await phoneQuery.ListAsync(cancellationToken); - phoneIds = phoneContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + phoneIds = new HashSet(StringComparer.OrdinalIgnoreCase); + _logger.LogWarning("The phone number filter for activity batch '{BatchId}' does not contain any digits.", batch.ItemId); + } + else + { + var phoneQuery = batch.OnlyPublishedLeads + ? readonlySession.QueryIndex(index => index.Published) + : readonlySession.QueryIndex(index => index.Latest); + + phoneQuery = ApplyPhoneFilter(phoneQuery, searchTerm, batch.PhoneNumberMatchType); + + var phoneContacts = await phoneQuery.ListAsync(cancellationToken); + phoneIds = phoneContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + } } if (hasTimeZoneFilter) @@ -486,6 +487,49 @@ SELECT MAX(a2.{completedCol}) await _session.SaveChangesAsync(cancellationToken); } + private static IQueryIndex ApplyPhoneFilter( + IQueryIndex query, + PhoneNumberSearchTerm searchTerm, + PhoneNumberMatchType matchType) + { + if (searchTerm.IsE164) + { + return matchType switch + { + PhoneNumberMatchType.Exact => query.Where(index => + index.E164PrimaryCellPhoneNumber == searchTerm.Value || + index.E164PrimaryHomePhoneNumber == searchTerm.Value), + PhoneNumberMatchType.BeginsWith => query.Where(index => + index.E164PrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.E164PrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + PhoneNumberMatchType.EndsWith => query.Where(index => + index.E164PrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.E164PrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + PhoneNumberMatchType.Contains => query.Where(index => + index.E164PrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.E164PrimaryHomePhoneNumber.Contains(searchTerm.Value)), + _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), + }; + } + + return matchType switch + { + PhoneNumberMatchType.Exact => query.Where(index => + index.NationalPrimaryCellPhoneNumber == searchTerm.Value || + index.NationalPrimaryHomePhoneNumber == searchTerm.Value), + PhoneNumberMatchType.BeginsWith => query.Where(index => + index.NationalPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.NationalPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + PhoneNumberMatchType.EndsWith => query.Where(index => + index.NationalPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.NationalPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + PhoneNumberMatchType.Contains => query.Where(index => + index.NationalPrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.NationalPrimaryHomePhoneNumber.Contains(searchTerm.Value)), + _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), + }; + } + private static bool TryGetActivityBatchSource(string source, ActivityBatchSourceOptions options, out ActivityBatchSourceEntry sourceEntry) { if (string.IsNullOrWhiteSpace(source)) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultSubjectActionExecutor.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultSubjectActionExecutor.cs index 50f4531d8..e46df19dc 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultSubjectActionExecutor.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultSubjectActionExecutor.cs @@ -108,9 +108,6 @@ private async Task ExecuteTryAgainAsync(SubjectAction action, SubjectActionExecu CampaignId = activity.CampaignId, Instructions = activity.Instructions, Attempts = activity.Attempts + 1, - AssignedToId = activity.CompletedById, - AssignedToUsername = activity.CompletedByUsername, - AssignedToUtc = now, CreatedById = activity.CompletedById, CreatedByUsername = activity.CompletedByUsername, CreatedUtc = now, @@ -122,7 +119,16 @@ private async Task ExecuteTryAgainAsync(SubjectAction action, SubjectActionExecu nextAttempt.ScheduledUtc = ResolveScheduleDate(action, context, metadata.DefaultScheduleHours); - await ResolveAssigneeAsync(nextAttempt, metadata.NormalizedUserName); + if (!await TryAssignOwnerAsync( + nextAttempt, + action, + metadata.AssignmentType, + metadata.NormalizedUserName, + activity, + now)) + { + return; + } await _session.SaveAsync(nextAttempt, collection: OmnichannelConstants.CollectionName); } @@ -155,9 +161,6 @@ private async Task ExecuteNewActivityAsync(SubjectAction action, SubjectActionEx CampaignId = activity.CampaignId, Instructions = null, Attempts = 1, - AssignedToId = activity.CompletedById, - AssignedToUsername = activity.CompletedByUsername, - AssignedToUtc = now, CreatedById = activity.CompletedById, CreatedByUsername = activity.CompletedByUsername, CreatedUtc = now, @@ -166,6 +169,19 @@ private async Task ExecuteNewActivityAsync(SubjectAction action, SubjectActionEx Status = ActivityStatus.NotStated, }; + newActivity.ScheduledUtc = ResolveScheduleDate(action, context, metadata.DefaultScheduleHours); + + if (!await TryAssignOwnerAsync( + newActivity, + action, + metadata.AssignmentType, + metadata.NormalizedUserName, + activity, + now)) + { + return; + } + newActivity.Subject = await _contentManager.NewAsync(targetSubjectContentType); if (flowSettings != null) @@ -176,10 +192,6 @@ private async Task ExecuteNewActivityAsync(SubjectAction action, SubjectActionEx newActivity.CampaignId = flowSettings.CampaignId ?? activity.CampaignId; } - newActivity.ScheduledUtc = ResolveScheduleDate(action, context, metadata.DefaultScheduleHours); - - await ResolveAssigneeAsync(newActivity, metadata.NormalizedUserName); - if (context.Contact is not null) { newActivity.PreferredDestination = OmnichannelHelper.GetPreferredDestenation(context.Contact, newActivity.Channel); @@ -249,19 +261,57 @@ private DateTime ResolveScheduleDate(SubjectAction action, SubjectActionExecutio return _clock.UtcNow.AddDays(1); } - private async Task ResolveAssigneeAsync(OmnichannelActivity activity, string normalizedUserName) + private async Task TryAssignOwnerAsync( + OmnichannelActivity followUpActivity, + SubjectAction action, + SubjectActionOwnerAssignmentType assignmentType, + string normalizedUserName, + OmnichannelActivity completedActivity, + DateTime assignedToUtc) { - if (string.IsNullOrEmpty(normalizedUserName)) + var effectiveAssignmentType = SubjectActionOwnerAssignmentTypeResolver.Resolve(assignmentType, normalizedUserName); + + if (effectiveAssignmentType == SubjectActionOwnerAssignmentType.SameOwner) { - return; + AssignOwner( + followUpActivity, + completedActivity.CompletedById, + completedActivity.CompletedByUsername, + assignedToUtc); + + return true; } var owner = await _session.Query(x => x.NormalizedUserName == normalizedUserName).FirstOrDefaultAsync(); - if (owner is not null) + if (owner is null) + { + _logger.LogWarning( + "The configured specific owner {NormalizedUserName} for subject action {SubjectActionId} was not found. Skipping follow-up activity creation.", + normalizedUserName, + action.ItemId); + + return false; + } + + AssignOwner(followUpActivity, owner.UserId, owner.UserName, assignedToUtc); + + return true; + } + + private static void AssignOwner( + OmnichannelActivity activity, + string ownerId, + string ownerName, + DateTime assignedToUtc) + { + activity.AssignedToId = ownerId; + activity.AssignedToUsername = ownerName; + + if (!string.IsNullOrWhiteSpace(ownerId) || !string.IsNullOrWhiteSpace(ownerName)) { - activity.AssignedToId = owner.UserId; - activity.AssignedToUsername = owner.UserName; + activity.AssignedToUtc = assignedToUtc; + activity.AssignmentStatus = ActivityAssignmentStatus.Assigned; } } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs new file mode 100644 index 000000000..fcff3dc2f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs @@ -0,0 +1,72 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using OrchardCore.ContentManagement; +using OrchardCore.Contents.Services; +using YesSql; +using YesSql.Filters.Query; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +internal sealed class OmnichannelContactPhoneContentsAdminListFilterProvider : IContentsAdminListFilterProvider +{ + public void Build(QueryEngineBuilder builder) + { + builder + .WithNamedTerm("phone", term => term + .OneCondition((value, query) => ApplyFilter(value, PhoneNumberMatchType.Contains, query))) + .WithNamedTerm("phone-exact", term => term + .OneCondition((value, query) => ApplyFilter(value, PhoneNumberMatchType.Exact, query))) + .WithNamedTerm("phone-starts", term => term + .OneCondition((value, query) => ApplyFilter(value, PhoneNumberMatchType.BeginsWith, query))) + .WithNamedTerm("phone-ends", term => term + .OneCondition((value, query) => ApplyFilter(value, PhoneNumberMatchType.EndsWith, query))); + } + + private static IQuery ApplyFilter( + string value, + PhoneNumberMatchType matchType, + IQuery query) + { + if (!PhoneNumberSearchTerm.TryParse(value, out var searchTerm)) + { + return query.With(index => index.ContentItemId == string.Empty); + } + + if (searchTerm.IsE164) + { + return matchType switch + { + PhoneNumberMatchType.Exact => query.With(index => + index.E164PrimaryCellPhoneNumber == searchTerm.Value || + index.E164PrimaryHomePhoneNumber == searchTerm.Value), + PhoneNumberMatchType.BeginsWith => query.With(index => + index.E164PrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.E164PrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + PhoneNumberMatchType.EndsWith => query.With(index => + index.E164PrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.E164PrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + PhoneNumberMatchType.Contains => query.With(index => + index.E164PrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.E164PrimaryHomePhoneNumber.Contains(searchTerm.Value)), + _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), + }; + } + + return matchType switch + { + PhoneNumberMatchType.Exact => query.With(index => + index.NationalPrimaryCellPhoneNumber == searchTerm.Value || + index.NationalPrimaryHomePhoneNumber == searchTerm.Value), + PhoneNumberMatchType.BeginsWith => query.With(index => + index.NationalPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.NationalPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + PhoneNumberMatchType.EndsWith => query.With(index => + index.NationalPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.NationalPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + PhoneNumberMatchType.Contains => query.With(index => + index.NationalPrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.NationalPrimaryHomePhoneNumber.Contains(searchTerm.Value)), + _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs index 6c2484011..417ff0cc7 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs @@ -20,6 +20,7 @@ internal sealed class PermissionProvider : IPermissionProvider OmnichannelConstants.Permissions.CompleteActivity, OmnichannelConstants.Permissions.CompleteOwnActivity, OmnichannelConstants.Permissions.ManageActivities, + OmnichannelConstants.Permissions.PurgeActivity, OmnichannelConstants.Permissions.ManageDispositions, OmnichannelConstants.Permissions.ManageCampaigns, OmnichannelConstants.Permissions.ManageChannelEndpoints, diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PhoneNumberSearchTerm.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PhoneNumberSearchTerm.cs new file mode 100644 index 000000000..53b5ee163 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PhoneNumberSearchTerm.cs @@ -0,0 +1,72 @@ +using System.Text; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +internal readonly record struct PhoneNumberSearchTerm +{ + private PhoneNumberSearchTerm(string value, bool isE164) + { + Value = value; + IsE164 = isE164; + } + + public string Value { get; } + + public bool IsE164 { get; } + + public static bool TryParse(string input, out PhoneNumberSearchTerm searchTerm) + { + searchTerm = default; + + if (string.IsNullOrWhiteSpace(input)) + { + return false; + } + + var trimmed = input.Trim(); + var digits = NormalizeDigits(trimmed); + + if (string.IsNullOrEmpty(digits)) + { + return false; + } + + var isE164 = trimmed[0] == '+'; + searchTerm = new PhoneNumberSearchTerm(isE164 ? $"+{digits}" : digits, isE164); + + return true; + } + + public string GetPattern(PhoneNumberMatchType matchType) + { + return matchType switch + { + PhoneNumberMatchType.Exact => Value, + PhoneNumberMatchType.BeginsWith => $"{Value}%", + PhoneNumberMatchType.EndsWith => $"%{Value}", + PhoneNumberMatchType.Contains => $"%{Value}%", + _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), + }; + } + + internal static string NormalizeDigits(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var builder = new StringBuilder(value.Length); + + foreach (var character in value) + { + if (char.IsAsciiDigit(character)) + { + builder.Append(character); + } + } + + return builder.Length == 0 ? null : builder.ToString(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 4c74a22e8..418106a7f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -27,6 +27,7 @@ using OrchardCore.BackgroundTasks; using OrchardCore.ContentManagement; using OrchardCore.ContentManagement.Display.ContentDisplay; +using OrchardCore.Contents.Services; using OrchardCore.ContentTypes.Editors; using OrchardCore.ContentTypes.Events; using OrchardCore.Data; @@ -170,8 +171,11 @@ public override void ConfigureServices(IServiceCollection services) services .AddIndexProvider() + .AddIndexProvider() .AddDataMigration(); + services.AddTransient(); + services.AddDataMigration(); services.AddContentPart(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs index 84931f9f6..f0d48e917 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs @@ -97,7 +97,7 @@ public class BulkManageActivityFilterViewModel /// /// Gets or sets the phone number match type. /// - public PhoneNumberMatchType PhoneNumberMatchType { get; set; } + public PhoneNumberMatchType PhoneNumberMatchType { get; set; } = PhoneNumberMatchType.Contains; /// /// Gets or sets the time zone identifiers to filter contacts by. diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/NewActivitySubjectActionViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/NewActivitySubjectActionViewModel.cs index c7b30c8da..20eba7979 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/NewActivitySubjectActionViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/NewActivitySubjectActionViewModel.cs @@ -19,6 +19,11 @@ public class NewActivitySubjectActionViewModel /// public ActivityUrgencyLevel? UrgencyLevel { get; set; } + /// + /// Gets or sets how the owner of the new activity is selected. + /// + public SubjectActionOwnerAssignmentType AssignmentType { get; set; } + /// /// Gets or sets the normalized username to assign the new activity to. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs index ecbfc0cd0..0f2055539 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs @@ -123,7 +123,7 @@ public class OmnichannelActivityBatchViewModel /// /// Gets or sets the phone number match type. /// - public PhoneNumberMatchType PhoneNumberMatchType { get; set; } + public PhoneNumberMatchType PhoneNumberMatchType { get; set; } = PhoneNumberMatchType.Contains; /// /// Gets or sets the time zone identifiers to filter leads by. diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/TryAgainSubjectActionViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/TryAgainSubjectActionViewModel.cs index c49df06cd..297839221 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/TryAgainSubjectActionViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/TryAgainSubjectActionViewModel.cs @@ -19,6 +19,11 @@ public class TryAgainSubjectActionViewModel /// public ActivityUrgencyLevel? UrgencyLevel { get; set; } + /// + /// Gets or sets how the owner of the retry activity is selected. + /// + public SubjectActionOwnerAssignmentType AssignmentType { get; set; } + /// /// Gets or sets the normalized username to assign the retry activity to. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml index fca824d71..fbed658b9 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml @@ -31,9 +31,10 @@ + placeholder="@T["Phone number"]" /> - @T["E.164 format required (e.g., +1800). Searches cell and home phone."] + + @T["Enter a national number or fragment (e.g., 702499). Start with + to search E.164 values (e.g., +1702499). Searches primary cell and home numbers."]
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainerScheduledActivity.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainerScheduledActivity.Buttons.SummaryAdmin.cshtml index d24b70c88..148fa7784 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainerScheduledActivity.Buttons.SummaryAdmin.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Items/OmnichannelActivityContainerScheduledActivity.Buttons.SummaryAdmin.cshtml @@ -20,3 +20,16 @@ asp-route-id="@Model.Value.Activity.ItemId" class="btn btn-primary btn-sm">@T["Edit"] } + +@if (await AuthorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.PurgeActivity, Model.Value.Activity)) +{ + @T["Purge"] +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml index a9feb2759..721ae1abc 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml @@ -4,6 +4,13 @@ @model NewActivitySubjectActionViewModel +@{ + var specificOwnerContainerId = $"{Html.IdFor(m => m.AssignmentType)}_SpecificOwner"; + var specificOwnerClass = Model.AssignmentType == SubjectActionOwnerAssignmentType.SpecificOwner + ? "ocat-wrapper" + : "ocat-wrapper d-none"; +} +
@@ -31,6 +38,17 @@
+ +
+ + @T["Choose whether the new activity stays with the completing user or is assigned to a specific user."] +
+
+ +
@await Component.InvokeAsync("ItemSelector", new @@ -54,7 +72,8 @@ availableItemsLabel = T["Available users"].Value, searchButtonText = T["Search"].Value, }) - @T["Assign the new activity to a specific user. Leave empty to assign it to the user who completed the activity."] + + @T["Select the user who will own the new activity."]
@@ -65,3 +84,23 @@ @T["Default hours ahead to schedule the new activity. Used when no schedule date is provided during completion. Defaults to 24 hours."]
+ + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml index c663a8d8d..c6ecb1743 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml @@ -261,10 +261,10 @@
- +
- @T["E.164 format required (e.g., +1800). Searches both cell and home phone numbers."] + @T["Enter a national number or fragment (e.g., 702499). Start with + to search E.164 values (e.g., +1702499). Searches primary cell and home numbers."]
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml index bd26ebfca..5d8669ecd 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml @@ -4,6 +4,13 @@ @model TryAgainSubjectActionViewModel +@{ + var specificOwnerContainerId = $"{Html.IdFor(m => m.AssignmentType)}_SpecificOwner"; + var specificOwnerClass = Model.AssignmentType == SubjectActionOwnerAssignmentType.SpecificOwner + ? "ocat-wrapper" + : "ocat-wrapper d-none"; +} +
@@ -29,6 +36,17 @@
+ +
+ + @T["Choose whether the retry stays with the completing user or is assigned to a specific user."] +
+
+ +
@await Component.InvokeAsync("ItemSelector", new @@ -52,7 +70,8 @@ availableItemsLabel = T["Available users"].Value, searchButtonText = T["Search"].Value, }) - @T["Assign the retry to a specific user. Leave empty to assign it to the user who completed the activity."] + + @T["Select the user who will own the retry activity."]
@@ -63,3 +82,23 @@ @T["Default hours ahead to schedule the retry. Used when no schedule date is provided during completion. Defaults to 24 hours."]
+ + diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs index 924e8dfa1..8553fd1ec 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs @@ -20,6 +20,22 @@ public void OmnichannelContactIndexProvider_ShouldUseDefaultCollection() Assert.True(string.IsNullOrEmpty(collectionName)); } + [Fact] + public void OmnichannelContactPhoneIndexProvider_ShouldUseDefaultCollection() + { + // Arrange + var provider = CreateProvider( + typeof(CrestApps.OrchardCore.Omnichannel.Managements.Startup).Assembly, + "CrestApps.OrchardCore.Omnichannel.Managements.Indexes.OmnichannelContactPhoneIndexProvider", + [new CrestApps.OrchardCore.PhoneNumbers.Core.Services.DefaultPhoneNumberService()]); + + // Act + var collectionName = GetCollectionName(provider); + + // Assert + Assert.True(string.IsNullOrEmpty(collectionName)); + } + [Fact] public void OmnichannelContactCommunicationPreferenceIndexProvider_ShouldUseDefaultCollection() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultSubjectActionExecutorTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultSubjectActionExecutorTests.cs new file mode 100644 index 000000000..2d1d68917 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/DefaultSubjectActionExecutorTests.cs @@ -0,0 +1,226 @@ +using System.Linq.Expressions; +using CrestApps.Core; +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore; +using OrchardCore.ContentManagement; +using OrchardCore.Entities; +using OrchardCore.Modules; +using OrchardCore.Users.Indexes; +using OrchardCore.Users.Models; +using YesSql; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements; + +public sealed class DefaultSubjectActionExecutorTests +{ + private static readonly DateTime _now = new(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc); + + [Theory] + [InlineData(SubjectActionOwnerAssignmentType.SameOwner, null, SubjectActionOwnerAssignmentType.SameOwner)] + [InlineData(SubjectActionOwnerAssignmentType.SameOwner, "LEGACY", SubjectActionOwnerAssignmentType.SpecificOwner)] + [InlineData(SubjectActionOwnerAssignmentType.SpecificOwner, null, SubjectActionOwnerAssignmentType.SpecificOwner)] + public void Resolve_AssignmentMetadata_InfersEffectiveLegacyMode( + SubjectActionOwnerAssignmentType assignmentType, + string normalizedUserName, + SubjectActionOwnerAssignmentType expected) + { + // Act + var result = SubjectActionOwnerAssignmentTypeResolver.Resolve(assignmentType, normalizedUserName); + + // Assert + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("TryAgain")] + [InlineData("NewActivity")] + public async Task ExecuteAsync_SameOwner_AssignsCompletingUser(string actionType) + { + // Arrange + var action = CreateAction(actionType, SubjectActionOwnerAssignmentType.SameOwner); + var session = new Mock(); + OmnichannelActivity savedActivity = null; + + SetupSave(session, activity => savedActivity = activity); + + var executor = CreateExecutor(action, session); + + // Act + await executor.ExecuteAsync(CreateContext(), TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(savedActivity); + Assert.Equal("completing-user-id", savedActivity.AssignedToId); + Assert.Equal("Completing User", savedActivity.AssignedToUsername); + Assert.Equal(_now, savedActivity.AssignedToUtc); + Assert.Equal(ActivityAssignmentStatus.Assigned, savedActivity.AssignmentStatus); + } + + [Theory] + [InlineData("TryAgain")] + [InlineData("NewActivity")] + public async Task ExecuteAsync_LegacyUsername_ResolvesSpecificOwner(string actionType) + { + // Arrange + var action = CreateAction(actionType, SubjectActionOwnerAssignmentType.SameOwner, "SPECIFIC"); + var owner = new User + { + UserId = "specific-user-id", + UserName = "Specific User", + NormalizedUserName = "SPECIFIC", + }; + var session = CreateSessionWithUser(owner); + OmnichannelActivity savedActivity = null; + + SetupSave(session, activity => savedActivity = activity); + + var executor = CreateExecutor(action, session); + + // Act + await executor.ExecuteAsync(CreateContext(), TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(savedActivity); + Assert.Equal(owner.UserId, savedActivity.AssignedToId); + Assert.Equal(owner.UserName, savedActivity.AssignedToUsername); + Assert.Equal(_now, savedActivity.AssignedToUtc); + Assert.Equal(ActivityAssignmentStatus.Assigned, savedActivity.AssignmentStatus); + } + + [Theory] + [InlineData("TryAgain")] + [InlineData("NewActivity")] + public async Task ExecuteAsync_MissingSpecificOwner_SkipsFollowUp(string actionType) + { + // Arrange + var action = CreateAction(actionType, SubjectActionOwnerAssignmentType.SpecificOwner, "DELETED"); + var session = CreateSessionWithUser(null); + var executor = CreateExecutor(action, session); + + // Act + await executor.ExecuteAsync(CreateContext(), TestContext.Current.CancellationToken); + + // Assert + session.Verify( + x => x.SaveAsync(It.IsAny(), false, OmnichannelConstants.CollectionName, It.IsAny()), + Times.Never); + } + + private static DefaultSubjectActionExecutor CreateExecutor( + SubjectAction action, + Mock session) + { + var actionCatalog = new Mock>(); + actionCatalog + .Setup(x => x.GetAllAsync(It.IsAny())) + .ReturnsAsync(new[] { action }); + + var contentManager = new Mock(); + contentManager + .Setup(x => x.NewAsync(It.IsAny())) + .ReturnsAsync((string contentType) => new ContentItem { ContentType = contentType }); + + var clock = new Mock(); + clock.SetupGet(x => x.UtcNow).Returns(_now); + + return new DefaultSubjectActionExecutor( + actionCatalog.Object, + Mock.Of(), + contentManager.Object, + session.Object, + clock.Object, + NullLogger.Instance); + } + + private static SubjectAction CreateAction( + string actionType, + SubjectActionOwnerAssignmentType assignmentType, + string normalizedUserName = null) + { + var action = new SubjectAction + { + ItemId = "action-id", + Source = actionType, + SubjectContentType = "Subject", + DispositionId = "disposition-id", + }; + + if (string.Equals(actionType, OmnichannelConstants.ActionTypes.TryAgain, StringComparison.Ordinal)) + { + action.Put(new TryAgainActionMetadata + { + AssignmentType = assignmentType, + NormalizedUserName = normalizedUserName, + }); + } + else + { + action.Put(new NewActivityActionMetadata + { + AssignmentType = assignmentType, + NormalizedUserName = normalizedUserName, + }); + } + + return action; + } + + private static SubjectActionExecutionContext CreateContext() + { + return new SubjectActionExecutionContext + { + Activity = new OmnichannelActivity + { + ItemId = "activity-id", + SubjectContentType = "Subject", + CompletedById = "completing-user-id", + CompletedByUsername = "Completing User", + Attempts = 1, + }, + Disposition = new OmnichannelDisposition + { + ItemId = "disposition-id", + }, + }; + } + + private static Mock CreateSessionWithUser(User user) + { + var query = new Mock>(); + query + .Setup(x => x.FirstOrDefaultAsync()) + .ReturnsAsync(user); + + var entityQuery = new Mock>(); + entityQuery + .Setup(x => x.With(It.IsAny>>())) + .Returns(query.Object); + + var rootQuery = new Mock(); + rootQuery + .Setup(x => x.For(It.IsAny())) + .Returns(entityQuery.Object); + + var session = new Mock(); + session + .Setup(x => x.Query(It.IsAny())) + .Returns(rootQuery.Object); + + return session; + } + + private static void SetupSave( + Mock session, + Action callback) + { + session + .Setup(x => x.SaveAsync(It.IsAny(), false, OmnichannelConstants.CollectionName, It.IsAny())) + .Callback((entity, _, _, _) => callback((OmnichannelActivity)entity)) + .Returns(Task.CompletedTask); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs new file mode 100644 index 000000000..d8aae5857 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs @@ -0,0 +1,148 @@ +using CrestApps.OrchardCore.ContentFields.Fields; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Indexes; +using CrestApps.OrchardCore.PhoneNumbers.Core.Services; +using OrchardCore.ContentFields.Fields; +using OrchardCore.ContentManagement; +using OrchardCore.Flows.Models; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Indexes; + +public sealed class OmnichannelContactPhoneIndexProviderTests +{ + [Fact] + public void CreateIndex_WhenContactHasPrimaryCellAndHome_IndexesBothFormats() + { + // Arrange + var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: true, + latest: false, + CreatePhoneNumber("+17024993350", "7024993350", "US", "Cell"), + CreatePhoneNumber("+12125550123", "2125550123", "US", "Home")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.Equal("contact-id", index.ContentItemId); + Assert.True(index.Published); + Assert.False(index.Latest); + Assert.Equal("+17024993350", index.E164PrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + Assert.Equal("+12125550123", index.E164PrimaryHomePhoneNumber); + Assert.Equal("2125550123", index.NationalPrimaryHomePhoneNumber); + } + + [Fact] + public void CreateIndex_WhenNationalNumberIsMissing_DerivesItFromE164() + { + // Arrange + var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: true, + CreatePhoneNumber("+17024993350", null, "US", "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.False(index.Published); + Assert.True(index.Latest); + Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + } + + [Fact] + public void CreateIndex_WhenRegionCannotBeDetermined_PreservesAllDigits() + { + // Arrange + var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: true, + CreatePhoneNumber("+999123456", null, null, "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.Equal("999123456", index.NationalPrimaryCellPhoneNumber); + } + + [Fact] + public void CreateIndex_WhenContentItemIsNeitherPublishedNorLatest_ReturnsNull() + { + // Arrange + var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: false, + CreatePhoneNumber("+17024993350", "7024993350", "US", "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.Null(index); + } + + private static ContentItem CreateContact( + bool published, + bool latest, + params ContentItem[] phoneNumbers) + { + var contact = new ContentItem + { + ContentItemId = "contact-id", + ContentType = "Contact", + Published = published, + Latest = latest, + }; + + contact.Alter(_ => { }); + + var bagPart = new BagPart(); + + foreach (var phoneNumber in phoneNumbers) + { + bagPart.ContentItems.Add(phoneNumber); + } + + contact.Apply(OmnichannelConstants.NamedParts.ContactMethods, bagPart); + + return contact; + } + + private static ContentItem CreatePhoneNumber( + string e164Number, + string nationalNumber, + string countryCode, + string type) + { + var contentItem = new ContentItem + { + ContentType = OmnichannelConstants.ContentTypes.PhoneNumber, + }; + + contentItem.Alter(part => + { + part.Number = new PhoneField + { + PhoneNumber = e164Number, + NationalNumber = nationalNumber, + CountryCode = countryCode, + }; + part.Type = new TextField + { + Text = type, + }; + }); + + return contentItem; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs new file mode 100644 index 000000000..a3dacb95c --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Services; + +public sealed class ActivityPurgeHelperTests +{ + [Fact] + public void Purge_SetsPurgedStatusAndClearsReservation() + { + // Arrange + var activity = new OmnichannelActivity + { + Status = ActivityStatus.Reserved, + ReservationId = "reservation-1", + ReservedById = "user-1", + ReservedByUsername = "agent", + ReservedUtc = new DateTime(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc), + ReservationExpiresUtc = new DateTime(2026, 7, 11, 12, 5, 0, DateTimeKind.Utc), + }; + + // Act + ActivityPurgeHelper.Purge(activity); + + // Assert + Assert.Equal(ActivityStatus.Purged, activity.Status); + Assert.Null(activity.ReservationId); + Assert.Null(activity.ReservedById); + Assert.Null(activity.ReservedByUsername); + Assert.Null(activity.ReservedUtc); + Assert.Null(activity.ReservationExpiresUtc); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs new file mode 100644 index 000000000..48efac130 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using Moq; +using OrchardCore.ContentManagement; +using YesSql; +using YesSql.Filters.Query; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Services; + +public sealed class OmnichannelContactPhoneContentsAdminListFilterProviderTests +{ + [Theory] + [InlineData("phone:702499", "7024993350", "+17024993350")] + [InlineData("phone-exact:7024993350", "7024993350", "+17024993350")] + [InlineData("phone-starts:+1702", "7024993350", "+17024993350")] + [InlineData("phone-ends:3350", "7024993350", "+17024993350")] + public async Task Build_WhenPhoneTermIsParsed_AppliesExpectedPredicate( + string filter, + string nationalNumber, + string e164Number) + { + // Arrange + var builder = new QueryEngineBuilder(); + var provider = new OmnichannelContactPhoneContentsAdminListFilterProvider(); + provider.Build(builder); + + Expression> predicate = null; + var indexedQuery = new Mock>(); + var query = new Mock>(); + query + .Setup(x => x.With(It.IsAny>>())) + .Callback>>(value => predicate = value) + .Returns(indexedQuery.Object); + + var matchingIndex = CreateIndex(nationalNumber, e164Number); + var nonMatchingIndex = CreateIndex("5551112222", "+15551112222"); + + // Act + await builder.Build().Parse(filter).ExecuteAsync(query.Object); + + // Assert + Assert.NotNull(predicate); + Assert.True(predicate.Compile()(matchingIndex)); + Assert.False(predicate.Compile()(nonMatchingIndex)); + } + + private static OmnichannelContactPhoneIndex CreateIndex( + string nationalNumber, + string e164Number) + { + return new OmnichannelContactPhoneIndex + { + ContentItemId = "contact-id", + NationalPrimaryCellPhoneNumber = nationalNumber, + NationalPrimaryHomePhoneNumber = string.Empty, + E164PrimaryCellPhoneNumber = e164Number, + E164PrimaryHomePhoneNumber = string.Empty, + }; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/PhoneNumberSearchTermTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/PhoneNumberSearchTermTests.cs new file mode 100644 index 000000000..2b0d03a6b --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/PhoneNumberSearchTermTests.cs @@ -0,0 +1,72 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Services; + +public sealed class PhoneNumberSearchTermTests +{ + [Theory] + [InlineData("702499", "702499", false)] + [InlineData("(702) 499-3350", "7024993350", false)] + [InlineData(" +1 (702) 499-3350 ", "+17024993350", true)] + public void TryParse_WhenInputContainsDigits_NormalizesSearchValue( + string input, + string expectedValue, + bool expectedIsE164) + { + // Act + var parsed = PhoneNumberSearchTerm.TryParse(input, out var searchTerm); + + // Assert + Assert.True(parsed); + Assert.Equal(expectedValue, searchTerm.Value); + Assert.Equal(expectedIsE164, searchTerm.IsE164); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("+")] + [InlineData("phone")] + public void TryParse_WhenInputHasNoDigits_ReturnsFalse(string input) + { + // Act + var parsed = PhoneNumberSearchTerm.TryParse(input, out _); + + // Assert + Assert.False(parsed); + } + + [Theory] + [InlineData(PhoneNumberMatchType.Exact, "702499")] + [InlineData(PhoneNumberMatchType.BeginsWith, "702499%")] + [InlineData(PhoneNumberMatchType.EndsWith, "%702499")] + [InlineData(PhoneNumberMatchType.Contains, "%702499%")] + public void GetPattern_WhenMatchTypeIsValid_ReturnsExpectedPattern( + PhoneNumberMatchType matchType, + string expectedPattern) + { + // Arrange + var parsed = PhoneNumberSearchTerm.TryParse("702499", out var searchTerm); + + // Act + var pattern = searchTerm.GetPattern(matchType); + + // Assert + Assert.True(parsed); + Assert.Equal(expectedPattern, pattern); + } + + [Fact] + public void PhoneNumberFilters_WhenCreated_DefaultToContains() + { + // Arrange + var batch = new OmnichannelActivityBatch(); + var filter = new BulkManageActivityFilter(); + + // Assert + Assert.Equal(PhoneNumberMatchType.Contains, batch.PhoneNumberMatchType); + Assert.Equal(PhoneNumberMatchType.Contains, filter.PhoneNumberMatchType); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/OmnichannelPermissionsTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/OmnichannelPermissionsTests.cs new file mode 100644 index 000000000..18fe9e862 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/OmnichannelPermissionsTests.cs @@ -0,0 +1,52 @@ +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel; + +public sealed class OmnichannelPermissionsTests +{ + [Fact] + public void PurgeActivity_HasCorrectProperties() + { + // Assert + Assert.Equal("PurgeActivity", OmnichannelConstants.Permissions.PurgeActivity.Name); + Assert.Equal("Purge activity", OmnichannelConstants.Permissions.PurgeActivity.Description); + } + + [Fact] + public void PurgeActivity_IsImpliedByManageActivities() + { + // Assert + Assert.NotNull(OmnichannelConstants.Permissions.PurgeActivity.ImpliedBy); + Assert.Contains( + OmnichannelConstants.Permissions.ManageActivities, + OmnichannelConstants.Permissions.PurgeActivity.ImpliedBy); + } + + [Fact] + public async Task GetPermissionsAsync_IncludesPurgeActivity() + { + // Arrange + var provider = new PermissionProvider(); + + // Act + var permissions = await provider.GetPermissionsAsync(); + + // Assert + Assert.Contains(OmnichannelConstants.Permissions.PurgeActivity, permissions); + } + + [Fact] + public void GetDefaultStereotypes_DoesNotGrantPurgeActivityToAgent() + { + // Arrange + var provider = new PermissionProvider(); + + // Act + var agent = provider.GetDefaultStereotypes() + .Single(stereotype => stereotype.Name == OmnichannelConstants.AgentRole); + + // Assert + Assert.DoesNotContain(OmnichannelConstants.Permissions.PurgeActivity, agent.Permissions); + } +} From 28628138f50cf6a5adb5012c33f27298e3852a46 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 11 Jul 2026 09:42:58 -0700 Subject: [PATCH 53/56] Refine omnichannel activity enhancements Reuse the shared contact index for version-aware phone searches, record purge audit metadata, and move assignment toggling into a registered JavaScript resource. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0683772-e324-4658-9aff-1eda6766ce4c --- .github/contact-center/PLAN.md | 1 + .../Indexes/OmnichannelContactIndex.cs | 24 +- .../Indexes/OmnichannelContactPhoneIndex.cs | 44 --- .../Models/OmnichannelActivity.cs | 15 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 +- .../docs/omnichannel/management.md | 4 +- .../Services/InboundContactLookup.cs | 6 +- .../Assets.json | 6 + .../js/subject-action-assignment-toggle.js | 74 +++++ .../Controllers/ActivitiesController.cs | 28 +- .../BulkManageActivityFilterHandler.cs | 18 +- ...oneListOmnichannelActivityFilterHandler.cs | 2 +- .../OmnichannelContactIndexProvider.cs | 196 ++++++++--- .../OmnichannelContactPhoneIndexProvider.cs | 137 -------- .../OmnichannelContactsMigrations.cs | 313 +++++++++++++++--- .../Services/ActivityPurgeHelper.cs | 9 +- .../DefaultContactActivityBatchLoader.cs | 32 +- ...mnichannelContactDuplicateLookupService.cs | 35 +- ...actPhoneContentsAdminListFilterProvider.cs | 34 +- .../ResourceManagementOptionsConfiguration.cs | 30 ++ .../Startup.cs | 3 +- ...NewActivitySubjectActionFields.Edit.cshtml | 28 +- .../TryAgainSubjectActionFields.Edit.cshtml | 28 +- .../subject-action-assignment-toggle.js | 64 ++++ .../subject-action-assignment-toggle.min.js | 1 + .../InboundContactLookupTests.cs | 54 +++ .../ContactIndexProviderCollectionTests.cs | 16 - .../BulkManageActivityFilterHandlerTests.cs | 42 +++ ...stOmnichannelActivityFilterHandlerTests.cs | 61 ++++ .../OmnichannelContactIndexProviderTests.cs | 225 +++++++++++++ ...nichannelContactPhoneIndexProviderTests.cs | 148 --------- .../Services/ActivityPurgeHelperTests.cs | 20 +- ...annelContactDuplicateLookupServiceTests.cs | 47 +++ ...oneContentsAdminListFilterProviderTests.cs | 16 +- 34 files changed, 1186 insertions(+), 579 deletions(-) delete mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets/js/subject-action-assignment-toggle.js delete mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ResourceManagementOptionsConfiguration.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.js create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.min.js create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundContactLookupTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/BulkManageActivityFilterHandlerTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandlerTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs delete mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index f4ba69f59..c1dcadc31 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1508,6 +1508,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement ### Change log +- 2026-07-11: Inbound contact phone lookup remains scoped to published contact versions after the shared omnichannel contact index became version-aware. - 2026-07-11: Closed the remaining "zombie inbound offer" gap where a queued call whose Asterisk channel no longer existed kept being offered and accepted, leaving the agent stuck (unable to disconnect, unable to receive new calls). Three provider-truth guards were added and unit-tested: (1) `VoiceContactCenterCallRouter.OfferNextAsync` now refreshes each queued interaction against provider truth in a bounded loop and, when the provider confirms the call is gone, removes it from the queue and releases the reservation/agent via `ReconcileEndedOfferAsync` before offering the next call — a dead call is never offered; (2) `ContactCenterCallCommandService` accept-media failures now re-check provider truth and reconcile a confirmed-gone call (removing the offer and releasing the agent) instead of leaving an accepted reservation stuck on a Cancel no-op; (3) `ProviderVoiceEventService` now seeds a freshly created call session with the interaction's pre-event state instead of the incoming terminal state, so a first-observed terminal event (e.g. a reconcile of an already-gone queued call) still records a non-terminal→terminal transition, publishes `CallEnded`, and runs ended-offer cleanup instead of silently leaving the interaction queued. +3 unit tests (1,279 total pass, clean `-warnaserror` build). Confirmed the Asterisk dashboard is already event-driven end to end (ARI `subscribeAll` forwarder → 50ms-coalesced SignalR snapshot → client `dashboardSnapshot` handler with reconciliation-poll fallback); the observed lag is provider emission/network, not a code defect. - 2026-07-11: Corrected the recurring inbound zombie path found in live logs. Agent reset/sign-in healing now reconciles provider-backed work before touching routing state, retries stale provider aliases through the tenant's current default provider, terminalizes calls the provider no longer reports, and preserves calls the provider confirms are still active instead of resetting them to `Created` and requeueing answered work. The soft phone now performs an authoritative active-call refresh when a terminal event arrives for a different call id than its stale browser state. The Asterisk development dashboard event stream now subscribes to all visible ARI resources so channel and bridge changes request immediate SignalR snapshots. - 2026-07-11: Eliminated zombie soft-phone calls by making provider events and provider-state lookup the only authorities for active call state. Telephony command responses no longer mutate the browser or broadcast `CallStateChanged`; active-call restoration validates the provider instead of trusting `TelephonyInteraction.Outcome`, and a new locked reconciliation service runs at tenant startup, every minute, after Asterisk listener reconnects, and during soft-phone reconnect/page restoration. Provider-confirmed missing calls delete orphaned in-progress Telephony records and disconnect connected clients, while transient lookup failures preserve the record for retry. Asterisk terminal projection now ignores duplicate terminal events and ambiguous down-state bridge-leave snapshots, and duplicate Contact Center events retain Contact Center ownership. diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs index f1828cc3e..385be9c47 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs @@ -12,6 +12,16 @@ public sealed class OmnichannelContactIndex : MapIndex /// public string ContentItemId { get; set; } + /// + /// Gets or sets whether the indexed content item version is published. + /// + public bool Published { get; set; } + + /// + /// Gets or sets whether the indexed content item version is the latest version. + /// + public bool Latest { get; set; } + /// /// Gets or sets the contact time zone identifier. /// @@ -23,20 +33,30 @@ public sealed class OmnichannelContactIndex : MapIndex public string PrimaryCellPhoneNumber { get; set; } /// - /// Gets or sets the normalized primary cell phone number. + /// Gets or sets the normalized primary cell phone number in E.164 format. /// public string NormalizedPrimaryCellPhoneNumber { get; set; } + /// + /// Gets or sets the primary cell phone number as national digits. + /// + public string NationalPrimaryCellPhoneNumber { get; set; } + /// /// Gets or sets the primary home phone number. /// public string PrimaryHomePhoneNumber { get; set; } /// - /// Gets or sets the normalized primary home phone number. + /// Gets or sets the normalized primary home phone number in E.164 format. /// public string NormalizedPrimaryHomePhoneNumber { get; set; } + /// + /// Gets or sets the primary home phone number as national digits. + /// + public string NationalPrimaryHomePhoneNumber { get; set; } + /// /// Gets or sets the primary email address. /// diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs deleted file mode 100644 index ece67c353..000000000 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactPhoneIndex.cs +++ /dev/null @@ -1,44 +0,0 @@ -using YesSql.Indexes; - -namespace CrestApps.OrchardCore.Omnichannel.Core.Indexes; - -/// -/// Represents searchable phone numbers for an omnichannel contact content item version. -/// -public sealed class OmnichannelContactPhoneIndex : MapIndex -{ - /// - /// Gets or sets the content item identifier. - /// - public string ContentItemId { get; set; } - - /// - /// Gets or sets whether the indexed content item version is published. - /// - public bool Published { get; set; } - - /// - /// Gets or sets whether the indexed content item version is the latest version. - /// - public bool Latest { get; set; } - - /// - /// Gets or sets the primary cell phone number in E.164 format. - /// - public string E164PrimaryCellPhoneNumber { get; set; } - - /// - /// Gets or sets the primary cell phone number in national format. - /// - public string NationalPrimaryCellPhoneNumber { get; set; } - - /// - /// Gets or sets the primary home phone number in E.164 format. - /// - public string E164PrimaryHomePhoneNumber { get; set; } - - /// - /// Gets or sets the primary home phone number in national format. - /// - public string NationalPrimaryHomePhoneNumber { get; set; } -} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs index cd74fe92a..a4205db00 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs @@ -154,6 +154,21 @@ public sealed class OmnichannelActivity : CatalogItem /// public string CompletedByUsername { get; set; } + /// + /// Gets or sets the UTC time the activity was purged. + /// + public DateTime? PurgedAtUtc { get; set; } + + /// + /// Gets or sets the identifier of the user who purged the activity. + /// + public string PurgedById { get; set; } + + /// + /// Gets or sets the username of the user who purged the activity. + /// + public string PurgedByUsername { get; set; } + /// /// Gets or sets the disposition id. /// diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index a58155168..58e8f1a28 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -519,9 +519,9 @@ At a high level, the platform changes are: - A new **Manage Activities** page (`Admin/omnichannel/manage-activities`) supports page-size selection, separate Contact and Activity filter groups, urgency icons, and bulk actions against selected or filtered activities. - 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. A dedicated **Purge activity** permission controls both single and bulk purge operations, and **Manage activities** implies that permission. +- 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. +- 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. - 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. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 26968dfda..01fdb71f6 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -247,7 +247,7 @@ Navigate to **Interaction Center** -> **Activities** to review scheduled omnicha The scheduled activities list now includes a **Time zone** filter alongside the existing urgency, subject, channel, and attempt filters so agents can narrow work to leads in call-safe regions. Activity summary rows also display the contact's current local time when a lead time zone is stored, and the tooltip shows the full local date/time plus the IANA time zone id so agents can confirm whether the lead is ahead of or behind their own day before opening or completing the activity. -Users with the **Purge activity** permission see a **Purge** button on each scheduled activity in a contact profile. Purging is irreversible, changes the activity status to `Purged`, and clears any reservation state. The same permission is required for the bulk **Purge** action on the Manage Activities page; **Manage activities** implies **Purge activity**. +Users with the **Purge activity** permission see a **Purge** button on each scheduled activity in a contact profile. Purging is irreversible, changes the activity status to `Purged`, records the UTC purge time and current user's identifier and username for auditing, and clears any reservation state while preserving assignment. The same permission is required for the bulk **Purge** action on the Manage Activities page; every activity in one bulk operation records the same purge time and actor, and **Manage activities** implies **Purge activity**. ### Phone number search @@ -257,6 +257,8 @@ Phone filters in **Load Inventory**, **Manage Activities**, and Content Admin se - Input whose trimmed value begins with `+` is matched against the E.164 value. The plus sign is a literal format indicator, not a wildcard. - **Contains** is the default match mode. **Exact match**, **Begins with**, and **Ends with** are also available in Load Inventory and Manage Activities. +Content Admin evaluates the displayed content version. Load Inventory uses published or latest contact values according to **Only published leads**, while Manage Activities uses the latest saved contact values. + Content Admin supports these named search terms: | Term | Match behavior | Example | diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs index 7c09a9757..843613784 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundContactLookup.cs @@ -45,11 +45,13 @@ public async Task> FindContactItemIdsAsync(string phoneNum var numbers = new[] { normalized }; var cellMatches = await _session - .QueryIndex(index => index.NormalizedPrimaryCellPhoneNumber.IsIn(numbers)) + .QueryIndex(index => + index.Published && index.NormalizedPrimaryCellPhoneNumber.IsIn(numbers)) .ListAsync(cancellationToken); var homeMatches = await _session - .QueryIndex(index => index.NormalizedPrimaryHomePhoneNumber.IsIn(numbers)) + .QueryIndex(index => + index.Published && index.NormalizedPrimaryHomePhoneNumber.IsIn(numbers)) .ListAsync(cancellationToken); var ids = new List(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets.json b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets.json index c7a4a7d90..c53ef196a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets.json +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets.json @@ -4,5 +4,11 @@ "Assets/js/activity-query-builder.js" ], "output": "wwwroot/scripts/activity-query-builder.js" + }, + { + "inputs": [ + "Assets/js/subject-action-assignment-toggle.js" + ], + "output": "wwwroot/scripts/subject-action-assignment-toggle.js" } ] diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets/js/subject-action-assignment-toggle.js b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets/js/subject-action-assignment-toggle.js new file mode 100644 index 000000000..04f337b6e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Assets/js/subject-action-assignment-toggle.js @@ -0,0 +1,74 @@ +(function () { + var selector = '[data-subject-action-assignment-toggle]'; + + function initializeToggle(assignmentTypeSelect) { + var targetId = assignmentTypeSelect.dataset.assignmentTargetId; + var specificOwnerValue = assignmentTypeSelect.dataset.assignmentSpecificOwnerValue; + + if (!targetId || + !specificOwnerValue || + assignmentTypeSelect.dataset.assignmentToggleInitialized === 'true') { + return; + } + + var specificOwnerContainer = document.getElementById(targetId); + + if (!specificOwnerContainer) { + return; + } + + assignmentTypeSelect.dataset.assignmentToggleInitialized = 'true'; + + function toggle() { + var isSpecificOwner = assignmentTypeSelect.value === specificOwnerValue; + + specificOwnerContainer.classList.toggle('d-none', !isSpecificOwner); + assignmentTypeSelect.setAttribute('aria-expanded', isSpecificOwner.toString()); + } + + assignmentTypeSelect.addEventListener('change', toggle); + toggle(); + } + + function initialize() { + document.querySelectorAll(selector).forEach(initializeToggle); + } + + function observeDynamicAdditions() { + var observer = new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var addedNodes = mutations[i].addedNodes; + + for (var j = 0; j < addedNodes.length; j++) { + var node = addedNodes[j]; + + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + + if (node.matches(selector)) { + initializeToggle(node); + } + else { + var toggles = node.querySelectorAll(selector); + toggles.forEach(initializeToggle); + } + } + } + }); + + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + initialize(); + observeDynamicAdditions(); + }, { once: true }); + + return; + } + + initialize(); + observeDynamicAdditions(); +})(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index d87cf4726..cfd725ca4 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -591,7 +591,15 @@ public async Task Purge(string contentItemId, string id) return Forbid(); } - ActivityPurgeHelper.Purge(activity); + var purgedAtUtc = _clock.UtcNow; + var purgedById = User.FindFirstValue(ClaimTypes.NameIdentifier); + var purgedByUsername = User.Identity?.Name; + + ActivityPurgeHelper.Purge( + activity, + purgedAtUtc, + purgedById, + purgedByUsername); await _omnichannelActivityManager.UpdateAsync(activity); await _notifier.SuccessAsync(H["The activity has been purged successfully."]); @@ -803,7 +811,15 @@ public async Task ManageActivitiesBulkActionPost( break; case BulkActivityAction.Purge: - processedCount = await BulkPurgeAsync(activities); + var purgedAtUtc = _clock.UtcNow; + var purgedById = User.FindFirstValue(ClaimTypes.NameIdentifier); + var purgedByUsername = User.Identity?.Name; + + processedCount = await BulkPurgeAsync( + activities, + purgedAtUtc, + purgedById, + purgedByUsername); break; case BulkActivityAction.SetInstructions: @@ -917,13 +933,17 @@ private async Task BulkRescheduleAsync(List activities return processedCount; } - private async Task BulkPurgeAsync(List activities) + private async Task BulkPurgeAsync( + List activities, + DateTime purgedAtUtc, + string purgedById, + string purgedByUsername) { var processedCount = 0; foreach (var activity in activities) { - ActivityPurgeHelper.Purge(activity); + ActivityPurgeHelper.Purge(activity, purgedAtUtc, purgedById, purgedByUsername); await _omnichannelActivityManager.UpdateAsync(activity); processedCount++; } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs index 615ec7bf8..0eecd6c3f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs @@ -154,8 +154,8 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, if (hasPhoneFilter) { var actContactCol = nameof(OmnichannelActivityIndex.ContactContentItemId); - var phoneTable = context.TableNameConvention.GetIndexTable(typeof(OmnichannelContactPhoneIndex)); - var contactItemIdCol = nameof(OmnichannelContactPhoneIndex.ContentItemId); + var phoneTable = context.TableNameConvention.GetIndexTable(typeof(OmnichannelContactIndex)); + var contactItemIdCol = nameof(OmnichannelContactIndex.ContentItemId); builder.Join( JoinType.Inner, @@ -168,7 +168,7 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, ContactPhoneAlias, actAlias); - var latestCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactPhoneIndex.Latest))}"; + var latestCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.Latest))}"; builder.Parameters["@PhoneLatest"] = true; builder.WhereAnd($"{latestCol} = @PhoneLatest"); @@ -179,11 +179,11 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, else { var cellColumnName = searchTerm.IsE164 - ? nameof(OmnichannelContactPhoneIndex.E164PrimaryCellPhoneNumber) - : nameof(OmnichannelContactPhoneIndex.NationalPrimaryCellPhoneNumber); + ? nameof(OmnichannelContactIndex.NormalizedPrimaryCellPhoneNumber) + : nameof(OmnichannelContactIndex.NationalPrimaryCellPhoneNumber); var homeColumnName = searchTerm.IsE164 - ? nameof(OmnichannelContactPhoneIndex.E164PrimaryHomePhoneNumber) - : nameof(OmnichannelContactPhoneIndex.NationalPrimaryHomePhoneNumber); + ? nameof(OmnichannelContactIndex.NormalizedPrimaryHomePhoneNumber) + : nameof(OmnichannelContactIndex.NationalPrimaryHomePhoneNumber); var cellCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(cellColumnName)}"; var homeCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(homeColumnName)}"; var comparison = filter.PhoneNumberMatchType == PhoneNumberMatchType.Exact ? "=" : "LIKE"; @@ -210,9 +210,13 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, ContactAlias, actAlias); + var latestCol = $"{dialect.QuoteForAliasName(ContactAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.Latest))}"; var tzCol = $"{dialect.QuoteForAliasName(ContactAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelContactIndex.TimeZoneId))}"; var placeholders = new string[filter.TimeZoneIds.Length]; + builder.Parameters["@TimeZoneLatest"] = true; + builder.WhereAnd($"{latestCol} = @TimeZoneLatest"); + for (var i = 0; i < filter.TimeZoneIds.Length; i++) { var paramName = $"@TZ{i}"; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandler.cs index a58780179..cd57ccb15 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandler.cs @@ -30,7 +30,7 @@ public async Task FilteringAsync(ListOmnichannelActivityFilterContext context, C } var contactContentItemIds = (await _session.QueryIndex( - index => index.TimeZoneId == context.Filter.TimeZoneId) + index => index.Published && index.TimeZoneId == context.Filter.TimeZoneId) .ListAsync(cancellationToken)) .Select(index => index.ContentItemId) .Where(contentItemId => !string.IsNullOrEmpty(contentItemId)) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs index 25baeeab1..da4874aa8 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs @@ -1,7 +1,9 @@ using CrestApps.Core; +using CrestApps.OrchardCore.ContentFields.Fields; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Indexes; using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; using CrestApps.OrchardCore.PhoneNumbers; using OrchardCore.ContentManagement; using OrchardCore.Flows.Models; @@ -22,69 +24,155 @@ public override void Describe(DescribeContext context) { context .For() - .Map(contact => + .Map(CreateIndex); + } + + internal OmnichannelContactIndex CreateIndex(ContentItem contact) + { + if ((!contact.Published && !contact.Latest) || + !contact.TryGet(out var contactPart)) + { + return null; + } + + var index = new OmnichannelContactIndex + { + ContentItemId = contact.ContentItemId, + Published = contact.Published, + Latest = contact.Latest, + TimeZoneId = TruncateTrimmed(contactPart.TimeZoneId, 64), + }; + + if (!contact.TryGet(OmnichannelConstants.NamedParts.ContactMethods, out var bagPart) || + bagPart.ContentItems is null || + bagPart.ContentItems.Count == 0) + { + return index; + } + + foreach (var contactMethod in bagPart.ContentItems) + { + if (string.Equals(contactMethod.ContentType, OmnichannelConstants.ContentTypes.EmailAddress, StringComparison.Ordinal) && + string.IsNullOrEmpty(index.PrimaryEmailAddress) && + contactMethod.TryGet(out var emailPart) && + !string.IsNullOrEmpty(emailPart.Email?.Text)) { - if (!contact.Published || !contact.TryGet(out var contactPart)) - { - return null; - } - - var index = new OmnichannelContactIndex - { - ContentItemId = contact.ContentItemId, - TimeZoneId = Truncate(contactPart.TimeZoneId, 64), - }; - - if (contact.TryGet(OmnichannelConstants.NamedParts.ContactMethods, out var bagPart) && - bagPart.ContentItems is not null && - bagPart.ContentItems.Count > 0) - { - foreach (var contentMethod in bagPart.ContentItems) - { - if (contentMethod.ContentType == OmnichannelConstants.ContentTypes.EmailAddress && - string.IsNullOrEmpty(index.PrimaryEmailAddress) && - contentMethod.TryGet(out var emailPart) && - !string.IsNullOrEmpty(emailPart.Email?.Text)) - { - index.PrimaryEmailAddress = emailPart.Email.Text.Substring(0, Math.Min(255, emailPart.Email.Text.Length)); - } - - if (contentMethod.ContentType == OmnichannelConstants.ContentTypes.PhoneNumber && - contentMethod.TryGet(out var phonePart) && - !string.IsNullOrEmpty(phonePart.Number?.PhoneNumber)) - { - if (string.IsNullOrEmpty(index.PrimaryCellPhoneNumber) && phonePart.Type?.Text == "Cell") - { - index.PrimaryCellPhoneNumber = phonePart.Number.PhoneNumber.Substring(0, Math.Min(50, phonePart.Number.PhoneNumber.Length)); - index.NormalizedPrimaryCellPhoneNumber = NormalizeToE164(phonePart.Number.PhoneNumber); - } - - if (string.IsNullOrEmpty(index.PrimaryHomePhoneNumber) && phonePart.Type?.Text == "Home") - { - index.PrimaryHomePhoneNumber = phonePart.Number.PhoneNumber.Substring(0, Math.Min(50, phonePart.Number.PhoneNumber.Length)); - index.NormalizedPrimaryHomePhoneNumber = NormalizeToE164(phonePart.Number.PhoneNumber); - } - } - } - } - - return index; - }); + index.PrimaryEmailAddress = Truncate(emailPart.Email.Text, 255); + } + + if (!string.Equals(contactMethod.ContentType, OmnichannelConstants.ContentTypes.PhoneNumber, StringComparison.Ordinal) || + !contactMethod.TryGet(out var phonePart) || + string.IsNullOrWhiteSpace(phonePart.Number?.PhoneNumber)) + { + continue; + } + + if (string.Equals(phonePart.Type?.Text, "Cell", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(index.PrimaryCellPhoneNumber)) + { + SetPrimaryCellPhoneNumber(index, phonePart.Number); + } + else if (string.Equals(phonePart.Type?.Text, "Home", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrEmpty(index.PrimaryHomePhoneNumber)) + { + SetPrimaryHomePhoneNumber(index, phonePart.Number); + } + } + + return index; + } + + private void SetPrimaryCellPhoneNumber(OmnichannelContactIndex index, PhoneField field) + { + index.PrimaryCellPhoneNumber = Truncate(field.PhoneNumber, 50); + index.NormalizedPrimaryCellPhoneNumber = NormalizeToE164(field); + index.NationalPrimaryCellPhoneNumber = NormalizeNationalNumber( + field, + index.NormalizedPrimaryCellPhoneNumber); } - private string NormalizeToE164(string phoneNumber) + private void SetPrimaryHomePhoneNumber(OmnichannelContactIndex index, PhoneField field) { - if (_phoneNumberService.TryFormatToE164(phoneNumber, null, out var e164)) + index.PrimaryHomePhoneNumber = Truncate(field.PhoneNumber, 50); + index.NormalizedPrimaryHomePhoneNumber = NormalizeToE164(field); + index.NationalPrimaryHomePhoneNumber = NormalizeNationalNumber( + field, + index.NormalizedPrimaryHomePhoneNumber); + } + + private string NormalizeToE164(PhoneField field) + { + var phoneNumber = field.PhoneNumber?.Trim(); + + if (_phoneNumberService.TryFormatToE164(phoneNumber, field.CountryCode, out var e164Number)) + { + return Truncate(e164Number, 50); + } + + if (PhoneNumberSearchTerm.TryParse(phoneNumber, out var searchTerm) && searchTerm.IsE164) { - return e164; + return Truncate(searchTerm.Value, 50); } - // If the number is already in E.164 format, return it as-is. - return phoneNumber; + return null; + } + + private string NormalizeNationalNumber(PhoneField field, string e164Number) + { + var nationalNumber = PhoneNumberSearchTerm.NormalizeDigits(field.NationalNumber); + + if (!string.IsNullOrEmpty(nationalNumber)) + { + return Truncate(nationalNumber, 50); + } + + var digits = PhoneNumberSearchTerm.NormalizeDigits(e164Number ?? field.PhoneNumber); + + if (string.IsNullOrEmpty(digits) || string.IsNullOrEmpty(e164Number)) + { + return Truncate(digits, 50); + } + + var regionCode = string.IsNullOrWhiteSpace(field.CountryCode) + ? _phoneNumberService.GetRegionCode(e164Number) + : field.CountryCode; + + if (string.IsNullOrWhiteSpace(regionCode)) + { + return Truncate(digits, 50); + } + + var countryCode = _phoneNumberService.GetCountryCode(regionCode); + var countryCodeText = countryCode > 0 + ? countryCode.ToString() + : null; + + if (!string.IsNullOrEmpty(countryCodeText) && + digits.Length > countryCodeText.Length && + digits.StartsWith(countryCodeText, StringComparison.Ordinal)) + { + digits = digits.Substring(countryCodeText.Length); + } + + return Truncate(digits, 50); } private static string Truncate(string value, int maxLength) - => string.IsNullOrWhiteSpace(value) + { + return string.IsNullOrEmpty(value) ? null - : value.Trim().Substring(0, Math.Min(maxLength, value.Trim().Length)); + : value.Substring(0, Math.Min(maxLength, value.Length)); + } + + private static string TruncateTrimmed(string value, int maxLength) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + var trimmed = value.Trim(); + + return Truncate(trimmed, maxLength); + } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs deleted file mode 100644 index 6ae999cb7..000000000 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactPhoneIndexProvider.cs +++ /dev/null @@ -1,137 +0,0 @@ -using CrestApps.OrchardCore.ContentFields.Fields; -using CrestApps.OrchardCore.Omnichannel.Core; -using CrestApps.OrchardCore.Omnichannel.Core.Indexes; -using CrestApps.OrchardCore.Omnichannel.Core.Models; -using CrestApps.OrchardCore.Omnichannel.Managements.Services; -using CrestApps.OrchardCore.PhoneNumbers; -using OrchardCore.ContentManagement; -using OrchardCore.Flows.Models; -using YesSql.Indexes; - -namespace CrestApps.OrchardCore.Omnichannel.Managements.Indexes; - -internal sealed class OmnichannelContactPhoneIndexProvider : IndexProvider -{ - private readonly IPhoneNumberService _phoneNumberService; - - public OmnichannelContactPhoneIndexProvider(IPhoneNumberService phoneNumberService) - { - _phoneNumberService = phoneNumberService; - } - - public override void Describe(DescribeContext context) - { - context - .For() - .Map(CreateIndex); - } - - internal OmnichannelContactPhoneIndex CreateIndex(ContentItem contact) - { - if ((!contact.Published && !contact.Latest) || - !contact.TryGet(out _) || - !contact.TryGet(OmnichannelConstants.NamedParts.ContactMethods, out var bagPart) || - bagPart.ContentItems is null || - bagPart.ContentItems.Count == 0) - { - return null; - } - - var index = new OmnichannelContactPhoneIndex - { - ContentItemId = contact.ContentItemId, - Published = contact.Published, - Latest = contact.Latest, - }; - - foreach (var contactMethod in bagPart.ContentItems) - { - if (!string.Equals(contactMethod.ContentType, OmnichannelConstants.ContentTypes.PhoneNumber, StringComparison.Ordinal) || - !contactMethod.TryGet(out var phonePart) || - string.IsNullOrWhiteSpace(phonePart.Number?.PhoneNumber)) - { - continue; - } - - var e164Number = NormalizeToE164(phonePart.Number); - var nationalNumber = NormalizeNationalNumber(phonePart.Number, e164Number); - - if (string.Equals(phonePart.Type?.Text, "Cell", StringComparison.OrdinalIgnoreCase) && - string.IsNullOrEmpty(index.E164PrimaryCellPhoneNumber)) - { - index.E164PrimaryCellPhoneNumber = e164Number; - index.NationalPrimaryCellPhoneNumber = nationalNumber; - } - else if (string.Equals(phonePart.Type?.Text, "Home", StringComparison.OrdinalIgnoreCase) && - string.IsNullOrEmpty(index.E164PrimaryHomePhoneNumber)) - { - index.E164PrimaryHomePhoneNumber = e164Number; - index.NationalPrimaryHomePhoneNumber = nationalNumber; - } - } - - return index; - } - - private string NormalizeToE164(PhoneField field) - { - var phoneNumber = field.PhoneNumber?.Trim(); - - if (_phoneNumberService.TryFormatToE164(phoneNumber, field.CountryCode, out var e164Number)) - { - return Truncate(e164Number, 50); - } - - if (PhoneNumberSearchTerm.TryParse(phoneNumber, out var searchTerm) && searchTerm.IsE164) - { - return Truncate(searchTerm.Value, 50); - } - - return null; - } - - private string NormalizeNationalNumber(PhoneField field, string e164Number) - { - var nationalNumber = PhoneNumberSearchTerm.NormalizeDigits(field.NationalNumber); - - if (!string.IsNullOrEmpty(nationalNumber)) - { - return Truncate(nationalNumber, 50); - } - - var digits = PhoneNumberSearchTerm.NormalizeDigits(e164Number ?? field.PhoneNumber); - - if (string.IsNullOrEmpty(digits) || string.IsNullOrEmpty(e164Number)) - { - return Truncate(digits, 50); - } - - var regionCode = string.IsNullOrWhiteSpace(field.CountryCode) - ? _phoneNumberService.GetRegionCode(e164Number) - : field.CountryCode; - - if (string.IsNullOrWhiteSpace(regionCode)) - { - return Truncate(digits, 50); - } - - var countryCode = _phoneNumberService.GetCountryCode(regionCode); - var countryCodeText = countryCode > 0 ? countryCode.ToString() : null; - - if (!string.IsNullOrEmpty(countryCodeText) && - digits.Length > countryCodeText.Length && - digits.StartsWith(countryCodeText, StringComparison.Ordinal)) - { - digits = digits.Substring(countryCodeText.Length); - } - - return Truncate(digits, 50); - } - - private static string Truncate(string value, int maxLength) - { - return string.IsNullOrEmpty(value) - ? null - : value.Substring(0, Math.Min(maxLength, value.Length)); - } -} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs index 7091511ee..f135b71f7 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs @@ -21,6 +21,7 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Migrations; /// public sealed class OmnichannelContactsMigrations : DataMigration { + private const string LegacyPhoneIndexTableName = "OmnichannelContactPhoneIndex"; private const int ReindexBatchSize = 100; private readonly IContentDefinitionManager _contentDefinitionManager; @@ -48,7 +49,7 @@ public OmnichannelContactsMigrations( } /// - /// Creates a new async. + /// Creates the omnichannel contact definition and final version-aware contact index. /// public async Task CreateAsync() { @@ -58,19 +59,10 @@ await _contentDefinitionManager.AlterPartDefinitionAsync(OmnichannelConstants.Co .WithDescription("Provides a way to configure a content type to act as an omnichannel contact record.") ); - await SchemaBuilder.CreateMapIndexTableAsync(table => table - .Column("ContentItemId", column => column.WithLength(26)) - .Column("PrimaryCellPhoneNumber", column => column.WithLength(50)) - .Column("NormalizedPrimaryCellPhoneNumber", column => column.WithLength(50)) - .Column("PrimaryHomePhoneNumber", column => column.WithLength(50)) - .Column("NormalizedPrimaryHomePhoneNumber", column => column.WithLength(50)) - .Column("PrimaryEmailAddress", column => column.WithLength(255)) - .Column("TimeZoneId", column => column.WithLength(64)) - ); - - await CreatePhoneIndexTableAsync(); + await CreateContactIndexTableAsync(); + await CreateContactIndexIndexesAsync(); - return 7; + return 8; } /// @@ -167,73 +159,128 @@ public async Task UpdateFrom5Async() } /// - /// Adds the version-aware contact phone search index. + /// Upgrades tenants at version 6 directly to the final version-aware contact index. /// public async Task UpdateFrom6Async() { - await CreatePhoneIndexTableAsync(); - ShellScope.AddDeferredTask(ReindexContactSearchVersionsAsync); + await EnsureDefaultContactIndexTableAsync(); + ShellScope.AddDeferredTask(ReindexContactVersionsAsync); - return 7; + return 8; } - private async Task CreatePhoneIndexTableAsync() + /// + /// Merges the version-aware phone search fields into the shared contact index for tenants already at version 7. + /// + public async Task UpdateFrom7Async() { - await SchemaBuilder.CreateMapIndexTableAsync(table => table + await EnsureDefaultContactIndexTableAsync(); + await DropLegacyPhoneIndexTableAsync(); + ShellScope.AddDeferredTask(ReindexContactVersionsAsync); + + return 8; + } + + private async Task CreateContactIndexTableAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table .Column("ContentItemId", column => column.WithLength(26)) - .Column("Published") - .Column("Latest") - .Column("E164PrimaryCellPhoneNumber", column => column.WithLength(50)) + .Column("Published", column => column.NotNull().WithDefault(false)) + .Column("Latest", column => column.NotNull().WithDefault(false)) + .Column("PrimaryCellPhoneNumber", column => column.WithLength(50)) + .Column("NormalizedPrimaryCellPhoneNumber", column => column.WithLength(50)) .Column("NationalPrimaryCellPhoneNumber", column => column.WithLength(50)) - .Column("E164PrimaryHomePhoneNumber", column => column.WithLength(50)) + .Column("PrimaryHomePhoneNumber", column => column.WithLength(50)) + .Column("NormalizedPrimaryHomePhoneNumber", column => column.WithLength(50)) .Column("NationalPrimaryHomePhoneNumber", column => column.WithLength(50)) + .Column("PrimaryEmailAddress", column => column.WithLength(255)) + .Column("TimeZoneId", column => column.WithLength(64)) ); + } - await SchemaBuilder.AlterIndexTableAsync(table => table + private async Task CreateContactIndexIndexesAsync() + { + await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCPhone_DocumentId", + "IDX_OmnichannelContactIndex_DocumentId", "DocumentId", "ContentItemId") ); - await SchemaBuilder.AlterIndexTableAsync(table => table + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OmnichannelContactIndex_NormalizedPrimaryCellPhoneNumber", + "DocumentId", + "NormalizedPrimaryCellPhoneNumber") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OmnichannelContactIndex_NormalizedPrimaryHomePhoneNumber", + "DocumentId", + "NormalizedPrimaryHomePhoneNumber") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OmnichannelContactIndex_TimeZoneId", + "DocumentId", + "TimeZoneId") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCPhone_ContentItemLatest", + "IDX_OCIndex_ContentItemLatest", "ContentItemId", "Latest") ); - await SchemaBuilder.AlterIndexTableAsync(table => table + await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCPhone_E164Cell", - "E164PrimaryCellPhoneNumber", + "IDX_OCIndex_ContentItemPublished", + "ContentItemId", + "Published") + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_E164Cell", + "NormalizedPrimaryCellPhoneNumber", "Published", "Latest") ); - await SchemaBuilder.AlterIndexTableAsync(table => table + await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCPhone_NationalCell", + "IDX_OCIndex_NationalCell", "NationalPrimaryCellPhoneNumber", "Published", "Latest") ); - await SchemaBuilder.AlterIndexTableAsync(table => table + await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCPhone_E164Home", - "E164PrimaryHomePhoneNumber", + "IDX_OCIndex_E164Home", + "NormalizedPrimaryHomePhoneNumber", "Published", "Latest") ); - await SchemaBuilder.AlterIndexTableAsync(table => table + await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCPhone_NationalHome", + "IDX_OCIndex_NationalHome", "NationalPrimaryHomePhoneNumber", "Published", "Latest") ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_TimeZoneVersion", + "TimeZoneId", + "Published", + "Latest") + ); } private async Task EnsureDefaultContactIndexTableAsync() @@ -242,21 +289,35 @@ private async Task EnsureDefaultContactIndexTableAsync() try { - await SchemaBuilder.CreateMapIndexTableAsync(table => table - .Column("ContentItemId", column => column.WithLength(26)) - .Column("PrimaryCellPhoneNumber", column => column.WithLength(50)) - .Column("NormalizedPrimaryCellPhoneNumber", column => column.WithLength(50)) - .Column("PrimaryHomePhoneNumber", column => column.WithLength(50)) - .Column("NormalizedPrimaryHomePhoneNumber", column => column.WithLength(50)) - .Column("PrimaryEmailAddress", column => column.WithLength(255)) - .Column("TimeZoneId", column => column.WithLength(64)) - ); + await CreateContactIndexTableAsync(); } catch (Exception ex) { _logger.LogWarning(ex, "The default-collection OmnichannelContactIndex table may already exist."); } + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.AddColumn("Published", column => column.NotNull().WithDefault(false)) + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'Published' column may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.AddColumn("Latest", column => column.NotNull().WithDefault(false)) + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'Latest' column may already exist on the default-collection OmnichannelContactIndex table."); + } + try { await SchemaBuilder.AlterIndexTableAsync(table => @@ -268,6 +329,17 @@ await SchemaBuilder.AlterIndexTableAsync(table => _logger.LogWarning(ex, "The 'NormalizedPrimaryCellPhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); } + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.AddColumn("NationalPrimaryCellPhoneNumber", column => column.WithLength(50)) + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'NationalPrimaryCellPhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); + } + try { await SchemaBuilder.AlterIndexTableAsync(table => @@ -279,6 +351,17 @@ await SchemaBuilder.AlterIndexTableAsync(table => _logger.LogWarning(ex, "The 'NormalizedPrimaryHomePhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); } + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.AddColumn("NationalPrimaryHomePhoneNumber", column => column.WithLength(50)) + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'NationalPrimaryHomePhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); + } + try { await SchemaBuilder.AlterIndexTableAsync(table => @@ -345,6 +428,140 @@ await SchemaBuilder.AlterIndexTableAsync(table => table { _logger.LogWarning(ex, "The 'IDX_OmnichannelContactIndex_TimeZoneId' index may already exist on the default-collection OmnichannelContactIndex table."); } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_ContentItemLatest", + "ContentItemId", + "Latest") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_ContentItemLatest' index may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_ContentItemPublished", + "ContentItemId", + "Published") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_ContentItemPublished' index may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_E164Cell", + "NormalizedPrimaryCellPhoneNumber", + "Published", + "Latest") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_E164Cell' index may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_NationalCell", + "NationalPrimaryCellPhoneNumber", + "Published", + "Latest") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_NationalCell' index may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_E164Home", + "NormalizedPrimaryHomePhoneNumber", + "Published", + "Latest") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_E164Home' index may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_NationalHome", + "NationalPrimaryHomePhoneNumber", + "Published", + "Latest") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_NationalHome' index may already exist on the default-collection OmnichannelContactIndex table."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex( + "IDX_OCIndex_TimeZoneVersion", + "TimeZoneId", + "Published", + "Latest") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The 'IDX_OCIndex_TimeZoneVersion' index may already exist on the default-collection OmnichannelContactIndex table."); + } + } + + private async Task DropLegacyPhoneIndexTableAsync() + { + var dialect = _store.Configuration.SqlDialect; + var table = $"{_store.Configuration.TablePrefix}{LegacyPhoneIndexTableName}"; + var quotedTable = dialect.QuoteForTableName(table, _store.Configuration.Schema); + + try + { + await using var connection = _dbConnectionAccessor.CreateConnection(); + await connection.OpenAsync(); + await connection.ExecuteAsync($"drop table {quotedTable}"); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Dropped the obsolete default-collection contact phone index table '{TableName}'.", + table); + } + } + catch (Exception ex) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + ex, + "The obsolete default-collection contact phone index table '{TableName}' was not dropped because it was unavailable or already removed.", + table); + } + } } private async Task RemoveLegacyCollectionContactIndexTableAsync() @@ -436,7 +653,7 @@ private static async Task ReindexPublishedContactsAsync(ShellScope scope) } } - private static async Task ReindexContactSearchVersionsAsync(ShellScope scope) + private static async Task ReindexContactVersionsAsync(ShellScope scope) { var contentDefinitionManager = scope.ServiceProvider.GetRequiredService(); var logger = scope.ServiceProvider.GetRequiredService>(); @@ -481,7 +698,7 @@ private static async Task ReindexContactSearchVersionsAsync(ShellScope scope) if (logger.IsEnabled(LogLevel.Information)) { logger.LogInformation( - "Reindexed {ReindexedCount} published or latest omnichannel contact content item version(s) for phone search.", + "Reindexed {ReindexedCount} published or latest omnichannel contact content item version(s) after upgrading the shared contact index.", reindexedCount); } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs index 1ed21d44c..886b8eb4b 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ActivityPurgeHelper.cs @@ -4,11 +4,18 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; internal static class ActivityPurgeHelper { - internal static void Purge(OmnichannelActivity activity) + internal static void Purge( + OmnichannelActivity activity, + DateTime purgedAtUtc, + string purgedById, + string purgedByUsername) { ArgumentNullException.ThrowIfNull(activity); activity.Status = ActivityStatus.Purged; + activity.PurgedAtUtc = purgedAtUtc; + activity.PurgedById = purgedById; + activity.PurgedByUsername = purgedByUsername; activity.ReservationId = null; activity.ReservedById = null; activity.ReservedByUsername = null; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs index 751d76002..db0019b02 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs @@ -216,8 +216,8 @@ public virtual async Task LoadAsync(ActivityBatchLoadContext context, Cancellati else { var phoneQuery = batch.OnlyPublishedLeads - ? readonlySession.QueryIndex(index => index.Published) - : readonlySession.QueryIndex(index => index.Latest); + ? readonlySession.QueryIndex(index => index.Published) + : readonlySession.QueryIndex(index => index.Latest); phoneQuery = ApplyPhoneFilter(phoneQuery, searchTerm, batch.PhoneNumberMatchType); @@ -228,8 +228,12 @@ public virtual async Task LoadAsync(ActivityBatchLoadContext context, Cancellati if (hasTimeZoneFilter) { - var tzContacts = await readonlySession.QueryIndex( - index => index.TimeZoneId.IsIn(batch.TimeZoneIds)) + var timeZoneQuery = batch.OnlyPublishedLeads + ? readonlySession.QueryIndex(index => index.Published) + : readonlySession.QueryIndex(index => index.Latest); + + var tzContacts = await timeZoneQuery + .Where(index => index.TimeZoneId.IsIn(batch.TimeZoneIds)) .ListAsync(cancellationToken); timeZoneIds = tzContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); @@ -487,8 +491,8 @@ SELECT MAX(a2.{completedCol}) await _session.SaveChangesAsync(cancellationToken); } - private static IQueryIndex ApplyPhoneFilter( - IQueryIndex query, + private static IQueryIndex ApplyPhoneFilter( + IQueryIndex query, PhoneNumberSearchTerm searchTerm, PhoneNumberMatchType matchType) { @@ -497,17 +501,17 @@ private static IQueryIndex ApplyPhoneFilter( return matchType switch { PhoneNumberMatchType.Exact => query.Where(index => - index.E164PrimaryCellPhoneNumber == searchTerm.Value || - index.E164PrimaryHomePhoneNumber == searchTerm.Value), + index.NormalizedPrimaryCellPhoneNumber == searchTerm.Value || + index.NormalizedPrimaryHomePhoneNumber == searchTerm.Value), PhoneNumberMatchType.BeginsWith => query.Where(index => - index.E164PrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || - index.E164PrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + index.NormalizedPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.NormalizedPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), PhoneNumberMatchType.EndsWith => query.Where(index => - index.E164PrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || - index.E164PrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + index.NormalizedPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.NormalizedPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), PhoneNumberMatchType.Contains => query.Where(index => - index.E164PrimaryCellPhoneNumber.Contains(searchTerm.Value) || - index.E164PrimaryHomePhoneNumber.Contains(searchTerm.Value)), + index.NormalizedPrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.NormalizedPrimaryHomePhoneNumber.Contains(searchTerm.Value)), _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), }; } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactDuplicateLookupService.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactDuplicateLookupService.cs index e5f843f7b..ece133bfd 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactDuplicateLookupService.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactDuplicateLookupService.cs @@ -1,6 +1,5 @@ using CrestApps.OrchardCore.Omnichannel.Core.Indexes; using CrestApps.OrchardCore.PhoneNumbers; -using OrchardCore.ContentManagement.Records; using YesSql; using YesSql.Services; @@ -47,10 +46,12 @@ public async Task> GetExistingNormalizedPhoneNumbersAsync( var existingPhoneNumbers = new HashSet(StringComparer.OrdinalIgnoreCase); var cellPhoneMatches = await _session.QueryIndex( - index => index.NormalizedPrimaryCellPhoneNumber.IsIn(normalizedPhoneNumbers)) + index => index.Published && + index.NormalizedPrimaryCellPhoneNumber.IsIn(normalizedPhoneNumbers)) .ListAsync(cancellationToken); var homePhoneMatches = await _session.QueryIndex( - index => index.NormalizedPrimaryHomePhoneNumber.IsIn(normalizedPhoneNumbers)) + index => index.Published && + index.NormalizedPrimaryHomePhoneNumber.IsIn(normalizedPhoneNumbers)) .ListAsync(cancellationToken); foreach (var match in cellPhoneMatches) @@ -92,38 +93,16 @@ public async Task> GetAllExistingNormalizedPhoneNumbersAsync(Can public async Task> GetAllExistingNormalizedPhoneNumberOwnersAsync(CancellationToken cancellationToken) { var phoneOwners = new Dictionary>(StringComparer.OrdinalIgnoreCase); - var allIndexes = await _session.QueryIndex() + var publishedIndexes = await _session.QueryIndex(index => index.Published) .ListAsync(cancellationToken); - if (!allIndexes.Any()) + if (!publishedIndexes.Any()) { return []; } - var contentItemIds = allIndexes - .Select(index => index.ContentItemId) - .Where(id => !string.IsNullOrWhiteSpace(id)) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (contentItemIds.Length == 0) - { - return []; - } - - var latestContentItemIds = (await _session.QueryIndex(index => - index.ContentItemId.IsIn(contentItemIds) && index.Latest) - .ListAsync(cancellationToken)) - .Select(index => index.ContentItemId) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - - foreach (var index in allIndexes) + foreach (var index in publishedIndexes) { - if (!latestContentItemIds.Contains(index.ContentItemId)) - { - continue; - } - AddPhoneOwners(phoneOwners, index.ContentItemId, index.NormalizedPrimaryCellPhoneNumber, index.PrimaryCellPhoneNumber); AddPhoneOwners(phoneOwners, index.ContentItemId, index.NormalizedPrimaryHomePhoneNumber, index.PrimaryHomePhoneNumber); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs index fcff3dc2f..b062e2821 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs @@ -29,41 +29,41 @@ private static IQuery ApplyFilter( { if (!PhoneNumberSearchTerm.TryParse(value, out var searchTerm)) { - return query.With(index => index.ContentItemId == string.Empty); + return query.With(index => index.ContentItemId == string.Empty); } if (searchTerm.IsE164) { return matchType switch { - PhoneNumberMatchType.Exact => query.With(index => - index.E164PrimaryCellPhoneNumber == searchTerm.Value || - index.E164PrimaryHomePhoneNumber == searchTerm.Value), - PhoneNumberMatchType.BeginsWith => query.With(index => - index.E164PrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || - index.E164PrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), - PhoneNumberMatchType.EndsWith => query.With(index => - index.E164PrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || - index.E164PrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), - PhoneNumberMatchType.Contains => query.With(index => - index.E164PrimaryCellPhoneNumber.Contains(searchTerm.Value) || - index.E164PrimaryHomePhoneNumber.Contains(searchTerm.Value)), + PhoneNumberMatchType.Exact => query.With(index => + index.NormalizedPrimaryCellPhoneNumber == searchTerm.Value || + index.NormalizedPrimaryHomePhoneNumber == searchTerm.Value), + PhoneNumberMatchType.BeginsWith => query.With(index => + index.NormalizedPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.NormalizedPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + PhoneNumberMatchType.EndsWith => query.With(index => + index.NormalizedPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.NormalizedPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + PhoneNumberMatchType.Contains => query.With(index => + index.NormalizedPrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.NormalizedPrimaryHomePhoneNumber.Contains(searchTerm.Value)), _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), }; } return matchType switch { - PhoneNumberMatchType.Exact => query.With(index => + PhoneNumberMatchType.Exact => query.With(index => index.NationalPrimaryCellPhoneNumber == searchTerm.Value || index.NationalPrimaryHomePhoneNumber == searchTerm.Value), - PhoneNumberMatchType.BeginsWith => query.With(index => + PhoneNumberMatchType.BeginsWith => query.With(index => index.NationalPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || index.NationalPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), - PhoneNumberMatchType.EndsWith => query.With(index => + PhoneNumberMatchType.EndsWith => query.With(index => index.NationalPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || index.NationalPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), - PhoneNumberMatchType.Contains => query.With(index => + PhoneNumberMatchType.Contains => query.With(index => index.NationalPrimaryCellPhoneNumber.Contains(searchTerm.Value) || index.NationalPrimaryHomePhoneNumber.Contains(searchTerm.Value)), _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ResourceManagementOptionsConfiguration.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ResourceManagementOptionsConfiguration.cs new file mode 100644 index 000000000..792646613 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/ResourceManagementOptionsConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using OrchardCore.ResourceManagement; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Registers the Omnichannel Management script resources. +/// +internal sealed class ResourceManagementOptionsConfiguration : IConfigureOptions +{ + private static readonly ResourceManifest _manifest; + + static ResourceManagementOptionsConfiguration() + { + _manifest = new ResourceManifest(); + + _manifest + .DefineScript("subject-action-assignment-toggle") + .SetUrl( + "~/CrestApps.OrchardCore.Omnichannel.Managements/scripts/subject-action-assignment-toggle.min.js", + "~/CrestApps.OrchardCore.Omnichannel.Managements/scripts/subject-action-assignment-toggle.js") + .SetVersion("1.0.0"); + } + + /// + public void Configure(ResourceManagementOptions options) + { + options.ResourceManifests.Add(_manifest); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 418106a7f..0fdb3c8df 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -53,6 +53,8 @@ public Startup(IStringLocalizer stringLocalizer) public override void ConfigureServices(IServiceCollection services) { + services.AddResourceConfiguration(); + services.AddCatalogs() .AddCatalogManagers(); @@ -171,7 +173,6 @@ public override void ConfigureServices(IServiceCollection services) services .AddIndexProvider() - .AddIndexProvider() .AddDataMigration(); services.AddTransient(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml index 721ae1abc..b63c3e399 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/NewActivitySubjectActionFields.Edit.cshtml @@ -40,7 +40,13 @@
- @@ -85,22 +91,4 @@
- + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml index 5d8669ecd..f2931f033 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/TryAgainSubjectActionFields.Edit.cshtml @@ -38,7 +38,13 @@
- @@ -83,22 +89,4 @@
- + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.js b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.js new file mode 100644 index 000000000..67af03fd6 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.js @@ -0,0 +1,64 @@ +/* +** NOTE: This file is generated by Gulp and should not be edited directly! +** Any changes made directly to this file will be overwritten next time its asset group is processed by Gulp. +*/ + +(function () { + var selector = '[data-subject-action-assignment-toggle]'; + function initializeToggle(assignmentTypeSelect) { + var targetId = assignmentTypeSelect.dataset.assignmentTargetId; + var specificOwnerValue = assignmentTypeSelect.dataset.assignmentSpecificOwnerValue; + if (!targetId || !specificOwnerValue || assignmentTypeSelect.dataset.assignmentToggleInitialized === 'true') { + return; + } + var specificOwnerContainer = document.getElementById(targetId); + if (!specificOwnerContainer) { + return; + } + assignmentTypeSelect.dataset.assignmentToggleInitialized = 'true'; + function toggle() { + var isSpecificOwner = assignmentTypeSelect.value === specificOwnerValue; + specificOwnerContainer.classList.toggle('d-none', !isSpecificOwner); + assignmentTypeSelect.setAttribute('aria-expanded', isSpecificOwner.toString()); + } + assignmentTypeSelect.addEventListener('change', toggle); + toggle(); + } + function initialize() { + document.querySelectorAll(selector).forEach(initializeToggle); + } + function observeDynamicAdditions() { + var observer = new MutationObserver(function (mutations) { + for (var i = 0; i < mutations.length; i++) { + var addedNodes = mutations[i].addedNodes; + for (var j = 0; j < addedNodes.length; j++) { + var node = addedNodes[j]; + if (node.nodeType !== Node.ELEMENT_NODE) { + continue; + } + if (node.matches(selector)) { + initializeToggle(node); + } else { + var toggles = node.querySelectorAll(selector); + toggles.forEach(initializeToggle); + } + } + } + }); + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function () { + initialize(); + observeDynamicAdditions(); + }, { + once: true + }); + return; + } + initialize(); + observeDynamicAdditions(); +})(); \ No newline at end of file diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.min.js b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.min.js new file mode 100644 index 000000000..144c7c19a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/wwwroot/scripts/subject-action-assignment-toggle.min.js @@ -0,0 +1 @@ +!function(){var e="[data-subject-action-assignment-toggle]";function t(e){var t=e.dataset.assignmentTargetId,n=e.dataset.assignmentSpecificOwnerValue;if(t&&n&&"true"!==e.dataset.assignmentToggleInitialized){var a=document.getElementById(t);a&&(e.dataset.assignmentToggleInitialized="true",e.addEventListener("change",o),o())}function o(){var t=e.value===n;a.classList.toggle("d-none",!t),e.setAttribute("aria-expanded",t.toString())}}function n(){document.querySelectorAll(e).forEach(t)}function a(){new MutationObserver(function(n){for(var a=0;a>>(); + var indexQuery = new Mock>(); + indexQuery + .Setup(query => query.Where(It.IsAny>>())) + .Callback>>(predicate => predicates.Add(predicate)) + .Returns(indexQuery.Object); + indexQuery + .Setup(query => query.ListAsync(It.IsAny())) + .ReturnsAsync([]); + + var rootQuery = new Mock(); + rootQuery + .Setup(query => query.ForIndex()) + .Returns(indexQuery.Object); + + var session = new Mock(); + session + .Setup(currentSession => currentSession.Query(null)) + .Returns(rootQuery.Object); + + var lookup = new InboundContactLookup(session.Object, new DefaultPhoneNumberService()); + + // Act + await lookup.FindContactItemIdsAsync( + "+17024993350", + TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, predicates.Count); + Assert.All(predicates, predicate => + { + var conjunction = Assert.IsAssignableFrom(predicate.Body); + Assert.Equal(ExpressionType.AndAlso, conjunction.NodeType); + + var publishedMember = Assert.IsAssignableFrom(conjunction.Left); + Assert.Equal(nameof(OmnichannelContactIndex.Published), publishedMember.Member.Name); + }); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs index 8553fd1ec..924e8dfa1 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/ContactIndexProviderCollectionTests.cs @@ -20,22 +20,6 @@ public void OmnichannelContactIndexProvider_ShouldUseDefaultCollection() Assert.True(string.IsNullOrEmpty(collectionName)); } - [Fact] - public void OmnichannelContactPhoneIndexProvider_ShouldUseDefaultCollection() - { - // Arrange - var provider = CreateProvider( - typeof(CrestApps.OrchardCore.Omnichannel.Managements.Startup).Assembly, - "CrestApps.OrchardCore.Omnichannel.Managements.Indexes.OmnichannelContactPhoneIndexProvider", - [new CrestApps.OrchardCore.PhoneNumbers.Core.Services.DefaultPhoneNumberService()]); - - // Act - var collectionName = GetCollectionName(provider); - - // Assert - Assert.True(string.IsNullOrEmpty(collectionName)); - } - [Fact] public void OmnichannelContactCommunicationPreferenceIndexProvider_ShouldUseDefaultCollection() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/BulkManageActivityFilterHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/BulkManageActivityFilterHandlerTests.cs new file mode 100644 index 000000000..1c9b438f6 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/BulkManageActivityFilterHandlerTests.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.Handlers; +using Moq; +using YesSql; +using YesSql.Services; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Handlers; + +public sealed class BulkManageActivityFilterHandlerTests +{ + [Fact] + public async Task FilteringAsync_WhenPhoneAndTimeZoneFiltersAreUsed_ConstrainsBothJoinsToLatestContacts() + { + // Arrange + var filter = new BulkManageActivityFilter + { + PhoneNumber = "702499", + TimeZoneIds = ["America/Los_Angeles"], + }; + var dialect = Mock.Of(); + var sqlBuilder = new SqlBuilder(string.Empty, dialect); + var context = new BulkManageActivityFilterContext( + filter, + sqlBuilder, + dialect, + string.Empty, + Mock.Of(), + schema: null, + activityTableAlias: "a"); + var handler = new BulkManageActivityFilterHandler(); + + // Act + await handler.FilteringAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.True((bool)sqlBuilder.Parameters["@PhoneLatest"]); + Assert.True((bool)sqlBuilder.Parameters["@TimeZoneLatest"]); + Assert.Equal("%702499%", sqlBuilder.Parameters["@PhonePattern"]); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandlerTests.cs new file mode 100644 index 000000000..4d03b63d9 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/TimeZoneListOmnichannelActivityFilterHandlerTests.cs @@ -0,0 +1,61 @@ +using System.Linq.Expressions; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Managements.Handlers; +using Moq; +using YesSql; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Handlers; + +public sealed class TimeZoneListOmnichannelActivityFilterHandlerTests +{ + [Fact] + public async Task FilteringAsync_WhenTimeZoneIsSpecified_QueriesPublishedContacts() + { + // Arrange + Expression> predicate = null; + var indexQuery = new Mock>(); + indexQuery + .Setup(query => query.Where(It.IsAny>>())) + .Callback>>(value => predicate = value) + .Returns(indexQuery.Object); + indexQuery + .Setup(query => query.ListAsync(It.IsAny())) + .ReturnsAsync([]); + + var rootQuery = new Mock(); + rootQuery + .Setup(query => query.ForIndex()) + .Returns(indexQuery.Object); + + var session = new Mock(); + session + .Setup(currentSession => currentSession.Query(null)) + .Returns(rootQuery.Object); + + var activityQuery = new Mock>(); + activityQuery + .Setup(query => query.Where(It.IsAny>>())) + .Returns(activityQuery.Object); + + var context = new ListOmnichannelActivityFilterContext( + new ListOmnichannelActivityFilter + { + TimeZoneId = "America/Los_Angeles", + }, + activityQuery.Object); + var handler = new TimeZoneListOmnichannelActivityFilterHandler(session.Object); + + // Act + await handler.FilteringAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(predicate); + var conjunction = Assert.IsAssignableFrom(predicate.Body); + Assert.Equal(ExpressionType.AndAlso, conjunction.NodeType); + + var publishedMember = Assert.IsAssignableFrom(conjunction.Left); + Assert.Equal(nameof(OmnichannelContactIndex.Published), publishedMember.Member.Name); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs new file mode 100644 index 000000000..331b0292e --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs @@ -0,0 +1,225 @@ +using CrestApps.OrchardCore.ContentFields.Fields; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Indexes; +using CrestApps.OrchardCore.PhoneNumbers.Core.Services; +using OrchardCore.ContentFields.Fields; +using OrchardCore.ContentManagement; +using OrchardCore.Flows.Models; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Indexes; + +public sealed class OmnichannelContactIndexProviderTests +{ + [Fact] + public void CreateIndex_WhenContactIsPublished_IndexesVersionAndPrimaryContactDetails() + { + // Arrange + var provider = new OmnichannelContactIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: true, + latest: false, + " America/Los_Angeles ", + CreateEmailAddress("lead@example.com"), + CreatePhoneNumber("(702) 499-3350", "702 499 3350", "US", "cell"), + CreatePhoneNumber("+15551112222", "5551112222", "US", "Cell"), + CreatePhoneNumber("+12125550123", null, null, "HOME"), + CreatePhoneNumber("+13105550123", "3105550123", "US", "Work")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.Equal("contact-id", index.ContentItemId); + Assert.True(index.Published); + Assert.False(index.Latest); + Assert.Equal("America/Los_Angeles", index.TimeZoneId); + Assert.Equal("lead@example.com", index.PrimaryEmailAddress); + Assert.Equal("(702) 499-3350", index.PrimaryCellPhoneNumber); + Assert.Equal("+17024993350", index.NormalizedPrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + Assert.Equal("+12125550123", index.PrimaryHomePhoneNumber); + Assert.Equal("+12125550123", index.NormalizedPrimaryHomePhoneNumber); + Assert.Equal("2125550123", index.NationalPrimaryHomePhoneNumber); + } + + [Fact] + public void CreateIndex_WhenContactIsLatestDraft_IndexesLatestVersion() + { + // Arrange + var provider = new OmnichannelContactIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: true, + timeZoneId: null, + CreatePhoneNumber("+17024993350", null, "US", "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.False(index.Published); + Assert.True(index.Latest); + Assert.Equal("+17024993350", index.NormalizedPrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + } + + [Fact] + public void CreateIndex_WhenRegionIsNullAndNumberIsNational_PreservesNationalDigits() + { + // Arrange + var provider = new OmnichannelContactIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: true, + timeZoneId: null, + CreatePhoneNumber("(702) 499-3350", null, null, "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.Null(index.NormalizedPrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + } + + [Fact] + public void CreateIndex_WhenRegionIsUnknown_PreservesAllDigitsAndExplicitE164() + { + // Arrange + var provider = new OmnichannelContactIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: true, + timeZoneId: null, + CreatePhoneNumber("+999123456", null, "XX", "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.Equal("+999123456", index.NormalizedPrimaryCellPhoneNumber); + Assert.Equal("999123456", index.NationalPrimaryCellPhoneNumber); + } + + [Fact] + public void CreateIndex_WhenContactHasNoMethods_StillIndexesEligibleVersion() + { + // Arrange + var provider = new OmnichannelContactIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: true, + latest: true, + "UTC"); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.NotNull(index); + Assert.True(index.Published); + Assert.True(index.Latest); + Assert.Equal("UTC", index.TimeZoneId); + Assert.Null(index.PrimaryCellPhoneNumber); + Assert.Null(index.PrimaryHomePhoneNumber); + } + + [Fact] + public void CreateIndex_WhenContentItemIsNeitherPublishedNorLatest_ReturnsNull() + { + // Arrange + var provider = new OmnichannelContactIndexProvider(new DefaultPhoneNumberService()); + var contact = CreateContact( + published: false, + latest: false, + timeZoneId: null, + CreatePhoneNumber("+17024993350", "7024993350", "US", "Cell")); + + // Act + var index = provider.CreateIndex(contact); + + // Assert + Assert.Null(index); + } + + private static ContentItem CreateContact( + bool published, + bool latest, + string timeZoneId, + params ContentItem[] contactMethods) + { + var contact = new ContentItem + { + ContentItemId = "contact-id", + ContentType = "Contact", + Published = published, + Latest = latest, + }; + + contact.Alter(part => part.TimeZoneId = timeZoneId); + + if (contactMethods.Length > 0) + { + var bagPart = new BagPart(); + + foreach (var contactMethod in contactMethods) + { + bagPart.ContentItems.Add(contactMethod); + } + + contact.Apply(OmnichannelConstants.NamedParts.ContactMethods, bagPart); + } + + return contact; + } + + private static ContentItem CreatePhoneNumber( + string phoneNumber, + string nationalNumber, + string countryCode, + string type) + { + var contentItem = new ContentItem + { + ContentType = OmnichannelConstants.ContentTypes.PhoneNumber, + }; + + contentItem.Alter(part => + { + part.Number = new PhoneField + { + PhoneNumber = phoneNumber, + NationalNumber = nationalNumber, + CountryCode = countryCode, + }; + part.Type = new TextField + { + Text = type, + }; + }); + + return contentItem; + } + + private static ContentItem CreateEmailAddress(string email) + { + var contentItem = new ContentItem + { + ContentType = OmnichannelConstants.ContentTypes.EmailAddress, + }; + + contentItem.Alter(part => + { + part.Email = new TextField + { + Text = email, + }; + }); + + return contentItem; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs deleted file mode 100644 index d8aae5857..000000000 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactPhoneIndexProviderTests.cs +++ /dev/null @@ -1,148 +0,0 @@ -using CrestApps.OrchardCore.ContentFields.Fields; -using CrestApps.OrchardCore.Omnichannel.Core; -using CrestApps.OrchardCore.Omnichannel.Core.Models; -using CrestApps.OrchardCore.Omnichannel.Managements.Indexes; -using CrestApps.OrchardCore.PhoneNumbers.Core.Services; -using OrchardCore.ContentFields.Fields; -using OrchardCore.ContentManagement; -using OrchardCore.Flows.Models; - -namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Indexes; - -public sealed class OmnichannelContactPhoneIndexProviderTests -{ - [Fact] - public void CreateIndex_WhenContactHasPrimaryCellAndHome_IndexesBothFormats() - { - // Arrange - var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); - var contact = CreateContact( - published: true, - latest: false, - CreatePhoneNumber("+17024993350", "7024993350", "US", "Cell"), - CreatePhoneNumber("+12125550123", "2125550123", "US", "Home")); - - // Act - var index = provider.CreateIndex(contact); - - // Assert - Assert.NotNull(index); - Assert.Equal("contact-id", index.ContentItemId); - Assert.True(index.Published); - Assert.False(index.Latest); - Assert.Equal("+17024993350", index.E164PrimaryCellPhoneNumber); - Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); - Assert.Equal("+12125550123", index.E164PrimaryHomePhoneNumber); - Assert.Equal("2125550123", index.NationalPrimaryHomePhoneNumber); - } - - [Fact] - public void CreateIndex_WhenNationalNumberIsMissing_DerivesItFromE164() - { - // Arrange - var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); - var contact = CreateContact( - published: false, - latest: true, - CreatePhoneNumber("+17024993350", null, "US", "Cell")); - - // Act - var index = provider.CreateIndex(contact); - - // Assert - Assert.NotNull(index); - Assert.False(index.Published); - Assert.True(index.Latest); - Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); - } - - [Fact] - public void CreateIndex_WhenRegionCannotBeDetermined_PreservesAllDigits() - { - // Arrange - var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); - var contact = CreateContact( - published: false, - latest: true, - CreatePhoneNumber("+999123456", null, null, "Cell")); - - // Act - var index = provider.CreateIndex(contact); - - // Assert - Assert.NotNull(index); - Assert.Equal("999123456", index.NationalPrimaryCellPhoneNumber); - } - - [Fact] - public void CreateIndex_WhenContentItemIsNeitherPublishedNorLatest_ReturnsNull() - { - // Arrange - var provider = new OmnichannelContactPhoneIndexProvider(new DefaultPhoneNumberService()); - var contact = CreateContact( - published: false, - latest: false, - CreatePhoneNumber("+17024993350", "7024993350", "US", "Cell")); - - // Act - var index = provider.CreateIndex(contact); - - // Assert - Assert.Null(index); - } - - private static ContentItem CreateContact( - bool published, - bool latest, - params ContentItem[] phoneNumbers) - { - var contact = new ContentItem - { - ContentItemId = "contact-id", - ContentType = "Contact", - Published = published, - Latest = latest, - }; - - contact.Alter(_ => { }); - - var bagPart = new BagPart(); - - foreach (var phoneNumber in phoneNumbers) - { - bagPart.ContentItems.Add(phoneNumber); - } - - contact.Apply(OmnichannelConstants.NamedParts.ContactMethods, bagPart); - - return contact; - } - - private static ContentItem CreatePhoneNumber( - string e164Number, - string nationalNumber, - string countryCode, - string type) - { - var contentItem = new ContentItem - { - ContentType = OmnichannelConstants.ContentTypes.PhoneNumber, - }; - - contentItem.Alter(part => - { - part.Number = new PhoneField - { - PhoneNumber = e164Number, - NationalNumber = nationalNumber, - CountryCode = countryCode, - }; - part.Type = new TextField - { - Text = type, - }; - }); - - return contentItem; - } -} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs index a3dacb95c..a17afb438 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/ActivityPurgeHelperTests.cs @@ -6,28 +6,40 @@ namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Services; public sealed class ActivityPurgeHelperTests { [Fact] - public void Purge_SetsPurgedStatusAndClearsReservation() + public void Purge_SetsAuditMetadataClearsReservationAndPreservesAssignment() { // Arrange + var purgedAtUtc = new DateTime(2026, 7, 11, 16, 0, 0, DateTimeKind.Utc); var activity = new OmnichannelActivity { Status = ActivityStatus.Reserved, + AssignedToId = "assigned-user-1", + AssignedToUsername = "assigned-agent", + AssignedToUtc = new DateTime(2026, 7, 11, 11, 0, 0, DateTimeKind.Utc), + AssignmentStatus = ActivityAssignmentStatus.Assigned, ReservationId = "reservation-1", - ReservedById = "user-1", - ReservedByUsername = "agent", + ReservedById = "reserved-user-1", + ReservedByUsername = "reserved-agent", ReservedUtc = new DateTime(2026, 7, 11, 12, 0, 0, DateTimeKind.Utc), ReservationExpiresUtc = new DateTime(2026, 7, 11, 12, 5, 0, DateTimeKind.Utc), }; // Act - ActivityPurgeHelper.Purge(activity); + ActivityPurgeHelper.Purge(activity, purgedAtUtc, "purging-user-1", "purging-agent"); // Assert Assert.Equal(ActivityStatus.Purged, activity.Status); + Assert.Equal(purgedAtUtc, activity.PurgedAtUtc); + Assert.Equal("purging-user-1", activity.PurgedById); + Assert.Equal("purging-agent", activity.PurgedByUsername); Assert.Null(activity.ReservationId); Assert.Null(activity.ReservedById); Assert.Null(activity.ReservedByUsername); Assert.Null(activity.ReservedUtc); Assert.Null(activity.ReservationExpiresUtc); + Assert.Equal("assigned-user-1", activity.AssignedToId); + Assert.Equal("assigned-agent", activity.AssignedToUsername); + Assert.Equal(new DateTime(2026, 7, 11, 11, 0, 0, DateTimeKind.Utc), activity.AssignedToUtc); + Assert.Equal(ActivityAssignmentStatus.Assigned, activity.AssignmentStatus); } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactDuplicateLookupServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactDuplicateLookupServiceTests.cs index d8bcd2aa9..543ec2fa1 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactDuplicateLookupServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactDuplicateLookupServiceTests.cs @@ -1,13 +1,60 @@ +using System.Linq.Expressions; using CrestApps.OrchardCore.Omnichannel.Core.Indexes; using CrestApps.OrchardCore.Omnichannel.Managements.Services; using CrestApps.OrchardCore.PhoneNumbers.Core.Services; using Moq; +using YesSql; using ISession = YesSql.ISession; namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Services; public sealed class OmnichannelContactDuplicateLookupServiceTests { + [Fact] + public async Task GetExistingNormalizedPhoneNumbersAsync_WhenQueryingIndexes_RequiresPublishedVersions() + { + // Arrange + var predicates = new List>>(); + var indexQuery = new Mock>(); + indexQuery + .Setup(query => query.Where(It.IsAny>>())) + .Callback>>(predicate => predicates.Add(predicate)) + .Returns(indexQuery.Object); + indexQuery + .Setup(query => query.ListAsync(It.IsAny())) + .ReturnsAsync([]); + + var rootQuery = new Mock(); + rootQuery + .Setup(query => query.ForIndex()) + .Returns(indexQuery.Object); + + var session = new Mock(); + session + .Setup(currentSession => currentSession.Query(null)) + .Returns(rootQuery.Object); + + var service = new OmnichannelContactDuplicateLookupService( + session.Object, + new DefaultPhoneNumberService()); + + // Act + await service.GetExistingNormalizedPhoneNumbersAsync( + ["+17024993350"], + TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, predicates.Count); + Assert.All(predicates, predicate => + { + var conjunction = Assert.IsAssignableFrom(predicate.Body); + Assert.Equal(ExpressionType.AndAlso, conjunction.NodeType); + + var publishedMember = Assert.IsAssignableFrom(conjunction.Left); + Assert.Equal(nameof(OmnichannelContactIndex.Published), publishedMember.Member.Name); + }); + } + [Fact] public void AddLegacyMatches_ShouldMatchInputAgainstLegacyPhoneValues() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs index 48efac130..6b52650d1 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs @@ -25,12 +25,12 @@ public async Task Build_WhenPhoneTermIsParsed_AppliesExpectedPredicate( var provider = new OmnichannelContactPhoneContentsAdminListFilterProvider(); provider.Build(builder); - Expression> predicate = null; - var indexedQuery = new Mock>(); + Expression> predicate = null; + var indexedQuery = new Mock>(); var query = new Mock>(); query - .Setup(x => x.With(It.IsAny>>())) - .Callback>>(value => predicate = value) + .Setup(x => x.With(It.IsAny>>())) + .Callback>>(value => predicate = value) .Returns(indexedQuery.Object); var matchingIndex = CreateIndex(nationalNumber, e164Number); @@ -45,17 +45,17 @@ public async Task Build_WhenPhoneTermIsParsed_AppliesExpectedPredicate( Assert.False(predicate.Compile()(nonMatchingIndex)); } - private static OmnichannelContactPhoneIndex CreateIndex( + private static OmnichannelContactIndex CreateIndex( string nationalNumber, string e164Number) { - return new OmnichannelContactPhoneIndex + return new OmnichannelContactIndex { ContentItemId = "contact-id", NationalPrimaryCellPhoneNumber = nationalNumber, NationalPrimaryHomePhoneNumber = string.Empty, - E164PrimaryCellPhoneNumber = e164Number, - E164PrimaryHomePhoneNumber = string.Empty, + NormalizedPrimaryCellPhoneNumber = e164Number, + NormalizedPrimaryHomePhoneNumber = string.Empty, }; } } From c156dcd5f878090a5dff20815011bf022c08a100 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 11 Jul 2026 10:10:04 -0700 Subject: [PATCH 54/56] Reuse primary contact phone columns Store national digits in the primary phone columns, retain E.164 in normalized columns, and migrate existing v8 indexes to the consolidated v9 schema. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: f0683772-e324-4658-9aff-1eda6766ce4c --- .../Indexes/OmnichannelContactIndex.cs | 14 +-- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 2 +- .../docs/omnichannel/management.md | 2 + .../BulkManageActivityFilterHandler.cs | 4 +- .../OmnichannelContactIndexProvider.cs | 6 +- .../OmnichannelContactsMigrations.cs | 109 ++++++++++++------ .../DefaultContactActivityBatchLoader.cs | 16 +-- ...actPhoneContentsAdminListFilterProvider.cs | 16 +-- .../OmnichannelContactIndexProviderTests.cs | 12 +- ...oneContentsAdminListFilterProviderTests.cs | 4 +- 10 files changed, 104 insertions(+), 81 deletions(-) diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs index 385be9c47..efc480015 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Indexes/OmnichannelContactIndex.cs @@ -28,7 +28,7 @@ public sealed class OmnichannelContactIndex : MapIndex public string TimeZoneId { get; set; } /// - /// Gets or sets the primary cell phone number. + /// Gets or sets the primary cell phone number as national digits. /// public string PrimaryCellPhoneNumber { get; set; } @@ -38,12 +38,7 @@ public sealed class OmnichannelContactIndex : MapIndex public string NormalizedPrimaryCellPhoneNumber { get; set; } /// - /// Gets or sets the primary cell phone number as national digits. - /// - public string NationalPrimaryCellPhoneNumber { get; set; } - - /// - /// Gets or sets the primary home phone number. + /// Gets or sets the primary home phone number as national digits. /// public string PrimaryHomePhoneNumber { get; set; } @@ -52,11 +47,6 @@ public sealed class OmnichannelContactIndex : MapIndex ///
public string NormalizedPrimaryHomePhoneNumber { get; set; } - /// - /// Gets or sets the primary home phone number as national digits. - /// - public string NationalPrimaryHomePhoneNumber { get; set; } - /// /// Gets or sets the primary email address. /// diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 58e8f1a28..10b4ca679 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -521,7 +521,7 @@ At a high level, the platform changes are: - 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. +- 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. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 01fdb71f6..a8db87cf5 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -259,6 +259,8 @@ Phone filters in **Load Inventory**, **Manage Activities**, and Content Admin se Content Admin evaluates the displayed content version. Load Inventory uses published or latest contact values according to **Only published leads**, while Manage Activities uses the latest saved contact values. +The shared contact index stores primary Cell and Home numbers as national digits for national searches, while the corresponding normalized values remain in E.164 format. + Content Admin supports these named search terms: | Term | Match behavior | Example | diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs index 0eecd6c3f..4b6d49cd8 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs @@ -180,10 +180,10 @@ private static void ApplyContactFilters(BulkManageActivityFilterContext context, { var cellColumnName = searchTerm.IsE164 ? nameof(OmnichannelContactIndex.NormalizedPrimaryCellPhoneNumber) - : nameof(OmnichannelContactIndex.NationalPrimaryCellPhoneNumber); + : nameof(OmnichannelContactIndex.PrimaryCellPhoneNumber); var homeColumnName = searchTerm.IsE164 ? nameof(OmnichannelContactIndex.NormalizedPrimaryHomePhoneNumber) - : nameof(OmnichannelContactIndex.NationalPrimaryHomePhoneNumber); + : nameof(OmnichannelContactIndex.PrimaryHomePhoneNumber); var cellCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(cellColumnName)}"; var homeCol = $"{dialect.QuoteForAliasName(ContactPhoneAlias)}.{dialect.QuoteForColumnName(homeColumnName)}"; var comparison = filter.PhoneNumberMatchType == PhoneNumberMatchType.Exact ? "=" : "LIKE"; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs index da4874aa8..d96913b89 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Indexes/OmnichannelContactIndexProvider.cs @@ -84,18 +84,16 @@ bagPart.ContentItems is null || private void SetPrimaryCellPhoneNumber(OmnichannelContactIndex index, PhoneField field) { - index.PrimaryCellPhoneNumber = Truncate(field.PhoneNumber, 50); index.NormalizedPrimaryCellPhoneNumber = NormalizeToE164(field); - index.NationalPrimaryCellPhoneNumber = NormalizeNationalNumber( + index.PrimaryCellPhoneNumber = NormalizeNationalNumber( field, index.NormalizedPrimaryCellPhoneNumber); } private void SetPrimaryHomePhoneNumber(OmnichannelContactIndex index, PhoneField field) { - index.PrimaryHomePhoneNumber = Truncate(field.PhoneNumber, 50); index.NormalizedPrimaryHomePhoneNumber = NormalizeToE164(field); - index.NationalPrimaryHomePhoneNumber = NormalizeNationalNumber( + index.PrimaryHomePhoneNumber = NormalizeNationalNumber( field, index.NormalizedPrimaryHomePhoneNumber); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs index f135b71f7..be289ebe1 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelContactsMigrations.cs @@ -62,7 +62,7 @@ await _contentDefinitionManager.AlterPartDefinitionAsync(OmnichannelConstants.Co await CreateContactIndexTableAsync(); await CreateContactIndexIndexesAsync(); - return 8; + return 9; } /// @@ -166,7 +166,7 @@ public async Task UpdateFrom6Async() await EnsureDefaultContactIndexTableAsync(); ShellScope.AddDeferredTask(ReindexContactVersionsAsync); - return 8; + return 9; } /// @@ -178,7 +178,19 @@ public async Task UpdateFrom7Async() await DropLegacyPhoneIndexTableAsync(); ShellScope.AddDeferredTask(ReindexContactVersionsAsync); - return 8; + return 9; + } + + /// + /// Reuses the primary phone columns for national digits and removes the redundant national-number columns. + /// + public async Task UpdateFrom8Async() + { + await EnsureDefaultContactIndexTableAsync(); + await RemoveRedundantNationalPhoneColumnsAsync(); + ShellScope.AddDeferredTask(ReindexContactVersionsAsync); + + return 9; } private async Task CreateContactIndexTableAsync() @@ -189,10 +201,8 @@ await SchemaBuilder.CreateMapIndexTableAsync(table => t .Column("Latest", column => column.NotNull().WithDefault(false)) .Column("PrimaryCellPhoneNumber", column => column.WithLength(50)) .Column("NormalizedPrimaryCellPhoneNumber", column => column.WithLength(50)) - .Column("NationalPrimaryCellPhoneNumber", column => column.WithLength(50)) .Column("PrimaryHomePhoneNumber", column => column.WithLength(50)) .Column("NormalizedPrimaryHomePhoneNumber", column => column.WithLength(50)) - .Column("NationalPrimaryHomePhoneNumber", column => column.WithLength(50)) .Column("PrimaryEmailAddress", column => column.WithLength(255)) .Column("TimeZoneId", column => column.WithLength(64)) ); @@ -252,8 +262,8 @@ await SchemaBuilder.AlterIndexTableAsync(table => table await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCIndex_NationalCell", - "NationalPrimaryCellPhoneNumber", + "IDX_OCIndex_PrimaryCell", + "PrimaryCellPhoneNumber", "Published", "Latest") ); @@ -268,8 +278,8 @@ await SchemaBuilder.AlterIndexTableAsync(table => table await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCIndex_NationalHome", - "NationalPrimaryHomePhoneNumber", + "IDX_OCIndex_PrimaryHome", + "PrimaryHomePhoneNumber", "Published", "Latest") ); @@ -329,17 +339,6 @@ await SchemaBuilder.AlterIndexTableAsync(table => _logger.LogWarning(ex, "The 'NormalizedPrimaryCellPhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); } - try - { - await SchemaBuilder.AlterIndexTableAsync(table => - table.AddColumn("NationalPrimaryCellPhoneNumber", column => column.WithLength(50)) - ); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "The 'NationalPrimaryCellPhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); - } - try { await SchemaBuilder.AlterIndexTableAsync(table => @@ -351,17 +350,6 @@ await SchemaBuilder.AlterIndexTableAsync(table => _logger.LogWarning(ex, "The 'NormalizedPrimaryHomePhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); } - try - { - await SchemaBuilder.AlterIndexTableAsync(table => - table.AddColumn("NationalPrimaryHomePhoneNumber", column => column.WithLength(50)) - ); - } - catch (Exception ex) - { - _logger.LogWarning(ex, "The 'NationalPrimaryHomePhoneNumber' column may already exist on the default-collection OmnichannelContactIndex table."); - } - try { await SchemaBuilder.AlterIndexTableAsync(table => @@ -476,15 +464,15 @@ await SchemaBuilder.AlterIndexTableAsync(table => table { await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCIndex_NationalCell", - "NationalPrimaryCellPhoneNumber", + "IDX_OCIndex_PrimaryCell", + "PrimaryCellPhoneNumber", "Published", "Latest") ); } catch (Exception ex) { - _logger.LogWarning(ex, "The 'IDX_OCIndex_NationalCell' index may already exist on the default-collection OmnichannelContactIndex table."); + _logger.LogWarning(ex, "The 'IDX_OCIndex_PrimaryCell' index may already exist on the default-collection OmnichannelContactIndex table."); } try @@ -506,15 +494,15 @@ await SchemaBuilder.AlterIndexTableAsync(table => table { await SchemaBuilder.AlterIndexTableAsync(table => table .CreateIndex( - "IDX_OCIndex_NationalHome", - "NationalPrimaryHomePhoneNumber", + "IDX_OCIndex_PrimaryHome", + "PrimaryHomePhoneNumber", "Published", "Latest") ); } catch (Exception ex) { - _logger.LogWarning(ex, "The 'IDX_OCIndex_NationalHome' index may already exist on the default-collection OmnichannelContactIndex table."); + _logger.LogWarning(ex, "The 'IDX_OCIndex_PrimaryHome' index may already exist on the default-collection OmnichannelContactIndex table."); } try @@ -533,6 +521,53 @@ await SchemaBuilder.AlterIndexTableAsync(table => table } } + private async Task RemoveRedundantNationalPhoneColumnsAsync() + { + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.DropIndex("IDX_OCIndex_NationalCell") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The obsolete 'IDX_OCIndex_NationalCell' index may already be removed."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.DropIndex("IDX_OCIndex_NationalHome") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The obsolete 'IDX_OCIndex_NationalHome' index may already be removed."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.DropColumn("NationalPrimaryCellPhoneNumber") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The obsolete 'NationalPrimaryCellPhoneNumber' column may already be removed."); + } + + try + { + await SchemaBuilder.AlterIndexTableAsync(table => + table.DropColumn("NationalPrimaryHomePhoneNumber") + ); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "The obsolete 'NationalPrimaryHomePhoneNumber' column may already be removed."); + } + } + private async Task DropLegacyPhoneIndexTableAsync() { var dialect = _store.Configuration.SqlDialect; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs index db0019b02..37715a86e 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs @@ -519,17 +519,17 @@ private static IQueryIndex ApplyPhoneFilter( return matchType switch { PhoneNumberMatchType.Exact => query.Where(index => - index.NationalPrimaryCellPhoneNumber == searchTerm.Value || - index.NationalPrimaryHomePhoneNumber == searchTerm.Value), + index.PrimaryCellPhoneNumber == searchTerm.Value || + index.PrimaryHomePhoneNumber == searchTerm.Value), PhoneNumberMatchType.BeginsWith => query.Where(index => - index.NationalPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || - index.NationalPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + index.PrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.PrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), PhoneNumberMatchType.EndsWith => query.Where(index => - index.NationalPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || - index.NationalPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + index.PrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.PrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), PhoneNumberMatchType.Contains => query.Where(index => - index.NationalPrimaryCellPhoneNumber.Contains(searchTerm.Value) || - index.NationalPrimaryHomePhoneNumber.Contains(searchTerm.Value)), + index.PrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.PrimaryHomePhoneNumber.Contains(searchTerm.Value)), _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), }; } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs index b062e2821..68b54d777 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProvider.cs @@ -55,17 +55,17 @@ private static IQuery ApplyFilter( return matchType switch { PhoneNumberMatchType.Exact => query.With(index => - index.NationalPrimaryCellPhoneNumber == searchTerm.Value || - index.NationalPrimaryHomePhoneNumber == searchTerm.Value), + index.PrimaryCellPhoneNumber == searchTerm.Value || + index.PrimaryHomePhoneNumber == searchTerm.Value), PhoneNumberMatchType.BeginsWith => query.With(index => - index.NationalPrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || - index.NationalPrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), + index.PrimaryCellPhoneNumber.StartsWith(searchTerm.Value) || + index.PrimaryHomePhoneNumber.StartsWith(searchTerm.Value)), PhoneNumberMatchType.EndsWith => query.With(index => - index.NationalPrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || - index.NationalPrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), + index.PrimaryCellPhoneNumber.EndsWith(searchTerm.Value) || + index.PrimaryHomePhoneNumber.EndsWith(searchTerm.Value)), PhoneNumberMatchType.Contains => query.With(index => - index.NationalPrimaryCellPhoneNumber.Contains(searchTerm.Value) || - index.NationalPrimaryHomePhoneNumber.Contains(searchTerm.Value)), + index.PrimaryCellPhoneNumber.Contains(searchTerm.Value) || + index.PrimaryHomePhoneNumber.Contains(searchTerm.Value)), _ => throw new ArgumentOutOfRangeException(nameof(matchType), matchType, "Unsupported phone number match type."), }; } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs index 331b0292e..1387fdb1e 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Indexes/OmnichannelContactIndexProviderTests.cs @@ -36,12 +36,10 @@ public void CreateIndex_WhenContactIsPublished_IndexesVersionAndPrimaryContactDe Assert.False(index.Latest); Assert.Equal("America/Los_Angeles", index.TimeZoneId); Assert.Equal("lead@example.com", index.PrimaryEmailAddress); - Assert.Equal("(702) 499-3350", index.PrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.PrimaryCellPhoneNumber); Assert.Equal("+17024993350", index.NormalizedPrimaryCellPhoneNumber); - Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); - Assert.Equal("+12125550123", index.PrimaryHomePhoneNumber); + Assert.Equal("2125550123", index.PrimaryHomePhoneNumber); Assert.Equal("+12125550123", index.NormalizedPrimaryHomePhoneNumber); - Assert.Equal("2125550123", index.NationalPrimaryHomePhoneNumber); } [Fact] @@ -63,7 +61,7 @@ public void CreateIndex_WhenContactIsLatestDraft_IndexesLatestVersion() Assert.False(index.Published); Assert.True(index.Latest); Assert.Equal("+17024993350", index.NormalizedPrimaryCellPhoneNumber); - Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.PrimaryCellPhoneNumber); } [Fact] @@ -83,7 +81,7 @@ public void CreateIndex_WhenRegionIsNullAndNumberIsNational_PreservesNationalDig // Assert Assert.NotNull(index); Assert.Null(index.NormalizedPrimaryCellPhoneNumber); - Assert.Equal("7024993350", index.NationalPrimaryCellPhoneNumber); + Assert.Equal("7024993350", index.PrimaryCellPhoneNumber); } [Fact] @@ -103,7 +101,7 @@ public void CreateIndex_WhenRegionIsUnknown_PreservesAllDigitsAndExplicitE164() // Assert Assert.NotNull(index); Assert.Equal("+999123456", index.NormalizedPrimaryCellPhoneNumber); - Assert.Equal("999123456", index.NationalPrimaryCellPhoneNumber); + Assert.Equal("999123456", index.PrimaryCellPhoneNumber); } [Fact] diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs index 6b52650d1..f44925cfc 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/OmnichannelContactPhoneContentsAdminListFilterProviderTests.cs @@ -52,8 +52,8 @@ private static OmnichannelContactIndex CreateIndex( return new OmnichannelContactIndex { ContentItemId = "contact-id", - NationalPrimaryCellPhoneNumber = nationalNumber, - NationalPrimaryHomePhoneNumber = string.Empty, + PrimaryCellPhoneNumber = nationalNumber, + PrimaryHomePhoneNumber = string.Empty, NormalizedPrimaryCellPhoneNumber = e164Number, NormalizedPrimaryHomePhoneNumber = string.Empty, }; From 8140691ceb24874356cfdce81268befb45b81b76 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 11 Jul 2026 15:10:31 -0700 Subject: [PATCH 55/56] Improve Contact Center queue operations Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b264ef76-66ac-4c60-a65c-1ebc6a664121 --- .github/contact-center/PLAN.md | 1 + .../Models/InboundVoiceRoutingResult.cs | 5 + .../Services/ActivityQueueService.cs | 7 + .../Services/AgentPresenceManagerService.cs | 138 +++++++++++++++-- .../Services/AgentProfileLock.cs | 9 ++ .../Services/IAgentPresenceManager.cs | 10 ++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 + .../docs/contact-center/agent-desktop.md | 4 +- .../contact-center/agents-queues-dialer.md | 2 + src/CrestApps.Docs/docs/telephony/asterisk.md | 4 +- .../Controllers/AgentSoftPhoneController.cs | 9 +- .../Endpoints/SupervisorDashboardEndpoints.cs | 35 ++++- .../Hubs/ContactCenterHub.cs | 57 ++++++- .../Services/VoiceContactCenterCallRouter.cs | 18 ++- .../ViewModels/SupervisorQueueViewModel.cs | 20 +++ .../ContactCenterSoftPhoneWork.View.cshtml | 25 ++- .../Views/SupervisorDashboard/Index.cshtml | 3 + .../scripts/contact-center-soft-phone.js | 142 +++++++++++++++++- .../wwwroot/scripts/supervisor-dashboard.js | 4 + .../CrestApps.OrchardCore.Asterisk.Web.csproj | 7 + .../Models/InboundCallSimulationResult.cs | 5 + .../Pages/Shared/_Layout.cshtml | 2 +- .../Pages/Simulator.cshtml | 10 +- .../Pages/Simulator.cshtml.cs | 5 + .../AsteriskInboundSimulationCoordinator.cs | 5 + .../wwwroot/js/dashboard.js | 47 +++++- .../ActivityQueueServiceTests.cs | 52 ++++++- .../AgentPresenceManagerServiceTests.cs | 43 ++++++ .../AgentSoftPhoneControllerTests.cs | 28 +++- .../ContactCenter/InboundVoiceServiceTests.cs | 16 ++ 30 files changed, 669 insertions(+), 48 deletions(-) create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentProfileLock.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index c1dcadc31..1f057f37e 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1508,6 +1508,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement ### Change log +- 2026-07-11: Hardened inbound queue routing and real-time operations after local Asterisk testing. New queue/activity writes are flushed before the immediate YesSql-backed assignment query, eliminating the read-after-write gap that reported no agent even when an eligible signed-in agent was available. Inbound results now distinguish durable `queued` work from immediately `routed` work, and the simulator labels waiting calls instead of presenting them as rejected. The soft-phone Work tab now validates empty sign-in attempts, displays signed-in queue/campaign names, and supports per-membership sign-out. Supervisor queue tiles now expose signed-in, available, busy/reserved/wrap-up, and other not-ready staffing counts. The Asterisk Web dashboard serves SignalR locally and polls only while the SignalR connection is unavailable. - 2026-07-11: Inbound contact phone lookup remains scoped to published contact versions after the shared omnichannel contact index became version-aware. - 2026-07-11: Closed the remaining "zombie inbound offer" gap where a queued call whose Asterisk channel no longer existed kept being offered and accepted, leaving the agent stuck (unable to disconnect, unable to receive new calls). Three provider-truth guards were added and unit-tested: (1) `VoiceContactCenterCallRouter.OfferNextAsync` now refreshes each queued interaction against provider truth in a bounded loop and, when the provider confirms the call is gone, removes it from the queue and releases the reservation/agent via `ReconcileEndedOfferAsync` before offering the next call — a dead call is never offered; (2) `ContactCenterCallCommandService` accept-media failures now re-check provider truth and reconcile a confirmed-gone call (removing the offer and releasing the agent) instead of leaving an accepted reservation stuck on a Cancel no-op; (3) `ProviderVoiceEventService` now seeds a freshly created call session with the interaction's pre-event state instead of the incoming terminal state, so a first-observed terminal event (e.g. a reconcile of an already-gone queued call) still records a non-terminal→terminal transition, publishes `CallEnded`, and runs ended-offer cleanup instead of silently leaving the interaction queued. +3 unit tests (1,279 total pass, clean `-warnaserror` build). Confirmed the Asterisk dashboard is already event-driven end to end (ARI `subscribeAll` forwarder → 50ms-coalesced SignalR snapshot → client `dashboardSnapshot` handler with reconciliation-poll fallback); the observed lag is provider emission/network, not a code defect. - 2026-07-11: Corrected the recurring inbound zombie path found in live logs. Agent reset/sign-in healing now reconciles provider-backed work before touching routing state, retries stale provider aliases through the tenant's current default provider, terminalizes calls the provider no longer reports, and preserves calls the provider confirms are still active instead of resetting them to `Created` and requeueing answered work. The soft phone now performs an authoritative active-call refresh when a terminal event arrives for a different call id than its stale browser state. The Asterisk development dashboard event stream now subscribes to all visible ARI resources so channel and bridge changes request immediate SignalR snapshots. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs index 00b7c3478..fff3cf123 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/InboundVoiceRoutingResult.cs @@ -10,6 +10,11 @@ public sealed class InboundVoiceRoutingResult /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs index 1b308394b..ea5b757f0 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityQueueService.cs @@ -3,6 +3,7 @@ using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; using OrchardCore.Modules; +using YesSql; namespace CrestApps.OrchardCore.ContactCenter.Core.Services; @@ -16,6 +17,7 @@ public sealed class ActivityQueueService : IActivityQueueService private readonly IOmnichannelActivityManager _activityManager; private readonly IBusinessHoursService _businessHours; private readonly IContactCenterEventPublisher _publisher; + private readonly ISession _session; private readonly IClock _clock; /// @@ -26,6 +28,7 @@ public sealed class ActivityQueueService : IActivityQueueService /// The CRM activity manager. /// 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 clock used to stamp queue times. public ActivityQueueService( IQueueItemManager queueItemManager, @@ -33,6 +36,7 @@ public ActivityQueueService( IOmnichannelActivityManager activityManager, IBusinessHoursService businessHours, IContactCenterEventPublisher publisher, + ISession session, IClock clock) { _queueItemManager = queueItemManager; @@ -40,6 +44,7 @@ public ActivityQueueService( _activityManager = activityManager; _businessHours = businessHours; _publisher = publisher; + _session = session; _clock = clock; } @@ -75,6 +80,8 @@ public async Task EnqueueAsync(string activityItemId, string queueId, await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); } + await _session.SaveChangesAsync(cancellationToken); + await _publisher.PublishAsync(new InteractionEvent { EventType = ContactCenterConstants.Events.QueueItemAdded, diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs index 171ea3687..e605e87ec 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -56,8 +56,15 @@ public async Task SignInAsync(string userId, IEnumerable q { ArgumentException.ThrowIfNullOrEmpty(userId); + 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( - GetSignInLockKey(userId), + AgentProfileLock.GetKey(userId), _signInLockTimeout, _signInLockExpiration); @@ -68,7 +75,7 @@ public async Task SignInAsync(string userId, IEnumerable q await using var acquiredLock = locker; - var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); + profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); if (profile is null) { @@ -76,11 +83,6 @@ public async Task SignInAsync(string userId, IEnumerable q profile.UserId = userId; profile.Name = userId; } - else if (_agentWorkStateHealingService is not null) - { - await _agentWorkStateHealingService.HealForResetAsync(profile.ItemId, cancellationToken); - profile = await _agentManager.FindByIdAsync(profile.ItemId, cancellationToken) ?? profile; - } profile.QueueIds = queueIds?.Distinct().ToList() ?? []; profile.CampaignIds = campaignIds?.Distinct().ToList() ?? []; @@ -96,6 +98,44 @@ public async Task SignInAsync(string userId, IEnumerable q 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; + } + + profile.QueueIds = queueIds?.Distinct().ToList() ?? []; + profile.CampaignIds = campaignIds?.Distinct().ToList() ?? []; + + await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await SyncSessionMembershipAsync(userId, profile.QueueIds, profile.CampaignIds, cancellationToken); + await PublishAsync(ContactCenterConstants.Events.AgentSignedIn, profile, cancellationToken); + + return profile; + } + /// public async Task SignOutAsync(string userId, CancellationToken cancellationToken = default) { @@ -126,7 +166,25 @@ public async Task SignOutAsync(string userId, CancellationToken ca if (_agentWorkStateHealingService is not null) { await _agentWorkStateHealingService.HealForResetAsync(profile.ItemId, cancellationToken); - profile = await _agentManager.FindByIdAsync(profile.ItemId, cancellationToken) ?? profile; + } + + (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; } profile.PresenceStatus = AgentPresenceStatus.Offline; @@ -169,7 +227,27 @@ public async Task SetPresenceAsync(string userId, AgentPresenceSta // 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); - profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken) ?? profile; + } + + (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; } if (status == AgentPresenceStatus.RequestBreak) @@ -213,6 +291,25 @@ public async Task StartWrapUpAsync(string agentId, CancellationTok 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.PresenceStatus = AgentPresenceStatus.WrapUp; profile.ActiveReservationId = null; profile.PresenceChangedUtc = _clock.UtcNow; @@ -235,6 +332,25 @@ public async Task CompleteWorkAsync(string agentId, CancellationTo 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.PresenceStatus = profile.RequestedPresenceStatus ?? AgentPresenceUtilities.ResolveDefaultReadyState(profile); profile.RequestedPresenceStatus = null; profile.ActiveReservationId = null; @@ -303,8 +419,4 @@ private Task PublishAsync(string eventType, AgentProfile profile, CancellationTo }, cancellationToken); } - private static string GetSignInLockKey(string userId) - { - return $"ContactCenterAgentSignIn:{userId}"; - } } 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/IAgentPresenceManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs index 905637c99..9313f403a 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IAgentPresenceManager.cs @@ -18,6 +18,16 @@ public interface IAgentPresenceManager /// 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. /// diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 10b4ca679..d4d4021c1 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -278,6 +278,10 @@ At a high level, the platform changes are: - 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. diff --git a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md index f8816de42..cc81330fe 100644 --- a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md +++ b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md @@ -44,7 +44,7 @@ Open **Interaction Center → Live dashboard**. The dashboard connects to the re It shows three sections: - **Summary metrics** - total items waiting across all queues, the number of available agents, the total agent count, and the queue count. -- **Queue tiles** - one tile per enabled queue showing the waiting count, the longest current wait, and the number of items that have breached the queue's SLA threshold. Tiles turn amber as waits approach the SLA and red once items breach it, so hotspots stand out at a glance. +- **Queue tiles** - one tile per enabled queue showing the waiting count, signed-in agents, available agents, busy/reserved/wrap-up agents, other not-ready agents, the longest current wait, and the number of items that have breached the queue's SLA threshold. Tiles turn amber as waits approach the SLA and red once items breach it, so managers can compare demand and staffing at a glance. - **Agent board** - every agent with a live presence dot (available, busy, wrap-up, break, and so on), their current reason, and how many interactions they are handling. Use it to spot a backing-up queue, an SLA breach, or too few available agents, and then rebalance staffing, adjust queue priorities, or open a campaign. @@ -94,6 +94,8 @@ Open **Interaction Center → My workspace**. This is the screen an agent keeps ### 1. Sign in and set your presence - **Sign in to queues and campaigns** from the soft phone's **Work** tab. You can only choose queues and campaigns you are allowed to handle. +- Select at least one queue or campaign before signing in. The Work tab shows an inline error when nothing is selected. +- After sign-in, the Work tab lists every queue and campaign you are signed in to. Use the individual **Sign out** action to leave one membership while remaining signed in to the others, or **Sign out of all** to leave every membership. - If inbound voice work is already waiting in one of those queues, signing in or switching back to **Available** immediately asks routing to offer the next queued call instead of waiting for another inbound event. - The soft-phone **Work** tab now signs you in and out over the Contact Center SignalR hub instead of reloading the page, so queue membership updates stay in-place and the same browser connection immediately joins or leaves the live queue groups. - If the browser refreshes or the soft phone reconnects while you are still signed in and available, Contact Center now re-checks those queues again as soon as the soft phone reconnects, so already-waiting calls are re-offered instead of staying parked until the next inbound routing event. diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index f1fdca9e8..f44e68403 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -44,6 +44,8 @@ Queues can require one or more skills. Agents must have every required skill ass A **queue** holds activities waiting for an agent, with a default priority, an SLA threshold, required skills, an optional inbound channel endpoint mapping, a reservation timeout, a routing policy, an optional business-hours calendar, and optional overflow settings. Activities enter a queue as **queue items**; the system pairs the highest-priority, oldest waiting item with an eligible available agent signed in to that queue and creates a short-lived **reservation**. +If no eligible agent is available when an activity enters the queue, the activity remains durable waiting work; it is not rejected merely because no agent is immediately available. Signing in, returning to **Available**, the assignment background task, or another routing trigger can offer it later. Business-hours, overflow, reservation-timeout, voicemail, and rejection policies determine when waiting work should move or end. + Routing is strategy-based. The strategy chain first rejects agents that do not have every required queue skill, then rejects agents that are already handling their maximum number of concurrent interactions, then applies the queue's selected scoring strategy. Each assignment publishes an auditable routing-decision event that records the queue item, selected agent, candidate scores, and reasons, so later supervisor and analytics features can explain why work was offered to an agent. ### Routing policy diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md index 2a4dc667b..e50b4f529 100644 --- a/src/CrestApps.Docs/docs/telephony/asterisk.md +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -126,7 +126,7 @@ Because all requests are issued server-side, the ARI password never reaches the 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 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 and mute variables, preventing a channel destroyed during the multi-request lookup from being reported as connected. 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. +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 and mute variables, preventing a channel destroyed during the multi-request lookup from being reported as connected. 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 startup 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. @@ -182,7 +182,7 @@ Visiting `http://localhost:8088/` returns **Not Found** by design because the co The default Aspire endpoint template uses `Local/{number}@default`, which loops the originated call back into the bundled demo dialplan. That local development path now 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 still stays inside Asterisk. -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 **Inbound Simulator** for burst testing. The dashboard treats the Asterisk Stasis event stream as the primary update path: when live events arrive, the server coalesces only a short event burst, reads the independent ARI diagnostics endpoints concurrently, enriches all active channels concurrently, and pushes the new snapshot to connected browsers over SignalR. A slower 15-second reconciliation refresh remains in place so the dashboard can recover from missed events or transient SignalR disconnects. The dashboard still 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. 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. +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 **Inbound Simulator** for burst testing. The dashboard treats the Asterisk Stasis event stream as the primary update path: when live events arrive, the server coalesces only a short event burst, reads the independent ARI diagnostics endpoints concurrently, enriches all active channels concurrently, and pushes the new snapshot to connected browsers over SignalR. 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. The dashboard still 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. The 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. Production deployments should supply their own ARI credentials, dialplan, endpoints, and media/network configuration. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs index 640559e77..41f23d5d0 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs @@ -58,8 +58,15 @@ public async Task SignIn( return Forbid(); } + var queues = ContactCenterFormHelpers.NormalizeList(selectedQueueIds); var campaigns = ContactCenterFormHelpers.NormalizeList(selectedCampaignIds); - await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); + + if (queues.Count == 0 && campaigns.Count == 0) + { + return BadRequest("Select at least one queue or campaign before signing in."); + } + + await _presenceManager.SignInAsync(GetUserId(), queues, campaigns); return RedirectToReturnLocation(returnUrl); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Endpoints/SupervisorDashboardEndpoints.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Endpoints/SupervisorDashboardEndpoints.cs index 346b9c623..ed3f8dcab 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Endpoints/SupervisorDashboardEndpoints.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Endpoints/SupervisorDashboardEndpoints.cs @@ -21,7 +21,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Endpoints; internal static class SupervisorDashboardEndpoints { - private const int MaxAgents = 200; + private const int AgentPageSize = 200; public const string StateRouteName = "ContactCenterSupervisorDashboardState"; public const string EngageRouteName = "ContactCenterSupervisorDashboardEngage"; @@ -59,11 +59,15 @@ private static async Task HandleStateAsync( ServerTimeUtc = now, }; + var agents = await ListAgentsAsync(agentManager, httpContext.RequestAborted); var queues = await queueManager.ListEnabledAsync(httpContext.RequestAborted); foreach (var queue in queues) { var waiting = await queueItemManager.ListWaitingAsync(queue.ItemId, httpContext.RequestAborted); + var signedInAgents = agents + .Where(agent => agent.QueueIds.Contains(queue.ItemId, StringComparer.OrdinalIgnoreCase)) + .ToArray(); var longestWaitSeconds = 0; var slaBreachCount = 0; @@ -83,6 +87,10 @@ private static async Task HandleStateAsync( Id = queue.ItemId, Name = queue.Name, WaitingCount = waiting.Count, + SignedInAgentCount = signedInAgents.Length, + AvailableAgentCount = signedInAgents.Count(agent => agent.PresenceStatus == AgentPresenceStatus.Available), + BusyAgentCount = signedInAgents.Count(agent => agent.PresenceStatus is AgentPresenceStatus.Reserved or AgentPresenceStatus.Busy or AgentPresenceStatus.WrapUp), + NotReadyAgentCount = signedInAgents.Count(agent => agent.PresenceStatus is not AgentPresenceStatus.Available and not AgentPresenceStatus.Reserved and not AgentPresenceStatus.Busy and not AgentPresenceStatus.WrapUp), LongestWaitSeconds = longestWaitSeconds, SlaBreachCount = slaBreachCount, SlaThresholdSeconds = queue.SlaThresholdSeconds, @@ -91,9 +99,7 @@ private static async Task HandleStateAsync( model.TotalWaiting += waiting.Count; } - var agents = await agentManager.PageAsync(1, MaxAgents, new QueryContext(), httpContext.RequestAborted); - - foreach (var agent in agents.Entries) + foreach (var agent in agents) { var activeInteractions = await interactionManager.CountActiveByAgentAsync(agent.ItemId, httpContext.RequestAborted); var activeInteraction = await interactionManager.FindActiveByAgentAsync(agent.ItemId, httpContext.RequestAborted); @@ -119,6 +125,27 @@ private static async Task HandleStateAsync( return TypedResults.Ok(model); } + private static async Task> ListAgentsAsync( + IAgentProfileManager agentManager, + CancellationToken cancellationToken) + { + var agents = new List(); + var page = 1; + + while (true) + { + var result = await agentManager.PageAsync(page, AgentPageSize, new QueryContext(), cancellationToken); + agents.AddRange(result.Entries); + + if (result.Entries.Count < AgentPageSize) + { + return agents; + } + + page++; + } + } + private static async Task HandleEngageAsync( [FromForm] EngageRequest request, IAuthorizationService authorizationService, diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs index 182b1626c..f9f66c0ca 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs @@ -229,12 +229,15 @@ await ShellScope.UsingChildScopeAsync(async scope => var sessionService = scope.ServiceProvider.GetRequiredService(); var presenceManager = scope.ServiceProvider.GetRequiredService(); var previousSnapshot = await sessionService.BuildSnapshotAsync(userId, Context.ConnectionAborted); + var normalizedQueueIds = ContactCenterFormHelpers.NormalizeList(queueIds); + var normalizedCampaignIds = ContactCenterFormHelpers.NormalizeList(campaignIds); - await presenceManager.SignInAsync( - userId, - ContactCenterFormHelpers.NormalizeList(queueIds), - ContactCenterFormHelpers.NormalizeList(campaignIds), - Context.ConnectionAborted); + if (normalizedQueueIds.Count == 0 && normalizedCampaignIds.Count == 0) + { + throw new HubException("Select at least one queue or campaign before signing in."); + } + + await presenceManager.SignInAsync(userId, normalizedQueueIds, normalizedCampaignIds, Context.ConnectionAborted); snapshot = await sessionService.BuildSnapshotAsync(userId, Context.ConnectionAborted); await UpdateQueueGroupsAsync(previousSnapshot?.QueueIds, snapshot?.QueueIds); @@ -269,6 +272,50 @@ await ShellScope.UsingChildScopeAsync(async scope => return snapshot; } + /// + /// Updates the current agent's queue and campaign memberships without changing their presence or active work. + /// + /// The queues to remain signed in to. + /// The campaigns to remain signed in to. + /// The updated agent snapshot. + public async Task UpdateMemberships(IList queueIds, IList campaignIds) + { + var userId = EnsureUserId(); + AgentDesktopSnapshot snapshot = null; + + await ShellScope.UsingChildScopeAsync(async scope => + { + await EnsureAuthorizedAsync(scope.ServiceProvider, ContactCenterPermissions.SignIntoQueues); + + var sessionService = scope.ServiceProvider.GetRequiredService(); + var presenceManager = scope.ServiceProvider.GetRequiredService(); + var previousSnapshot = await sessionService.BuildSnapshotAsync(userId, Context.ConnectionAborted); + var normalizedQueueIds = ContactCenterFormHelpers.NormalizeList(queueIds); + var normalizedCampaignIds = ContactCenterFormHelpers.NormalizeList(campaignIds); + + if (normalizedQueueIds.Count == 0 && normalizedCampaignIds.Count == 0) + { + throw new HubException("Use sign out to leave the final queue or campaign."); + } + + var profile = await presenceManager.UpdateMembershipsAsync( + userId, + normalizedQueueIds, + normalizedCampaignIds, + Context.ConnectionAborted); + + if (profile is null) + { + throw new HubException("Sign in before changing queue or campaign memberships."); + } + + snapshot = await sessionService.BuildSnapshotAsync(userId, Context.ConnectionAborted); + await UpdateQueueGroupsAsync(previousSnapshot?.QueueIds, snapshot?.QueueIds); + }); + + return snapshot; + } + /// /// Re-checks the signed-in queues for already-waiting inbound voice work. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs index ea81ee444..9d9a17516 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs @@ -32,6 +32,7 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter private readonly IContentManager _contentManager; private readonly IInteractionManager _interactionManager; private readonly IActivityQueueManager _queueManager; + private readonly IQueueItemManager _queueItemManager; private readonly IActivityQueueService _queueService; private readonly IActivityAssignmentService _assignmentService; private readonly IActivityReservationService _reservationService; @@ -54,6 +55,7 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter /// The content manager used to create the subject and load contacts. /// The interaction manager used to record communication history. /// The queue manager used to resolve the inbound queue. + /// The queue item manager used to determine the current queue state of an existing call. /// The queue service used to enqueue the activity. /// The assignment service used to reserve an available agent. /// The reservation service used to release invalid offers. @@ -73,6 +75,7 @@ public VoiceContactCenterCallRouter( IContentManager contentManager, IInteractionManager interactionManager, IActivityQueueManager queueManager, + IQueueItemManager queueItemManager, IActivityQueueService queueService, IActivityAssignmentService assignmentService, IActivityReservationService reservationService, @@ -92,6 +95,7 @@ public VoiceContactCenterCallRouter( _contentManager = contentManager; _interactionManager = interactionManager; _queueManager = queueManager; + _queueItemManager = queueItemManager; _queueService = queueService; _assignmentService = assignmentService; _reservationService = reservationService; @@ -211,10 +215,16 @@ private async Task RouteInboundCoreAsync( if (existing is not null) { + var queueItem = !string.IsNullOrEmpty(existing.ActivityItemId) + ? await _queueItemManager.FindByActivityIdAsync(existing.ActivityItemId, cancellationToken) + : null; + result.ActivityItemId = existing.ActivityItemId; result.InteractionId = existing.ItemId; - result.QueueId = existing.QueueId; - result.Routed = !string.IsNullOrEmpty(existing.AgentId); + result.QueueId = queueItem?.QueueId ?? existing.QueueId; + result.Routed = !string.IsNullOrEmpty(existing.AgentId) || + queueItem?.Status is QueueItemStatus.Reserved or QueueItemStatus.Assigned; + result.Queued = queueItem?.Status == QueueItemStatus.Waiting; result.Reason = "The provider call is already tracked by the Contact Center."; return result; @@ -270,17 +280,19 @@ private async Task RouteInboundCoreAsync( var priority = plan is not null ? plan.Priority : (InteractionPriority?)null; await _queueService.EnqueueAsync(activity.ItemId, queue.ItemId, priority, cancellationToken); + result.Queued = true; var agentUserId = await OfferNextAsync(queue.ItemId, cancellationToken); if (string.IsNullOrEmpty(agentUserId)) { - result.Reason = "The call was queued; no agent is currently available."; + result.Reason = "The call is waiting in the queue for the next eligible agent."; return result; } result.Routed = true; + result.Queued = false; result.AgentUserId = agentUserId; result.Reason = "Offered to an available agent."; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/SupervisorQueueViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/SupervisorQueueViewModel.cs index dfb837187..3126af191 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/SupervisorQueueViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/SupervisorQueueViewModel.cs @@ -20,6 +20,26 @@ public sealed class SupervisorQueueViewModel /// public int WaitingCount { get; set; } + /// + /// Gets or sets the number of agents currently signed in to the queue. + /// + public int SignedInAgentCount { get; set; } + + /// + /// Gets or sets the number of signed-in agents currently available for new work. + /// + public int AvailableAgentCount { get; set; } + + /// + /// Gets or sets the number of signed-in agents currently reserved, busy, or completing after-call work. + /// + public int BusyAgentCount { get; set; } + + /// + /// Gets or sets the number of signed-in agents currently in another not-ready state. + /// + public int NotReadyAgentCount { get; set; } + /// /// Gets or sets the longest current wait time in the queue, in seconds. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml index 97a11bfbf..67acaacd0 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml @@ -15,6 +15,10 @@ data-contact-center-hub-url="@viewModel.HubUrl" data-contact-center-signed-in-text="@T["Signed in"]" data-contact-center-offline-text="@T["Offline"]" + data-contact-center-selection-required="@T["Select at least one queue or campaign before signing in."]" + data-contact-center-queue-text="@T["Queue"]" + data-contact-center-campaign-text="@T["Campaign"]" + data-contact-center-sign-out-item-text="@T["Sign out"]" data-contact-center-sync-url="@Url.Action("SyncQueuedVoiceWork", "AgentSoftPhone", new { area = "CrestApps.OrchardCore.ContactCenter" })" data-contact-center-current-offer-url="@Url.Action("CurrentIncomingOffer", "AgentSoftPhone", new { area = "CrestApps.OrchardCore.ContactCenter" })" hidden> @@ -25,6 +29,8 @@ + + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/SupervisorDashboard/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/SupervisorDashboard/Index.cshtml index 9dd36384f..a9e1803ab 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/SupervisorDashboard/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/SupervisorDashboard/Index.cshtml @@ -15,6 +15,9 @@ { ["waiting"] = T["Waiting"].Value, ["available"] = T["Available agents"].Value, + ["signedIn"] = T["Signed-in agents"].Value, + ["busy"] = T["Busy agents"].Value, + ["notReady"] = T["Not-ready agents"].Value, ["agents"] = T["Agents"].Value, ["queues"] = T["Queues"].Value, ["noQueues"] = T["No queues are configured."].Value, diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-soft-phone.js b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-soft-phone.js index 3ca45535a..fe53117a2 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-soft-phone.js @@ -32,6 +32,13 @@ .map(function (option) { return option.value; }); } + function escapeHtml(value) { + var node = document.createElement('div'); + node.textContent = value == null ? '' : String(value); + + return node.innerHTML; + } + function applySelectedValues(select, values) { if (!select) { return; @@ -62,11 +69,76 @@ }); } + function showMembershipError(root, api, message) { + var error = root && root.querySelector('[data-contact-center-membership-error]'); + + if (error) { + error.textContent = message || ''; + error.hidden = !message; + } + + if (message && api && typeof api.showError === 'function') { + api.showError(message); + } + } + + function getOptionText(select, value) { + if (!select) { + return value; + } + + var option = Array.prototype.find.call(select.options, function (candidate) { + return candidate.value === String(value); + }); + + return option ? option.text : value; + } + + function renderMembershipList(root, snapshot, queueSelect, campaignSelect) { + var list = root.querySelector('[data-contact-center-membership-list]'); + + if (!list) { + return; + } + + var queueText = root.getAttribute('data-contact-center-queue-text') || 'Queue'; + var campaignText = root.getAttribute('data-contact-center-campaign-text') || 'Campaign'; + var signOutText = root.getAttribute('data-contact-center-sign-out-item-text') || 'Sign out'; + var memberships = []; + + (snapshot.queueIds || []).forEach(function (queueId) { + memberships.push({ + kind: 'queue', + id: queueId, + type: queueText, + name: getOptionText(queueSelect, queueId) + }); + }); + + (snapshot.campaignIds || []).forEach(function (campaignId) { + memberships.push({ + kind: 'campaign', + id: campaignId, + type: campaignText, + name: getOptionText(campaignSelect, campaignId) + }); + }); + + list.innerHTML = memberships.map(function (membership) { + return '
' + + '' + escapeHtml(membership.type) + ': ' + escapeHtml(membership.name) + '' + + '' + + '
'; + }).join(''); + } + function updateMembershipUi(root, snapshot) { if (!root || !snapshot) { return; } + root.__contactCenterMembershipSnapshot = snapshot; + var isSignedIn = !!(snapshot.queueIds && snapshot.queueIds.length) || !!(snapshot.campaignIds && snapshot.campaignIds.length); var signedInText = root.getAttribute('data-contact-center-signed-in-text') || 'Signed in'; var offlineText = root.getAttribute('data-contact-center-offline-text') || 'Offline'; @@ -92,6 +164,8 @@ applySelectedValues(queueSelect, snapshot.queueIds || []); applySelectedValues(campaignSelect, snapshot.campaignIds || []); + renderMembershipList(root, snapshot, queueSelect, campaignSelect); + showMembershipError(root, null, null); refreshPickers(root); } @@ -186,23 +260,35 @@ if (signInForm) { signInForm.addEventListener('submit', function (event) { + var queueIds = getSelectedValues(queueSelect); + var campaignIds = getSelectedValues(campaignSelect); + + if (!queueIds.length && !campaignIds.length) { + event.preventDefault(); + showMembershipError( + root, + api, + root.getAttribute('data-contact-center-selection-required') || 'Select at least one queue or campaign before signing in.'); + + return; + } + if (!client) { return; } event.preventDefault(); setBusy(root, true); + showMembershipError(root, null, null); - invokeHub(client, 'SignIn', getSelectedValues(queueSelect), getSelectedValues(campaignSelect)) + invokeHub(client, 'SignIn', queueIds, campaignIds) .then(function (snapshot) { updateMembershipUi(root, snapshot); return recoverSoftPhoneState(root, api, client); }) .catch(function (error) { - if (api && typeof api.showError === 'function') { - api.showError(error && error.message ? error.message : String(error)); - } + showMembershipError(root, api, error && error.message ? error.message : String(error)); }) .finally(function () { setBusy(root, false); @@ -218,6 +304,7 @@ event.preventDefault(); setBusy(root, true); + showMembershipError(root, null, null); invokeHub(client, 'SignOut') .then(function (snapshot) { @@ -228,15 +315,56 @@ } }) .catch(function (error) { - if (api && typeof api.showError === 'function') { - api.showError(error && error.message ? error.message : String(error)); - } + showMembershipError(root, api, error && error.message ? error.message : String(error)); }) .finally(function () { setBusy(root, false); }); }); } + + root.addEventListener('click', function (event) { + var button = event.target.closest('[data-contact-center-membership-sign-out]'); + + if (!button || !client) { + return; + } + + event.preventDefault(); + + var snapshot = root.__contactCenterMembershipSnapshot || { + queueIds: getSelectedValues(queueSelect), + campaignIds: getSelectedValues(campaignSelect) + }; + var kind = button.getAttribute('data-membership-kind'); + var membershipId = button.getAttribute('data-membership-id'); + var queueIds = (snapshot.queueIds || []).filter(function (queueId) { + return kind !== 'queue' || queueId !== membershipId; + }); + var campaignIds = (snapshot.campaignIds || []).filter(function (campaignId) { + return kind !== 'campaign' || campaignId !== membershipId; + }); + var method = queueIds.length || campaignIds.length ? 'UpdateMemberships' : 'SignOut'; + var args = method === 'UpdateMemberships' ? [queueIds, campaignIds] : []; + + setBusy(root, true); + showMembershipError(root, null, null); + + invokeHub.apply(null, [client, method].concat(args)) + .then(function (updatedSnapshot) { + updateMembershipUi(root, updatedSnapshot); + + if (method === 'SignOut' && api && typeof api.clearIncomingOffer === 'function') { + api.clearIncomingOffer(); + } + }) + .catch(function (error) { + showMembershipError(root, api, error && error.message ? error.message : String(error)); + }) + .finally(function () { + setBusy(root, false); + }); + }); } function wireSoftPhone(root, api) { diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js index 6de2a4698..24fdedb2e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js @@ -124,6 +124,10 @@ return '
' + '
' + escapeHtml(queue.name) + '
' + '
' + escapeHtml(label('waiting', 'Waiting')) + '' + queue.waitingCount + '
' + + '
' + escapeHtml(label('signedIn', 'Signed-in agents')) + '' + queue.signedInAgentCount + '
' + + '
' + escapeHtml(label('available', 'Available agents')) + '' + queue.availableAgentCount + '
' + + '
' + escapeHtml(label('busy', 'Busy agents')) + '' + queue.busyAgentCount + '
' + + '
' + escapeHtml(label('notReady', 'Not-ready agents')) + '' + queue.notReadyAgentCount + '
' + '
' + escapeHtml(label('longestWait', 'Longest wait')) + '' + formatWait(queue.longestWaitSeconds) + '
' + '
' + escapeHtml(label('slaBreaches', 'SLA breaches')) + '' + queue.slaBreachCount + '
' + '
'; diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj index b39cb74ec..d79899af4 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj @@ -5,4 +5,11 @@ false + + + + diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationResult.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationResult.cs index 5c21328ea..53f001e86 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationResult.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Models/InboundCallSimulationResult.cs @@ -50,6 +50,11 @@ public sealed class InboundCallSimulationResult ///
public bool? Routed { get; set; } + /// + /// Gets or sets a value indicating whether the call is waiting in a Contact Center queue. + /// + public bool? Queued { get; set; } + /// /// Gets or sets the interaction identifier created for the simulated call. /// diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Shared/_Layout.cshtml b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Shared/_Layout.cshtml index f3ec2d8c4..65654374e 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Shared/_Layout.cshtml +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Shared/_Layout.cshtml @@ -25,7 +25,7 @@ @RenderBody() - + diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml index 52b2c5310..3199fc12b 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml @@ -126,7 +126,7 @@

Last burst

-

@Model.SuccessfulCount succeeded, @Model.RoutedCount routed to an agent.

+

@Model.SuccessfulCount succeeded, @Model.RoutedCount offered to an agent, @Model.QueuedCount waiting in a queue.

@@ -158,7 +158,13 @@
@result.DurationMilliseconds ms
@T["Name"]@T["Default Priority"]@T["Default priority"] @T["SLA (s)"]@T["Required skills"] @T["Enabled"]
@queue.Name @queue.DefaultPriority @queue.SlaThresholdSeconds@string.Join(", ", queue.RequiredSkills) @(queue.Enabled ? T["Yes"] : T["No"])
diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs index 245b391ea..52db3bc6c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs @@ -75,7 +75,7 @@ public async Task AssignNextAsync_ReservesTopItemForLongestIdleAgent() .Setup(s => s.ReserveAsync(topItem, idleAgent, 45, It.IsAny())) .ReturnsAsync(new ActivityReservation { ItemId = "r1" }); - var service = new ActivityAssignmentService(queueItemManager.Object, agentManager.Object, queueManager.Object, reservationService.Object); + var service = CreateService(queueItemManager, agentManager, queueManager, reservationService); // Act var reservation = await service.AssignNextAsync("q1", TestContext.Current.CancellationToken); @@ -85,6 +85,61 @@ public async Task AssignNextAsync_ReservesTopItemForLongestIdleAgent() reservationService.Verify(s => s.ReserveAsync(topItem, idleAgent, 45, It.IsAny()), Times.Once); } + [Fact] + public async Task AssignNextAsync_WhenQueueRequiresSkill_SelectsSkilledAgent() + { + // Arrange + var topItem = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var queueItemManager = new Mock(); + queueItemManager + .Setup(m => m.ListWaitingAsync("q1", It.IsAny())) + .ReturnsAsync([topItem]); + + var missingSkillAgent = new AgentProfile + { + ItemId = "a1", + Skills = ["general"], + PresenceChangedUtc = new DateTime(2026, 1, 1), + }; + + var skilledAgent = new AgentProfile + { + ItemId = "a2", + Skills = ["billing"], + PresenceChangedUtc = new DateTime(2026, 1, 2), + }; + + var agentManager = new Mock(); + agentManager + .Setup(m => m.ListAvailableForQueueAsync("q1", It.IsAny())) + .ReturnsAsync([missingSkillAgent, skilledAgent]); + + var queueManager = new Mock(); + queueManager + .Setup(m => m.FindByIdAsync("q1", It.IsAny())) + .ReturnsAsync(new ActivityQueue + { + ItemId = "q1", + ReservationTimeoutSeconds = 45, + RequiredSkills = ["billing"], + Enabled = true, + }); + + var reservationService = new Mock(); + reservationService + .Setup(s => s.ReserveAsync(topItem, skilledAgent, 45, It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r1" }); + + var service = CreateService(queueItemManager, agentManager, queueManager, reservationService); + + // Act + var reservation = await service.AssignNextAsync("q1", TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(reservation); + reservationService.Verify(s => s.ReserveAsync(topItem, skilledAgent, 45, It.IsAny()), Times.Once); + } + private static ActivityAssignmentService CreateService( Mock queueItemManager, Mock agentManager, @@ -92,6 +147,30 @@ private static ActivityAssignmentService CreateService( { var queueManager = new Mock(); - return new ActivityAssignmentService(queueItemManager.Object, agentManager.Object, queueManager.Object, reservationService.Object); + return CreateService(queueItemManager, agentManager, queueManager, reservationService); + } + + private static ActivityAssignmentService CreateService( + Mock queueItemManager, + Mock agentManager, + Mock queueManager, + Mock reservationService) + { + return new ActivityAssignmentService( + queueItemManager.Object, + agentManager.Object, + queueManager.Object, + CreateRoutingService(), + reservationService.Object, + new Mock().Object); + } + + private static ActivityRoutingService CreateRoutingService() + { + return new ActivityRoutingService( + [ + new RequiredSkillsRoutingStrategy(), + new LongestIdleRoutingStrategy(), + ]); } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs index 085bbe596..55d2e47b2 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -63,6 +63,31 @@ public async Task ExpireDueAsync_ReleasesPendingReservationsAndReturnsItemToQueu Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); } + [Fact] + public async Task CancelAsync_ReleasesPendingReservationAndMarksCanceled() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", QueueItemId = "qi-1", AgentId = "a1", ActivityItemId = "act-1", Status = ReservationStatus.Pending }; + var reservationManager = new Mock(); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())).ReturnsAsync(reservation); + var queueItem = new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }; + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(queueItem); + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + + // Act + var canceled = await service.CancelAsync("r1", TestContext.Current.CancellationToken); + + // Assert + Assert.Same(reservation, canceled); + Assert.Equal(ReservationStatus.Canceled, reservation.Status); + Assert.Equal(QueueItemStatus.Waiting, queueItem.Status); + } + private static ActivityReservationService CreateService( Mock reservationManager, Mock queueItemManager, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityRoutingServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityRoutingServiceTests.cs new file mode 100644 index 000000000..27bf8eeba --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityRoutingServiceTests.cs @@ -0,0 +1,61 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ActivityRoutingServiceTests +{ + [Fact] + public async Task SelectAgentAsync_WhenQueueRequiresSkills_RejectsMissingSkillCandidates() + { + // Arrange + var service = CreateService(); + var queue = new ActivityQueue { ItemId = "q1", RequiredSkills = ["billing"] }; + var item = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var missingSkillAgent = new AgentProfile { ItemId = "a1", Skills = ["general"] }; + var skilledAgent = new AgentProfile { ItemId = "a2", Skills = ["billing"] }; + + // Act + var decision = await service.SelectAgentAsync( + queue, + item, + [missingSkillAgent, skilledAgent], + TestContext.Current.CancellationToken); + + // Assert + Assert.True(decision.Succeeded); + Assert.Same(skilledAgent, decision.Agent); + Assert.False(decision.Candidates.Single(candidate => candidate.Agent == missingSkillAgent).IsEligible); + } + + [Fact] + public async Task SelectAgentAsync_WhenMultipleAgentsEligible_SelectsLongestIdle() + { + // Arrange + var service = CreateService(); + var queue = new ActivityQueue { ItemId = "q1" }; + var item = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var newestAgent = new AgentProfile { ItemId = "a1", PresenceChangedUtc = new DateTime(2026, 1, 2) }; + var longestIdleAgent = new AgentProfile { ItemId = "a2", PresenceChangedUtc = new DateTime(2026, 1, 1) }; + + // Act + var decision = await service.SelectAgentAsync( + queue, + item, + [newestAgent, longestIdleAgent], + TestContext.Current.CancellationToken); + + // Assert + Assert.True(decision.Succeeded); + Assert.Same(longestIdleAgent, decision.Agent); + } + + private static ActivityRoutingService CreateService() + { + return new ActivityRoutingService( + [ + new RequiredSkillsRoutingStrategy(), + new LongestIdleRoutingStrategy(), + ]); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index 5a95142f9..cd84d524c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -2,6 +2,7 @@ using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; using Moq; +using OrchardCore.Locking.Distributed; using OrchardCore.Modules; namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; @@ -21,13 +22,14 @@ public async Task SignInAsync_CreatesAvailableProfile_AndJoinsQueues() var publisher = new Mock(); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, publisher.Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, publisher.Object, CreateDistributedLock().Object, clock.Object); // Act var profile = await service.SignInAsync("u1", ["q1", "q2"], [], TestContext.Current.CancellationToken); // Assert Assert.Equal(AgentPresenceStatus.Available, profile.PresenceStatus); + Assert.Null(profile.ActiveReservationId); Assert.Equal(2, profile.QueueIds.Count); agentManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); } @@ -41,7 +43,7 @@ public async Task SignOutAsync_SetsOffline() agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, clock.Object); + var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); // Act var profile = await service.SignOutAsync("u1", TestContext.Current.CancellationToken); @@ -49,4 +51,14 @@ public async Task SignOutAsync_SetsOffline() // Assert Assert.Equal(AgentPresenceStatus.Offline, profile.PresenceStatus); } + + private static Mock CreateDistributedLock() + { + var distributedLock = new Mock(); + distributedLock + .Setup(l => l.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, true)); + + return distributedLock; + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs new file mode 100644 index 000000000..6623e2662 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs @@ -0,0 +1,177 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.Extensions.Logging; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class DialerServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task RunCycleAsync_WhenProviderFails_CancelsReservationAndMarksInteractionFailed() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", ActivityItemId = "act1", AgentId = "a1" }; + var assignmentService = CreateAssignmentService(reservation); + var reservationService = new Mock(); + var interaction = new Interaction { ItemId = "int1" }; + var interactionManager = CreateInteractionManager(interaction); + var activity = new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }; + var activityManager = CreateActivityManager(activity); + var provider = CreateProvider(DialerDialResult.Failure("provider_failed", "Provider rejected the request.")); + var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, provider); + + // Act + var started = await service.RunCycleAsync(CreateProfile(), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, started); + Assert.Equal(InteractionStatus.Failed, interaction.Status); + reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task RunCycleAsync_WhenMaxAttemptsReached_CancelsReservationWithoutDialing() + { + // Arrange + var reservation = new ActivityReservation { ItemId = "r1", ActivityItemId = "act1", AgentId = "a1" }; + var assignmentService = CreateAssignmentService(reservation); + var reservationService = new Mock(); + var interactionManager = CreateInteractionManager(new Interaction { ItemId = "int1" }); + var activity = new OmnichannelActivity + { + ItemId = "act1", + PreferredDestination = "+15551112222", + Attempts = 3, + }; + + var activityManager = CreateActivityManager(activity); + var provider = CreateProvider(DialerDialResult.Success("call1")); + var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, provider); + + // Act + var started = await service.RunCycleAsync(CreateProfile(), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, started); + Assert.Equal(ActivityStatus.Failed, activity.Status); + provider.Verify(p => p.PlaceCallAsync(It.IsAny(), It.IsAny()), Times.Never); + reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); + } + + [Fact] + public async Task RunCycleAsync_WhenPowerMode_StopsAtCallsPerAgentPacingLimit() + { + // Arrange + var firstReservation = new ActivityReservation { ItemId = "r1", ActivityItemId = "act1", AgentId = "a1" }; + var secondReservation = new ActivityReservation { ItemId = "r2", ActivityItemId = "act1", AgentId = "a2" }; + var assignmentService = new Mock(); + assignmentService + .SetupSequence(s => s.AssignNextAsync("q1", It.IsAny())) + .ReturnsAsync(firstReservation) + .ReturnsAsync(secondReservation) + .ReturnsAsync((ActivityReservation)null); + + var reservationService = new Mock(); + var interactionManager = CreateInteractionManager(new Interaction { ItemId = "int1" }); + var activityManager = CreateActivityManager(new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }); + var provider = CreateProvider(DialerDialResult.Success("call1")); + var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, provider); + var profile = CreateProfile(); + profile.CallsPerAgent = 1; + + // Act + var started = await service.RunCycleAsync(profile, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, started); + assignmentService.Verify(s => s.AssignNextAsync("q1", It.IsAny()), Times.Once); + } + + private static DialerProfile CreateProfile() + { + return new DialerProfile + { + ItemId = "profile1", + Name = "Power", + QueueId = "q1", + ProviderName = "test", + Mode = DialerMode.Power, + MaxAttempts = 3, + Enabled = true, + }; + } + + private static Mock CreateAssignmentService(ActivityReservation reservation) + { + var assignmentService = new Mock(); + assignmentService + .SetupSequence(s => s.AssignNextAsync("q1", It.IsAny())) + .ReturnsAsync(reservation) + .ReturnsAsync((ActivityReservation)null); + + return assignmentService; + } + + private static Mock CreateInteractionManager(Interaction interaction) + { + var interactionManager = new Mock(); + interactionManager + .Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(interaction); + + return interactionManager; + } + + private static Mock CreateActivityManager(OmnichannelActivity activity) + { + var activityManager = new Mock(); + activityManager + .Setup(m => m.FindByIdAsync("act1", It.IsAny())) + .ReturnsAsync(activity); + + return activityManager; + } + + private static Mock CreateProvider(DialerDialResult result) + { + var provider = new Mock(); + provider.SetupGet(p => p.TechnicalName).Returns("test"); + provider.SetupGet(p => p.Capabilities).Returns(DialerProviderCapabilities.Outbound); + provider + .Setup(p => p.PlaceCallAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(result); + + return provider; + } + + private static DialerService CreateService( + Mock assignmentService, + Mock reservationService, + Mock interactionManager, + Mock activityManager, + Mock provider) + { + var providerResolver = new Mock(); + providerResolver.Setup(r => r.Get("test")).Returns(provider.Object); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new DialerService( + assignmentService.Object, + reservationService.Object, + interactionManager.Object, + activityManager.Object, + providerResolver.Object, + new Mock().Object, + clock.Object, + new Mock>().Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index 267f293f9..9fc88decd 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -163,6 +163,29 @@ public async Task HandleInboundAsync_WhenNoAgentAvailable_QueuesWithoutOffering( harness.IncomingCallDispatcher.Verify(d => d.DispatchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task OfferNextAsync_WhenReservedAgentCannotBeLoaded_ReleasesReservation() + { + // Arrange + var harness = new Harness(); + harness.AssignmentService + .Setup(m => m.AssignNextAsync("q1", It.IsAny())) + .ReturnsAsync(new ActivityReservation { ItemId = "r1", AgentId = "a1", ActivityItemId = "act1", QueueId = "q1" }); + + harness.AgentManager + .Setup(m => m.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync((AgentProfile)null); + + var service = harness.CreateService(); + + // Act + var agentUserId = await service.OfferNextAsync("q1", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(agentUserId); + harness.ReservationService.Verify(s => s.RejectAsync("r1", It.IsAny()), Times.Once); + } + private sealed class Harness { public Mock ChannelEndpointManager { get; } = new(); @@ -181,6 +204,8 @@ private sealed class Harness public Mock AssignmentService { get; } = new(); + public Mock ReservationService { get; } = new(); + public Mock AgentManager { get; } = new(); public Mock ContactLookup { get; } = new(); @@ -224,6 +249,7 @@ public InboundVoiceService CreateService() QueueManager.Object, QueueService.Object, AssignmentService.Object, + ReservationService.Object, AgentManager.Object, ContactLookup.Object, IncomingCallDispatcher.Object, From aca69af7ba21e9538fb562f13eee54455f31e765 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 13:06:46 -0700 Subject: [PATCH 07/56] [Refactor] Align Contact Center admin and voice routing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> AI-Model: gpt-5.5 --- .github/contact-center/PLAN.md | 9 +- .../Models/DialerProfile.cs | 2 +- .../Services/DialerService.cs | 56 +- .../Services/IDialerService.cs | 2 +- .../Services/IVoiceContactCenterCallRouter.cs | 42 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 12 +- .../contact-center/agents-queues-dialer.md | 50 +- .../docs/contact-center/index.md | 38 +- .../Controllers/AgentWorkspaceController.cs | 29 +- .../Controllers/DialerProfilesController.cs | 207 +- .../Controllers/QueuesController.cs | 199 +- .../Controllers/VoiceIngressController.cs | 12 +- .../Drivers/ActivityQueueDisplayDriver.cs | 103 + .../Drivers/DialerProfileDisplayDriver.cs | 120 + .../Handlers/ActivityQueueHandler.cs | 55 + .../Handlers/DialerProfileHandler.cs | 55 + .../Manifest.cs | 9 +- .../ContactCenterAdminFormOptionsProvider.cs | 182 + .../Services/ContactCenterAdminMenu.cs | 19 +- .../Services/ContactCenterDialerAdminMenu.cs | 40 + ...ice.cs => VoiceContactCenterCallRouter.cs} | 73 +- .../Startup.cs | 22 +- .../ViewModels/AgentWorkspaceViewModel.cs | 19 +- .../ViewModels/ContactCenterFormHelpers.cs | 11 + .../ViewModels/DialerProfileViewModel.cs | 18 +- .../ViewModels/QueueViewModel.cs | 15 +- .../Views/ActivityQueue.SummaryAdmin.cshtml | 29 + .../Views/ActivityQueueFields.Edit.cshtml | 80 + .../Views/AgentWorkspace/Index.cshtml | 23 +- .../Views/DialerProfile.SummaryAdmin.cshtml | 29 + .../Views/DialerProfileFields.Edit.cshtml | 118 + .../Views/DialerProfiles/Create.cshtml | 122 +- .../Views/DialerProfiles/Edit.cshtml | 123 +- .../Views/DialerProfiles/Index.cshtml | 88 +- .../ActivityQueue.Buttons.SummaryAdmin.cshtml | 14 + ...ivityQueue.DefaultMeta.SummaryAdmin.cshtml | 17 + .../ActivityQueue.Fields.SummaryAdmin.cshtml | 24 + .../DialerProfile.Buttons.SummaryAdmin.cshtml | 14 + ...lerProfile.DefaultMeta.SummaryAdmin.cshtml | 17 + .../DialerProfile.Fields.SummaryAdmin.cshtml | 23 + .../Views/Queues/Create.cshtml | 89 +- .../Views/Queues/Edit.cshtml | 90 +- .../Views/Queues/Index.cshtml | 90 +- .../Views/_ViewImports.cshtml | 7 +- .../DialPadConstants.cs | 2 +- .../DialerStartup.cs | 7 +- .../CrestApps.OrchardCore.DialPad/Manifest.cs | 7 +- .../DialPadContactCenterVoiceProvider.cs | 135 + .../Services/DialPadDialerProvider.cs | 83 - .../Assets.json | 28 + .../ResourceManagementOptionsConfiguration.cs | 21 + .../package-lock.json | 41 + .../package.json | 1 + .../bootstrap-select/css/bootstrap-select.css | 667 +++ .../css/bootstrap-select.min.css | 9 + .../bootstrap-select/js/bootstrap-select.js | 3817 +++++++++++++++++ .../js/bootstrap-select.min.js | 10 + .../ContactCenter/DialerServiceTests.cs | 57 +- .../ContactCenter/InboundVoiceServiceTests.cs | 8 +- 59 files changed, 6481 insertions(+), 808 deletions(-) create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IVoiceContactCenterCallRouter.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/DialerProfileDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ActivityQueueHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/DialerProfileHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs rename src/Modules/CrestApps.OrchardCore.ContactCenter/Services/{InboundVoiceService.cs => VoiceContactCenterCallRouter.cs} (81%) create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Buttons.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.DefaultMeta.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Fields.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Buttons.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.DefaultMeta.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Fields.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs delete mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.css create mode 100644 src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css create mode 100644 src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.js create mode 100644 src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 2273c0667..2427d5757 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1387,8 +1387,8 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] `Queues` feature: ActivityQueue, QueueItem, ActivityReservation models/stores/managers/indexes; queue + reservation lifecycle (reserve/accept/reject/expire); reservation-expiry background task - [x] Availability-based assignment service (longest-idle agent ↔ highest-priority item); agent/queue/dialer permissions; admin menu + CRUD UI; unit tests - [~] Phase 3 — Routing MVP (availability + priority + required-skills filtering + longest-idle assignment + auditable routing decision events shipped; sticky-agent and business-hours strategies pending) -- [~] Phase 4 — Voice integration with Telephony (`Voice` feature: inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService`), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; transfer/conference taxonomy and a standalone call-session aggregate pending) -- [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, dialer-agnostic `IDialerProvider`/resolver, power/progressive pacing, dialer batch sources, DialPad.Dialer provider; retry/callback/suppression pending) +- [~] Phase 4 — Voice integration with Telephony (`Voice` feature: Voice Contact Center Call Router (`IVoiceContactCenterCallRouter`) for inbound and outbound voice routing, inbound voice ingress + normalization boundary (`InboundVoiceEvent`/`IInboundVoiceService` compatibility), inbound activity+subject+interaction creation, queue→endpoint routing, agent offer routing, outbound provider dispatch through `IContactCenterVoiceProvider`, and the Telephony soft-phone incoming-call modal with `IIncomingCallContextProvider`/`IIncomingCallDispatcher` extensibility + voicemail capability; transfer/conference taxonomy and a standalone call-session aggregate pending) +- [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider; retry/callback/suppression pending) - [~] Phase 6 — Wrap-up and disposition lifecycle (source-neutral `IActivityDispositionService` implemented and CRM activity completion routed through it so inbound and outbound disposition share the subject workflow; wrap-up timers and required-disposition policies pending) - [ ] Phase 7 — Agent desktop and supervisor real-time UX - [ ] Phase 8 — Inbound entry points, IVR and self-service @@ -1406,6 +1406,7 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 0 started: promoted the session plan to this durable repo-tracked document, added the copilot-instructions pointer, and created the public Contact Center docs landing page. - Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). - Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. -- Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, with power/progressive pacing and dialer batch sources (Phase 5 core). Added the `DialPad.Dialer` feature implementing `IDialerProvider` over the DialPad telephony provider. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). +- Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing initially used `IDialerProvider`/`IDialerProviderResolver`, power/progressive pacing, dialer batch sources, and a `DialPad.Dialer` provider; this was later corrected so outbound voice calls route through `IVoiceContactCenterCallRouter` and `IContactCenterVoiceProvider`. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). - 2026-06-29: Phase 3 routing advanced with an extensible `IActivityRoutingStrategy` pipeline, required-skills eligibility, longest-idle scoring, and `RoutingDecisionMade` audit events that capture candidate scores and reasons. Contact Center voice-provider resolution was added through `IContactCenterVoiceProviderResolver`; outbound dialer failure paths now cancel reservations and enforce max-attempt boundaries; inbound offer failures release reservations immediately; agent sign-in clears stale reservations and serializes profile creation. Queue, dialer, and agent workspace admin UI now uses Orchard `ocat-*` layout and exposes routing skills, inbound endpoint mapping, retry/do-not-call settings, and agent skill sign-in. Added routing/dialer/reservation tests and updated docs/changelog. Next: sticky-agent and business-hours routing, then wrap-up timers and required-disposition policies before Phase 7 real-time desktop work. -- Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent`/`IInboundVoiceService`/`InboundVoiceService` (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). +- Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). +- 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent Workspace campaign and skill sign-in fields now use searchable multi-select lists backed by the CrestApps bootstrap-select resource; campaigns come from the Interaction Center campaign catalog. Queue and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs index 1d29455a8..603ead234 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DialerProfile.cs @@ -35,7 +35,7 @@ public sealed class DialerProfile : CatalogItem, INameAwareModel, IModifiedUtcAw public DialerMode Mode { get; set; } = DialerMode.Preview; /// - /// Gets or sets the technical name of the dialer provider that places calls, or null for the default. + /// 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; } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs index d2d18d154..7c5d4d78c 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/DialerService.cs @@ -9,7 +9,8 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Provides the default implementation of . The Contact Center owns the -/// agent reservation, attempt limits, and compliance decisions; calling is delegated to a provider. +/// agent reservation, attempt limits, and compliance decisions; voice calling is routed through the +/// Voice Contact Center Call Router. /// public sealed class DialerService : IDialerService { @@ -17,7 +18,7 @@ public sealed class DialerService : IDialerService private readonly IActivityReservationService _reservationService; private readonly IInteractionManager _interactionManager; private readonly IOmnichannelActivityManager _activityManager; - private readonly IDialerProviderResolver _providerResolver; + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; private readonly IContactCenterEventPublisher _publisher; private readonly IClock _clock; private readonly ILogger _logger; @@ -29,7 +30,7 @@ public sealed class DialerService : IDialerService /// The reservation service used to release failed attempts. /// The interaction manager used to record attempts. /// The CRM activity manager. - /// The dialer provider resolver. + /// The voice call router. /// The Contact Center event publisher. /// The clock used to stamp attempts. /// The logger instance. @@ -38,7 +39,7 @@ public DialerService( IActivityReservationService reservationService, IInteractionManager interactionManager, IOmnichannelActivityManager activityManager, - IDialerProviderResolver providerResolver, + IVoiceContactCenterCallRouter voiceCallRouter, IContactCenterEventPublisher publisher, IClock clock, ILogger logger) @@ -47,7 +48,7 @@ public DialerService( _reservationService = reservationService; _interactionManager = interactionManager; _activityManager = activityManager; - _providerResolver = providerResolver; + _voiceCallRouter = voiceCallRouter; _publisher = publisher; _clock = clock; _logger = logger; @@ -68,21 +69,9 @@ public async Task RunCycleAsync(DialerProfile profile, CancellationToken ca return 0; } - var provider = _providerResolver.Get(profile.ProviderName); - - if (provider is null) + if (!_voiceCallRouter.CanRouteOutbound(profile.ProviderName)) { - _logger.LogWarning("No dialer provider is registered for dialer profile '{Profile}'.", profile.Name); - - return 0; - } - - if (!provider.Capabilities.HasFlag(DialerProviderCapabilities.Outbound)) - { - _logger.LogWarning( - "Dialer provider '{Provider}' does not support outbound dialing for dialer profile '{Profile}'.", - provider.TechnicalName, - profile.Name); + _logger.LogWarning("No Contact Center voice provider can route outbound calls for dialer profile '{Profile}'.", profile.Name); return 0; } @@ -99,7 +88,7 @@ public async Task RunCycleAsync(DialerProfile profile, CancellationToken ca { attempted++; - if (await TryDialAsync(profile, reservation, provider, cancellationToken)) + if (await TryDialAsync(profile, reservation, cancellationToken)) { started++; } @@ -113,7 +102,7 @@ public async Task RunCycleAsync(DialerProfile profile, CancellationToken ca return started; } - private async Task TryDialAsync(DialerProfile profile, ActivityReservation reservation, IDialerProvider provider, CancellationToken cancellationToken) + private async Task TryDialAsync(DialerProfile profile, ActivityReservation reservation, CancellationToken cancellationToken) { var activity = await _activityManager.FindByIdAsync(reservation.ActivityItemId, cancellationToken); @@ -140,15 +129,15 @@ private async Task TryDialAsync(DialerProfile profile, ActivityReservation interaction.ActivityItemId = activity.ItemId; interaction.QueueId = profile.QueueId; interaction.AgentId = reservation.AgentId; - interaction.ProviderName = provider.TechnicalName; + interaction.ProviderName = _voiceCallRouter.GetOutboundProviderName(profile.ProviderName); interaction.CustomerAddress = activity.PreferredDestination; await _interactionManager.CreateAsync(interaction, cancellationToken: cancellationToken); - DialerDialResult result; + ContactCenterVoiceProviderResult result; try { - result = await provider.PlaceCallAsync(new DialerDialRequest + result = await _voiceCallRouter.RouteOutboundAsync(new ContactCenterDialRequest { ActivityId = activity.ItemId, InteractionId = interaction.ItemId, @@ -157,23 +146,32 @@ private async Task TryDialAsync(DialerProfile profile, ActivityReservation CampaignId = profile.CampaignId, Destination = activity.PreferredDestination, CallerId = profile.CallerId, - }, cancellationToken); + }, profile.ProviderName, cancellationToken); } catch (Exception ex) { _logger.LogError( ex, - "Dialer provider '{Provider}' failed while dialing activity '{ActivityItemId}' for profile '{Profile}'.", - provider.TechnicalName, + "The Voice Contact Center Call Router failed while dialing activity '{ActivityItemId}' for profile '{Profile}'.", activity.ItemId, profile.Name); - result = DialerDialResult.Failure("provider_exception", ex.Message); + result = new ContactCenterVoiceProviderResult + { + Succeeded = false, + ErrorCode = "provider_exception", + ErrorMessage = ex.Message, + }; } if (result.Succeeded && string.IsNullOrEmpty(result.ProviderCallId)) { - result = DialerDialResult.Failure("missing_provider_call_id", "The dialer provider did not return a call identifier."); + result = new ContactCenterVoiceProviderResult + { + Succeeded = false, + ErrorCode = "missing_provider_call_id", + ErrorMessage = "The Contact Center voice provider did not return a call identifier.", + }; } activity.Attempts++; diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs index 95552e768..89d08879b 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IDialerService.cs @@ -4,7 +4,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Core.Services; /// /// Orchestrates outbound dialing: reserves agents through routing, creates communication-history -/// interactions, and asks a dialer-agnostic provider to place each call. +/// interactions, and asks the Voice Contact Center Call Router to place each call. /// public interface IDialerService { 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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index d328c0a9c..5aa61af09 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -173,22 +173,22 @@ At a high level, the platform changes are: - 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 dialer-agnostic dialing +#### 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 an Agent Workspace. - 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 activity batches. The dialer is dialer-agnostic via `IDialerProvider`/`IDialerProviderResolver`, so the Contact Center owns assignment, queue, pacing, and compliance while any platform places calls. -- Adds the `CrestApps.OrchardCore.DialPad.Dialer` feature, a DialPad implementation of the dialer-agnostic provider over the existing DialPad telephony provider. +- Adds the `CrestApps.OrchardCore.ContactCenter.Dialer` feature: dialer profiles, dialing modes, pacing, and dialer activity batches. 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.Dialer` 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 queue, dialer, and agent workspace admin screens now use the standard Orchard admin field layout, expose routing skills and inbound endpoint mapping, and preserve agent campaign/skill sign-in state. +- Contact Center admin entries now live under **Interaction Center**. Queue and dialer profile CRUD screens follow the Omnichannel Campaigns display-driver UI pattern, and the Agent Workspace uses searchable multi-select lists for Omnichannel campaigns and routing skills instead of raw identifier fields. - 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: inbound provider calls are normalized into a CRM activity (`Kind = Call`, `Source = Inbound`) with its Subject, recorded as a voice `Interaction`, enqueued, routed to the longest-idle available agent signed in to the inbound queue, and offered through the Telephony soft-phone 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, routed to the longest-idle available agent signed in to the inbound queue, and offered through the Telephony soft-phone incoming-call modal; outbound dialer calls also route through this voice boundary. - 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 `IInboundVoiceService` directly. +- 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. - 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). diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 9020a6522..45df6cf20 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -2,12 +2,13 @@ sidebar_label: Agents, Queues & Dialer sidebar_position: 1 title: Agents, Queues, Routing, and Dialer -description: Contact Center agent presence, queues, skill-aware routing, reservations, availability-based assignment, and dialer-agnostic outbound dialing. +description: Contact Center agent presence, queues, skill-aware routing, reservations, availability-based assignment, and voice-routed outbound dialing. --- This phase adds the operational core of the Contact Center: agent presence, work queues, -reservations, skill-aware routing, availability-based assignment, and a dialer-agnostic outbound -dialer. Each capability is a separate, feature-gated module so tenants enable only what they need. +reservations, skill-aware routing, availability-based assignment, and an outbound dialer that routes +voice calls through Contact Center Voice providers. Each capability is a separate, feature-gated +module so tenants enable only what they need. ## Features @@ -15,8 +16,8 @@ dialer. Each capability is a separate, feature-gated module so tenants enable on | --- | --- | --- | | Contact Center Agents | `CrestApps.OrchardCore.ContactCenter.Agents` | Agent profiles, presence, capacity, skills, and queue/campaign sign-in. | | Contact Center Queues | `CrestApps.OrchardCore.ContactCenter.Queues` | Work queues, queue items, reservations, and availability-based assignment. | -| Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Dialer-agnostic outbound profiles, pacing, and dialer activity batches. | -| DialPad Dialer | `CrestApps.OrchardCore.DialPad.Dialer` | DialPad implementation of the dialer-agnostic provider. | +| Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Outbound profiles, pacing, and dialer activity batches routed through Contact Center Voice. | +| DialPad Contact Center Voice | `CrestApps.OrchardCore.DialPad.Dialer` | DialPad implementation of the Contact Center voice provider boundary. | ## Agents and presence @@ -24,9 +25,11 @@ An **agent profile** links an Orchard user to Contact Center configuration: disp skills, queue membership, campaign membership, and live presence. Presence states are `Offline`, `Available`, `Reserved`, `Busy`, `WrapUp`, and `Break`. -Agents sign in from **Contact Center → Agent Workspace**, selecting the queues and campaigns they -want to receive work from. Signing in sets presence to `Available`; signing out sets it to `Offline`. -The `SignIntoQueues` permission grants self-service sign-in and presence changes. +Agents sign in from **Interaction Center → Agent Workspace**, selecting the queues and campaigns they +want to receive work from. Campaigns come from Omnichannel Management and are shown in a searchable +multi-select list; skills are also selected from a searchable multi-select list. Signing in sets +presence to `Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission grants +self-service sign-in and presence changes. ## Queues, reservations, and assignment @@ -48,26 +51,33 @@ background task expires stale reservations and assigns waiting work every minute ## Dialer -A **dialer profile** ties a campaign and queue to a dialing mode (`Manual`, `Preview`, `Power`, -`Progressive`, `Predictive`), a provider, calls-per-agent pacing, and attempt limits. Power and -progressive profiles run automatically each minute: the Contact Center reserves an available agent, -creates an outbound interaction, and asks the configured provider to place the call. Manual and -preview profiles wait for agent action. Dialer activity batches load **unassigned** inventory the -dialer reserves later. +A **dialer profile** ties an Interaction Center campaign and queue to a dialing mode (`Manual`, +`Preview`, `Power`, `Progressive`, `Predictive`), a Contact Center voice provider, calls-per-agent +pacing, and attempt limits. Power and progressive profiles run automatically each minute: the Contact +Center reserves an available agent, creates an outbound interaction, and asks the Voice Contact Center +Call Router to place the call. Manual and preview profiles wait for agent action. Dialer activity +batches load **unassigned** inventory the dialer reserves later. -## Dialer-agnostic providers +## Voice Contact Center Call Router -The dialer never talks to a telephony platform directly. It calls `IDialerProvider` and resolves the -configured provider through `IDialerProviderResolver`, so any platform can be the calling engine -while the Contact Center keeps all assignment, queue, pacing, and compliance logic. The -`DialPad.Dialer` feature implements `IDialerProvider` over the DialPad telephony provider; enable it -to dial through DialPad. +The dialer never talks to a telephony platform directly. It calls `IVoiceContactCenterCallRouter`, +which resolves the configured `IContactCenterVoiceProvider`, so the Contact Center keeps assignment, +queue, pacing, and compliance logic while the provider executes call operations. The +`DialPad.Dialer` feature implements `IContactCenterVoiceProvider` over the DialPad telephony +provider; enable it to dial through DialPad. Voice providers that support contact-center orchestration beyond soft-phone call control can also register `IContactCenterVoiceProvider`. The `IContactCenterVoiceProviderResolver` resolves those providers by technical name so future PBX integrations can participate in provider-side queueing, call assignment, and voice-specific orchestration without coupling Contact Center to one provider. +## Admin UX and extensibility + +Contact Center admin entries live under **Interaction Center**. Queue and dialer profile CRUD screens +match the Omnichannel Campaigns UI: searchable list pages render summary shapes, and create/edit +screens render display-driver editor shapes. This keeps the UI extensible for provider panels, +compliance fields, routing strategies, and future supervisor controls. + ## Enable via recipe ```json diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index e49650f42..a50988c22 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -96,8 +96,8 @@ projected into the interaction. ## Agent workspace -Agents receive Contact Center work in the Contact Center Agent Workspace inside the CRM admin -experience. The workspace is the place where agents sign in to allowed queues and outbound +Agents receive Contact Center work in the **Interaction Center → Agent Workspace** screen inside the +CRM admin experience. The workspace is the place where agents sign in to allowed queues and outbound campaigns, set presence and reason codes, receive activity offers, accept or reject reservations, work the active CRM activity, use injected Telephony call controls, review interaction history, and complete wrap-up/disposition. @@ -107,9 +107,10 @@ compliance rules. Inbound queues, callback queues, preview dial queues, power/pr campaigns, and future channels all offer Activities through the same real-time workspace model. The current workspace lets agents choose queues, campaigns, skills, and presence using the standard -Orchard admin layout. Queue and dialer admin screens expose routing skills, inbound endpoint mapping, -provider names, pacing limits, retry settings, and do-not-call preference flags so providers and -future desktop panels can extend the model without replacing the base UI. +Orchard admin layout. Campaign and skill selection use searchable multi-select lists instead of raw +identifier fields; campaigns come from the Omnichannel Management **Interaction Center** campaign +catalog. 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 @@ -122,18 +123,21 @@ orchestration beyond basic call control: - 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: it selects the Activity, queue, agent, campaign, dialer mode, and -compliance gates, then sends provider-neutral intents to the provider adapter. +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`, mirroring the dialer provider resolver pattern. +`IContactCenterVoiceProviderResolver`. The router uses those providers for outbound dialing and +keeps provider-side queue placement and call assignment behind the same voice boundary. ## Inbound voice > **Feature ID** `CrestApps.OrchardCore.ContactCenter.Voice` -The **Contact Center Voice** feature routes inbound provider calls to an available agent and offers -them through the [Telephony](../telephony/index.md) soft-phone incoming-call modal. It depends on the +The **Contact Center Voice** feature adds the Voice Contact Center Call Router for inbound and +outbound voice work. For inbound calls, it routes provider calls to an available agent and offers them +through the [Telephony](../telephony/index.md) soft-phone incoming-call modal. It depends on the Queues feature and the Telephony soft phone. When a normalized inbound call arrives, the feature: @@ -174,8 +178,8 @@ 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 `IInboundVoiceService` directly, the same way the -Omnichannel SMS webhook handles inbound messages. +their own provider signature can instead call `IVoiceContactCenterCallRouter` directly, the same way +the Omnichannel SMS webhook handles inbound messages. ### Shared disposition for inbound and outbound @@ -187,11 +191,11 @@ outbound calls are wrapped up through the same subject workflow. ## UI extensibility All Contact Center UI is built with Orchard Core display management: shapes, display drivers, -placement, templates, and shape alternates. CRUD screens should follow the AI Profile UI pattern, -where 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. +placement, templates, and shape alternates. The 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 diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs index c7c5e3ebe..f03d4b028 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs @@ -2,6 +2,7 @@ using CrestApps.OrchardCore.ContactCenter.Core; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; using CrestApps.OrchardCore.ContactCenter.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -14,12 +15,13 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; /// Provides the agent workspace where agents sign in to queues and campaigns and change presence. /// [Admin] -[Feature(ContactCenterConstants.Feature.Agents)] +[Feature(ContactCenterConstants.Feature.Queues)] public sealed class AgentWorkspaceController : Controller { private readonly IAgentPresenceManager _presenceManager; private readonly IAgentProfileManager _agentManager; private readonly IActivityQueueManager _queueManager; + private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; private readonly IAuthorizationService _authorizationService; /// @@ -28,16 +30,19 @@ public sealed class AgentWorkspaceController : Controller /// The agent presence manager. /// The agent profile manager. /// The queue manager. + /// The admin form options provider. /// The authorization service. public AgentWorkspaceController( IAgentPresenceManager presenceManager, IAgentProfileManager agentManager, IActivityQueueManager queueManager, + ContactCenterAdminFormOptionsProvider optionsProvider, IAuthorizationService authorizationService) { _presenceManager = presenceManager; _agentManager = agentManager; _queueManager = queueManager; + _optionsProvider = optionsProvider; _authorizationService = authorizationService; } @@ -55,14 +60,18 @@ public async Task Index() var queues = await _queueManager.ListEnabledAsync(); var profile = await _agentManager.FindByUserIdAsync(GetUserId()); + var selectedCampaignIds = profile?.CampaignIds ?? []; + var selectedSkills = profile?.Skills ?? []; return View(new AgentWorkspaceViewModel { Profile = profile, AvailableQueues = [.. queues], SelectedQueueIds = profile?.QueueIds ?? [], - CampaignIds = ContactCenterFormHelpers.FormatList(profile?.CampaignIds), - Skills = ContactCenterFormHelpers.FormatList(profile?.Skills), + CampaignOptions = await _optionsProvider.GetCampaignOptionsAsync(selectedCampaignIds), + SelectedCampaignIds = selectedCampaignIds, + SkillOptions = await _optionsProvider.GetSkillOptionsAsync(selectedSkills), + SelectedSkills = selectedSkills, PresenceReason = profile?.PresenceReason, }); } @@ -71,24 +80,26 @@ public async Task Index() /// Signs the agent in to the selected queues and campaigns. /// /// The queues to sign in to. - /// The comma-separated campaigns to sign in to. - /// The comma-separated skills to keep on the agent profile. + /// The campaigns to sign in to. + /// The skills to keep on the agent profile. /// A redirect to the workspace. [HttpPost] [ValidateAntiForgeryToken] - public async Task SignIn(IEnumerable selectedQueueIds, string campaignIds, string skills) + public async Task SignIn( + IEnumerable selectedQueueIds, + IEnumerable selectedCampaignIds, + IEnumerable selectedSkills) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) { return Forbid(); } - var campaigns = ContactCenterFormHelpers.ParseList(campaignIds); + var campaigns = ContactCenterFormHelpers.NormalizeList(selectedCampaignIds); var profile = await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); - profile.Skills = ContactCenterFormHelpers.ParseList(skills); + profile.Skills = ContactCenterFormHelpers.NormalizeList(selectedSkills); await _agentManager.UpdateAsync(profile); - return RedirectToAction(nameof(Index)); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs index 843f2973e..10f6b5860 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/DialerProfilesController.cs @@ -1,10 +1,22 @@ +using CrestApps.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; -using CrestApps.OrchardCore.ContactCenter.ViewModels; +using CrestApps.OrchardCore.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using QueryContext = CrestApps.Core.Models.QueryContext; namespace CrestApps.OrchardCore.ContactCenter.Controllers; @@ -15,37 +27,117 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; [Feature(ContactCenterConstants.Feature.Dialer)] public sealed class DialerProfilesController : Controller { + private const string _optionsSearch = "Options.Search"; + private readonly IDialerProfileManager _manager; private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; /// /// Initializes a new instance of the class. /// /// The dialer profile manager. /// The authorization service. + /// The update model accessor. + /// The display manager. + /// The notifier. + /// The HTML localizer. + /// The string localizer. public DialerProfilesController( IDialerProfileManager manager, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) { _manager = manager; _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; } /// /// Lists the dialer profiles. /// + /// The catalog entry options. + /// The pager parameters. + /// The pager options. + /// The shape factory. /// The list view. [Admin("contact-center/dialers", "ContactCenterDialersIndex")] - public async Task Index() + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) { return Forbid(); } - var profiles = await _manager.GetAllAsync(); + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Name = options.Search, + }); + + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListCatalogEntryViewModel> + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayManager.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } - return View(profiles); + return View(viewModel); + } + + /// + /// Applies the dialer profile list filter. + /// + /// The submitted list model. + /// A redirect to the filtered list. + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("contact-center/dialers", "ContactCenterDialersIndex")] + public async Task IndexFilterPost(ListCatalogEntryViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) + { + return Forbid(); + } + + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); } /// @@ -60,33 +152,46 @@ public async Task Create() return Forbid(); } - return View(new DialerProfileViewModel()); + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["Dialer Profile"], + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); } /// /// Persists a new dialer profile. /// - /// The submitted profile. /// A redirect to the list or the form when invalid. [HttpPost] [ActionName(nameof(Create))] - public async Task CreatePost(DialerProfileViewModel model) + [Admin("contact-center/dialers/create", "ContactCenterDialersCreate")] + public async Task CreatePost() { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) { return Forbid(); } - if (!ModelState.IsValid) + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel { - return View(model); - } + DisplayName = S["New Dialer Profile"], + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; - var profile = await _manager.NewAsync(); - Apply(profile, model); - await _manager.CreateAsync(profile); + if (ModelState.IsValid) + { + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new dialer profile has been created successfully."]); - return RedirectToAction(nameof(Index)); + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); } /// @@ -102,61 +207,59 @@ public async Task Edit(string id) return Forbid(); } - var profile = await _manager.FindByIdAsync(id); + var model = await _manager.FindByIdAsync(id); - if (profile is null) + if (model is null) { return NotFound(); } - return View(new DialerProfileViewModel - { - Id = profile.ItemId, - Name = profile.Name, - Description = profile.Description, - CampaignId = profile.CampaignId, - QueueId = profile.QueueId, - Mode = profile.Mode, - ProviderName = profile.ProviderName, - CallsPerAgent = profile.CallsPerAgent, - MaxAttempts = profile.MaxAttempts, - RetryDelayMinutes = profile.RetryDelayMinutes, - CallerId = profile.CallerId, - RespectDoNotCall = profile.RespectDoNotCall, - Enabled = profile.Enabled, - }); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); } /// /// Persists changes to a dialer profile. /// - /// The submitted profile. + /// The profile identifier. /// A redirect to the list or the form when invalid. [HttpPost] [ActionName(nameof(Edit))] - public async Task EditPost(DialerProfileViewModel model) + [Admin("contact-center/dialers/edit/{id}", "ContactCenterDialersEdit")] + public async Task EditPost(string id) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) { return Forbid(); } - var profile = await _manager.FindByIdAsync(model.Id); + var model = await _manager.FindByIdAsync(id); - if (profile is null) + if (model is null) { return NotFound(); } - if (!ModelState.IsValid) + var viewModel = new EditCatalogEntryViewModel { - return View(model); - } + DisplayName = model.Name, + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _manager.UpdateAsync(model); + await _notifier.SuccessAsync(H["The dialer profile has been updated successfully."]); - Apply(profile, model); - await _manager.UpdateAsync(profile); + return RedirectToAction(nameof(Index)); + } - return RedirectToAction(nameof(Index)); + return View(viewModel); } /// @@ -165,6 +268,7 @@ public async Task EditPost(DialerProfileViewModel model) /// The profile identifier. /// A redirect to the list. [HttpPost] + [Admin("contact-center/dialers/delete/{id}", "ContactCenterDialersDelete")] public async Task Delete(string id) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageDialer)) @@ -177,24 +281,9 @@ public async Task Delete(string id) if (profile is not null) { await _manager.DeleteAsync(profile); + await _notifier.SuccessAsync(H["The dialer profile has been deleted successfully."]); } return RedirectToAction(nameof(Index)); } - - private static void Apply(Core.Models.DialerProfile profile, DialerProfileViewModel model) - { - profile.Name = model.Name; - profile.Description = model.Description; - profile.CampaignId = model.CampaignId; - profile.QueueId = model.QueueId; - profile.Mode = model.Mode; - profile.ProviderName = model.ProviderName; - profile.CallsPerAgent = model.CallsPerAgent; - profile.MaxAttempts = model.MaxAttempts; - profile.RetryDelayMinutes = model.RetryDelayMinutes; - profile.CallerId = model.CallerId; - profile.RespectDoNotCall = model.RespectDoNotCall; - profile.Enabled = model.Enabled; - } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs index b5c750d7a..d3c2f92f6 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/QueuesController.cs @@ -1,10 +1,22 @@ +using CrestApps.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; -using CrestApps.OrchardCore.ContactCenter.ViewModels; +using CrestApps.OrchardCore.Core.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Navigation; using OrchardCore.Modules; +using OrchardCore.Routing; +using QueryContext = CrestApps.Core.Models.QueryContext; namespace CrestApps.OrchardCore.ContactCenter.Controllers; @@ -15,37 +27,117 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; [Feature(ContactCenterConstants.Feature.Queues)] public sealed class QueuesController : Controller { + private const string _optionsSearch = "Options.Search"; + private readonly IActivityQueueManager _manager; private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; /// /// Initializes a new instance of the class. /// /// The queue manager. /// The authorization service. + /// The update model accessor. + /// The display manager. + /// The notifier. + /// The HTML localizer. + /// The string localizer. public QueuesController( IActivityQueueManager manager, - IAuthorizationService authorizationService) + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) { _manager = manager; _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; } /// /// Lists the queues. /// + /// The catalog entry options. + /// The pager parameters. + /// The pager options. + /// The shape factory. /// The queues list view. [Admin("contact-center/queues", "ContactCenterQueuesIndex")] - public async Task Index() + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) { return Forbid(); } - var queues = await _manager.GetAllAsync(); + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Name = options.Search, + }); + + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListCatalogEntryViewModel> + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayManager.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } - return View(queues); + return View(viewModel); + } + + /// + /// Applies the queues list filter. + /// + /// The submitted list model. + /// A redirect to the filtered list. + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("contact-center/queues", "ContactCenterQueuesIndex")] + public async Task IndexFilterPost(ListCatalogEntryViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); } /// @@ -60,33 +152,46 @@ public async Task Create() return Forbid(); } - return View(new QueueViewModel()); + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["Queue"], + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); } /// /// Persists a new queue. /// - /// The submitted queue. /// A redirect to the list or the form when invalid. [HttpPost] [ActionName(nameof(Create))] - public async Task CreatePost(QueueViewModel model) + [Admin("contact-center/queues/create", "ContactCenterQueuesCreate")] + public async Task CreatePost() { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) { return Forbid(); } - if (!ModelState.IsValid) + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel { - return View(model); - } + DisplayName = S["New Queue"], + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; - var queue = await _manager.NewAsync(); - Apply(queue, model); - await _manager.CreateAsync(queue); + if (ModelState.IsValid) + { + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new queue has been created successfully."]); - return RedirectToAction(nameof(Index)); + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); } /// @@ -102,57 +207,59 @@ public async Task Edit(string id) return Forbid(); } - var queue = await _manager.FindByIdAsync(id); + var model = await _manager.FindByIdAsync(id); - if (queue is null) + if (model is null) { return NotFound(); } - return View(new QueueViewModel - { - Id = queue.ItemId, - Name = queue.Name, - Description = queue.Description, - DefaultPriority = queue.DefaultPriority, - SlaThresholdSeconds = queue.SlaThresholdSeconds, - ReservationTimeoutSeconds = queue.ReservationTimeoutSeconds, - RequiredSkills = ContactCenterFormHelpers.FormatList(queue.RequiredSkills), - InboundChannelEndpointId = queue.InboundChannelEndpointId, - Enabled = queue.Enabled, - }); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); } /// /// Persists changes to a queue. /// - /// The submitted queue. + /// The queue identifier. /// A redirect to the list or the form when invalid. [HttpPost] [ActionName(nameof(Edit))] - public async Task EditPost(QueueViewModel model) + [Admin("contact-center/queues/edit/{id}", "ContactCenterQueuesEdit")] + public async Task EditPost(string id) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) { return Forbid(); } - var queue = await _manager.FindByIdAsync(model.Id); + var model = await _manager.FindByIdAsync(id); - if (queue is null) + if (model is null) { return NotFound(); } - if (!ModelState.IsValid) + var viewModel = new EditCatalogEntryViewModel { - return View(model); - } + DisplayName = model.Name, + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _manager.UpdateAsync(model); + await _notifier.SuccessAsync(H["The queue has been updated successfully."]); - Apply(queue, model); - await _manager.UpdateAsync(queue); + return RedirectToAction(nameof(Index)); + } - return RedirectToAction(nameof(Index)); + return View(viewModel); } /// @@ -161,6 +268,7 @@ public async Task EditPost(QueueViewModel model) /// The queue identifier. /// A redirect to the list. [HttpPost] + [Admin("contact-center/queues/delete/{id}", "ContactCenterQueuesDelete")] public async Task Delete(string id) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) @@ -173,20 +281,9 @@ public async Task Delete(string id) if (queue is not null) { await _manager.DeleteAsync(queue); + await _notifier.SuccessAsync(H["The queue has been deleted successfully."]); } return RedirectToAction(nameof(Index)); } - - private static void Apply(Core.Models.ActivityQueue queue, QueueViewModel model) - { - queue.Name = model.Name; - queue.Description = model.Description; - queue.DefaultPriority = model.DefaultPriority; - queue.SlaThresholdSeconds = model.SlaThresholdSeconds; - queue.ReservationTimeoutSeconds = model.ReservationTimeoutSeconds; - queue.RequiredSkills = ContactCenterFormHelpers.ParseList(model.RequiredSkills); - queue.InboundChannelEndpointId = model.InboundChannelEndpointId; - queue.Enabled = model.Enabled; - } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs index 61d924eda..5992445d7 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/VoiceIngressController.cs @@ -12,7 +12,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; /// Provider-agnostic ingress for inbound voice events. A telephony provider or PBX integration posts /// a normalized to this endpoint, which routes the call through the /// Contact Center. Provider-specific webhooks that validate provider signatures may alternatively call -/// directly. +/// directly. /// [ApiController] [Authorize] @@ -20,19 +20,19 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; [Feature(ContactCenterConstants.Feature.Voice)] public sealed class VoiceIngressController : ControllerBase { - private readonly IInboundVoiceService _inboundVoiceService; + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; private readonly IAuthorizationService _authorizationService; /// /// Initializes a new instance of the class. /// - /// The inbound voice service that routes the call. + /// The voice call router. /// The authorization service. public VoiceIngressController( - IInboundVoiceService inboundVoiceService, + IVoiceContactCenterCallRouter voiceCallRouter, IAuthorizationService authorizationService) { - _inboundVoiceService = inboundVoiceService; + _voiceCallRouter = voiceCallRouter; _authorizationService = authorizationService; } @@ -54,7 +54,7 @@ public async Task> Inbound([FromBody] In return BadRequest(); } - var result = await _inboundVoiceService.HandleInboundAsync(inboundEvent, HttpContext.RequestAborted); + var result = await _voiceCallRouter.RouteInboundAsync(inboundEvent, HttpContext.RequestAborted); return Ok(result); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs new file mode 100644 index 000000000..d9da50dce --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs @@ -0,0 +1,103 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.Extensions.Localization; +using OrchardCore; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class ActivityQueueDisplayDriver : DisplayDriver +{ + private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The admin form options provider. + /// The string localizer. + public ActivityQueueDisplayDriver( + ContactCenterAdminFormOptionsProvider optionsProvider, + IStringLocalizer stringLocalizer) + { + _optionsProvider = optionsProvider; + S = stringLocalizer; + } + + /// + public override Task DisplayAsync(ActivityQueue queue, BuildDisplayContext context) + { + return CombineAsync( + View("ActivityQueue_Fields_SummaryAdmin", queue) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Content:1"), + View("ActivityQueue_Buttons_SummaryAdmin", queue) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Actions:5"), + View("ActivityQueue_DefaultMeta_SummaryAdmin", queue) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Meta:5") + ); + } + + /// + public override async Task EditAsync(ActivityQueue queue, BuildEditorContext context) + { + var viewModel = new QueueViewModel + { + Id = queue.ItemId, + Name = queue.Name, + Description = queue.Description, + DefaultPriority = queue.DefaultPriority, + SlaThresholdSeconds = queue.SlaThresholdSeconds, + ReservationTimeoutSeconds = queue.ReservationTimeoutSeconds, + RequiredSkills = queue.RequiredSkills, + InboundChannelEndpointId = queue.InboundChannelEndpointId, + Enabled = queue.Enabled, + }; + + await _optionsProvider.PopulateQueueEditorAsync(viewModel); + + return Initialize("ActivityQueueFields_Edit", model => + { + model.Id = viewModel.Id; + model.Name = viewModel.Name; + model.Description = viewModel.Description; + model.DefaultPriority = viewModel.DefaultPriority; + model.SlaThresholdSeconds = viewModel.SlaThresholdSeconds; + model.ReservationTimeoutSeconds = viewModel.ReservationTimeoutSeconds; + model.RequiredSkills = viewModel.RequiredSkills; + model.SkillOptions = viewModel.SkillOptions; + model.InboundChannelEndpointId = viewModel.InboundChannelEndpointId; + model.InboundChannelEndpointOptions = viewModel.InboundChannelEndpointOptions; + model.Enabled = viewModel.Enabled; + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(ActivityQueue queue, UpdateEditorContext context) + { + var model = new QueueViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is a required field."]); + } + + queue.Name = model.Name?.Trim(); + queue.Description = model.Description?.Trim(); + queue.DefaultPriority = model.DefaultPriority; + queue.SlaThresholdSeconds = model.SlaThresholdSeconds; + queue.ReservationTimeoutSeconds = model.ReservationTimeoutSeconds; + queue.RequiredSkills = ContactCenterFormHelpers.NormalizeList(model.RequiredSkills); + queue.InboundChannelEndpointId = string.IsNullOrWhiteSpace(model.InboundChannelEndpointId) + ? null + : model.InboundChannelEndpointId.Trim(); + queue.Enabled = model.Enabled; + + return await EditAsync(queue, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/DialerProfileDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/DialerProfileDisplayDriver.cs new file mode 100644 index 000000000..e1abf2c56 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/DialerProfileDisplayDriver.cs @@ -0,0 +1,120 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.Extensions.Localization; +using OrchardCore; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class DialerProfileDisplayDriver : DisplayDriver +{ + private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The admin form options provider. + /// The string localizer. + public DialerProfileDisplayDriver( + ContactCenterAdminFormOptionsProvider optionsProvider, + IStringLocalizer stringLocalizer) + { + _optionsProvider = optionsProvider; + S = stringLocalizer; + } + + /// + public override Task DisplayAsync(DialerProfile profile, BuildDisplayContext context) + { + return CombineAsync( + View("DialerProfile_Fields_SummaryAdmin", profile) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Content:1"), + View("DialerProfile_Buttons_SummaryAdmin", profile) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Actions:5"), + View("DialerProfile_DefaultMeta_SummaryAdmin", profile) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Meta:5") + ); + } + + /// + public override async Task EditAsync(DialerProfile profile, BuildEditorContext context) + { + var viewModel = new DialerProfileViewModel + { + Id = profile.ItemId, + Name = profile.Name, + Description = profile.Description, + CampaignId = profile.CampaignId, + QueueId = profile.QueueId, + Mode = profile.Mode, + ProviderName = profile.ProviderName, + CallsPerAgent = profile.CallsPerAgent, + MaxAttempts = profile.MaxAttempts, + RetryDelayMinutes = profile.RetryDelayMinutes, + CallerId = profile.CallerId, + RespectDoNotCall = profile.RespectDoNotCall, + Enabled = profile.Enabled, + }; + + await _optionsProvider.PopulateDialerProfileEditorAsync(viewModel); + + return Initialize("DialerProfileFields_Edit", model => + { + model.Id = viewModel.Id; + model.Name = viewModel.Name; + model.Description = viewModel.Description; + model.CampaignId = viewModel.CampaignId; + model.CampaignOptions = viewModel.CampaignOptions; + model.QueueId = viewModel.QueueId; + model.QueueOptions = viewModel.QueueOptions; + model.Mode = viewModel.Mode; + model.ProviderName = viewModel.ProviderName; + model.ProviderOptions = viewModel.ProviderOptions; + model.CallsPerAgent = viewModel.CallsPerAgent; + model.MaxAttempts = viewModel.MaxAttempts; + model.RetryDelayMinutes = viewModel.RetryDelayMinutes; + model.CallerId = viewModel.CallerId; + model.RespectDoNotCall = viewModel.RespectDoNotCall; + model.Enabled = viewModel.Enabled; + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(DialerProfile profile, UpdateEditorContext context) + { + var model = new DialerProfileViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is a required field."]); + } + + profile.Name = model.Name?.Trim(); + profile.Description = model.Description?.Trim(); + profile.CampaignId = string.IsNullOrWhiteSpace(model.CampaignId) + ? null + : model.CampaignId.Trim(); + profile.QueueId = string.IsNullOrWhiteSpace(model.QueueId) + ? null + : model.QueueId.Trim(); + profile.Mode = model.Mode; + profile.ProviderName = string.IsNullOrWhiteSpace(model.ProviderName) + ? null + : model.ProviderName.Trim(); + profile.CallsPerAgent = model.CallsPerAgent; + profile.MaxAttempts = model.MaxAttempts; + profile.RetryDelayMinutes = model.RetryDelayMinutes; + profile.CallerId = model.CallerId?.Trim(); + profile.RespectDoNotCall = model.RespectDoNotCall; + profile.Enabled = model.Enabled; + + return await EditAsync(profile, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ActivityQueueHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ActivityQueueHandler.cs new file mode 100644 index 000000000..bb8615c0a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ActivityQueueHandler.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +internal sealed class ActivityQueueHandler : CatalogEntryHandlerBase +{ + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The clock used to stamp audit times. + /// The string localizer. + public ActivityQueueHandler( + IClock clock, + IStringLocalizer stringLocalizer) + { + _clock = clock; + S = stringLocalizer; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(ActivityQueue.Name)])); + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/DialerProfileHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/DialerProfileHandler.cs new file mode 100644 index 000000000..16899391c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/DialerProfileHandler.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +internal sealed class DialerProfileHandler : CatalogEntryHandlerBase +{ + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The clock used to stamp audit times. + /// The string localizer. + public DialerProfileHandler( + IClock clock, + IStringLocalizer stringLocalizer) + { + _clock = clock; + S = stringLocalizer; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(DialerProfile.Name)])); + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs index 8d5afaf9e..cd1629b29 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -1,5 +1,6 @@ using CrestApps.OrchardCore; using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.Omnichannel.Core; using OrchardCore.Modules.Manifest; [assembly: Module( @@ -18,6 +19,7 @@ Category = "Communication", Dependencies = [ + OmnichannelConstants.Features.Managements, "OrchardCore.Users", ] )] @@ -41,25 +43,24 @@ Dependencies = [ ContactCenterConstants.Feature.Agents, - "CrestApps.OrchardCore.Omnichannel.Managements", ] )] [assembly: Feature( Id = ContactCenterConstants.Feature.Dialer, Name = "Contact Center Dialer", - Description = "Adds dialer-agnostic outbound dialing profiles, pacing, and dialer activity batches over any installed dialer provider.", + Description = "Adds outbound dialing profiles, pacing, and dialer activity batches that route calls through Contact Center Voice providers.", Category = "Communication", Dependencies = [ - ContactCenterConstants.Feature.Queues, + ContactCenterConstants.Feature.Voice, ] )] [assembly: Feature( Id = ContactCenterConstants.Feature.Voice, Name = "Contact Center Voice", - Description = "Routes inbound provider calls into queued CRM activities and offers them to available agents through the Telephony soft phone.", + Description = "Routes inbound and outbound voice calls through the Voice Contact Center Call Router while Telephony providers execute media operations.", Category = "Communication", Dependencies = [ diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs new file mode 100644 index 000000000..6656a9184 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs @@ -0,0 +1,182 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Provides select-list options used by Contact Center admin forms. +/// +public sealed class ContactCenterAdminFormOptionsProvider +{ + private readonly ICatalogManager _campaignManager; + private readonly IActivityQueueManager _queueManager; + private readonly IAgentProfileManager _agentProfileManager; + private readonly IOmnichannelChannelEndpointManager _channelEndpointManager; + private readonly IEnumerable _voiceProviders; + + /// + /// Initializes a new instance of the class. + /// + /// The omnichannel campaign manager. + /// The activity queue manager. + /// The agent profile manager. + /// The omnichannel channel endpoint manager. + /// The registered voice call providers. + public ContactCenterAdminFormOptionsProvider( + ICatalogManager campaignManager, + IActivityQueueManager queueManager, + IAgentProfileManager agentProfileManager, + IOmnichannelChannelEndpointManager channelEndpointManager, + IEnumerable voiceProviders) + { + _campaignManager = campaignManager; + _queueManager = queueManager; + _agentProfileManager = agentProfileManager; + _channelEndpointManager = channelEndpointManager; + _voiceProviders = voiceProviders; + } + + internal async Task> GetCampaignOptionsAsync(IEnumerable selectedCampaignIds) + { + var selected = CreateSelectedSet(selectedCampaignIds, StringComparer.Ordinal); + var campaigns = await _campaignManager.GetAllAsync(); + + var options = campaigns + .OrderBy(campaign => campaign.DisplayText, StringComparer.CurrentCultureIgnoreCase) + .Select(campaign => new SelectListItem(GetCampaignText(campaign), campaign.ItemId, selected.Contains(campaign.ItemId))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.Ordinal); + + return options; + } + + internal async Task> GetQueueOptionsAsync(string selectedQueueId) + { + var selected = CreateSelectedSet([selectedQueueId], StringComparer.Ordinal); + var queues = await _queueManager.GetAllAsync(); + + var options = queues + .OrderBy(queue => queue.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(queue => new SelectListItem(queue.Name ?? queue.ItemId, queue.ItemId, selected.Contains(queue.ItemId))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.Ordinal); + + return options; + } + + internal async Task> GetSkillOptionsAsync(IEnumerable selectedSkills) + { + var selected = CreateSelectedSet(selectedSkills, StringComparer.OrdinalIgnoreCase); + var queues = await _queueManager.GetAllAsync(); + var agents = await _agentProfileManager.GetAllAsync(); + + var skills = queues + .SelectMany(queue => queue.RequiredSkills) + .Concat(agents.SelectMany(agent => agent.Skills)) + .Concat(selected) + .Where(skill => !string.IsNullOrWhiteSpace(skill)) + .Select(skill => skill.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(skill => skill, StringComparer.CurrentCultureIgnoreCase); + + return skills + .Select(skill => new SelectListItem(skill, skill, selected.Contains(skill))) + .ToArray(); + } + + internal async Task> GetInboundChannelEndpointOptionsAsync(string selectedEndpointId) + { + var selected = CreateSelectedSet([selectedEndpointId], StringComparer.Ordinal); + var endpoints = await _channelEndpointManager.GetAllAsync(); + + var options = endpoints + .OrderBy(endpoint => endpoint.DisplayText, StringComparer.CurrentCultureIgnoreCase) + .Select(endpoint => new SelectListItem(GetEndpointText(endpoint), endpoint.ItemId, selected.Contains(endpoint.ItemId))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.Ordinal); + + return options; + } + + internal IList GetVoiceProviderOptions(string selectedProviderName) + { + var selected = CreateSelectedSet([selectedProviderName], StringComparer.OrdinalIgnoreCase); + var options = _voiceProviders + .OrderBy(provider => provider.Name.Value, StringComparer.CurrentCultureIgnoreCase) + .Select(provider => new SelectListItem(provider.Name.Value, provider.TechnicalName, selected.Contains(provider.TechnicalName))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.OrdinalIgnoreCase); + + return options; + } + + internal async Task PopulateQueueEditorAsync(QueueViewModel model) + { + model.RequiredSkills = ContactCenterFormHelpers.NormalizeList(model.RequiredSkills); + model.SkillOptions = await GetSkillOptionsAsync(model.RequiredSkills); + model.InboundChannelEndpointOptions = await GetInboundChannelEndpointOptionsAsync(model.InboundChannelEndpointId); + } + + internal async Task PopulateDialerProfileEditorAsync(DialerProfileViewModel model) + { + model.CampaignOptions = await GetCampaignOptionsAsync([model.CampaignId]); + model.QueueOptions = await GetQueueOptionsAsync(model.QueueId); + model.ProviderOptions = GetVoiceProviderOptions(model.ProviderName); + } + + private static HashSet CreateSelectedSet(IEnumerable values, StringComparer comparer) + { + return values is null + ? [] + : values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .ToHashSet(comparer); + } + + private static string GetCampaignText(OmnichannelCampaign campaign) + { + return string.IsNullOrWhiteSpace(campaign.DisplayText) + ? campaign.ItemId + : campaign.DisplayText; + } + + private static string GetEndpointText(OmnichannelChannelEndpoint endpoint) + { + var displayText = string.IsNullOrWhiteSpace(endpoint.DisplayText) + ? endpoint.ItemId + : endpoint.DisplayText; + + if (!string.IsNullOrWhiteSpace(endpoint.Channel) && !string.IsNullOrWhiteSpace(endpoint.Value)) + { + return $"{displayText} ({endpoint.Channel}: {endpoint.Value})"; + } + + return displayText; + } + + private static void AddMissingSelectedOptions(List options, IEnumerable selectedValues, StringComparer comparer) + { + var existingValues = options + .Select(option => option.Value) + .ToHashSet(comparer); + + foreach (var selectedValue in selectedValues) + { + if (!existingValues.Contains(selectedValue)) + { + options.Add(new SelectListItem(selectedValue, selectedValue, selected: true)); + } + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs index ae2cb4505..6420b7eef 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs @@ -5,7 +5,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Services; /// -/// Adds the Contact Center entries to the admin navigation. +/// Adds the Contact Center entries to the Interaction Center admin navigation. /// public sealed class ContactCenterAdminMenu : AdminNavigationProvider { @@ -24,21 +24,22 @@ public ContactCenterAdminMenu(IStringLocalizer stringLoc protected override ValueTask BuildAsync(NavigationBuilder builder) { builder - .Add(S["Contact Center"], "6", contactCenter => contactCenter - .AddClass("contact-center") - .Id("contactCenter") + .Add(S["Interaction Center"], "80", interactionCenter => interactionCenter + .AddClass("interaction-center") + .Id("interactionCenter") .Add(S["Agent Workspace"], S["Agent Workspace"].PrefixPosition(), workspace => workspace + .AddClass("agent-workspace") + .Id("agentWorkspace") .Action("Index", "AgentWorkspace", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.SignIntoQueues) .LocalNav()) .Add(S["Queues"], S["Queues"].PrefixPosition(), queues => queues + .AddClass("contact-center-queues") + .Id("contactCenterQueues") .Action("Index", "Queues", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.ManageQueues) - .LocalNav()) - .Add(S["Dialer Profiles"], S["Dialer Profiles"].PrefixPosition(), dialer => dialer - .Action("Index", "DialerProfiles", "CrestApps.OrchardCore.ContactCenter") - .Permission(ContactCenterPermissions.ManageDialer) - .LocalNav())); + .LocalNav()), + priority: 1); return ValueTask.CompletedTask; } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs new file mode 100644 index 000000000..75c208914 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Adds the Contact Center dialer entries to the Interaction Center admin navigation. +/// +public sealed class ContactCenterDialerAdminMenu : AdminNavigationProvider +{ + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ContactCenterDialerAdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder + .Add(S["Interaction Center"], "80", interactionCenter => interactionCenter + .AddClass("interaction-center") + .Id("interactionCenter") + .Add(S["Dialer Profiles"], S["Dialer Profiles"].PrefixPosition(), dialer => dialer + .AddClass("dialer-profiles") + .Id("dialerProfiles") + .Action("Index", "DialerProfiles", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageDialer) + .LocalNav()), + priority: 1); + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs similarity index 81% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs rename to src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs index 6cba5dd20..f4e1746e0 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/InboundVoiceService.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs @@ -13,11 +13,11 @@ namespace CrestApps.OrchardCore.ContactCenter.Services; /// -/// Default implementation. It creates the CRM activity and the -/// interaction for an inbound call, resolves the target queue and subject, enqueues the work, reserves -/// an available agent, and offers the ringing call to that agent through the Telephony soft phone. +/// Default implementation. It routes inbound voice calls +/// into CRM activities and outbound voice dial requests to provider implementations while Telephony +/// remains responsible for media execution. /// -public sealed class InboundVoiceService : IInboundVoiceService +public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter, IInboundVoiceService { private const string ServiceAddressMetadataKey = "serviceAddress"; @@ -33,10 +33,11 @@ public sealed class InboundVoiceService : IInboundVoiceService private readonly IAgentProfileManager _agentManager; private readonly IInboundContactLookup _contactLookup; private readonly IIncomingCallDispatcher _incomingCallDispatcher; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; private readonly IClock _clock; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The channel endpoint manager used to map the dialed number to an endpoint. /// The subject flow settings service used to resolve the subject and campaign. @@ -50,8 +51,9 @@ public sealed class InboundVoiceService : IInboundVoiceService /// The agent profile manager used to resolve the reserved agent. /// The contact lookup used to resolve the caller. /// The dispatcher used to offer the ringing call to the agent. + /// The voice provider resolver used for outbound voice calls. /// The clock used to stamp times. - public InboundVoiceService( + public VoiceContactCenterCallRouter( IOmnichannelChannelEndpointManager channelEndpointManager, ISubjectFlowSettingsService subjectFlowSettingsService, IOmnichannelActivityManager activityManager, @@ -64,6 +66,7 @@ public InboundVoiceService( IAgentProfileManager agentManager, IInboundContactLookup contactLookup, IIncomingCallDispatcher incomingCallDispatcher, + IContactCenterVoiceProviderResolver voiceProviderResolver, IClock clock) { _channelEndpointManager = channelEndpointManager; @@ -78,11 +81,57 @@ public InboundVoiceService( _agentManager = agentManager; _contactLookup = contactLookup; _incomingCallDispatcher = incomingCallDispatcher; + _voiceProviderResolver = voiceProviderResolver; _clock = clock; } /// - public async Task HandleInboundAsync(InboundVoiceEvent inboundEvent, CancellationToken cancellationToken = default) + public bool CanRouteOutbound(string providerName = null) + { + var provider = _voiceProviderResolver.Get(providerName); + + return provider?.Capabilities.HasFlag(ContactCenterVoiceProviderCapabilities.DialerDial) == true; + } + + /// + public string GetOutboundProviderName(string providerName = null) + { + return _voiceProviderResolver.Get(providerName)?.TechnicalName; + } + + /// + public Task HandleInboundAsync(InboundVoiceEvent inboundEvent, CancellationToken cancellationToken = default) + { + return RouteInboundAsync(inboundEvent, cancellationToken); + } + + /// + public async Task RouteOutboundAsync( + ContactCenterDialRequest request, + string providerName = null, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var provider = _voiceProviderResolver.Get(providerName); + + if (provider is null) + { + return Failure("provider_unavailable", "No Contact Center voice provider is registered for outbound voice routing."); + } + + if (!provider.Capabilities.HasFlag(ContactCenterVoiceProviderCapabilities.DialerDial)) + { + return Failure("dialing_not_supported", "The Contact Center voice provider does not support outbound dialing."); + } + + var result = await provider.DialAsync(request, cancellationToken); + + return result ?? Failure("provider_returned_no_result", "The Contact Center voice provider did not return a result."); + } + + /// + public async Task RouteInboundAsync(InboundVoiceEvent inboundEvent, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(inboundEvent); @@ -197,6 +246,16 @@ private static string ResolveServiceAddress(Core.Models.Interaction interaction) : null; } + private static ContactCenterVoiceProviderResult Failure(string errorCode, string errorMessage) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + }; + } + private async Task ResolveFlowAsync(OmnichannelChannelEndpoint endpoint, CancellationToken cancellationToken) { if (endpoint is null) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 0816aa8c3..3cd07221e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -2,6 +2,7 @@ using CrestApps.OrchardCore.ContactCenter.BackgroundTasks; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Drivers; using CrestApps.OrchardCore.ContactCenter.Handlers; using CrestApps.OrchardCore.ContactCenter.Indexes; using CrestApps.OrchardCore.ContactCenter.Migrations; @@ -14,6 +15,7 @@ using OrchardCore.BackgroundTasks; using OrchardCore.Data; using OrchardCore.Data.Migration; +using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Modules; using OrchardCore.Navigation; using OrchardCore.Security.Permissions; @@ -45,7 +47,6 @@ public override void ConfigureServices(IServiceCollection services) .AddDataMigration(); services.AddPermissionProvider(); - services.AddNavigationProvider(); } } @@ -88,9 +89,12 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); services + .AddDisplayDriver() + .AddScoped, ActivityQueueHandler>() .AddIndexProvider() .AddDataMigration() .AddIndexProvider() @@ -99,11 +103,12 @@ public override void ConfigureServices(IServiceCollection services) .AddDataMigration(); services.AddSingleton(); + services.AddNavigationProvider(); } } /// -/// Registers dialer-agnostic outbound dialing profiles, pacing, and dialer activity batch sources. +/// Registers outbound dialing profiles, pacing, and dialer activity batch sources. /// [Feature(ContactCenterConstants.Feature.Dialer)] public sealed class DialerStartup : StartupBase @@ -124,10 +129,13 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped(); services + .AddDisplayDriver() + .AddScoped, DialerProfileHandler>() .AddIndexProvider() .AddDataMigration(); services.AddSingleton(); + services.AddNavigationProvider(); services.Configure(options => { @@ -149,8 +157,8 @@ public override void ConfigureServices(IServiceCollection services) } /// -/// Registers inbound voice routing that turns provider calls into queued CRM activities and offers -/// them to available agents through the Telephony soft phone. +/// Registers the Voice Contact Center Call Router that routes inbound and outbound voice calls while +/// Telephony providers execute media operations. /// [Feature(ContactCenterConstants.Feature.Voice)] public sealed class VoiceStartup : StartupBase @@ -160,7 +168,9 @@ public override void ConfigureServices(IServiceCollection services) services .AddScoped() .AddScoped() - .AddScoped() + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped(sp => sp.GetRequiredService()) .AddScoped(); } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs index 28ef2ee62..9063020d1 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.AspNetCore.Mvc.Rendering; namespace CrestApps.OrchardCore.ContactCenter.ViewModels; @@ -24,14 +25,24 @@ public class AgentWorkspaceViewModel public IList SelectedQueueIds { get; set; } = []; /// - /// Gets or sets the campaign identifiers, comma separated, the agent is signed in to. + /// Gets or sets the campaigns the agent can sign in to for outbound dialer work. /// - public string CampaignIds { get; set; } + public IList CampaignOptions { get; set; } = []; /// - /// Gets or sets the skill identifiers, comma separated, used by routing strategies. + /// Gets or sets the selected campaign identifiers. /// - public string Skills { get; set; } + public IList SelectedCampaignIds { get; set; } = []; + + /// + /// Gets or sets the skills the agent can select for routing. + /// + public IList SkillOptions { get; set; } = []; + + /// + /// Gets or sets the selected skill names. + /// + public IList SelectedSkills { get; set; } = []; /// /// Gets or sets the optional presence reason submitted by the agent. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs index 7f84be834..39160cfd7 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterFormHelpers.cs @@ -2,6 +2,17 @@ namespace CrestApps.OrchardCore.ContactCenter.ViewModels; internal static class ContactCenterFormHelpers { + internal static IList NormalizeList(IEnumerable values) + { + return values is null + ? [] + : values + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + internal static IList ParseList(string value) { return string.IsNullOrWhiteSpace(value) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs index 82fe963bd..339c7f28a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/DialerProfileViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Mvc.Rendering; namespace CrestApps.OrchardCore.ContactCenter.ViewModels; @@ -29,21 +30,36 @@ public class DialerProfileViewModel /// public string CampaignId { get; set; } + /// + /// Gets or sets the available campaigns. + /// + public IList CampaignOptions { get; set; } = []; + /// /// Gets or sets the queue identifier. /// public string QueueId { get; set; } + /// + /// Gets or sets the available queues. + /// + public IList QueueOptions { get; set; } = []; + /// /// Gets or sets the dialing mode. /// public DialerMode Mode { get; set; } = DialerMode.Preview; /// - /// Gets or sets the dialer provider technical name. + /// Gets or sets the Contact Center voice provider technical name. /// public string ProviderName { get; set; } + /// + /// Gets or sets the available voice call providers. + /// + public IList ProviderOptions { get; set; } = []; + /// /// Gets or sets the number of calls per available agent. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs index 7dcdf36a7..f880f4b73 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Mvc.Rendering; namespace CrestApps.OrchardCore.ContactCenter.ViewModels; @@ -42,15 +43,25 @@ public class QueueViewModel public int ReservationTimeoutSeconds { get; set; } = 30; /// - /// Gets or sets the comma-separated skills required to receive work from the queue. + /// Gets or sets the selected skills required to receive work from the queue. /// - public string RequiredSkills { get; set; } + public IList RequiredSkills { get; set; } = []; + + /// + /// Gets or sets the available skills used by routing strategies. + /// + public IList SkillOptions { get; set; } = []; /// /// Gets or sets the inbound channel endpoint identifier mapped to this queue. /// public string InboundChannelEndpointId { get; set; } + /// + /// Gets or sets the available inbound channel endpoints. + /// + public IList InboundChannelEndpointOptions { get; set; } = []; + /// /// Gets or sets a value indicating whether the queue is enabled. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.SummaryAdmin.cshtml new file mode 100644 index 000000000..d07548c93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.SummaryAdmin.cshtml @@ -0,0 +1,29 @@ +
+
+
+
+
+ @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
+ + @if (Model.Meta != null) + { + + } +
+
+
+
+
+ @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } +
+
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml new file mode 100644 index 000000000..bb103117d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml @@ -0,0 +1,80 @@ +@model QueueViewModel + + + + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["Agents must have every selected skill to receive work from this queue."] +
+
+ +
+ +
+ + + @T["Calls received on this Omnichannel endpoint route to this queue. Leave empty for a default inbound queue."] +
+
+ +
+
+
+ + +
+ @T["Disabled queues do not receive routing assignments."] +
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml index bb63397e9..87df0288a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml @@ -1,5 +1,8 @@ @model AgentWorkspaceViewModel + + +

@T["Agent workspace"]

@if (Model.Profile is not null) @@ -32,21 +35,21 @@ -
- +
+
- - - @T["Enter comma-separated campaign identifiers for outbound dialer work."] + + + @T["Select the outbound campaigns you can receive dialer work from."]
-
- +
+
- - - @T["Enter comma-separated skill names used by queue routing."] + + + @T["Select the routing skills that match the work you can handle."]
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.SummaryAdmin.cshtml new file mode 100644 index 000000000..d07548c93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.SummaryAdmin.cshtml @@ -0,0 +1,29 @@ +
+
+
+
+
+ @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
+ + @if (Model.Meta != null) + { + + } +
+
+
+
+
+ @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } +
+
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml new file mode 100644 index 000000000..79e7d6430 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml @@ -0,0 +1,118 @@ +@model DialerProfileViewModel + + + + +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["The dialer uses CRM activities associated with the selected Interaction Center campaign."] +
+
+ +
+ +
+ + + @T["Agents signed in to this queue are reserved before power or progressive dialing."] +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + + @T["The Voice Contact Center Call Router resolves this provider when placing outbound campaign calls."] +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+
+
+ + +
+ @T["Suppression enforcement is shared with the outbound compliance phase."] +
+
+ +
+
+
+ + +
+
+
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml index c7c1b360d..e04ce8036 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Create.cshtml @@ -1,121 +1,19 @@ -@model DialerProfileViewModel +@using CrestApps.OrchardCore.Core.Models -

@T["Add dialer profile"]

+@model EditCatalogEntryViewModel - -
+ +

@RenderTitleSegments(T["New '{0}' Dialer Profile", Model.DisplayName])

+
-
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - - @T["Agents signed in to this queue are reserved before power or progressive dialing."] -
-
- -
- -
- - -
-
- -
- -
- - - @T["Use the dialer provider technical name, or leave empty to use the default registered provider."] -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
-
-
- - -
- @T["Suppression enforcement is shared with the outbound compliance phase."] -
-
- -
-
-
- - -
-
-
+ + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor)
- - @T["Cancel"] + + @T["Cancel"]
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml index 57ef87208..2da41095d 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Edit.cshtml @@ -1,122 +1,19 @@ -@model DialerProfileViewModel +@using CrestApps.OrchardCore.Core.Models -

@T["Edit dialer profile"]

+@model EditCatalogEntryViewModel -
- -
+ +

@RenderTitleSegments(T["Edit '{0}' Dialer Profile", Model.DisplayName])

+
-
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - - @T["Agents signed in to this queue are reserved before power or progressive dialing."] -
-
- -
- -
- - -
-
- -
- -
- - - @T["Use the dialer provider technical name, or leave empty to use the default registered provider."] -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
- -
- - -
-
- -
-
-
- - -
- @T["Suppression enforcement is shared with the outbound compliance phase."] -
-
- -
-
-
- - -
-
-
+ + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor)
- - @T["Cancel"] + + @T["Cancel"]
diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml index cdf2241d8..8301dafa9 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfiles/Index.cshtml @@ -1,35 +1,57 @@ -@model IEnumerable - -

@T["Dialer profiles"]

- - - - - - - - - - - - - - - @foreach (var profile in Model) +@using CrestApps.Core.Models +@using CrestApps.OrchardCore.Core.Models + +@model ListCatalogEntryViewModel> + +

@RenderTitleSegments(T["Dialer profiles"])

+ +@* The form is necessary to generate an antiforgery token for delete actions. *@ +
+ + +
+
+
+
+ +
+ +
+
+
+ +
    + @if (Model.Models.Count > 0) { -
- - - - - - +
  • + @(Model.Models.Count == 1 ? T["1 item"] : T["{0} items", Model.Models.Count]) +
  • + + @foreach (var entry in Model.Models) + { +
  • +
    + @await DisplayAsync(entry.Shape) +
    +
  • + } + } + else + { +
  • + @T["Nothing here! There are no dialer profiles at the moment."] +
  • } - -
    @T["Name"]@T["Mode"]@T["Provider"]@T["Enabled"]
    @profile.Name@profile.Mode@profile.ProviderName@(profile.Enabled ? T["Yes"] : T["No"]) - - - -
    + + + + + +@await DisplayAsync(Model.Pager) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Buttons.SummaryAdmin.cshtml new file mode 100644 index 000000000..6d8bbe50e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,14 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@T["Edit"] +@T["Delete"] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 000000000..df2de8622 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,17 @@ +@using System.Globalization +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var modifiedAt = Model.Value.ModifiedUtc ?? Model.Value.CreatedUtc; +} + +@if (modifiedAt != default) +{ + + + + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Fields.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Fields.SummaryAdmin.cshtml new file mode 100644 index 000000000..29699bfbf --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ActivityQueue.Fields.SummaryAdmin.cshtml @@ -0,0 +1,24 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +
    @Model.Value.Name
    + +@if (!string.IsNullOrWhiteSpace(Model.Value.Description)) +{ +
    @Model.Value.Description
    +} + +
    + @T["Priority: {0}", Model.Value.DefaultPriority] + @T["SLA: {0}s", Model.Value.SlaThresholdSeconds] + @if (Model.Value.RequiredSkills.Count > 0) + { + @T["Skills: {0}", string.Join(", ", Model.Value.RequiredSkills)] + } + @if (!Model.Value.Enabled) + { + @T["Disabled"] + } +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Buttons.SummaryAdmin.cshtml new file mode 100644 index 000000000..af205ff42 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,14 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@T["Edit"] +@T["Delete"] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 000000000..b0b89c4af --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,17 @@ +@using System.Globalization +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var modifiedAt = Model.Value.ModifiedUtc ?? Model.Value.CreatedUtc; +} + +@if (modifiedAt != default) +{ + + + + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Fields.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Fields.SummaryAdmin.cshtml new file mode 100644 index 000000000..691f35bf3 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/DialerProfile.Fields.SummaryAdmin.cshtml @@ -0,0 +1,23 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +
    @Model.Value.Name
    + +@if (!string.IsNullOrWhiteSpace(Model.Value.Description)) +{ +
    @Model.Value.Description
    +} + +
    + @T["Mode: {0}", Model.Value.Mode] + @if (!string.IsNullOrWhiteSpace(Model.Value.ProviderName)) + { + @T["Provider: {0}", Model.Value.ProviderName] + } + @if (!Model.Value.Enabled) + { + @T["Disabled"] + } +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml index 55f335d6e..41b194d04 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Create.cshtml @@ -1,88 +1,19 @@ -@model QueueViewModel +@using CrestApps.OrchardCore.Core.Models -

    @T["Add queue"]

    +@model EditCatalogEntryViewModel -
    -
    + +

    @RenderTitleSegments(T["New '{0}' Queue", Model.DisplayName])

    +
    -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - - @T["Enter comma-separated skill names. Agents must have every listed skill to receive work from this queue."] -
    -
    - -
    - -
    - - - @T["Optional. Calls received on this Omnichannel endpoint route to this queue. Leave empty for a default inbound queue."] -
    -
    - -
    -
    -
    - - -
    - @T["Disabled queues do not receive routing assignments."] -
    -
    + + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor)
    - - @T["Cancel"] + + @T["Cancel"]
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml index 4270801e6..dc069c0ab 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Edit.cshtml @@ -1,89 +1,19 @@ -@model QueueViewModel +@using CrestApps.OrchardCore.Core.Models -

    @T["Edit queue"]

    +@model EditCatalogEntryViewModel -
    - -
    + +

    @RenderTitleSegments(T["Edit '{0}' Queue", Model.DisplayName])

    +
    -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - -
    -
    - -
    - -
    - - - @T["Enter comma-separated skill names. Agents must have every listed skill to receive work from this queue."] -
    -
    - -
    - -
    - - - @T["Optional. Calls received on this Omnichannel endpoint route to this queue. Leave empty for a default inbound queue."] -
    -
    - -
    -
    -
    - - -
    - @T["Disabled queues do not receive routing assignments."] -
    -
    + + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor)
    - - @T["Cancel"] + + @T["Cancel"]
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml index ef721abbe..ed40daac9 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Queues/Index.cshtml @@ -1,37 +1,57 @@ -@model IEnumerable - -

    @T["Queues"]

    - - - - - - - - - - - - - - - - @foreach (var queue in Model) +@using CrestApps.Core.Models +@using CrestApps.OrchardCore.Core.Models + +@model ListCatalogEntryViewModel> + +

    @RenderTitleSegments(T["Queues"])

    + +@* The form is necessary to generate an antiforgery token for delete actions. *@ +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
      + @if (Model.Models.Count > 0) { -
    - - - - - - - +
  • + @(Model.Models.Count == 1 ? T["1 item"] : T["{0} items", Model.Models.Count]) +
  • + + @foreach (var entry in Model.Models) + { +
  • +
    + @await DisplayAsync(entry.Shape) +
    +
  • + } + } + else + { +
  • + @T["Nothing here! There are no queues at the moment."] +
  • } - -
    @T["Name"]@T["Default priority"]@T["SLA (s)"]@T["Required skills"]@T["Enabled"]
    @queue.Name@queue.DefaultPriority@queue.SlaThresholdSeconds@string.Join(", ", queue.RequiredSkills)@(queue.Enabled ? T["Yes"] : T["No"]) - - - -
    + + + + + +@await DisplayAsync(Model.Pager) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml index b76412916..1e780db34 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/_ViewImports.cshtml @@ -1,7 +1,12 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@using CrestApps.OrchardCore @using CrestApps.OrchardCore.ContactCenter.Core.Models @using CrestApps.OrchardCore.ContactCenter.ViewModels @using CrestApps.OrchardCore.ContactCenter.Models +@using CrestApps.OrchardCore.Core.Models +@using OrchardCore.DisplayManagement +@using OrchardCore.DisplayManagement.Views @addTagHelper *, OrchardCore.DisplayManagement @addTagHelper *, OrchardCore.ResourceManagement @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers -@inject Microsoft.AspNetCore.Mvc.Localization.IViewLocalizer T diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs index d3b6cb26a..e377f6d4b 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs @@ -90,7 +90,7 @@ public static class Feature public const string Area = "CrestApps.OrchardCore.DialPad"; /// - /// The identifier of the DialPad Contact Center dialer feature. + /// The identifier of the DialPad Contact Center voice-provider feature. /// public const string Dialer = "CrestApps.OrchardCore.DialPad.Dialer"; } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs index af7c10153..5fdf1aa09 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs @@ -6,13 +6,16 @@ namespace CrestApps.OrchardCore.DialPad; /// -/// Registers the DialPad implementation of the Contact Center dialer-agnostic provider. +/// Registers the DialPad implementation of the Contact Center voice provider boundary. /// [Feature(DialPadConstants.Feature.Dialer)] public sealed class DialerStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) { - services.AddScoped(); + services + .AddScoped() + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped(sp => sp.GetRequiredService()); } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs index c704ac946..fc954e7cf 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs @@ -1,4 +1,5 @@ using CrestApps.OrchardCore; +using CrestApps.OrchardCore.ContactCenter; using CrestApps.OrchardCore.DialPad; using CrestApps.OrchardCore.Telephony; using OrchardCore.Modules.Manifest; @@ -25,12 +26,12 @@ [assembly: Feature( Id = DialPadConstants.Feature.Dialer, - Name = "DialPad Dialer", - Description = "Implements the dialer-agnostic Contact Center dialer provider over DialPad so outbound campaigns dial through DialPad.", + Name = "DialPad Contact Center Voice", + Description = "Implements the Contact Center voice provider boundary over DialPad so the Voice Contact Center Call Router can place outbound calls through DialPad.", Category = "Communications", Dependencies = [ DialPadConstants.Feature.Area, - "CrestApps.OrchardCore.ContactCenter.Dialer", + ContactCenterConstants.Feature.Voice, ] )] diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs new file mode 100644 index 000000000..e0e8048a0 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs @@ -0,0 +1,135 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Implements the Contact Center voice provider boundary over the DialPad telephony provider so the +/// Contact Center routes voice work while DialPad executes provider-specific call operations. +/// +public sealed class DialPadContactCenterVoiceProvider : IContactCenterVoiceProvider, IDialerProvider +{ + private readonly ITelephonyProviderResolver _telephonyResolver; + + /// + /// Initializes a new instance of the class. + /// + /// The telephony provider resolver. + /// The string localizer. + public DialPadContactCenterVoiceProvider( + ITelephonyProviderResolver telephonyResolver, + IStringLocalizer stringLocalizer) + { + _telephonyResolver = telephonyResolver; + Name = stringLocalizer["DialPad"]; + } + + /// + public string TechnicalName => DialPadConstants.ProviderTechnicalName; + + /// + public LocalizedString Name { get; } + + /// + public ContactCenterVoiceProviderCapabilities Capabilities => ContactCenterVoiceProviderCapabilities.DialerDial; + + /// + LocalizedString IDialerProvider.DisplayName => Name; + + /// + DialerProviderCapabilities IDialerProvider.Capabilities => DialerProviderCapabilities.Outbound | DialerProviderCapabilities.CallerId | DialerProviderCapabilities.Cancellation; + + /// + public async Task DialAsync(ContactCenterDialRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + return await DialCoreAsync(request.Destination, request.CallerId, request.Metadata, cancellationToken); + } + + /// + public async Task PlaceCallAsync(DialerDialRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var result = await DialCoreAsync(request.Destination, request.CallerId, request.Metadata, cancellationToken); + + return result.Succeeded + ? DialerDialResult.Success(result.ProviderCallId) + : DialerDialResult.Failure(result.ErrorCode, result.ErrorMessage); + } + + /// + public Task AssignCallAsync(ContactCenterCallAssignmentRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(Failure("not_supported", "DialPad does not support provider-side Contact Center call assignment.")); + } + + /// + public Task QueueCallAsync(ContactCenterQueueCallRequest request, CancellationToken cancellationToken = default) + { + return Task.FromResult(Failure("not_supported", "DialPad does not support provider-side Contact Center queue placement.")); + } + + /// + public async Task EndCallAsync(string providerCallId, CancellationToken cancellationToken = default) + { + var provider = await _telephonyResolver.GetAsync(DialPadConstants.ProviderTechnicalName); + + if (provider is null) + { + return DialerDialResult.Failure("provider_unavailable", "The DialPad telephony provider is not configured."); + } + + var result = await provider.HangupAsync(new CallReference { CallId = providerCallId }, cancellationToken); + + return result.Succeeded + ? DialerDialResult.Success(providerCallId) + : DialerDialResult.Failure("hangup_failed", result.Error); + } + + private async Task DialCoreAsync( + string destination, + string callerId, + IDictionary metadata, + CancellationToken cancellationToken) + { + var provider = await _telephonyResolver.GetAsync(DialPadConstants.ProviderTechnicalName); + + if (provider is null) + { + return Failure("provider_unavailable", "The DialPad telephony provider is not configured."); + } + + var result = await provider.DialAsync(new DialRequest + { + To = destination, + From = callerId, + Metadata = metadata, + }, cancellationToken); + + if (!result.Succeeded) + { + return Failure("dial_failed", result.Error); + } + + return new ContactCenterVoiceProviderResult + { + Succeeded = true, + ProviderCallId = result.Call?.CallId, + }; + } + + private static ContactCenterVoiceProviderResult Failure(string errorCode, string errorMessage) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs deleted file mode 100644 index 124d48221..000000000 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadDialerProvider.cs +++ /dev/null @@ -1,83 +0,0 @@ -using CrestApps.OrchardCore.ContactCenter; -using CrestApps.OrchardCore.ContactCenter.Models; -using CrestApps.OrchardCore.Telephony; -using CrestApps.OrchardCore.Telephony.Models; -using Microsoft.Extensions.Localization; - -namespace CrestApps.OrchardCore.DialPad.Services; - -/// -/// Implements the Contact Center dialer-agnostic over the DialPad telephony -/// provider so the Contact Center can place outbound campaign calls through DialPad while owning all -/// assignment, queue, pacing, and compliance logic. -/// -public sealed class DialPadDialerProvider : IDialerProvider -{ - private readonly ITelephonyProviderResolver _telephonyResolver; - - /// - /// Initializes a new instance of the class. - /// - /// The telephony provider resolver. - /// The string localizer. - public DialPadDialerProvider( - ITelephonyProviderResolver telephonyResolver, - IStringLocalizer stringLocalizer) - { - _telephonyResolver = telephonyResolver; - DisplayName = stringLocalizer["DialPad"]; - } - - /// - public string TechnicalName => DialPadConstants.ProviderTechnicalName; - - /// - public LocalizedString DisplayName { get; } - - /// - public DialerProviderCapabilities Capabilities => DialerProviderCapabilities.Outbound | DialerProviderCapabilities.CallerId | DialerProviderCapabilities.Cancellation; - - /// - public async Task PlaceCallAsync(DialerDialRequest request, CancellationToken cancellationToken = default) - { - ArgumentNullException.ThrowIfNull(request); - - var provider = await _telephonyResolver.GetAsync(DialPadConstants.ProviderTechnicalName); - - if (provider is null) - { - return DialerDialResult.Failure("provider_unavailable", "The DialPad telephony provider is not configured."); - } - - var result = await provider.DialAsync(new DialRequest - { - To = request.Destination, - From = request.CallerId, - Metadata = request.Metadata, - }, cancellationToken); - - if (!result.Succeeded) - { - return DialerDialResult.Failure("dial_failed", result.Error); - } - - return DialerDialResult.Success(result.Call?.CallId); - } - - /// - public async Task EndCallAsync(string providerCallId, CancellationToken cancellationToken = default) - { - var provider = await _telephonyResolver.GetAsync(DialPadConstants.ProviderTechnicalName); - - if (provider is null) - { - return DialerDialResult.Failure("provider_unavailable", "The DialPad telephony provider is not configured."); - } - - var result = await provider.HangupAsync(new CallReference { CallId = providerCallId }, cancellationToken); - - return result.Succeeded - ? DialerDialResult.Success(providerCallId) - : DialerDialResult.Failure("hangup_failed", result.Error); - } -} diff --git a/src/Modules/CrestApps.OrchardCore.Resources/Assets.json b/src/Modules/CrestApps.OrchardCore.Resources/Assets.json index 4a5d5fab7..f7fc53b5c 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/Assets.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/Assets.json @@ -190,6 +190,34 @@ ], "output": "wwwroot/vendors/crestapps/document-drop-zone.min.css" }, + { + "copy": true, + "inputs": [ + "node_modules/@crestapps/bootstrap-select/dist/js/bootstrap-select.js" + ], + "output": "wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.js" + }, + { + "copy": true, + "inputs": [ + "node_modules/@crestapps/bootstrap-select/dist/js/bootstrap-select.min.js" + ], + "output": "wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js" + }, + { + "copy": true, + "inputs": [ + "node_modules/@crestapps/bootstrap-select/dist/css/bootstrap-select.css" + ], + "output": "wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.css" + }, + { + "copy": true, + "inputs": [ + "node_modules/@crestapps/bootstrap-select/dist/css/bootstrap-select.min.css" + ], + "output": "wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css" + }, { "copy": true, "inputs": [ diff --git a/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs b/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs index 06e7c5437..929849302 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs +++ b/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs @@ -182,6 +182,27 @@ static ResourceManagementOptionsConfiguration() "sha384-Nej+SC8Gi+UVsc5GZ9b4NlJ7tGxoqyyAuoB3lMOur7MN0vjxNxpEa3N4qNh7peOO") .SetVersion("1.0.0"); + _manifest + .DefineStyle("crestapps-bootstrap-select") + .SetUrl( + "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css", + "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/css/bootstrap-select.css") + .SetCdn( + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/css/bootstrap-select.min.css", + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/css/bootstrap-select.css") + .SetVersion("1.2.0"); + + _manifest + .DefineScript("crestapps-bootstrap-select") + .SetUrl( + "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js", + "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/js/bootstrap-select.js") + .SetCdn( + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/js/bootstrap-select.min.js", + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/js/bootstrap-select.js") + .SetDependencies("bootstrap") + .SetVersion("1.2.0"); + _manifest .DefineStyle("intl-tel-input") .SetUrl( diff --git a/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json b/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json index 3be3a9538..a78696a47 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json @@ -7,6 +7,7 @@ "name": "crestapps.orchardcore.resources", "dependencies": { "@crestapps/ai-chat-ui": "1.0.0-rc.3", + "@crestapps/bootstrap-select": "1.2.0", "chart.js": "^4.5.1", "intl-tel-input": "29.1.0" }, @@ -32,12 +33,33 @@ } } }, + "node_modules/@crestapps/bootstrap-select": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@crestapps/bootstrap-select/-/bootstrap-select-1.2.0.tgz", + "integrity": "sha512-Wbizy8hRrg/y3/bkVlcarJ3jHK8RrzhRNFljmgrVjEboeOV02g1pCV7eTheZ9+tDQArZZE+4M3F14luq9hgoFQ==", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "bootstrap": ">=5.0.0" + } + }, "node_modules/@kurkle/color": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@types/codemirror": { "version": "5.60.15", "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz", @@ -80,6 +102,25 @@ "license": "MIT", "optional": true }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "peer": true, + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/chart.js": { "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", diff --git a/src/Modules/CrestApps.OrchardCore.Resources/package.json b/src/Modules/CrestApps.OrchardCore.Resources/package.json index 85d5ca2aa..ab779b467 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/package.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@crestapps/ai-chat-ui": "1.0.0-rc.3", + "@crestapps/bootstrap-select": "1.2.0", "chart.js": "^4.5.1", "intl-tel-input": "29.1.0" }, diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.css b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.css new file mode 100644 index 000000000..d4d7701c3 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.css @@ -0,0 +1,667 @@ +@charset "UTF-8"; +/*! + * Bootstrap-select v1.2.0 (https://github.com/CrestApps/bootstrap-select) + * + * CrestApps fork (vanilla JavaScript, Bootstrap 5+) of snapappointments/bootstrap-select + * Copyright 2012-2018 SnapAppointments, LLC (original work) + * Fork modifications Copyright 2024-2026 CrestApps + * Licensed under MIT (https://github.com/CrestApps/bootstrap-select/blob/main/LICENSE) + */ +@keyframes bs-notify-fadeOut { + 0% { + opacity: 0.9; + } + 100% { + opacity: 0; + } +} +select.bs-select-hidden, +.bootstrap-select > select.bs-select-hidden, +select.selectpicker { + display: none !important; +} + +.bootstrap-select { + width: 100%; + vertical-align: middle; +} +.bootstrap-select > .dropdown-toggle { + position: relative; + width: 100%; + text-align: right; + white-space: nowrap; + display: inline-flex; + align-items: center; + justify-content: space-between; +} +.bootstrap-select > .dropdown-toggle:after { + margin-top: -1px; +} +.bootstrap-select > .dropdown-toggle.bs-placeholder, .bootstrap-select > .dropdown-toggle.bs-placeholder:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder:active { + color: #999; +} +.bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-primary:active, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-secondary:active, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-success:active, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-danger:active, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-info:active, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:hover, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:focus, .bootstrap-select > .dropdown-toggle.bs-placeholder.btn-dark:active { + color: rgba(255, 255, 255, 0.5); +} +.bootstrap-select > select { + position: absolute !important; + bottom: 0; + left: 50%; + display: block !important; + width: 0.5px !important; + height: 100% !important; + padding: 0 !important; + opacity: 0 !important; + border: none; + z-index: 0 !important; +} +.bootstrap-select > select.mobile-device { + top: 0; + left: 0; + display: block !important; + width: 100% !important; + z-index: 2 !important; +} +.has-error .bootstrap-select .dropdown-toggle, .error .bootstrap-select .dropdown-toggle, .bootstrap-select.is-invalid .dropdown-toggle, .was-validated .bootstrap-select select:invalid + .dropdown-toggle { + border-color: rgb(185, 74, 72); +} +.bootstrap-select.is-valid .dropdown-toggle, .was-validated .bootstrap-select select:valid + .dropdown-toggle { + border-color: #28a745; +} +.bootstrap-select:not([class*=col-]):not([class*=form-control]):not(.input-group-btn) { + width: 100%; +} +.bootstrap-select > select.mobile-device:focus + .dropdown-toggle, +.bootstrap-select .dropdown-toggle:focus { + outline: thin dotted #333333 !important; + outline: 5px auto -webkit-focus-ring-color !important; + outline-offset: -2px; +} + +.bootstrap-select.form-control { + margin-bottom: 0; + padding: 0; + border: none; + height: auto; +} +:not(.input-group) > .bootstrap-select.form-control:not([class*=col-]) { + width: 100%; +} +.bootstrap-select.form-control.input-group-btn { + float: none; + z-index: auto; +} +.form-inline .bootstrap-select, .form-inline .bootstrap-select.form-control:not([class*=col-]) { + width: auto; +} +.bootstrap-select:not(.input-group-btn), .bootstrap-select[class*=col-] { + float: none; + display: inline-block; + margin-left: 0; +} +.bootstrap-select.dropdown-menu-end, .bootstrap-select[class*=col-].dropdown-menu-end, .row .bootstrap-select[class*=col-].dropdown-menu-end { + float: right; +} +.form-inline .bootstrap-select, .form-horizontal .bootstrap-select, .form-group .bootstrap-select { + margin-bottom: 0; +} +.form-group-lg .bootstrap-select.form-control, .form-group-sm .bootstrap-select.form-control { + padding: 0; +} +.form-group-lg .bootstrap-select.form-control .dropdown-toggle, .form-group-sm .bootstrap-select.form-control .dropdown-toggle { + height: 100%; + font-size: inherit; + line-height: inherit; + border-radius: inherit; +} +.bootstrap-select.form-control-sm .dropdown-toggle, .bootstrap-select.form-control-lg .dropdown-toggle { + font-size: inherit; + line-height: inherit; + border-radius: inherit; +} +.bootstrap-select.form-control-sm .dropdown-toggle { + padding: 0.25rem 0.5rem; +} +.bootstrap-select.form-control-lg .dropdown-toggle { + padding: 0.5rem 1rem; +} +.form-inline .bootstrap-select .form-control { + width: 100%; +} +.bootstrap-select.disabled, +.bootstrap-select > .disabled { + cursor: not-allowed; +} +.bootstrap-select.disabled:focus, +.bootstrap-select > .disabled:focus { + outline: none !important; +} +.bootstrap-select.bs-container { + position: absolute; + top: 0; + left: 0; + height: 0 !important; + padding: 0 !important; +} +.bootstrap-select.bs-container .dropdown-menu { + z-index: 1060; +} +.bootstrap-select .dropdown-toggle .filter-option { + position: static; + top: 0; + left: 0; + float: left; + height: 100%; + width: 100%; + text-align: left; + overflow: hidden; + flex: 0 1 auto; +} +.bootstrap-select .dropdown-toggle .filter-option-inner-inner { + overflow: hidden; +} +.bootstrap-select .dropdown-toggle .filter-expand { + width: 0 !important; + float: left; + opacity: 0 !important; + overflow: hidden; +} +.bootstrap-select .dropdown-toggle .caret { + position: absolute; + top: 50%; + right: 12px; + margin-top: -2px; + vertical-align: middle; +} +.bootstrap-select .dropdown-toggle .bs-select-clear-selected { + position: relative; + display: block; + margin-right: 5px; + text-align: center; +} +.bootstrap-select .dropdown-toggle .bs-select-clear-selected span { + position: relative; + top: calc((-0.6666666667em + 1ex) / 2); + pointer-events: none; +} +.bootstrap-select .dropdown-toggle.bs-placeholder .bs-select-clear-selected { + display: none; +} +.input-group .bootstrap-select.form-control .dropdown-toggle { + border-radius: inherit; +} +.bootstrap-select[class*=col-] .dropdown-toggle { + width: 100%; +} +.bootstrap-select .dropdown-menu { + min-width: 100%; + box-sizing: border-box; +} +.bootstrap-select .dropdown-menu > .inner:focus { + outline: none !important; +} +.bootstrap-select .dropdown-menu.inner { + position: static; + float: none; + border: 0; + padding: 0; + margin: 0; + border-radius: 0; + box-shadow: none; +} +.bootstrap-select .dropdown-menu li { + position: relative; +} +.bootstrap-select .dropdown-menu li.active small { + color: rgba(255, 255, 255, 0.5) !important; +} +.bootstrap-select .dropdown-menu li.disabled a { + cursor: not-allowed; +} +.bootstrap-select .dropdown-menu li a { + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} +.bootstrap-select .dropdown-menu li a.opt { + position: relative; + padding-left: 2.25em; +} +.bootstrap-select .dropdown-menu li a span.check-mark { + display: none; +} +.bootstrap-select .dropdown-menu li a span.text { + display: inline-block; +} +.bootstrap-select .dropdown-menu li small { + padding-left: 0.5em; +} +.bootstrap-select .dropdown-menu .notify { + position: absolute; + bottom: 5px; + width: 96%; + margin: 0 2%; + min-height: 26px; + padding: 3px 5px; + background: rgb(245, 245, 245); + border: 1px solid rgb(227, 227, 227); + box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.05); + pointer-events: none; + opacity: 0.9; + box-sizing: border-box; +} +.bootstrap-select .dropdown-menu .notify.fadeOut { + animation: 300ms linear 750ms forwards bs-notify-fadeOut; +} +.bootstrap-select .no-results { + padding: 3px; + background: #f5f5f5; + margin: 0 5px; + white-space: nowrap; +} +.bootstrap-select.fit-width .dropdown-toggle .filter-option { + position: static; + display: inline; + padding: 0; +} +.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner, +.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner-inner { + display: inline; +} +.bootstrap-select.fit-width .dropdown-toggle .bs-caret:before { + content: " "; +} +.bootstrap-select.fit-width .dropdown-toggle .caret { + position: static; + top: auto; + margin-top: -1px; +} +.bootstrap-select.show-tick .dropdown-menu .selected span.check-mark { + position: absolute; + display: inline-block; + right: 15px; + top: 50%; + margin-top: -0.55rem; +} +.bootstrap-select.show-tick .dropdown-menu li a span.text { + margin-right: 34px; +} +.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a, .bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a { + padding-left: 2.5rem; +} +.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a span.text, .bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a span.text { + margin-right: 0; +} +.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a span.check-mark, .bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a span.check-mark { + position: absolute; + display: inline-flex; + align-items: center; + justify-content: center; + left: 0.75rem; + right: auto; + top: 50%; + width: 1rem; + height: 1rem; + margin-top: -0.5rem; + border: 1px solid var(--bs-border-color, #ced4da); + border-radius: 0.25rem; + background: var(--bs-body-bg, #fff); + color: transparent; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a span.check-mark { + border-radius: 0.25rem; +} +.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu .selected span.check-mark { + background: var(--bs-primary, #0d6efd); + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-white, #fff); +} +.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu .selected span.check-mark::after { + content: ""; + width: 0.3rem; + height: 0.55rem; + border: solid currentColor; + border-width: 0 0.14rem 0.14rem 0; + transform: rotate(45deg); + margin-top: -0.05rem; +} +.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a span.check-mark { + border-radius: 50%; +} +.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu .selected span.check-mark { + border-color: var(--bs-primary, #0d6efd); + color: var(--bs-primary, #0d6efd); +} +.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu .selected span.check-mark::after { + content: ""; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: currentColor; +} +.bootstrap-select .bs-ok-default:after { + content: ""; + display: block; + width: 0.5em; + height: 1em; + border-style: solid; + border-width: 0 0.26em 0.26em 0; + transform-style: preserve-3d; + transform: rotate(45deg); +} + +.bootstrap-select.show-menu-arrow.open > .dropdown-toggle, .bootstrap-select.show-menu-arrow.show > .dropdown-toggle { + z-index: 1061; +} +.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:before { + content: ""; + border-left: 7px solid transparent; + border-right: 7px solid transparent; + border-bottom: 7px solid rgba(204, 204, 204, 0.2); + position: absolute; + bottom: -4px; + left: 9px; + display: none; +} +.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:after { + content: ""; + border-left: 6px solid transparent; + border-right: 6px solid transparent; + border-bottom: 6px solid white; + position: absolute; + bottom: -4px; + left: 10px; + display: none; +} +.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:before { + bottom: auto; + top: -4px; + border-top: 7px solid rgba(204, 204, 204, 0.2); + border-bottom: 0; +} +.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:after { + bottom: auto; + top: -4px; + border-top: 6px solid white; + border-bottom: 0; +} +.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:before { + right: 12px; + left: auto; +} +.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:after { + right: 13px; + left: auto; +} +.bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:before, .bootstrap-select.show-menu-arrow.open > .dropdown-toggle .filter-option:after, .bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:before, .bootstrap-select.show-menu-arrow.show > .dropdown-toggle .filter-option:after { + display: block; +} + +.bs-searchbox, +.bs-actionsbox, +.bs-donebutton { + padding: 4px 8px; +} + +.popover-header { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.625rem 0.875rem; +} +.popover-header .popover-header-text { + flex: 1 1 auto; + min-width: 0; +} +.popover-header .btn-close, +.popover-header .close { + flex: 0 0 auto; + margin: 0; + margin-left: auto; +} +.popover-header .btn-close { + width: 0.875rem; + height: 0.875rem; + padding: 0.25rem; + background-size: 0.75rem; +} +.popover-header .close { + font-size: 0.875rem; + line-height: 1; +} + +.bs-actionsbox { + width: 100%; + box-sizing: border-box; +} +.bs-actionsbox .btn-group { + display: block; +} +.bs-actionsbox .btn-group button { + width: 50%; +} + +.bs-donebutton { + float: left; + width: 100%; + box-sizing: border-box; +} +.bs-donebutton .btn-group { + display: block; +} +.bs-donebutton .btn-group button { + width: 100%; +} + +.bs-searchbox { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.75rem; + border-bottom: 1px solid var(--bs-border-color-translucent, rgba(0, 0, 0, 0.1)); +} +.bs-searchbox + .bs-actionsbox { + padding: 0 8px 4px; +} +.bs-searchbox .form-control { + margin-bottom: 0; + width: 100%; + float: none; + min-height: calc(1.5em + 0.75rem + 2px); +} +.bs-searchbox .bs-create-option { + display: block; + padding: 0.5rem 0.75rem; + border: 1px dashed rgba(13, 110, 253, 0.45); + border-radius: 0.375rem; + background: rgba(13, 110, 253, 0.06); + color: var(--bs-primary, #0d6efd); + white-space: normal; + text-align: left; +} +.bs-searchbox .bs-create-option:hover, .bs-searchbox .bs-create-option:focus { + background: rgba(13, 110, 253, 0.12); + color: var(--bs-primary-text-emphasis, var(--bs-primary, #0d6efd)); +} + +.bs-selected-items { + display: flex; + flex-wrap: wrap; + gap: 0.375rem 0.5rem; + margin-top: 0.5rem; +} + +.bs-selected-items-external { + padding: 0 0.125rem; +} + +.bs-selected-item { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 0.375rem; + max-width: 100%; + min-height: calc(1.5em + 0.25rem + 2px); + padding: 0.1875rem 0.25rem 0.1875rem 0.625rem; + border: 1px solid var(--bs-border-color, #ced4da); + border-radius: 0.875rem; + background-color: var(--bs-tertiary-bg, #f8f9fa); + color: var(--bs-body-color, inherit); + font-size: 0.8125rem; + line-height: 1.125rem; + text-align: left; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; + -moz-appearance: none; + appearance: none; + -webkit-appearance: none; +} + +.bs-selected-item-content { + display: inline-flex; + align-items: center; + gap: 0.5rem; + min-width: 0; +} + +.bs-selected-item-icon { + flex: 0 0 auto; +} + +.bs-selected-item:hover, +.bs-selected-item:focus { + border-color: rgba(13, 110, 253, 0.35); + background-color: var(--bs-secondary-bg, #e9ecef); + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.12); + outline: 0; +} + +.bs-selected-item-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bs-selected-item-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + border-radius: 999px; + background: rgba(108, 117, 125, 0.14); + color: var(--bs-secondary-color, #6c757d); + font-size: 0.875rem; + font-weight: 900; + line-height: 1; + flex: 0 0 auto; + padding-bottom: 0; +} + +.bootstrap-select.selected-items-style-list .bs-selected-items { + display: block; + margin-top: 0.5rem; +} + +.bootstrap-select.selected-items-style-list .bs-selected-items-external { + padding: 0; +} + +.bootstrap-select.selected-items-style-list .bs-selected-item { + width: 100%; + min-height: 0; + padding: 0.75rem 1rem; + border: 1px solid var(--bs-list-group-border-color, rgba(0, 0, 0, 0.125)); + border-radius: 0; + background-color: var(--bs-list-group-bg, #fff); + color: var(--bs-list-group-color, inherit); + font-size: 1rem; + line-height: 1.5; + box-shadow: none; +} + +.bootstrap-select.selected-items-style-list .bs-selected-item:first-child { + border-top-left-radius: var(--bs-list-group-border-radius, 0.375rem); + border-top-right-radius: var(--bs-list-group-border-radius, 0.375rem); +} + +.bootstrap-select.selected-items-style-list .bs-selected-item:last-child { + border-bottom-left-radius: var(--bs-list-group-border-radius, 0.375rem); + border-bottom-right-radius: var(--bs-list-group-border-radius, 0.375rem); +} + +.bootstrap-select.selected-items-style-list .bs-selected-item + .bs-selected-item { + margin-top: -1px; +} + +.bootstrap-select.selected-items-style-list .bs-selected-item:hover, +.bootstrap-select.selected-items-style-list .bs-selected-item:focus { + border-color: var(--bs-list-group-border-color, rgba(0, 0, 0, 0.125)); + background-color: var(--bs-list-group-action-hover-bg, #f8f9fa); + color: var(--bs-list-group-action-hover-color, inherit); + box-shadow: none; +} + +.bootstrap-select.selected-items-style-list .bs-selected-item-content { + flex: 1 1 auto; +} + +.bootstrap-select.selected-items-style-list .bs-selected-item-label { + white-space: normal; +} + +.bootstrap-select.selected-items-style-list .bs-selected-item-remove { + width: auto; + height: auto; + margin-left: auto; + border-radius: 0; + background: transparent; + font-size: 1rem; + color: var(--bs-secondary-color, #6c757d); +} + +.form-floating > .bootstrap-select.show-selected-tags { + position: relative; + min-height: calc(3.5rem + 2px); + border: var(--bs-border-width, 1px) solid var(--bs-border-color, #ced4da); + border-radius: var(--bs-border-radius, 0.375rem); + background-color: var(--bs-body-bg, #fff); + transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +.form-floating > .bootstrap-select.show-selected-tags:focus-within, .form-floating > .bootstrap-select.show-selected-tags.show { + border-color: #86b7fe; + box-shadow: 0 0 0 0.25rem rgba(13, 110, 253, 0.25); +} +.form-floating > .bootstrap-select.show-selected-tags > .dropdown-toggle { + min-height: 0; + height: auto; + padding-top: 1.375rem; + padding-bottom: 0.25rem; + border: 0; + background-color: transparent; + box-shadow: none; +} +.form-floating > .bootstrap-select.show-selected-tags > .dropdown-toggle:hover, +.form-floating > .bootstrap-select.show-selected-tags > .dropdown-toggle:focus, +.form-floating > .bootstrap-select.show-selected-tags > .dropdown-toggle:active { + background-color: transparent; + box-shadow: none; +} +.form-floating > .bootstrap-select.show-selected-tags > .dropdown-toggle .filter-option-inner-inner { + opacity: 0; +} +.form-floating > .bootstrap-select.show-selected-tags > .bs-selected-items-external { + position: relative; + z-index: 3; + margin: 0 2.25rem 0 0.75rem; + margin-top: 0; + margin-bottom: 0; + padding-bottom: 1.375rem; +} + +.form-floating > .bootstrap-select.show-selected-tags ~ label { + padding-top: 0.75rem; +} +/*# sourceMappingURL=bootstrap-select.css.map */ diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css new file mode 100644 index 000000000..8dd0b6b9d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css @@ -0,0 +1,9 @@ +@charset "UTF-8";/*! + * Bootstrap-select v1.2.0 (https://github.com/CrestApps/bootstrap-select) + * + * CrestApps fork (vanilla JavaScript, Bootstrap 5+) of snapappointments/bootstrap-select + * Copyright 2012-2018 SnapAppointments, LLC (original work) + * Fork modifications Copyright 2024-2026 CrestApps + * Licensed under MIT (https://github.com/CrestApps/bootstrap-select/blob/main/LICENSE) + */ +@keyframes bs-notify-fadeOut{0%{opacity:.9}to{opacity:0}}.bootstrap-select>select.bs-select-hidden,select.bs-select-hidden,select.selectpicker{display:none!important}.bootstrap-select{vertical-align:middle;width:100%}.bootstrap-select>.dropdown-toggle{align-items:center;display:inline-flex;justify-content:space-between;position:relative;text-align:right;white-space:nowrap;width:100%}.bootstrap-select>.dropdown-toggle:after{margin-top:-1px}.bootstrap-select>.dropdown-toggle.bs-placeholder,.bootstrap-select>.dropdown-toggle.bs-placeholder:active,.bootstrap-select>.dropdown-toggle.bs-placeholder:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder:hover{color:#999}.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-danger:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-dark:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-info:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-primary:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary:hover,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success:active,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success:focus,.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-success:hover{color:hsla(0,0%,100%,.5)}.bootstrap-select>select{border:none;bottom:0;display:block!important;height:100%!important;left:50%;opacity:0!important;padding:0!important;position:absolute!important;width:.5px!important;z-index:0!important}.bootstrap-select>select.mobile-device{display:block!important;left:0;top:0;width:100%!important;z-index:2!important}.bootstrap-select.is-invalid .dropdown-toggle,.error .bootstrap-select .dropdown-toggle,.has-error .bootstrap-select .dropdown-toggle,.was-validated .bootstrap-select select:invalid+.dropdown-toggle{border-color:#b94a48}.bootstrap-select.is-valid .dropdown-toggle,.was-validated .bootstrap-select select:valid+.dropdown-toggle{border-color:#28a745}.bootstrap-select:not([class*=col-]):not([class*=form-control]):not(.input-group-btn){width:100%}.bootstrap-select .dropdown-toggle:focus,.bootstrap-select>select.mobile-device:focus+.dropdown-toggle{outline:thin dotted #333!important;outline:5px auto -webkit-focus-ring-color!important;outline-offset:-2px}.bootstrap-select.form-control{border:none;height:auto;margin-bottom:0;padding:0}:not(.input-group)>.bootstrap-select.form-control:not([class*=col-]){width:100%}.bootstrap-select.form-control.input-group-btn{float:none;z-index:auto}.form-inline .bootstrap-select,.form-inline .bootstrap-select.form-control:not([class*=col-]){width:auto}.bootstrap-select:not(.input-group-btn),.bootstrap-select[class*=col-]{display:inline-block;float:none;margin-left:0}.bootstrap-select.dropdown-menu-end,.bootstrap-select[class*=col-].dropdown-menu-end,.row .bootstrap-select[class*=col-].dropdown-menu-end{float:right}.form-group .bootstrap-select,.form-horizontal .bootstrap-select,.form-inline .bootstrap-select{margin-bottom:0}.form-group-lg .bootstrap-select.form-control,.form-group-sm .bootstrap-select.form-control{padding:0}.form-group-lg .bootstrap-select.form-control .dropdown-toggle,.form-group-sm .bootstrap-select.form-control .dropdown-toggle{border-radius:inherit;font-size:inherit;height:100%;line-height:inherit}.bootstrap-select.form-control-lg .dropdown-toggle,.bootstrap-select.form-control-sm .dropdown-toggle{border-radius:inherit;font-size:inherit;line-height:inherit}.bootstrap-select.form-control-sm .dropdown-toggle{padding:.25rem .5rem}.bootstrap-select.form-control-lg .dropdown-toggle{padding:.5rem 1rem}.form-inline .bootstrap-select .form-control{width:100%}.bootstrap-select.disabled,.bootstrap-select>.disabled{cursor:not-allowed}.bootstrap-select.disabled:focus,.bootstrap-select>.disabled:focus{outline:none!important}.bootstrap-select.bs-container{height:0!important;left:0;padding:0!important;position:absolute;top:0}.bootstrap-select.bs-container .dropdown-menu{z-index:1060}.bootstrap-select .dropdown-toggle .filter-option{flex:0 1 auto;float:left;height:100%;left:0;overflow:hidden;position:static;text-align:left;top:0;width:100%}.bootstrap-select .dropdown-toggle .filter-option-inner-inner{overflow:hidden}.bootstrap-select .dropdown-toggle .filter-expand{float:left;opacity:0!important;overflow:hidden;width:0!important}.bootstrap-select .dropdown-toggle .caret{margin-top:-2px;position:absolute;right:12px;top:50%;vertical-align:middle}.bootstrap-select .dropdown-toggle .bs-select-clear-selected{display:block;margin-right:5px;position:relative;text-align:center}.bootstrap-select .dropdown-toggle .bs-select-clear-selected span{pointer-events:none;position:relative;top:calc(-.33333em + .5ex)}.bootstrap-select .dropdown-toggle.bs-placeholder .bs-select-clear-selected{display:none}.input-group .bootstrap-select.form-control .dropdown-toggle{border-radius:inherit}.bootstrap-select[class*=col-] .dropdown-toggle{width:100%}.bootstrap-select .dropdown-menu{box-sizing:border-box;min-width:100%}.bootstrap-select .dropdown-menu>.inner:focus{outline:none!important}.bootstrap-select .dropdown-menu.inner{border:0;border-radius:0;box-shadow:none;float:none;margin:0;padding:0;position:static}.bootstrap-select .dropdown-menu li{position:relative}.bootstrap-select .dropdown-menu li.active small{color:hsla(0,0%,100%,.5)!important}.bootstrap-select .dropdown-menu li.disabled a{cursor:not-allowed}.bootstrap-select .dropdown-menu li a{cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none}.bootstrap-select .dropdown-menu li a.opt{padding-left:2.25em;position:relative}.bootstrap-select .dropdown-menu li a span.check-mark{display:none}.bootstrap-select .dropdown-menu li a span.text{display:inline-block}.bootstrap-select .dropdown-menu li small{padding-left:.5em}.bootstrap-select .dropdown-menu .notify{background:#f5f5f5;border:1px solid #e3e3e3;bottom:5px;box-shadow:inset 0 1px 1px rgba(0,0,0,.05);box-sizing:border-box;margin:0 2%;min-height:26px;opacity:.9;padding:3px 5px;pointer-events:none;position:absolute;width:96%}.bootstrap-select .dropdown-menu .notify.fadeOut{animation:bs-notify-fadeOut .3s linear .75s forwards}.bootstrap-select .no-results{background:#f5f5f5;margin:0 5px;padding:3px;white-space:nowrap}.bootstrap-select.fit-width .dropdown-toggle .filter-option{display:inline;padding:0;position:static}.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner,.bootstrap-select.fit-width .dropdown-toggle .filter-option-inner-inner{display:inline}.bootstrap-select.fit-width .dropdown-toggle .bs-caret:before{content:" "}.bootstrap-select.fit-width .dropdown-toggle .caret{margin-top:-1px;position:static;top:auto}.bootstrap-select.show-tick .dropdown-menu .selected span.check-mark{display:inline-block;margin-top:-.55rem;position:absolute;right:15px;top:50%}.bootstrap-select.show-tick .dropdown-menu li a span.text{margin-right:34px}.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a,.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a{padding-left:2.5rem}.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a span.text,.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a span.text{margin-right:0}.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a span.check-mark,.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a span.check-mark{align-items:center;background:var(--bs-body-bg,#fff);border:1px solid var(--bs-border-color,#ced4da);border-radius:.25rem;color:transparent;display:inline-flex;height:1rem;justify-content:center;left:.75rem;margin-top:-.5rem;position:absolute;right:auto;top:50%;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,color .15s ease-in-out,box-shadow .15s ease-in-out;width:1rem}.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu li a span.check-mark{border-radius:.25rem}.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu .selected span.check-mark{background:var(--bs-primary,#0d6efd);border-color:var(--bs-primary,#0d6efd);color:var(--bs-white,#fff)}.bootstrap-select.selection-indicator-checkbox.show-tick .dropdown-menu .selected span.check-mark:after{border:solid;border-width:0 .14rem .14rem 0;content:"";height:.55rem;margin-top:-.05rem;transform:rotate(45deg);width:.3rem}.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu li a span.check-mark{border-radius:50%}.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu .selected span.check-mark{border-color:var(--bs-primary,#0d6efd);color:var(--bs-primary,#0d6efd)}.bootstrap-select.selection-indicator-radio.show-tick .dropdown-menu .selected span.check-mark:after{background:currentColor;border-radius:50%;content:"";height:.5rem;width:.5rem}.bootstrap-select .bs-ok-default:after{border-style:solid;border-width:0 .26em .26em 0;content:"";display:block;height:1em;transform:rotate(45deg);transform-style:preserve-3d;width:.5em}.bootstrap-select.show-menu-arrow.open>.dropdown-toggle,.bootstrap-select.show-menu-arrow.show>.dropdown-toggle{z-index:1061}.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:before{border-bottom:7px solid hsla(0,0%,80%,.2);border-left:7px solid transparent;border-right:7px solid transparent;bottom:-4px;content:"";display:none;left:9px;position:absolute}.bootstrap-select.show-menu-arrow .dropdown-toggle .filter-option:after{border-bottom:6px solid #fff;border-left:6px solid transparent;border-right:6px solid transparent;bottom:-4px;content:"";display:none;left:10px;position:absolute}.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:before{border-bottom:0;border-top:7px solid hsla(0,0%,80%,.2);bottom:auto;top:-4px}.bootstrap-select.show-menu-arrow.dropup .dropdown-toggle .filter-option:after{border-bottom:0;border-top:6px solid #fff;bottom:auto;top:-4px}.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:before{left:auto;right:12px}.bootstrap-select.show-menu-arrow.pull-right .dropdown-toggle .filter-option:after{left:auto;right:13px}.bootstrap-select.show-menu-arrow.open>.dropdown-toggle .filter-option:after,.bootstrap-select.show-menu-arrow.open>.dropdown-toggle .filter-option:before,.bootstrap-select.show-menu-arrow.show>.dropdown-toggle .filter-option:after,.bootstrap-select.show-menu-arrow.show>.dropdown-toggle .filter-option:before{display:block}.bs-actionsbox,.bs-donebutton,.bs-searchbox{padding:4px 8px}.popover-header{align-items:center;display:flex;gap:.75rem;padding:.625rem .875rem}.popover-header .popover-header-text{flex:1 1 auto;min-width:0}.popover-header .btn-close,.popover-header .close{flex:0 0 auto;margin:0 0 0 auto}.popover-header .btn-close{background-size:.75rem;height:.875rem;padding:.25rem;width:.875rem}.popover-header .close{font-size:.875rem;line-height:1}.bs-actionsbox{box-sizing:border-box;width:100%}.bs-actionsbox .btn-group{display:block}.bs-actionsbox .btn-group button{width:50%}.bs-donebutton{box-sizing:border-box;float:left;width:100%}.bs-donebutton .btn-group{display:block}.bs-donebutton .btn-group button{width:100%}.bs-searchbox{border-bottom:1px solid var(--bs-border-color-translucent,rgba(0,0,0,.1));display:flex;flex-direction:column;gap:.75rem;padding:.75rem}.bs-searchbox+.bs-actionsbox{padding:0 8px 4px}.bs-searchbox .form-control{float:none;margin-bottom:0;min-height:calc(1.5em + .75rem + 2px);width:100%}.bs-searchbox .bs-create-option{background:rgba(13,110,253,.06);border:1px dashed rgba(13,110,253,.45);border-radius:.375rem;color:var(--bs-primary,#0d6efd);display:block;padding:.5rem .75rem;text-align:left;white-space:normal}.bs-searchbox .bs-create-option:focus,.bs-searchbox .bs-create-option:hover{background:rgba(13,110,253,.12);color:var(--bs-primary-text-emphasis,var(--bs-primary,#0d6efd))}.bs-selected-items{display:flex;flex-wrap:wrap;gap:.375rem .5rem;margin-top:.5rem}.bs-selected-items-external{padding:0 .125rem}.bs-selected-item{align-items:center;-moz-appearance:none;appearance:none;-webkit-appearance:none;background-color:var(--bs-tertiary-bg,#f8f9fa);border:1px solid var(--bs-border-color,#ced4da);border-radius:.875rem;box-shadow:0 1px 2px rgba(0,0,0,.04);color:var(--bs-body-color,inherit);display:inline-flex;font-size:.8125rem;gap:.375rem;justify-content:space-between;line-height:1.125rem;max-width:100%;min-height:calc(1.5em + .25rem + 2px);padding:.1875rem .25rem .1875rem .625rem;text-align:left;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.bs-selected-item-content{align-items:center;display:inline-flex;gap:.5rem;min-width:0}.bs-selected-item-icon{flex:0 0 auto}.bs-selected-item:focus,.bs-selected-item:hover{background-color:var(--bs-secondary-bg,#e9ecef);border-color:rgba(13,110,253,.35);box-shadow:0 0 0 .2rem rgba(13,110,253,.12);outline:0}.bs-selected-item-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.bs-selected-item-remove{align-items:center;background:rgba(108,117,125,.14);border-radius:999px;color:var(--bs-secondary-color,#6c757d);display:inline-flex;flex:0 0 auto;font-size:.875rem;font-weight:900;height:1.125rem;justify-content:center;line-height:1;padding-bottom:0;width:1.125rem}.bootstrap-select.selected-items-style-list .bs-selected-items{display:block;margin-top:.5rem}.bootstrap-select.selected-items-style-list .bs-selected-items-external{padding:0}.bootstrap-select.selected-items-style-list .bs-selected-item{background-color:var(--bs-list-group-bg,#fff);border:1px solid var(--bs-list-group-border-color,rgba(0,0,0,.125));border-radius:0;box-shadow:none;color:var(--bs-list-group-color,inherit);font-size:1rem;line-height:1.5;min-height:0;padding:.75rem 1rem;width:100%}.bootstrap-select.selected-items-style-list .bs-selected-item:first-child{border-top-left-radius:var(--bs-list-group-border-radius,.375rem);border-top-right-radius:var(--bs-list-group-border-radius,.375rem)}.bootstrap-select.selected-items-style-list .bs-selected-item:last-child{border-bottom-left-radius:var(--bs-list-group-border-radius,.375rem);border-bottom-right-radius:var(--bs-list-group-border-radius,.375rem)}.bootstrap-select.selected-items-style-list .bs-selected-item+.bs-selected-item{margin-top:-1px}.bootstrap-select.selected-items-style-list .bs-selected-item:focus,.bootstrap-select.selected-items-style-list .bs-selected-item:hover{background-color:var(--bs-list-group-action-hover-bg,#f8f9fa);border-color:var(--bs-list-group-border-color,rgba(0,0,0,.125));box-shadow:none;color:var(--bs-list-group-action-hover-color,inherit)}.bootstrap-select.selected-items-style-list .bs-selected-item-content{flex:1 1 auto}.bootstrap-select.selected-items-style-list .bs-selected-item-label{white-space:normal}.bootstrap-select.selected-items-style-list .bs-selected-item-remove{background:transparent;border-radius:0;color:var(--bs-secondary-color,#6c757d);font-size:1rem;height:auto;margin-left:auto;width:auto}.form-floating>.bootstrap-select.show-selected-tags{background-color:var(--bs-body-bg,#fff);border:var(--bs-border-width,1px) solid var(--bs-border-color,#ced4da);border-radius:var(--bs-border-radius,.375rem);min-height:calc(3.5rem + 2px);position:relative;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-floating>.bootstrap-select.show-selected-tags.show,.form-floating>.bootstrap-select.show-selected-tags:focus-within{border-color:#86b7fe;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-floating>.bootstrap-select.show-selected-tags>.dropdown-toggle{background-color:initial;border:0;box-shadow:none;height:auto;min-height:0;padding-bottom:.25rem;padding-top:1.375rem}.form-floating>.bootstrap-select.show-selected-tags>.dropdown-toggle:active,.form-floating>.bootstrap-select.show-selected-tags>.dropdown-toggle:focus,.form-floating>.bootstrap-select.show-selected-tags>.dropdown-toggle:hover{background-color:initial;box-shadow:none}.form-floating>.bootstrap-select.show-selected-tags>.dropdown-toggle .filter-option-inner-inner{opacity:0}.form-floating>.bootstrap-select.show-selected-tags>.bs-selected-items-external{margin:0 2.25rem 0 .75rem;padding-bottom:1.375rem;position:relative;z-index:3}.form-floating>.bootstrap-select.show-selected-tags~label{padding-top:.75rem} diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.js b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.js new file mode 100644 index 000000000..36b5db6d7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.js @@ -0,0 +1,3817 @@ +/*! + * Bootstrap-select v1.2.0 (https://github.com/CrestApps/bootstrap-select) + * + * CrestApps fork (vanilla JavaScript, Bootstrap 5+) of snapappointments/bootstrap-select + * Copyright 2012-2018 SnapAppointments, LLC (original work) + * Fork modifications Copyright 2024-2026 CrestApps + * Licensed under MIT (https://github.com/CrestApps/bootstrap-select/blob/main/LICENSE) + */ +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as an anonymous module. + define(['bootstrap'], factory); + } else if (typeof module === 'object' && module.exports) { + // CommonJS-like environments (Node, bundlers). + var bootstrap; + try { + bootstrap = require('bootstrap'); + } catch (e) { + bootstrap = undefined; + } + module.exports = factory(bootstrap); + } else { + // Browser globals. + factory(typeof window !== 'undefined' ? window.bootstrap : undefined); + } +}(function (bootstrap) { + var __SELECTPICKER_EXPOSE_GLOBAL__ = true; + +/* eslint-disable no-unused-vars */ +// Shared ordered source fragment consumed by the Grunt JS build. + +'use strict'; + +// Resolve Bootstrap's Dropdown component (Bootstrap 5+). It may be provided +// by the UMD factory (`bootstrap`), or available as a global. +function getDropdown () { + var bs = bootstrap || (typeof window !== 'undefined' ? window.bootstrap : undefined); + return (bs && bs.Dropdown) || (typeof window !== 'undefined' ? window.Dropdown : undefined); +} + +// +function createFromHTML (html) { + var wrapper = document.createElement('div'); + wrapper.innerHTML = html.trim(); + return wrapper.firstChild; +} + +function toInteger (value) { + return parseInt(value, 10) || 0; +} + +function offset (el) { + var rect = el.getBoundingClientRect(); + return { + top: rect.top + window.pageYOffset, + left: rect.left + window.pageXOffset + }; +} + +// Resolves a container option (selector string or element) to an element. +function resolveContainer (container) { + if (!container) return null; + return typeof container === 'string' ? document.querySelector(container) : container; +} + +function outerHeight (el, includeMargin) { + var height = el.offsetHeight; + if (includeMargin) { + var style = window.getComputedStyle(el); + height += toInteger(style.marginTop) + toInteger(style.marginBottom); + } + return height; +} + +function setStyles (el, styles) { + for (var prop in styles) { + if (Object.prototype.hasOwnProperty.call(styles, prop)) { + el.style[prop] = styles[prop]; + } + } +} + +function triggerNative (el, eventName) { + el.dispatchEvent(new Event(eventName, { bubbles: true })); +} + +// shallow array comparison +function isEqual (array1, array2) { + return array1.length === array2.length && array1.every(function (element, index) { + return element === array2[index]; + }); +} + +function toKebabCase (str) { + return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, function ($, ofs) { + return (ofs ? '-' : '') + $.toLowerCase(); + }); +} + +function toCamelCase (str) { + return str.replace(/-([a-z])/g, function (m, letter) { + return letter.toUpperCase(); + }); +} + +// Read options from data-* attributes using native values where possible. +function convertDataValue (value) { + if (value === 'true') return true; + if (value === 'false') return false; + if (value === 'null') return null; + if (value === +value + '') return +value; + if (/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/.test(value)) { + try { + return JSON.parse(value); + } catch (e) { + return value; + } + } + return value; +} + +function getDataset (el) { + var dataset = {}, + attributes = el.attributes; + + for (var i = 0; i < attributes.length; i++) { + var name = attributes[i].name; + if (name.indexOf('data-') === 0) { + dataset[toCamelCase(name.slice(5))] = convertDataValue(attributes[i].value); + } + } + + return dataset; +} +// + +// +var DISALLOWED_ATTRIBUTES = ['sanitize', 'whiteList', 'sanitizeFn']; + +var uriAttrs = [ + 'background', + 'cite', + 'href', + 'itemtype', + 'longdesc', + 'poster', + 'src', + 'xlink:href' +]; + +var ARIA_ATTRIBUTE_PATTERN = /^aria-[\w-]*$/i; + +var DefaultWhitelist = { + // Global attributes allowed on any supplied element below. + '*': ['class', 'dir', 'id', 'lang', 'role', 'tabindex', 'style', ARIA_ATTRIBUTE_PATTERN], + a: ['target', 'href', 'title', 'rel'], + area: [], + b: [], + br: [], + col: [], + code: [], + div: [], + em: [], + hr: [], + h1: [], + h2: [], + h3: [], + h4: [], + h5: [], + h6: [], + i: [], + img: ['src', 'alt', 'title', 'width', 'height'], + li: [], + ol: [], + p: [], + pre: [], + s: [], + small: [], + span: [], + sub: [], + sup: [], + strong: [], + u: [], + ul: [] +}; + +// A pattern that recognizes a commonly useful subset of URLs that are safe. +var SAFE_URL_PATTERN = /^(?:(?:https?|mailto|ftp|tel|file):|[^&:/?#]*(?:[/?#]|$))/gi; + +// A pattern that matches safe data URLs. Only matches image, video and audio types. +var DATA_URL_PATTERN = /^data:(?:image\/(?:bmp|gif|jpeg|jpg|png|tiff|webp)|video\/(?:mpeg|mp4|ogg|webm)|audio\/(?:mp3|oga|ogg|opus));base64,[a-z0-9+/]+=*$/i; + +var ParseableAttributes = ['placeholder']; // attributes to use as settings, can add others in the future + +function applyLegacyOptions (element, config) { + if (!config.placeholder) { + var title = element.getAttribute('title'); + if (title) config.placeholder = title; + } + + return config; +} + +function allowedAttribute (attr, allowedAttributeList) { + var attrName = attr.nodeName.toLowerCase(); + + if (allowedAttributeList.indexOf(attrName) !== -1) { + if (uriAttrs.indexOf(attrName) !== -1) { + return Boolean(attr.nodeValue.match(SAFE_URL_PATTERN) || attr.nodeValue.match(DATA_URL_PATTERN)); + } + + return true; + } + + var regExp = allowedAttributeList.filter(function (value) { + return value instanceof RegExp; + }); + + // Check if a regular expression validates the attribute. + for (var i = 0, l = regExp.length; i < l; i++) { + if (attrName.match(regExp[i])) { + return true; + } + } + + return false; +} + +function sanitizeHtml (unsafeElements, whiteList, sanitizeFn) { + if (sanitizeFn && typeof sanitizeFn === 'function') { + return sanitizeFn(unsafeElements); + } + + var whitelistKeys = Object.keys(whiteList); + + for (var i = 0, len = unsafeElements.length; i < len; i++) { + var elements = unsafeElements[i].querySelectorAll('*'); + + for (var j = 0, len2 = elements.length; j < len2; j++) { + var el = elements[j]; + var elName = el.nodeName.toLowerCase(); + + if (whitelistKeys.indexOf(elName) === -1) { + el.parentNode.removeChild(el); + + continue; + } + + var attributeList = [].slice.call(el.attributes); + var whitelistedAttributes = [].concat(whiteList['*'] || [], whiteList[elName] || []); + + for (var k = 0, len3 = attributeList.length; k < len3; k++) { + var attr = attributeList[k]; + + if (!allowedAttribute(attr, whitelistedAttributes)) { + el.removeAttribute(attr.nodeName); + } + } + } + } +} +// + +function getAttributesObject (element) { + var attributesObject = {}, + attrVal; + + ParseableAttributes.forEach(function (item) { + attrVal = element.getAttribute(item); + if (attrVal) attributesObject[item] = attrVal; + }); + + return attributesObject; +} + + +/* eslint-disable no-unused-vars */ +// Shared ordered source fragment consumed by the Grunt JS build. + +// +function stringSearch (li, searchString, method, normalize) { + var stringTypes = [ + 'display', + 'subtext', + 'tokens' + ], + searchSuccess = false; + + for (var i = 0; i < stringTypes.length; i++) { + var stringType = stringTypes[i], + string = li[stringType]; + + if (string) { + string = string.toString(); + + // Strip HTML tags. This isn't perfect, but it's much faster than any other method + if (stringType === 'display') { + string = string.replace(/<[^>]+>/g, ''); + } + + if (normalize) string = normalizeToBase(string); + string = string.toUpperCase(); + + if (typeof method === 'function') { + searchSuccess = method(string, searchString); + } else if (method === 'contains') { + searchSuccess = string.indexOf(searchString) >= 0; + } else { + searchSuccess = string.startsWith(searchString); + } + + if (searchSuccess) break; + } + } + + return searchSuccess; +} + +function normalizeSearchInput (value, normalize) { + if (value === undefined || value === null) value = ''; + value = value.toString().trim(); + + if (normalize && value) value = normalizeToBase(value); + + return value.toUpperCase(); +} + +function getOptionLabelText (option) { + if (!option) return ''; + + return option.title || option.text || option.value || ''; +} + +// Borrowed from Lodash (_.deburr) +/** Used to map Latin Unicode letters to basic Latin letters. */ +var deburredLetters = { + // Latin-1 Supplement block. + '\xc0': 'A', '\xc1': 'A', '\xc2': 'A', '\xc3': 'A', '\xc4': 'A', '\xc5': 'A', + '\xe0': 'a', '\xe1': 'a', '\xe2': 'a', '\xe3': 'a', '\xe4': 'a', '\xe5': 'a', + '\xc7': 'C', '\xe7': 'c', + '\xd0': 'D', '\xf0': 'd', + '\xc8': 'E', '\xc9': 'E', '\xca': 'E', '\xcb': 'E', + '\xe8': 'e', '\xe9': 'e', '\xea': 'e', '\xeb': 'e', + '\xcc': 'I', '\xcd': 'I', '\xce': 'I', '\xcf': 'I', + '\xec': 'i', '\xed': 'i', '\xee': 'i', '\xef': 'i', + '\xd1': 'N', '\xf1': 'n', + '\xd2': 'O', '\xd3': 'O', '\xd4': 'O', '\xd5': 'O', '\xd6': 'O', '\xd8': 'O', + '\xf2': 'o', '\xf3': 'o', '\xf4': 'o', '\xf5': 'o', '\xf6': 'o', '\xf8': 'o', + '\xd9': 'U', '\xda': 'U', '\xdb': 'U', '\xdc': 'U', + '\xf9': 'u', '\xfa': 'u', '\xfb': 'u', '\xfc': 'u', + '\xdd': 'Y', '\xfd': 'y', '\xff': 'y', + '\xc6': 'Ae', '\xe6': 'ae', + '\xde': 'Th', '\xfe': 'th', + '\xdf': 'ss', + // Latin Extended-A block. + '\u0100': 'A', '\u0102': 'A', '\u0104': 'A', + '\u0101': 'a', '\u0103': 'a', '\u0105': 'a', + '\u0106': 'C', '\u0108': 'C', '\u010a': 'C', '\u010c': 'C', + '\u0107': 'c', '\u0109': 'c', '\u010b': 'c', '\u010d': 'c', + '\u010e': 'D', '\u0110': 'D', '\u010f': 'd', '\u0111': 'd', + '\u0112': 'E', '\u0114': 'E', '\u0116': 'E', '\u0118': 'E', '\u011a': 'E', + '\u0113': 'e', '\u0115': 'e', '\u0117': 'e', '\u0119': 'e', '\u011b': 'e', + '\u011c': 'G', '\u011e': 'G', '\u0120': 'G', '\u0122': 'G', + '\u011d': 'g', '\u011f': 'g', '\u0121': 'g', '\u0123': 'g', + '\u0124': 'H', '\u0126': 'H', '\u0125': 'h', '\u0127': 'h', + '\u0128': 'I', '\u012a': 'I', '\u012c': 'I', '\u012e': 'I', '\u0130': 'I', + '\u0129': 'i', '\u012b': 'i', '\u012d': 'i', '\u012f': 'i', '\u0131': 'i', + '\u0134': 'J', '\u0135': 'j', + '\u0136': 'K', '\u0137': 'k', '\u0138': 'k', + '\u0139': 'L', '\u013b': 'L', '\u013d': 'L', '\u013f': 'L', '\u0141': 'L', + '\u013a': 'l', '\u013c': 'l', '\u013e': 'l', '\u0140': 'l', '\u0142': 'l', + '\u0143': 'N', '\u0145': 'N', '\u0147': 'N', '\u014a': 'N', + '\u0144': 'n', '\u0146': 'n', '\u0148': 'n', '\u014b': 'n', + '\u014c': 'O', '\u014e': 'O', '\u0150': 'O', + '\u014d': 'o', '\u014f': 'o', '\u0151': 'o', + '\u0154': 'R', '\u0156': 'R', '\u0158': 'R', + '\u0155': 'r', '\u0157': 'r', '\u0159': 'r', + '\u015a': 'S', '\u015c': 'S', '\u015e': 'S', '\u0160': 'S', + '\u015b': 's', '\u015d': 's', '\u015f': 's', '\u0161': 's', + '\u0162': 'T', '\u0164': 'T', '\u0166': 'T', + '\u0163': 't', '\u0165': 't', '\u0167': 't', + '\u0168': 'U', '\u016a': 'U', '\u016c': 'U', '\u016e': 'U', '\u0170': 'U', '\u0172': 'U', + '\u0169': 'u', '\u016b': 'u', '\u016d': 'u', '\u016f': 'u', '\u0171': 'u', '\u0173': 'u', + '\u0174': 'W', '\u0175': 'w', + '\u0176': 'Y', '\u0177': 'y', '\u0178': 'Y', + '\u0179': 'Z', '\u017b': 'Z', '\u017d': 'Z', + '\u017a': 'z', '\u017c': 'z', '\u017e': 'z', + '\u0132': 'IJ', '\u0133': 'ij', + '\u0152': 'Oe', '\u0153': 'oe', + '\u0149': "'n", '\u017f': 's' +}; + +/** Used to match Latin Unicode letters (excluding mathematical operators). */ +var reLatin = /[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g; + +/** Used to compose unicode character classes. */ +var rsComboMarksRange = '\\u0300-\\u036f', + reComboHalfMarksRange = '\\ufe20-\\ufe2f', + rsComboSymbolsRange = '\\u20d0-\\u20ff', + rsComboMarksExtendedRange = '\\u1ab0-\\u1aff', + rsComboMarksSupplementRange = '\\u1dc0-\\u1dff', + rsComboRange = rsComboMarksRange + reComboHalfMarksRange + rsComboSymbolsRange + rsComboMarksExtendedRange + rsComboMarksSupplementRange; + +/** Used to compose unicode capture groups. */ +var rsCombo = '[' + rsComboRange + ']'; + +var reComboMark = RegExp(rsCombo, 'g'); + +function deburrLetter (key) { + return deburredLetters[key]; +} + +function normalizeToBase (string) { + string = string.toString(); + return string && string.replace(reLatin, deburrLetter).replace(reComboMark, ''); +} + +// List of HTML entities for escaping. +var escapeMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''', + '`': '`' +}; + +var createEscaper = function (map) { + var escaper = function (match) { + return map[match]; + }; + var source = '(?:' + Object.keys(map).join('|') + ')'; + var testRegexp = RegExp(source); + var replaceRegexp = RegExp(source, 'g'); + return function (string) { + string = string == null ? '' : '' + string; + return testRegexp.test(string) ? string.replace(replaceRegexp, escaper) : string; + }; +}; + +var htmlEscape = createEscaper(escapeMap); +// + + +/* eslint-disable no-undef, no-unused-vars */ +// Shared ordered source fragment consumed by the Grunt JS build. + +// +var keyCodeMap = { + 32: ' ', 48: '0', 49: '1', 50: '2', 51: '3', 52: '4', 53: '5', 54: '6', + 55: '7', 56: '8', 57: '9', 59: ';', + 65: 'A', 66: 'B', 67: 'C', 68: 'D', 69: 'E', 70: 'F', 71: 'G', 72: 'H', + 73: 'I', 74: 'J', 75: 'K', 76: 'L', 77: 'M', 78: 'N', 79: 'O', 80: 'P', + 81: 'Q', 82: 'R', 83: 'S', 84: 'T', 85: 'U', 86: 'V', 87: 'W', 88: 'X', + 89: 'Y', 90: 'Z', + 96: '0', 97: '1', 98: '2', 99: '3', 100: '4', 101: '5', 102: '6', + 103: '7', 104: '8', 105: '9' +}; + +var keyCodes = { + ESCAPE: 27, + ENTER: 13, + SPACE: 32, + TAB: 9, + ARROW_UP: 38, + ARROW_DOWN: 40 +}; + +var selectId = 0; + +var EVENT_KEY = '.bs.select'; + +// Bootstrap 5 class names. +var classNames = { + DISABLED: 'disabled', + DIVIDER: 'dropdown-divider', + SHOW: 'show', + DROPUP: 'dropup', + MENU: 'dropdown-menu', + MENUEND: 'dropdown-menu-end', + BUTTONCLASS: 'btn-light', + POPOVERHEADER: 'popover-header', + ICONBASE: '', + TICKICON: 'bs-ok-default' +}; + +var Selector = { + MENU: '.' + classNames.MENU, + DATA_TOGGLE: 'data-bs-toggle="dropdown"' +}; + +var elementTemplates = { + div: document.createElement('div'), + span: document.createElement('span'), + i: document.createElement('i'), + subtext: document.createElement('small'), + a: document.createElement('a'), + li: document.createElement('li'), + whitespace: document.createTextNode('\u00A0'), + fragment: document.createDocumentFragment(), + option: document.createElement('option') +}; + +elementTemplates.selectedOption = elementTemplates.option.cloneNode(false); +elementTemplates.selectedOption.setAttribute('selected', true); + +elementTemplates.noResults = elementTemplates.li.cloneNode(false); +elementTemplates.noResults.className = 'no-results'; + +elementTemplates.a.setAttribute('role', 'option'); +elementTemplates.a.className = 'dropdown-item'; + +elementTemplates.subtext.className = 'text-muted'; + +elementTemplates.text = elementTemplates.span.cloneNode(false); +elementTemplates.text.className = 'text'; + +elementTemplates.checkMark = elementTemplates.span.cloneNode(false); + +var REGEXP_ARROW = new RegExp(keyCodes.ARROW_UP + '|' + keyCodes.ARROW_DOWN); +var REGEXP_TAB_OR_ESCAPE = new RegExp('^' + keyCodes.TAB + '$|' + keyCodes.ESCAPE); + +var generateOption = { + li: function (content, classes, optgroup) { + var li = elementTemplates.li.cloneNode(false); + + if (content) { + if (content.nodeType === 1 || content.nodeType === 11) { + li.appendChild(content); + } else { + li.innerHTML = content; + } + } + + if (typeof classes !== 'undefined' && classes !== '') li.className = classes; + if (typeof optgroup !== 'undefined' && optgroup !== null) li.classList.add('optgroup-' + optgroup); + + return li; + }, + + a: function (text, classes, inline) { + var a = elementTemplates.a.cloneNode(true); + + if (text) { + if (text.nodeType === 11) { + a.appendChild(text); + } else { + a.insertAdjacentHTML('beforeend', text); + } + } + + if (typeof classes !== 'undefined' && classes !== '') a.classList.add.apply(a.classList, classes.split(/\s+/)); + if (inline) a.setAttribute('style', inline); + + return a; + }, + + text: function (options, useFragment) { + var textElement = elementTemplates.text.cloneNode(false), + subtextElement, + iconElement; + + if (options.content) { + textElement.innerHTML = options.content; + } else { + textElement.textContent = options.text; + + if (options.icon) { + var whitespace = elementTemplates.whitespace.cloneNode(false); + + // need to use for icons in the button to prevent a breaking change + iconElement = (useFragment === true ? elementTemplates.i : elementTemplates.span).cloneNode(false); + iconElement.className = this.options.iconBase + ' ' + options.icon; + + elementTemplates.fragment.appendChild(iconElement); + elementTemplates.fragment.appendChild(whitespace); + } + + if (options.subtext) { + subtextElement = elementTemplates.subtext.cloneNode(false); + subtextElement.textContent = options.subtext; + textElement.appendChild(subtextElement); + } + } + + if (useFragment === true) { + while (textElement.childNodes.length > 0) { + elementTemplates.fragment.appendChild(textElement.childNodes[0]); + } + } else { + elementTemplates.fragment.appendChild(textElement); + } + + return elementTemplates.fragment; + }, + + label: function (options) { + var textElement = elementTemplates.text.cloneNode(false), + subtextElement, + iconElement; + + textElement.innerHTML = options.display; + + if (options.icon) { + var whitespace = elementTemplates.whitespace.cloneNode(false); + + iconElement = elementTemplates.span.cloneNode(false); + iconElement.className = this.options.iconBase + ' ' + options.icon; + + elementTemplates.fragment.appendChild(iconElement); + elementTemplates.fragment.appendChild(whitespace); + } + + if (options.subtext) { + subtextElement = elementTemplates.subtext.cloneNode(false); + subtextElement.textContent = options.subtext; + textElement.appendChild(subtextElement); + } + + elementTemplates.fragment.appendChild(textElement); + + return elementTemplates.fragment; + } +}; + +var getOptionData = { + fromOption: function (option, type) { + var value; + + switch (type) { + case 'divider': + value = option.getAttribute('data-divider') === 'true'; + break; + + case 'text': + value = option.textContent; + break; + + case 'label': + value = option.label; + break; + + case 'style': + value = option.style.cssText; + break; + + case 'title': + value = option.title; + break; + + default: + value = option.getAttribute('data-' + toKebabCase(type)); + break; + } + + return value; + }, + fromDataSource: function (option, type) { + var value; + + switch (type) { + case 'text': + case 'label': + value = option.text || option.value || ''; + break; + + default: + value = option[type]; + break; + } + + return value; + } +}; + +function showNoResults (searchMatch, searchValue) { + if (!searchMatch.length) { + elementTemplates.noResults.innerHTML = this.options.noneResultsText.replace('{0}', '"' + htmlEscape(searchValue) + '"'); + this.menuInner.firstChild.appendChild(elementTemplates.noResults); + } +} + +function filterHidden (item) { + return !(item.hidden || this.options.hideDisabled && item.disabled); +} + +function getSelectedOptions () { + var options = this.selectpicker.main.data; + + if (this.options.source.data || this.options.source.search) { + options = Object.values(this.selectpicker.optionValuesDataMap); + } + + var selectedOptions = options.filter(function (item) { + if (item.selected) { + if (this.options.hideDisabled && item.disabled) return false; + return true; + } + + return false; + }, this); + + // ensure only 1 option is selected if multiple are set in the data source + if (this.options.source.data && !this.multiple && selectedOptions.length > 1) { + for (var i = 0; i < selectedOptions.length - 1; i++) { + selectedOptions[i].selected = false; + } + + selectedOptions = [ selectedOptions[selectedOptions.length - 1] ]; + } + + return selectedOptions; +} + +function getSelectValues (selectedOptions) { + var value = [], + options = selectedOptions || getSelectedOptions.call(this), + opt; + + for (var i = 0, len = options.length; i < len; i++) { + opt = options[i]; + + if (!opt.disabled) { + value.push(opt.value === undefined ? opt.text : opt.value); + } + } + + if (!this.multiple) { + return !value.length ? null : value[0]; + } + + return value; +} +// + +var changedArguments = null; + +// shared flag for spacebar selection handling (mirrors original document data flag) +var spaceSelectFlag = false; + +var REMOVED_OPTIONS = ['container', 'display', 'mobile', 'styleBase', 'windowPadding']; + +function stripRemovedOptions (source) { + if (!source || typeof source !== 'object') return source; + + var result = Object.assign({}, source); + + for (var i = 0; i < REMOVED_OPTIONS.length; i++) { + delete result[REMOVED_OPTIONS[i]]; + } + + return result; +} + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + +class Selectpicker { + constructor (element, options) { + if (typeof element === 'string') { + element = document.querySelector(element); + } + + if (!element || element.tagName !== 'SELECT') { + throw new TypeError('Selectpicker requires a select element or selector.'); + } + + this.element = element; + this.newElement = null; + this.button = null; + this.menu = null; + this.options = Selectpicker._buildConfig(element, options || {}); + + // tracked event listeners for clean teardown + this._listeners = []; + this._named = {}; + + this.selectpicker = { + main: { + data: [], + optionQueue: elementTemplates.fragment.cloneNode(false), + hasMore: false + }, + search: { + data: [], + hasMore: false + }, + current: {}, // current is either equal to main or search depending on if a search is in progress + view: {}, + // map of option values and their respective data (only used in conjunction with options.source) + optionValuesDataMap: {}, + createdOptions: [], + openOption: { + isCreating: false + }, + isSearching: false, + keydown: { + keyHistory: '', + resetKeyHistory: { + start: () => { + return setTimeout(() => { + this.selectpicker.keydown.keyHistory = ''; + }, 800); + } + } + } + }; + + this.sizeInfo = {}; + + this.init(); + + instanceMap.set(element, this); + } + + // + _on (el, type, handler, options) { + el.addEventListener(type, handler, options); + this._listeners.push({ el: el, type: type, handler: handler, options: options }); + return handler; + } + + _delegate (el, type, selector, handler, options) { + var listener = function (e) { + var target = e.target.closest(selector); + if (target && el.contains(target)) { + handler.call(target, e); + } + }; + return this._on(el, type, listener, options); + } + + _emit (name, detail) { + var event = new CustomEvent(name + EVENT_KEY, { + bubbles: true, + cancelable: true, + detail: detail || null + }); + this.element.dispatchEvent(event); + return event; + } + + // adds an event listener that replaces any previously-registered listener under `key` + _replace (key, el, type, handler, options) { + this._removeNamed(key); + el.addEventListener(type, handler, options); + this._named[key] = { el: el, type: type, handler: handler, options: options }; + } + + _removeNamed (key) { + var prev = this._named[key]; + if (prev) { + prev.el.removeEventListener(prev.type, prev.handler, prev.options); + delete this._named[key]; + } + } + // + + init () { + var that = this, + id = this.element.getAttribute('id'), + element = this.element, + form = element.form; + + selectId++; + this.selectId = 'bs-select-' + selectId; + + element.classList.add('bs-select-hidden'); + + this.multiple = this.element.multiple; + this.autofocus = this.element.autofocus; + + if (element.classList.contains('show-tick')) { + this.options.showTick = true; + } + + this.newElement = this.createDropdown(); + + // insert newElement after element, then move element to be the first child of newElement + element.parentNode.insertBefore(this.newElement, element.nextSibling); + this.newElement.insertBefore(element, this.newElement.firstChild); + + // ensure select is associated with form element if it got unlinked after moving it inside newElement + if (form && element.form === null) { + if (!form.id) form.id = 'form-' + this.selectId; + element.setAttribute('form', form.id); + } + + this.button = this.newElement.querySelector(':scope > button'); + if (this.options.allowClear) this.clearButton = this.button.querySelector('.bs-select-clear-selected'); + this.menu = this.newElement.querySelector(':scope > ' + Selector.MENU); + this.menuInner = this.menu.querySelector('.inner'); + this.searchbox = this.menu.querySelector('input'); + this.selectedItems = this.newElement.querySelector(':scope > .bs-selected-items-external') || this.menu.querySelector('.bs-selected-items'); + this.createOptionButton = this.menu.querySelector('.bs-create-option'); + + element.classList.remove('bs-select-hidden'); + + this.fetchData(function () { + that.render(true); + that.buildList(); + + requestAnimationFrame(function () { + that._emit('loaded'); + }); + }); + + if (this.options.dropdownAlignRight === true) this.menu.classList.add(classNames.MENUEND); + + if (typeof id !== 'undefined' && id !== null) { + this.button.setAttribute('data-id', id); + } + + this.checkDisabled(); + this.clickListener(); + + var Dropdown = getDropdown(); + this.dropdown = new Dropdown(this.button); + + // store a reference to the instance for delegated handlers + this.newElement.bootstrapSelectInstance = this; + this.menu.bootstrapSelectInstance = this; + + if (this.options.liveSearch) { + this.liveSearchListener(); + this.focusedParent = this.searchbox; + } else { + this.focusedParent = this.menuInner; + } + + this.setStyle(); + this.setWidth(); + this._on(this.element, 'hide' + EVENT_KEY, function () { + if (that.isVirtual()) { + // empty menu on close + var menuInner = that.menuInner, + emptyMenu = menuInner.firstChild.cloneNode(false); + + // replace the existing UL with an empty one - this is faster than emptying it + menuInner.replaceChild(emptyMenu, menuInner.firstChild); + menuInner.scrollTop = 0; + } + }); + + // re-emit Bootstrap dropdown events as bootstrap-select events + this._on(this.newElement, 'hide.bs.dropdown', function (e) { + that._emit('hide', { bsEvent: e }); + }); + this._on(this.newElement, 'hidden.bs.dropdown', function (e) { + that._emit('hidden', { bsEvent: e }); + }); + this._on(this.newElement, 'show.bs.dropdown', function (e) { + that.onShow(e); + that._emit('show', { bsEvent: e }); + }); + this._on(this.newElement, 'shown.bs.dropdown', function (e) { + that._emit('shown', { bsEvent: e }); + }); + + if (element.hasAttribute('required')) { + this._on(this.element, 'invalid', function () { + that.button.classList.add('bs-invalid'); + + var onShownInvalid = function () { + // set the value to hide the validation message in Chrome when menu is opened + triggerNative(that.element, 'change'); + that.element.removeEventListener('shown' + EVENT_KEY, onShownInvalid); + }; + that._on(that.element, 'shown' + EVENT_KEY, onShownInvalid); + + var onRendered = function () { + // if select is no longer invalid, remove the bs-invalid class + if (that.element.validity.valid) that.button.classList.remove('bs-invalid'); + that.element.removeEventListener('rendered' + EVENT_KEY, onRendered); + }; + that._on(that.element, 'rendered' + EVENT_KEY, onRendered); + + var onBlur = function () { + that.element.focus(); + that.element.blur(); + that.button.removeEventListener('blur' + EVENT_KEY, onBlur); + }; + that._on(that.button, 'blur' + EVENT_KEY, onBlur); + }); + } + + if (form) { + this._on(form, 'reset', function () { + requestAnimationFrame(function () { + that.render(); + }); + }); + } + } + + createDropdown () { + // Multiple selects always show an indicator. Single selects also need the + // indicator column when selectionIndicator is enabled. + var usesSelectionIndicator = this.options.selectionIndicator === 'checkbox', + showTick = (this.multiple || this.options.showTick || usesSelectionIndicator) ? ' show-tick' : '', + showSelectedTags = this.options.showSelectedTags ? ' show-selected-tags' : '', + selectedItemsStyle = this.options.selectedItemsStyle === 'list' ? ' selected-items-style-list' : '', + selectionIndicator = usesSelectionIndicator + ? (this.multiple ? ' selection-indicator-checkbox' : ' selection-indicator-radio') + : '', + multiselectable = this.multiple ? ' aria-multiselectable="true"' : '', + autofocus = this.autofocus ? ' autofocus' : '', + liveSearchPlaceholder = this.options.liveSearchPlaceholder; + + if (liveSearchPlaceholder === null && (this.options.showSelectedTags || this.options.openOptions)) { + liveSearchPlaceholder = this.options.placeholder || 'Search'; + } + + // Elements + var drop, + header = '', + searchbox = '', + actionsbox = '', + donebutton = '', + clearButton = ''; + + if (this.options.header) { + header = + '
    ' + + '' + this.options.header + '' + + '' + + '
    '; + } + + if (this.options.liveSearch) { + searchbox = + ''; + } + + if (this.multiple && this.options.actionsBox) { + actionsbox = + '
    ' + + '
    ' + + '' + + '' + + '
    ' + + '
    '; + } + + if (this.multiple && this.options.doneButton) { + donebutton = + '
    ' + + '
    ' + + '' + + '
    ' + + '
    '; + } + + if (this.options.allowClear) { + clearButton = '×'; + } + + drop = + ''; + + return createFromHTML(drop); + } + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + // runs when the dropdown is about to be shown + onShow () { + if (this.options.liveSearch && this.searchbox.value) { + this.searchbox.value = ''; + this.selectpicker.search.previousValue = undefined; + } + + if (!this.newElement.classList.contains(classNames.SHOW)) { + this.setSize(); + } + } + + setPositionData () { + this.selectpicker.view.canHighlight = []; + this.selectpicker.view.size = 0; + this.selectpicker.view.firstHighlightIndex = false; + + for (var i = 0; i < this.selectpicker.current.data.length; i++) { + var li = this.selectpicker.current.data[i], + canHighlight = true; + + if (li.type === 'divider') { + canHighlight = false; + li.height = this.sizeInfo.dividerHeight; + } else if (li.type === 'optgroup-label') { + canHighlight = false; + li.height = this.sizeInfo.dropdownHeaderHeight; + } else { + li.height = this.sizeInfo.liHeight; + } + + if (li.disabled) canHighlight = false; + + this.selectpicker.view.canHighlight.push(canHighlight); + + if (canHighlight) { + this.selectpicker.view.size++; + li.posinset = this.selectpicker.view.size; + if (this.selectpicker.view.firstHighlightIndex === false) this.selectpicker.view.firstHighlightIndex = i; + } + + li.position = (i === 0 ? 0 : this.selectpicker.current.data[i - 1].position) + li.height; + } + } + + isVirtual () { + return (this.options.virtualScroll !== false) && (this.selectpicker.main.data.length >= this.options.virtualScroll) || this.options.virtualScroll === true; + } + + createView (isSearching, setSize, refresh) { + var that = this, + scrollTop = 0; + + this.selectpicker.isSearching = isSearching; + this.selectpicker.current = isSearching ? this.selectpicker.search : this.selectpicker.main; + + this.setPositionData(); + + if (setSize) { + if (refresh) { + scrollTop = this.menuInner.scrollTop; + } else if (!that.multiple) { + var element = that.element, + selectedIndex = (element.options[element.selectedIndex] || {}).liIndex; + + if (typeof selectedIndex === 'number' && that.options.size !== false) { + var selectedData = that.selectpicker.main.data[selectedIndex], + position = selectedData && selectedData.position; + + if (position) { + scrollTop = position - ((that.sizeInfo.menuInnerHeight + that.sizeInfo.liHeight) / 2); + } + } + } + } + + scroll(scrollTop, true); + + this._replace('createViewScroll', this.menuInner, 'scroll', function () { + if (!that.noScroll) scroll(that.menuInner.scrollTop); + that.noScroll = false; + }); + + function scroll (scrollTop, init) { + var size = that.selectpicker.current.data.length, + chunks = [], + chunkSize, + chunkCount, + firstChunk, + lastChunk, + currentChunk, + prevPositions, + positionIsDifferent, + previousElements, + menuIsDifferent = true, + isVirtual = that.isVirtual(); + + that.selectpicker.view.scrollTop = scrollTop; + + chunkSize = that.options.chunkSize; // number of options in a chunk + chunkCount = Math.ceil(size / chunkSize) || 1; // number of chunks + + for (var i = 0; i < chunkCount; i++) { + var endOfChunk = (i + 1) * chunkSize; + + if (i === chunkCount - 1) { + endOfChunk = size; + } + + chunks[i] = [ + (i) * chunkSize + (!i ? 0 : 1), + endOfChunk + ]; + + if (!size) break; + + if (currentChunk === undefined && scrollTop - 1 <= that.selectpicker.current.data[endOfChunk - 1].position - that.sizeInfo.menuInnerHeight) { + currentChunk = i; + } + } + + if (currentChunk === undefined) currentChunk = 0; + + prevPositions = [that.selectpicker.view.position0, that.selectpicker.view.position1]; + + // always display previous, current, and next chunks + firstChunk = Math.max(0, currentChunk - 1); + lastChunk = Math.min(chunkCount - 1, currentChunk + 1); + + that.selectpicker.view.position0 = isVirtual === false ? 0 : (Math.max(0, chunks[firstChunk][0]) || 0); + that.selectpicker.view.position1 = isVirtual === false ? size : (Math.min(size, chunks[lastChunk][1]) || 0); + + positionIsDifferent = prevPositions[0] !== that.selectpicker.view.position0 || prevPositions[1] !== that.selectpicker.view.position1; + + if (that.activeElement !== undefined) { + if (init) { + if (that.activeElement !== that.selectedElement) { + that.defocusItem(that.activeElement); + } + that.activeElement = undefined; + } + + if (that.activeElement !== that.selectedElement) { + that.defocusItem(that.selectedElement); + } + } + + if (that.prevActiveElement !== undefined && that.prevActiveElement !== that.activeElement && that.prevActiveElement !== that.selectedElement) { + that.defocusItem(that.prevActiveElement); + } + + if (init || positionIsDifferent || that.selectpicker.current.hasMore) { + previousElements = that.selectpicker.view.visibleElements ? that.selectpicker.view.visibleElements.slice() : []; + + if (isVirtual === false) { + that.selectpicker.view.visibleElements = that.selectpicker.current.elements; + } else { + that.selectpicker.view.visibleElements = that.selectpicker.current.elements.slice(that.selectpicker.view.position0, that.selectpicker.view.position1); + } + + that.setOptionStatus(); + + // if searching, check to make sure the list has actually been updated before updating DOM + // this prevents unnecessary repaints + if (isSearching || (isVirtual === false && init)) menuIsDifferent = !isEqual(previousElements, that.selectpicker.view.visibleElements); + + // if virtual scroll is disabled and not searching, + // menu should never need to be updated more than once + if ((init || isVirtual === true) && menuIsDifferent) { + var menuInner = that.menuInner, + menuFragment = document.createDocumentFragment(), + emptyMenu = menuInner.firstChild.cloneNode(false), + marginTop, + marginBottom, + elements = that.selectpicker.view.visibleElements, + toSanitize = []; + + // replace the existing UL with an empty one - this is faster than emptying it + menuInner.replaceChild(emptyMenu, menuInner.firstChild); + + for (var i = 0, visibleElementsLen = elements.length; i < visibleElementsLen; i++) { + var element = elements[i], + elText, + elementData; + + if (that.options.sanitize) { + elText = element.lastChild; + + if (elText) { + elementData = that.selectpicker.current.data[i + that.selectpicker.view.position0]; + + if (elementData && elementData.content && !elementData.sanitized) { + toSanitize.push(elText); + elementData.sanitized = true; + } + } + } + + menuFragment.appendChild(element); + } + + if (that.options.sanitize && toSanitize.length) { + sanitizeHtml(toSanitize, that.options.whiteList, that.options.sanitizeFn); + } + + if (isVirtual === true) { + marginTop = (that.selectpicker.view.position0 === 0 ? 0 : that.selectpicker.current.data[that.selectpicker.view.position0 - 1].position); + marginBottom = (that.selectpicker.view.position1 > size - 1 ? 0 : that.selectpicker.current.data[size - 1].position - that.selectpicker.current.data[that.selectpicker.view.position1 - 1].position); + + menuInner.firstChild.style.marginTop = marginTop + 'px'; + menuInner.firstChild.style.marginBottom = marginBottom + 'px'; + } else { + menuInner.firstChild.style.marginTop = 0; + menuInner.firstChild.style.marginBottom = 0; + } + + menuInner.firstChild.appendChild(menuFragment); + + // if an option is encountered that is wider than the current menu width, update the menu width accordingly + if (isVirtual === true && that.sizeInfo.hasScrollBar) { + var menuInnerInnerWidth = menuInner.firstChild.offsetWidth; + + if (init && menuInnerInnerWidth < that.sizeInfo.menuInnerInnerWidth && that.sizeInfo.totalMenuWidth > that.sizeInfo.selectWidth) { + menuInner.firstChild.style.minWidth = that.sizeInfo.menuInnerInnerWidth + 'px'; + } else if (menuInnerInnerWidth > that.sizeInfo.menuInnerInnerWidth) { + // set to 0 to get actual width of menu + that.menu.style.minWidth = 0; + + var actualMenuWidth = menuInner.firstChild.offsetWidth; + + if (actualMenuWidth > that.sizeInfo.menuInnerInnerWidth) { + that.sizeInfo.menuInnerInnerWidth = actualMenuWidth; + menuInner.firstChild.style.minWidth = that.sizeInfo.menuInnerInnerWidth + 'px'; + } + + // reset to default CSS styling + that.menu.style.minWidth = ''; + } + } + } + + if ((!isSearching && that.options.source.data || isSearching && that.options.source.search) && that.selectpicker.current.hasMore && currentChunk === chunkCount - 1) { + // Don't load the next chunk until scrolling has started + // This prevents unnecessary requests while the user is typing if pageSize is <= chunkSize + if (scrollTop > 0) { + // Chunks use 0-based indexing, but pages use 1-based. Add 1 to convert and add 1 again to get next page + var page = Math.floor((currentChunk * that.options.chunkSize) / that.options.source.pageSize) + 2; + + that.fetchData(function () { + that.render(); + that.buildList(size, isSearching); + that.setPositionData(); + scroll(scrollTop); + }, isSearching ? 'search' : 'data', page, isSearching ? that.selectpicker.search.previousValue : undefined); + } + } + } + + that.prevActiveElement = that.activeElement; + + if (!that.options.liveSearch) { + that.menuInner.focus(); + } else if (isSearching && init) { + var index = 0, + newActive; + + if (!that.selectpicker.view.canHighlight[index]) { + index = 1 + that.selectpicker.view.canHighlight.slice(1).indexOf(true); + } + + newActive = that.selectpicker.view.visibleElements[index]; + + that.defocusItem(that.selectpicker.view.currentActive); + + that.activeElement = (that.selectpicker.current.data[index] || {}).element; + + that.focusItem(newActive); + } + } + + this._replace('createViewResize', window, 'resize', function () { + var isActive = that.newElement.classList.contains(classNames.SHOW); + + if (isActive) scroll(that.menuInner.scrollTop); + }); + } + + focusItem (li, liData, noStyle) { + if (li) { + liData = liData || this.selectpicker.current.data[this.selectpicker.current.elements.indexOf(this.activeElement)]; + var a = li.firstChild; + + if (a) { + a.setAttribute('aria-setsize', this.selectpicker.view.size); + a.setAttribute('aria-posinset', liData.posinset); + + if (noStyle !== true) { + this.focusedParent.setAttribute('aria-activedescendant', a.id); + li.classList.add('active'); + a.classList.add('active'); + } + } + } + } + + defocusItem (li) { + if (li) { + li.classList.remove('active'); + if (li.firstChild) li.firstChild.classList.remove('active'); + } + } + + setPlaceholder () { + var that = this, + updateIndex = false; + + if ((this.options.placeholder || this.options.allowClear) && !this.multiple) { + if (!this.selectpicker.view.titleOption) this.selectpicker.view.titleOption = document.createElement('option'); + + // this option doesn't create a new
  • element, but does add a new option at the start, + // so startIndex should increase to prevent having to check every option for the bs-title-option class + updateIndex = true; + + var element = this.element, + selectTitleOption = false, + titleNotAppended = !this.selectpicker.view.titleOption.parentNode, + selectedIndex = element.selectedIndex, + selectedOption = element.options[selectedIndex], + firstSelectable = element.querySelector('select > *:not(:disabled)'), + firstSelectableIndex = firstSelectable ? firstSelectable.index : 0, + navigation = window.performance && window.performance.getEntriesByType('navigation'), + // Safari doesn't support getEntriesByType('navigation') - fall back to performance.navigation + isNotBackForward = (navigation && navigation.length) ? navigation[0].type !== 'back_forward' : window.performance.navigation.type !== 2; + + if (titleNotAppended) { + // Use native JS to prepend option (faster) + this.selectpicker.view.titleOption.className = 'bs-title-option'; + this.selectpicker.view.titleOption.value = ''; + + // Check if selected or data-selected attribute is already set on an option. If not, select the titleOption option. + selectTitleOption = !selectedOption || (selectedIndex === firstSelectableIndex && selectedOption.defaultSelected === false); + } + + if (titleNotAppended || this.selectpicker.view.titleOption.index !== 0) { + element.insertBefore(this.selectpicker.view.titleOption, element.firstChild); + } + + // Set selected *after* appending to select + if (selectTitleOption && isNotBackForward) { + element.selectedIndex = 0; + } else if (document.readyState !== 'complete') { + // if navigation type is back_forward, there's a chance the select will have its value set by BFCache + // wait for that value to be set, then run render again + window.addEventListener('pageshow', function () { + if (that.selectpicker.view.displayedValue !== element.value) that.render(); + }); + } + } + + return updateIndex; + } + + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + fetchData (callback, type, page, searchValue) { + page = page || 1; + type = type || 'data'; + + var that = this, + data = this.options.source[type], + builtData; + + if (data) { + this.options.virtualScroll = true; + + if (typeof data === 'function') { + data.call( + this, + function (data, more, totalItems) { + var current = that.selectpicker[type === 'search' ? 'search' : 'main']; + current.hasMore = more; + current.totalItems = totalItems; + builtData = that.buildData(data, type); + callback.call(that, builtData); + that._emit('fetched'); + }, + page, + searchValue + ); + } else if (Array.isArray(data)) { + builtData = that.buildData(data, type); + callback.call(that, builtData); + } + } else { + builtData = this.buildData(false, type); + callback.call(that, builtData); + } + } + + buildData (data, type) { + var that = this; + var dataGetter = data === false ? getOptionData.fromOption : getOptionData.fromDataSource; + + var optionSelector = ':not([hidden]):not([data-hidden="true"]):not([style*="display: none"])', + mainData = [], + startLen = this.selectpicker.main.data ? this.selectpicker.main.data.length : 0, + optID = 0, + startIndex = this.setPlaceholder() && !data ? 1 : 0; // append the titleOption if necessary and skip the first option in the loop + + if (type === 'search') { + startLen = this.selectpicker.search.data.length; + } + + if (this.options.hideDisabled) optionSelector += ':not(:disabled)'; + + var selectOptions = data ? data.filter(filterHidden, this) : this.element.querySelectorAll('select > *' + optionSelector); + + function addDivider (config) { + var previousData = mainData[mainData.length - 1]; + + // ensure optgroup doesn't create back-to-back dividers + if ( + previousData && + previousData.type === 'divider' && + (previousData.optID || config.optID) + ) { + return; + } + + config = config || {}; + config.type = 'divider'; + + mainData.push(config); + } + + function addOption (item, config) { + config = config || {}; + + config.divider = dataGetter(item, 'divider'); + + if (config.divider === true) { + addDivider({ + optID: config.optID + }); + } else { + var liIndex = mainData.length + startLen, + cssText = dataGetter(item, 'style'), + inlineStyle = cssText ? htmlEscape(cssText) : '', + optionClass = (item.className || '') + (config.optgroupClass || ''); + + if (config.optID) optionClass = 'opt ' + optionClass; + + config.optionClass = optionClass.trim(); + config.inlineStyle = inlineStyle; + + config.text = dataGetter(item, 'text'); + config.title = dataGetter(item, 'title'); + config.content = dataGetter(item, 'content'); + config.tokens = dataGetter(item, 'tokens'); + config.subtext = dataGetter(item, 'subtext'); + config.icon = dataGetter(item, 'icon'); + + config.display = config.content || config.text; + config.value = item.value === undefined ? item.text : item.value; + config.type = 'option'; + config.index = liIndex; + + config.option = !item.option ? item : item.option; // reference option element if it exists + config.option.liIndex = liIndex; + config.selected = !!item.selected; + config.disabled = config.disabled || !!item.disabled; + + if (data !== false) { + if (that.selectpicker.optionValuesDataMap[config.value]) { + config = Object.assign(that.selectpicker.optionValuesDataMap[config.value], config); + } else { + that.selectpicker.optionValuesDataMap[config.value] = config; + } + } + + mainData.push(config); + } + } + + function addOptgroup (index, selectOptions) { + var optgroup = selectOptions[index], + // skip placeholder option + previous = index - 1 < startIndex ? false : selectOptions[index - 1], + next = selectOptions[index + 1], + options = data ? optgroup.children.filter(filterHidden, this) : optgroup.querySelectorAll('option' + optionSelector); + + if (!options.length) return; + + var config = { + display: htmlEscape(dataGetter(item, 'label')), + subtext: dataGetter(optgroup, 'subtext'), + icon: dataGetter(optgroup, 'icon'), + type: 'optgroup-label', + optgroupClass: ' ' + (optgroup.className || ''), + optgroup: optgroup + }, + headerIndex, + lastIndex; + + optID++; + + if (previous) { + addDivider({ optID: optID }); + } + + config.optID = optID; + + mainData.push(config); + + for (var j = 0, len = options.length; j < len; j++) { + var option = options[j]; + + if (j === 0) { + headerIndex = mainData.length - 1; + lastIndex = headerIndex + len; + } + + addOption(option, { + headerIndex: headerIndex, + lastIndex: lastIndex, + optID: config.optID, + optgroupClass: config.optgroupClass, + disabled: optgroup.disabled + }); + } + + if (next) { + addDivider({ optID: optID }); + } + } + + var item; + + for (var len = selectOptions.length, i = startIndex; i < len; i++) { + item = selectOptions[i]; + var children = item.children; + + if (children && children.length) { + addOptgroup.call(this, i, selectOptions); + } else { + addOption.call(this, item, {}); + } + } + + switch (type) { + case 'data': { + if (!this.selectpicker.main.data) { + this.selectpicker.main.data = []; + } + Array.prototype.push.apply(this.selectpicker.main.data, mainData); + this.selectpicker.current.data = this.selectpicker.main.data; + break; + } + case 'search': { + Array.prototype.push.apply(this.selectpicker.search.data, mainData); + break; + } + } + + return mainData; + } + + buildList (size, searching) { + var that = this, + selectData = searching ? this.selectpicker.search.data : this.selectpicker.main.data, + mainElements = [], + widestOptionLength = 0; + + if (that.options.showTick || that.multiple || that.options.selectionIndicator === 'checkbox') { + elementTemplates.checkMark.className = this.options.selectionIndicator === 'checkbox' + ? 'check-mark bs-selection-indicator' + : this.options.iconBase + ' ' + that.options.tickIcon + ' check-mark'; + + if (!elementTemplates.checkMark.parentNode) { + elementTemplates.a.appendChild(elementTemplates.checkMark); + } + } + + function buildElement (mainElements, item) { + var liElement, + combinedLength = 0; + + switch (item.type) { + case 'divider': + liElement = generateOption.li( + false, + classNames.DIVIDER, + (item.optID ? item.optID + 'div' : undefined) + ); + + break; + + case 'option': + liElement = generateOption.li( + generateOption.a( + generateOption.text.call(that, item), + item.optionClass, + item.inlineStyle + ), + '', + item.optID + ); + + if (liElement.firstChild) { + liElement.firstChild.id = that.selectId + '-' + item.index; + } + + break; + + case 'optgroup-label': + liElement = generateOption.li( + generateOption.label.call(that, item), + 'dropdown-header' + item.optgroupClass, + item.optID + ); + + break; + } + + if (item.content) item.sanitized = false; + + if (!item.element) { + item.element = liElement; + } else { + item.element.innerHTML = liElement.innerHTML; + } + mainElements.push(item.element); + + // count the number of characters in the option - not perfect, but should work in most cases + if (item.display) combinedLength += item.display.length; + if (item.subtext) combinedLength += item.subtext.length; + // if there is an icon, ensure this option's width is checked + if (item.icon) combinedLength += 1; + + if (combinedLength > widestOptionLength) { + widestOptionLength = combinedLength; + + // guess which option is the widest + that.selectpicker.view.widestOption = mainElements[mainElements.length - 1]; + } + } + + var startIndex = size || 0; + + for (var len = selectData.length, i = startIndex; i < len; i++) { + var item = selectData[i]; + + buildElement(mainElements, item); + } + + if (size) { + if (searching) { + Array.prototype.push.apply(this.selectpicker.search.elements, mainElements); + } else { + Array.prototype.push.apply(this.selectpicker.main.elements, mainElements); + this.selectpicker.current.elements = this.selectpicker.main.elements; + } + } else { + if (searching) { + this.selectpicker.search.elements = mainElements; + } else { + this.selectpicker.main.elements = this.selectpicker.current.elements = mainElements; + } + } + } + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + findLis () { + return this.menuInner.querySelectorAll('.inner > li'); + } + + render (init) { + var that = this, + element = this.element, + // ensure titleOption is appended and selected (if necessary) before getting selectedOptions + placeholderSelected = this.setPlaceholder() && element.selectedIndex === 0, + selectedOptions = getSelectedOptions.call(this), + selectedCount = selectedOptions.length, + selectedValues = getSelectValues.call(this, selectedOptions), + button = this.button, + buttonInner = button.querySelector('.filter-option-inner-inner'), + multipleSeparator = document.createTextNode(this.options.multipleSeparator), + titleFragment = elementTemplates.fragment.cloneNode(false), + forceCount = this.multiple && this.options.showSelectedTags && selectedCount > 0, + showCount, + countMax, + hasContent = false; + + function createSelected (item) { + if (item.selected) { + that.createOption(item, true); + } else if (item.children && item.children.length) { + item.children.map(createSelected); + } + } + + // create selected option elements to ensure select value is correct + if (this.options.source.data && init) { + selectedOptions.map(createSelected); + element.appendChild(this.selectpicker.main.optionQueue); + + if (placeholderSelected) placeholderSelected = element.selectedIndex === 0; + } + + button.classList.toggle('bs-placeholder', that.multiple ? !selectedCount : !selectedValues && selectedValues !== 0); + + if (!that.multiple && selectedOptions.length === 1) { + that.selectpicker.view.displayedValue = selectedValues; + } + + if (this.options.selectedTextFormat === 'static') { + titleFragment = generateOption.text.call(this, { text: this.options.placeholder }, true); + } else { + showCount = forceCount || this.multiple && this.options.selectedTextFormat.indexOf('count') !== -1 && selectedCount > 0; + + // determine if the number of selected options will be shown (showCount === true) + if (showCount && !forceCount) { + countMax = this.options.selectedTextFormat.split('>'); + showCount = (countMax.length > 1 && selectedCount > countMax[1]) || (countMax.length === 1 && selectedCount >= 2); + } + + // only loop through all selected options if the count won't be shown + if (showCount === false) { + if (!placeholderSelected) { + for (var selectedIndex = 0; selectedIndex < selectedCount; selectedIndex++) { + if (selectedIndex < 50) { + var option = selectedOptions[selectedIndex], + titleOptions = {}; + + if (option) { + if (this.multiple && selectedIndex > 0) { + titleFragment.appendChild(multipleSeparator.cloneNode(false)); + } + + if (option.title) { + titleOptions.text = option.title; + } else if (option.content && that.options.showContent) { + titleOptions.content = option.content.toString(); + hasContent = true; + } else { + if (that.options.showIcon) { + titleOptions.icon = option.icon; + } + if (that.options.showSubtext && !that.multiple && option.subtext) titleOptions.subtext = ' ' + option.subtext; + titleOptions.text = option.text.trim(); + } + + titleFragment.appendChild(generateOption.text.call(this, titleOptions, true)); + } + } else { + break; + } + } + + // add ellipsis + if (selectedCount > 49) { + titleFragment.appendChild(document.createTextNode('...')); + } + } + } else { + var optionSelector = ':not([hidden]):not([data-hidden="true"]):not([data-divider="true"]):not([style*="display: none"])'; + if (this.options.hideDisabled) optionSelector += ':not(:disabled)'; + + // If this is a multiselect, and selectedTextFormat is count, then show 1 of 2 selected, etc. + var totalCount = this.element.querySelectorAll('select > option' + optionSelector + ', optgroup' + optionSelector + ' option' + optionSelector).length, + tr8nText = (typeof this.options.countSelectedText === 'function') ? this.options.countSelectedText(selectedCount, totalCount) : this.options.countSelectedText; + + titleFragment = generateOption.text.call(this, { + text: tr8nText.replace('{0}', selectedCount.toString()).replace('{1}', totalCount.toString()) + }, true); + } + } + + // If the select doesn't have a title, then use the default, or if nothing is set at all, use noneSelectedText + if (!titleFragment.childNodes.length) { + titleFragment = generateOption.text.call(this, { + text: this.options.placeholder ? this.options.placeholder : this.options.noneSelectedText + }, true); + } + + // if the select has a title, apply it to the button, and if not, apply titleFragment text + button.title = titleFragment.textContent.replace(/<[^>]*>?/g, '').trim(); + + if (this.options.sanitize && hasContent) { + sanitizeHtml([titleFragment], that.options.whiteList, that.options.sanitizeFn); + } + + buttonInner.innerHTML = ''; + buttonInner.appendChild(titleFragment); + + this.syncTagEditor(); + + this._emit('rendered'); + } + + usesTagEditor () { + return this.options.liveSearch && (this.options.showSelectedTags || this.options.openOptions); + } + + syncTagEditor () { + if (!this.usesTagEditor()) return; + + if (this.selectedItems) { + var selectedOptions = getSelectedOptions.call(this), + useListStyle = this.options.selectedItemsStyle === 'list'; + + this.selectedItems.innerHTML = ''; + this.selectedItems.hidden = !selectedOptions.length; + this.selectedItems.classList.toggle('list-group', useListStyle); + + for (var i = 0; i < selectedOptions.length; i++) { + var item = selectedOptions[i], + selectedTag = document.createElement('button'), + removeText = this.options.selectedTagRemoveLabel + ' ' + getOptionLabelText(item), + content = document.createElement('span'), + label = document.createElement('span'), + remove = document.createElement('span'), + icon; + + selectedTag.type = 'button'; + selectedTag.className = useListStyle + ? 'bs-selected-item list-group-item list-group-item-action' + : 'bs-selected-item'; + selectedTag.setAttribute('data-option-value', item.value); + selectedTag.setAttribute('aria-label', removeText); + selectedTag.title = removeText; + + content.className = 'bs-selected-item-content'; + + if (item.icon && this.options.showIcon) { + icon = document.createElement('span'); + icon.className = 'bs-selected-item-icon ' + this.options.iconBase + ' ' + item.icon; + icon.setAttribute('aria-hidden', 'true'); + content.appendChild(icon); + } + + label.className = 'bs-selected-item-label'; + label.textContent = getOptionLabelText(item); + content.appendChild(label); + + remove.className = 'bs-selected-item-remove'; + remove.setAttribute('aria-hidden', 'true'); + remove.textContent = '\u00d7'; + + selectedTag.appendChild(content); + selectedTag.appendChild(remove); + this.selectedItems.appendChild(selectedTag); + } + } + + this.syncOpenOptionButton(); + + if (this.newElement && this.newElement.classList.contains(classNames.SHOW)) { + this.setSize(true); + } + } + + syncOpenOptionButton () { + if (!this.createOptionButton) return; + + var searchValue = this.searchbox ? this.searchbox.value : '', + normalizedValue = searchValue.toString().trim(), + shouldShow = !!normalizedValue && + !this.selectpicker.openOption.isCreating && + !this.findOptionBySearchValue(normalizedValue); + + this.createOptionButton.hidden = !shouldShow; + this.createOptionButton.disabled = this.selectpicker.openOption.isCreating; + + if (shouldShow) { + this.createOptionButton.textContent = this.options.openOptionsText.replace('{0}', normalizedValue); + this.createOptionButton.setAttribute('data-search-value', normalizedValue); + } else { + this.createOptionButton.textContent = ''; + this.createOptionButton.removeAttribute('data-search-value'); + } + } + + findOptionByValue (value, dataSet) { + var options = dataSet || this.selectpicker.main.data, + stringValue = String(value); + + for (var i = 0; i < options.length; i++) { + var option = options[i]; + + if (option.type === 'option' && String(option.value) === stringValue) { + return option; + } + } + + return null; + } + + findOptionBySearchValue (searchValue) { + var options = this.options.source.data || this.options.source.search + ? Object.values(this.selectpicker.optionValuesDataMap) + : this.selectpicker.main.data, + normalizedSearch = normalizeSearchInput(searchValue, this.options.liveSearchNormalize); + + for (var i = 0; i < options.length; i++) { + var option = options[i]; + + if (option.type !== 'option') continue; + + if ( + normalizeSearchInput(option.text, this.options.liveSearchNormalize) === normalizedSearch || + normalizeSearchInput(option.value, this.options.liveSearchNormalize) === normalizedSearch || + normalizeSearchInput(option.title, this.options.liveSearchNormalize) === normalizedSearch + ) { + return option; + } + } + + return null; + } + + createOptionElement (optionData) { + var option = document.createElement('option'); + + option.value = optionData.value === undefined ? optionData.text : optionData.value; + option.textContent = optionData.text === undefined ? option.value : optionData.text; + + if (optionData.className) option.className = optionData.className; + if (optionData.title) option.title = optionData.title; + if (optionData.content) option.setAttribute('data-content', optionData.content); + if (optionData.tokens) option.setAttribute('data-tokens', optionData.tokens); + if (optionData.subtext) option.setAttribute('data-subtext', optionData.subtext); + if (optionData.icon) option.setAttribute('data-icon', optionData.icon); + if (optionData.disabled) option.disabled = true; + if (optionData.hidden) option.hidden = true; + + return option; + } + + appendCreatedSearchResults (searchValue) { + if (!this.selectpicker.createdOptions.length) return; + + var matches = []; + + for (var i = 0; i < this.selectpicker.createdOptions.length; i++) { + var option = this.selectpicker.createdOptions[i]; + + if ( + stringSearch(option, normalizeSearchInput(searchValue, this.options.liveSearchNormalize), this._searchStyle(), this.options.liveSearchNormalize) && + !this.findOptionByValue(option.value, this.selectpicker.search.data) + ) { + matches.push(option); + } + } + + if (matches.length) this.buildData(matches, 'search'); + } + + addCreatedOption (optionData) { + optionData = Object.assign({}, optionData); + optionData.value = optionData.value === undefined ? optionData.text : optionData.value; + optionData.text = optionData.text === undefined ? optionData.value : optionData.text; + + var size = this.selectpicker.main.elements ? this.selectpicker.main.elements.length : 0, + option = this.createOptionElement(optionData); + optionData.option = option; + + this.element.appendChild(option); + var builtOptions = this.buildData([optionData], 'data'), + builtOption = builtOptions[0]; + + this.buildList(size); + this.selectpicker.createdOptions.push(builtOption); + + return builtOption; + } + + removeSelectedTag (value) { + var option = this.findOptionByValue(value); + + if (!option || !option.selected) return; + + var prevValue = getSelectValues.call(this); + + this.setSelected(option, false); + changedArguments = [option.index, false, prevValue]; + triggerNative(this.element, 'change'); + + if (this.options.liveSearch) this.searchbox.focus(); + } + + createOpenOption (searchValue) { + searchValue = searchValue === undefined || searchValue === null ? '' : searchValue.toString().trim(); + + if (!searchValue || this.selectpicker.openOption.isCreating) return; + + var existingOption = this.findOptionBySearchValue(searchValue); + + if (existingOption) { + if (!existingOption.selected) { + var prevSelectedValue = getSelectValues.call(this); + + this.setSelected(existingOption, true); + changedArguments = [existingOption.index, true, prevSelectedValue]; + triggerNative(this.element, 'change'); + } + + if (this.options.liveSearch) this.searchbox.focus(); + return; + } + + var that = this, + prevValue = getSelectValues.call(this), + createHandler = this.options.source.create; + + this.selectpicker.openOption.isCreating = true; + this.syncOpenOptionButton(); + + function finalize (createdOption) { + that.selectpicker.openOption.isCreating = false; + + if (createdOption === undefined || createdOption === null || createdOption === false) { + that.syncOpenOptionButton(); + return; + } + + if (Array.isArray(createdOption)) createdOption = createdOption[0]; + if (typeof createdOption !== 'object') { + createdOption = { + text: createdOption, + value: createdOption + }; + } + + if (!createdOption.text && !createdOption.value) { + createdOption.text = searchValue; + } + + if (createdOption.value === undefined) createdOption.value = createdOption.text; + if (createdOption.text === undefined) createdOption.text = createdOption.value; + + var option = that.findOptionByValue(createdOption.value) || that.findOptionBySearchValue(createdOption.text); + + if (!option) { + option = that.addCreatedOption(createdOption); + } + + that.setSelected(option, true); + + if (that.options.source.data) that.element.appendChild(that.selectpicker.main.optionQueue); + + if (that.searchbox) { + that.searchbox.value = ''; + } + + that.selectpicker.search.previousValue = ''; + that.selectpicker.search.data = []; + that.selectpicker.search.elements = []; + that.createView(false); + + changedArguments = [option.index, true, prevValue]; + triggerNative(that.element, 'change'); + + if (that.options.liveSearch) that.searchbox.focus(); + } + + if (typeof createHandler === 'function') { + var returnedOption = createHandler.call(this, finalize, searchValue); + + if (returnedOption && typeof returnedOption.then === 'function') { + returnedOption.then(finalize); + } else if (returnedOption !== undefined) { + finalize(returnedOption); + } + } else { + finalize({ + text: searchValue, + value: searchValue + }); + } + } + + /** + * @param [newStyle] + * @param [status] + */ + setStyle (newStyle, status) { + var button = this.button, + newElement = this.newElement, + style = this.options.style.trim(), + buttonClass; + + if (this.element.getAttribute('class')) { + var extra = this.element.getAttribute('class').replace(/selectpicker|mobile-device|bs-select-hidden|validate\[.*\]/gi, '').trim(); + if (extra) newElement.classList.add.apply(newElement.classList, extra.split(/\s+/)); + } + + if (newStyle) { + buttonClass = newStyle.trim(); + } else { + buttonClass = style; + } + + if (status === 'add') { + if (buttonClass) button.classList.add.apply(button.classList, buttonClass.split(' ')); + } else if (status === 'remove') { + if (buttonClass) button.classList.remove.apply(button.classList, buttonClass.split(' ')); + } else { + if (style) button.classList.remove.apply(button.classList, style.split(' ')); + if (buttonClass) button.classList.add.apply(button.classList, buttonClass.split(' ')); + } + } + + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + liHeight (refresh) { + if (!refresh && (this.options.size === false || Object.keys(this.sizeInfo).length)) return; + + var newElement = elementTemplates.div.cloneNode(false), + menu = elementTemplates.div.cloneNode(false), + menuInner = elementTemplates.div.cloneNode(false), + menuInnerInner = document.createElement('ul'), + divider = elementTemplates.li.cloneNode(false), + dropdownHeader = elementTemplates.li.cloneNode(false), + li, + a = elementTemplates.a.cloneNode(false), + text = elementTemplates.span.cloneNode(false), + header = this.options.header && this.menu.querySelectorAll('.' + classNames.POPOVERHEADER).length > 0 ? this.menu.querySelector('.' + classNames.POPOVERHEADER).cloneNode(true) : null, + search = this.options.liveSearch && this.menu.querySelector('.bs-searchbox') + ? this.menu.querySelector('.bs-searchbox').cloneNode(true) + : null, + actions = this.options.actionsBox && this.multiple && this.menu.querySelectorAll('.bs-actionsbox').length > 0 ? this.menu.querySelector('.bs-actionsbox').cloneNode(true) : null, + doneButton = this.options.doneButton && this.multiple && this.menu.querySelectorAll('.bs-donebutton').length > 0 ? this.menu.querySelector('.bs-donebutton').cloneNode(true) : null, + firstOption = this.element.options[0]; + + this.sizeInfo.selectWidth = this.newElement.offsetWidth; + + text.className = 'text'; + a.className = 'dropdown-item ' + (firstOption ? firstOption.className : ''); + newElement.className = this.menu.parentNode.className + ' ' + classNames.SHOW; + newElement.style.width = 0; // ensure button width doesn't affect natural width of menu when calculating + menu.className = classNames.MENU + ' ' + classNames.SHOW; + menuInner.className = 'inner ' + classNames.SHOW; + menuInnerInner.className = classNames.MENU + ' inner ' + classNames.SHOW; + divider.className = classNames.DIVIDER; + dropdownHeader.className = 'dropdown-header'; + + text.appendChild(document.createTextNode('\u200b')); + + if (this.selectpicker.current.data.length) { + for (var i = 0; i < this.selectpicker.current.data.length; i++) { + var data = this.selectpicker.current.data[i]; + if (data.type === 'option' && window.getComputedStyle(data.element.firstChild).display !== 'none') { + li = data.element; + break; + } + } + } else { + li = elementTemplates.li.cloneNode(false); + a.appendChild(text); + li.appendChild(a); + } + + dropdownHeader.appendChild(text.cloneNode(true)); + + if (this.selectpicker.view.widestOption) { + menuInnerInner.appendChild(this.selectpicker.view.widestOption.cloneNode(true)); + } + + menuInnerInner.appendChild(li); + menuInnerInner.appendChild(divider); + menuInnerInner.appendChild(dropdownHeader); + if (header) menu.appendChild(header); + if (search) menu.appendChild(search); + if (actions) menu.appendChild(actions); + menuInner.appendChild(menuInnerInner); + menu.appendChild(menuInner); + if (doneButton) menu.appendChild(doneButton); + newElement.appendChild(menu); + + document.body.appendChild(newElement); + + var liHeight = li.offsetHeight, + dropdownHeaderHeight = dropdownHeader ? dropdownHeader.offsetHeight : 0, + headerHeight = header ? header.offsetHeight : 0, + searchHeight = search ? search.offsetHeight : 0, + actionsHeight = actions ? actions.offsetHeight : 0, + doneButtonHeight = doneButton ? doneButton.offsetHeight : 0, + dividerHeight = outerHeight(divider, true), + menuStyle = window.getComputedStyle(menu), + menuWidth = menu.offsetWidth, + menuPadding = { + vert: toInteger(menuStyle.paddingTop) + + toInteger(menuStyle.paddingBottom) + + toInteger(menuStyle.borderTopWidth) + + toInteger(menuStyle.borderBottomWidth), + horiz: toInteger(menuStyle.paddingLeft) + + toInteger(menuStyle.paddingRight) + + toInteger(menuStyle.borderLeftWidth) + + toInteger(menuStyle.borderRightWidth) + }, + menuExtras = { + vert: menuPadding.vert + + toInteger(menuStyle.marginTop) + + toInteger(menuStyle.marginBottom) + 2, + horiz: menuPadding.horiz + + toInteger(menuStyle.marginLeft) + + toInteger(menuStyle.marginRight) + 2 + }, + scrollBarWidth; + + menuInner.style.overflowY = 'scroll'; + + scrollBarWidth = menu.offsetWidth - menuWidth; + + document.body.removeChild(newElement); + + this.sizeInfo.liHeight = liHeight; + this.sizeInfo.dropdownHeaderHeight = dropdownHeaderHeight; + this.sizeInfo.headerHeight = headerHeight; + this.sizeInfo.searchHeight = searchHeight; + this.sizeInfo.actionsHeight = actionsHeight; + this.sizeInfo.doneButtonHeight = doneButtonHeight; + this.sizeInfo.dividerHeight = dividerHeight; + this.sizeInfo.menuPadding = menuPadding; + this.sizeInfo.menuExtras = menuExtras; + this.sizeInfo.menuWidth = menuWidth; + this.sizeInfo.menuInnerInnerWidth = menuWidth - menuPadding.horiz; + this.sizeInfo.totalMenuWidth = this.sizeInfo.menuWidth; + this.sizeInfo.scrollBarWidth = scrollBarWidth; + this.sizeInfo.selectHeight = this.newElement.offsetHeight; + + this.setPositionData(); + } + + getSelectPosition () { + var that = this, + winScrollTop = window.pageYOffset, + winScrollLeft = window.pageXOffset, + winHeight = document.documentElement.clientHeight, + winWidth = document.documentElement.clientWidth, + pos = offset(that.newElement); + + this.sizeInfo.selectOffsetTop = pos.top - winScrollTop; + this.sizeInfo.selectOffsetBot = winHeight - this.sizeInfo.selectOffsetTop - this.sizeInfo.selectHeight; + this.sizeInfo.selectOffsetLeft = pos.left - winScrollLeft; + this.sizeInfo.selectOffsetRight = winWidth - this.sizeInfo.selectOffsetLeft - this.sizeInfo.selectWidth; + } + + setMenuSize (isAuto) { + this.getSelectPosition(); + + var selectWidth = this.sizeInfo.selectWidth, + liHeight = this.sizeInfo.liHeight, + headerHeight = this.sizeInfo.headerHeight, + searchHeight = this.sizeInfo.searchHeight, + actionsHeight = this.sizeInfo.actionsHeight, + doneButtonHeight = this.sizeInfo.doneButtonHeight, + divHeight = this.sizeInfo.dividerHeight, + menuPadding = this.sizeInfo.menuPadding, + menuInnerHeight, + menuHeight, + divLength = 0, + minHeight, + _minHeight, + maxHeight, + menuInnerMinHeight, + estimate, + isDropup; + + if (this.options.dropupAuto) { + // Get the estimated height of the menu without scrollbars. + estimate = liHeight * this.selectpicker.current.data.length + menuPadding.vert; + + isDropup = this.sizeInfo.selectOffsetTop - this.sizeInfo.selectOffsetBot > this.sizeInfo.menuExtras.vert && estimate + this.sizeInfo.menuExtras.vert + 50 > this.sizeInfo.selectOffsetBot; + + // ensure dropup doesn't change while searching (so menu doesn't bounce back and forth) + if (this.selectpicker.isSearching === true) { + isDropup = this.selectpicker.dropup; + } + + this.newElement.classList.toggle(classNames.DROPUP, isDropup); + this.selectpicker.dropup = isDropup; + } + + if (this.options.size === 'auto') { + _minHeight = this.selectpicker.current.data.length > 3 ? this.sizeInfo.liHeight * 3 + this.sizeInfo.menuExtras.vert - 2 : 0; + menuHeight = this.sizeInfo.selectOffsetBot - this.sizeInfo.menuExtras.vert; + minHeight = _minHeight + headerHeight + searchHeight + actionsHeight + doneButtonHeight; + menuInnerMinHeight = Math.max(_minHeight - menuPadding.vert, 0); + + if (this.newElement.classList.contains(classNames.DROPUP)) { + menuHeight = this.sizeInfo.selectOffsetTop - this.sizeInfo.menuExtras.vert; + } + + maxHeight = menuHeight; + menuInnerHeight = menuHeight - headerHeight - searchHeight - actionsHeight - doneButtonHeight - menuPadding.vert; + } else if (this.options.size && this.options.size !== 'auto' && this.selectpicker.current.elements.length > this.options.size) { + for (var i = 0; i < this.options.size; i++) { + if (this.selectpicker.current.data[i].type === 'divider') divLength++; + } + + menuHeight = liHeight * this.options.size + divLength * divHeight + menuPadding.vert; + menuInnerHeight = menuHeight - menuPadding.vert; + maxHeight = menuHeight + headerHeight + searchHeight + actionsHeight + doneButtonHeight; + minHeight = menuInnerMinHeight = ''; + } + + setStyles(this.menu, { + maxHeight: maxHeight + 'px', + overflow: 'hidden', + minHeight: minHeight + 'px' + }); + + setStyles(this.menuInner, { + maxHeight: menuInnerHeight + 'px', + overflow: 'hidden auto', + minHeight: menuInnerMinHeight + 'px' + }); + + // ensure menuInnerHeight is always a positive number to prevent issues calculating chunkSize in createView + this.sizeInfo.menuInnerHeight = Math.max(menuInnerHeight, 1); + + if (this.selectpicker.current.data.length && this.selectpicker.current.data[this.selectpicker.current.data.length - 1].position > this.sizeInfo.menuInnerHeight) { + this.sizeInfo.hasScrollBar = true; + this.sizeInfo.totalMenuWidth = this.sizeInfo.menuWidth + this.sizeInfo.scrollBarWidth; + } + + if (this.options.dropdownAlignRight === 'auto') { + this.menu.classList.toggle(classNames.MENUEND, this.sizeInfo.selectOffsetLeft > this.sizeInfo.selectOffsetRight && this.sizeInfo.selectOffsetRight < (this.sizeInfo.totalMenuWidth - selectWidth)); + } + + if (this.dropdown && this.dropdown._popper) this.dropdown._popper.update(); + } + + setSize (refresh) { + this.liHeight(refresh); + + if (this.options.header) this.menu.style.paddingTop = 0; + + if (this.options.size !== false) { + var that = this; + + this.setMenuSize(); + + if (this.options.liveSearch) { + this._replace('setMenuSizeInput', this.searchbox, 'input', function () { + return that.setMenuSize(); + }); + } + + if (this.options.size === 'auto') { + var windowSizeHandler = function () { + return that.setMenuSize(); + }; + this._replace('setMenuSizeResize', window, 'resize', windowSizeHandler); + this._replace('setMenuSizeScroll', window, 'scroll', windowSizeHandler); + } else if (this.options.size && this.options.size !== 'auto' && this.selectpicker.current.elements.length > this.options.size) { + this._removeNamed('setMenuSizeResize'); + this._removeNamed('setMenuSizeScroll'); + } + } + + this.createView(false, true, refresh); + } + + setWidth () { + this.menu.style.minWidth = ''; + this.newElement.style.width = ''; + this.newElement.classList.remove('fit-width'); + + if (this.options.width === 'fit') { + this.newElement.classList.add('fit-width'); + return; + } + + if (this.options.width && this.options.width !== 'auto') { + this.newElement.style.width = this.options.width; + } + } + + selectPosition () { + this.bsContainer = createFromHTML('
    '); + + var that = this, + container = resolveContainer(this.options.container), + pos, + containerPos, + actualHeight, + getPlacement = function (element) { + var Dropdown = getDropdown(), + containerPosition = {}, + // fall back to dropdown's default display setting if display is not manually set + display = that.options.display || (Dropdown.Default ? Dropdown.Default.display : false); + + var extraClass = element.getAttribute('class').replace(/form-control|fit-width/gi, '').trim(); + if (extraClass) that.bsContainer.classList.add.apply(that.bsContainer.classList, extraClass.split(/\s+/)); + that.bsContainer.classList.toggle(classNames.DROPUP, element.classList.contains(classNames.DROPUP)); + pos = offset(element); + + if (container !== document.body) { + containerPos = offset(container); + var containerStyle = window.getComputedStyle(container); + containerPos.top += toInteger(containerStyle.borderTopWidth) - container.scrollTop; + containerPos.left += toInteger(containerStyle.borderLeftWidth) - container.scrollLeft; + } else { + containerPos = { top: 0, left: 0 }; + } + + actualHeight = element.classList.contains(classNames.DROPUP) ? 0 : element.offsetHeight; + + // Bootstrap 5 uses Popper for menu positioning + if (display === 'static') { + containerPosition.top = pos.top - containerPos.top + actualHeight; + containerPosition.left = pos.left - containerPos.left; + } + + containerPosition.width = element.offsetWidth; + + setStyles(that.bsContainer, { + top: containerPosition.top !== undefined ? containerPosition.top + 'px' : '', + left: containerPosition.left !== undefined ? containerPosition.left + 'px' : '', + width: containerPosition.width + 'px' + }); + }; + + this._on(this.button, 'click', function () { + if (that.isDisabled()) { + return; + } + + getPlacement(that.newElement); + + container.appendChild(that.bsContainer); + that.bsContainer.classList.toggle(classNames.SHOW, !that.button.classList.contains(classNames.SHOW)); + that.bsContainer.appendChild(that.menu); + }); + + var windowHandler = function () { + var isActive = that.newElement.classList.contains(classNames.SHOW); + + if (isActive) getPlacement(that.newElement); + }; + this._replace('selectPositionResize', window, 'resize', windowHandler); + this._replace('selectPositionScroll', window, 'scroll', windowHandler); + + this._on(this.element, 'hide' + EVENT_KEY, function () { + that._menuHeight = outerHeight(that.menu); + if (that.bsContainer.parentNode) that.bsContainer.parentNode.removeChild(that.bsContainer); + }); + } + + createOption (data, init) { + var optionData = !data.option ? data : data.option; + + if (optionData && optionData.nodeType !== 1) { + var option = (init ? elementTemplates.selectedOption : elementTemplates.option).cloneNode(true); + if (optionData.value !== undefined) option.value = optionData.value; + option.textContent = optionData.text; + + option.selected = true; + + if (optionData.liIndex !== undefined) { + option.liIndex = optionData.liIndex; + } else if (!init) { + option.liIndex = data.index; + } + + data.option = option; + + this.selectpicker.main.optionQueue.appendChild(option); + } + } + + setOptionStatus (selectedOnly) { + var that = this; + + that.noScroll = false; + + if (that.selectpicker.view.visibleElements && that.selectpicker.view.visibleElements.length) { + for (var i = 0; i < that.selectpicker.view.visibleElements.length; i++) { + var liData = that.selectpicker.current.data[i + that.selectpicker.view.position0], + option = liData.option; + + if (option) { + if (selectedOnly !== true) { + that.setDisabled(liData); + } + + that.setSelected(liData); + } + } + + // append optionQueue (documentFragment with option elements for select options) + if (this.options.source.data) this.element.appendChild(this.selectpicker.main.optionQueue); + } + } + + /** + * @param {Object} liData - the option object that is being changed + * @param {boolean} selected - true if the option is being selected, false if being deselected + */ + setSelected (liData, selected) { + selected = selected === undefined ? liData.selected : selected; + + var li = liData.element, + activeElementIsSet = this.activeElement !== undefined, + thisIsActive = this.activeElement === li, + prevActive, + a, + keepActive = thisIsActive || (selected && !this.multiple && !activeElementIsSet); + + if (selected !== undefined) { + liData.selected = selected; + if (liData.option) liData.option.selected = selected; + } + + if (selected && this.options.source.data) { + this.createOption(liData, false); + } + + if (!li) return; + + a = li.firstChild; + + if (selected) { + this.selectedElement = li; + } + + li.classList.toggle('selected', selected); + + if (keepActive) { + this.focusItem(li, liData); + this.selectpicker.view.currentActive = li; + this.activeElement = li; + } else { + this.defocusItem(li); + } + + if (a) { + a.classList.toggle('selected', selected); + + if (selected) { + a.setAttribute('aria-selected', true); + } else { + if (this.multiple) { + a.setAttribute('aria-selected', false); + } else { + a.removeAttribute('aria-selected'); + } + } + } + + if (!keepActive && !activeElementIsSet && selected && this.prevActiveElement !== undefined) { + prevActive = this.prevActiveElement; + + this.defocusItem(prevActive); + } + } + + /** + * @param {Object} liData - the option that is being disabled + */ + setDisabled (liData) { + var disabled = liData.disabled, + li = liData.element, + a; + + if (!li) return; + + a = li.firstChild; + + li.classList.toggle(classNames.DISABLED, disabled); + + if (a) { + a.classList.toggle(classNames.DISABLED, disabled); + + if (disabled) { + a.setAttribute('aria-disabled', disabled); + a.setAttribute('tabindex', -1); + } else { + a.removeAttribute('aria-disabled'); + a.setAttribute('tabindex', 0); + } + } + } + + isDisabled () { + return this.element.disabled; + } + + checkDisabled () { + if (this.isDisabled()) { + this.newElement.classList.add(classNames.DISABLED); + this.button.classList.add(classNames.DISABLED); + this.button.setAttribute('aria-disabled', true); + } else { + if (this.button.classList.contains(classNames.DISABLED)) { + this.newElement.classList.remove(classNames.DISABLED); + this.button.classList.remove(classNames.DISABLED); + this.button.setAttribute('aria-disabled', false); + } + } + } + + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + clickListener () { + var that = this; + + spaceSelectFlag = false; + + this._on(this.button, 'keyup', function (e) { + if (/(32)/.test(e.keyCode.toString(10)) && spaceSelectFlag) { + e.preventDefault(); + spaceSelectFlag = false; + } + }); + + function clearSelection (e) { + if (that.multiple) { + that.deselectAll(); + } else { + var element = that.element, + prevValue = element.value, + prevIndex = element.selectedIndex, + prevOption = element.options[prevIndex], + prevData = prevOption ? that.selectpicker.main.data[prevOption.liIndex] : false; + + if (prevData) { + that.setSelected(prevData, false); + } + + element.selectedIndex = 0; + + changedArguments = [prevIndex, false, prevValue]; + triggerNative(that.element, 'change'); + } + + // remove selected styling if menu is open + if (that.newElement.classList.contains(classNames.SHOW)) { + if (that.options.liveSearch) { + that.searchbox.focus(); + } + + that.createView(false); + } + } + + if (this.options.allowClear) { + this._on(this.button, 'click', function (e) { + var target = e.target, + clearButton = that.clearButton; + + if (target === clearButton || target.parentElement === clearButton) { + e.stopImmediatePropagation(); + clearSelection(e); + } + }); + } + + function setFocus () { + if (that.options.liveSearch) { + that.searchbox.focus(); + } else { + that.menuInner.focus(); + } + } + + function checkPopperExists () { + if (that.dropdown && that.dropdown._popper && that.dropdown._popper.state) { + setFocus(); + } else { + requestAnimationFrame(checkPopperExists); + } + } + + this._on(this.element, 'shown' + EVENT_KEY, function () { + if (that.menuInner.scrollTop !== that.selectpicker.view.scrollTop) { + that.menuInner.scrollTop = that.selectpicker.view.scrollTop; + } + + requestAnimationFrame(checkPopperExists); + }); + + // ensure posinset and setsize are correct before selecting an option via a click + this._delegate(this.menuInner, 'mouseover', 'li a', function () { + var hoverLi = this.parentElement, + position0 = that.isVirtual() ? that.selectpicker.view.position0 : 0, + index = Array.prototype.indexOf.call(hoverLi.parentElement.children, hoverLi), + hoverData = that.selectpicker.current.data[index + position0]; + + that.focusItem(hoverLi, hoverData, true); + }); + + this._delegate(this.menuInner, 'click', 'li a', function (e) { + that.onOptionClick(this, e); + }); + + this._delegate(this.menu, 'click', 'li.' + classNames.DISABLED + ' a, .' + classNames.POPOVERHEADER + ', .' + classNames.POPOVERHEADER + ' :not(.btn-close):not(.close)', function (e) { + if (e.currentTarget === this || e.target === this) { + e.preventDefault(); + e.stopPropagation(); + if (that.options.liveSearch && !e.target.classList.contains('btn-close') && !e.target.classList.contains('close')) { + that.searchbox.focus(); + } else { + that.button.focus(); + } + } + }); + + this._delegate(this.menuInner, 'click', '.divider, .dropdown-header', function (e) { + e.preventDefault(); + e.stopPropagation(); + if (that.options.liveSearch) { + that.searchbox.focus(); + } else { + that.button.focus(); + } + }); + + this._delegate(this.menu, 'click', '.' + classNames.POPOVERHEADER + ' .btn-close, .' + classNames.POPOVERHEADER + ' .close', function () { + that.dropdown.hide(); + }); + + this._delegate(this.newElement, 'click', '.bs-selected-item', function (e) { + e.preventDefault(); + e.stopPropagation(); + that.removeSelectedTag(this.getAttribute('data-option-value')); + }); + + this._delegate(this.menu, 'click', '.bs-create-option', function (e) { + e.preventDefault(); + e.stopPropagation(); + that.createOpenOption(this.getAttribute('data-search-value')); + }); + + if (this.searchbox) { + this._on(this.searchbox, 'click', function (e) { + e.stopPropagation(); + }); + } + + this._delegate(this.menu, 'click', '.actions-btn', function (e) { + if (that.options.liveSearch) { + that.searchbox.focus(); + } else { + that.button.focus(); + } + + e.preventDefault(); + e.stopPropagation(); + + if (this.classList.contains('bs-select-all')) { + that.selectAll(); + } else { + that.deselectAll(); + } + }); + + this._on(this.button, 'focus', function (e) { + var tabindex = that.element.getAttribute('tabindex'); + + // only change when button is actually focused + if (tabindex !== undefined && tabindex !== null && e.isTrusted) { + // apply select element's tabindex to ensure correct order is followed when tabbing to the next element + this.setAttribute('tabindex', tabindex); + // set element's tabindex to -1 to allow for reverse tabbing + that.element.setAttribute('tabindex', -1); + that.selectpicker.view.tabindex = tabindex; + } + }); + + this._on(this.button, 'blur', function (e) { + // revert everything to original tabindex + if (that.selectpicker.view.tabindex !== undefined && e.isTrusted) { + that.element.setAttribute('tabindex', that.selectpicker.view.tabindex); + this.setAttribute('tabindex', -1); + that.selectpicker.view.tabindex = undefined; + } + }); + + this._on(this.element, 'change', function () { + that.render(); + that._emit('changed', changedArguments ? { + clickedIndex: changedArguments[0], + isSelected: changedArguments[1], + previousValue: changedArguments[2] + } : null); + changedArguments = null; + }); + + this._on(this.element, 'focus', function () { + if (!that.options.mobile) that.button.focus(); + }); + } + + onOptionClick (clickedAnchor, e, retainActive) { + var that = this, + element = that.element, + li = clickedAnchor.parentElement, + position0 = that.isVirtual() ? that.selectpicker.view.position0 : 0, + clickedData = that.selectpicker.current.data[Array.prototype.indexOf.call(li.parentElement.children, li) + position0], + clickedElement = clickedData.element, + prevValue = getSelectValues.call(that), + prevIndex = element.selectedIndex, + prevOption = element.options[prevIndex], + prevData = prevOption ? that.selectpicker.main.data[prevOption.liIndex] : false, + triggerChange = true; + + // Don't close on multi choice menu + if (that.multiple && that.options.maxOptions !== 1) { + e.stopPropagation(); + } + + e.preventDefault(); + + // Don't run if the select is disabled + if (!that.isDisabled() && !li.classList.contains(classNames.DISABLED)) { + var option = clickedData.option, + state = option.selected, + optgroupData = that.selectpicker.current.data.find(function (datum) { + return datum.optID === clickedData.optID && datum.type === 'optgroup-label'; + }), + optgroup = optgroupData ? optgroupData.optgroup : undefined, + dataGetter = optgroup instanceof Element ? getOptionData.fromOption : getOptionData.fromDataSource, + optgroupOptions = optgroup && optgroup.children, + maxOptions = parseInt(that.options.maxOptions), + maxOptionsGrp = optgroup && parseInt(dataGetter(optgroup, 'maxOptions')) || false; + + if (clickedElement === that.activeElement) retainActive = true; + + if (!retainActive) { + that.prevActiveElement = that.activeElement; + that.activeElement = undefined; + } + + if (!that.multiple || maxOptions === 1) { // Deselect previous option if not multi select + if (prevData) that.setSelected(prevData, false); + that.setSelected(clickedData, true); + } else { // Toggle the clicked option if multi select. + that.setSelected(clickedData, !state); + that.focusedParent.focus(); + + if (maxOptions !== false || maxOptionsGrp !== false) { + var maxReached = maxOptions < getSelectedOptions.call(that).length, + selectedGroupOptions = 0; + + if (optgroup && optgroup.children) { + for (var i = 0; i < optgroup.children.length; i++) { + if (optgroup.children[i].selected) selectedGroupOptions++; + } + } + + var maxReachedGrp = maxOptionsGrp < selectedGroupOptions; + + if ((maxOptions && maxReached) || (maxOptionsGrp && maxReachedGrp)) { + if (maxOptions && maxOptions === 1) { + element.selectedIndex = -1; + that.setOptionStatus(true); + } else if (maxOptionsGrp && maxOptionsGrp === 1) { + for (var j = 0; j < optgroupOptions.length; j++) { + var _option = optgroupOptions[j]; + that.setSelected(that.selectpicker.current.data[_option.liIndex], false); + } + + that.setSelected(clickedData, true); + } else { + var maxOptionsText = typeof that.options.maxOptionsText === 'string' ? [that.options.maxOptionsText, that.options.maxOptionsText] : that.options.maxOptionsText, + maxOptionsArr = typeof maxOptionsText === 'function' ? maxOptionsText(maxOptions, maxOptionsGrp) : maxOptionsText, + maxTxt = maxOptionsArr[0].replace('{n}', maxOptions), + maxTxtGrp = maxOptionsArr[1].replace('{n}', maxOptionsGrp), + notify = createFromHTML('
    '); + + that.menu.appendChild(notify); + + if (maxOptions && maxReached) { + notify.appendChild(createFromHTML('
    ' + maxTxt + '
    ')); + triggerChange = false; + that._emit('maxReached'); + } + + if (maxOptionsGrp && maxReachedGrp) { + notify.appendChild(createFromHTML('
    ' + maxTxtGrp + '
    ')); + triggerChange = false; + that._emit('maxReachedGrp'); + } + + setTimeout(function () { + that.setSelected(clickedData, false); + }, 10); + + notify.classList.add('fadeOut'); + + setTimeout(function () { + notify.remove(); + }, 1050); + } + } + } + } + + if (that.options.source.data) that.element.appendChild(that.selectpicker.main.optionQueue); + + if (!that.multiple || (that.multiple && that.options.maxOptions === 1)) { + that.button.focus(); + } else if (that.options.liveSearch) { + that.searchbox.focus(); + } + + // Trigger select 'change' + if (triggerChange) { + if (that.multiple || prevIndex !== element.selectedIndex) { + changedArguments = [option.index, option.selected, prevValue]; + triggerNative(that.element, 'change'); + } + } + } + } + + liveSearchListener () { + var that = this; + + this._on(this.searchbox, 'click', function (e) { + e.stopPropagation(); + }); + this._on(this.searchbox, 'focus', function (e) { + e.stopPropagation(); + }); + this._on(this.searchbox, 'touchend', function (e) { + e.stopPropagation(); + }); + this._on(this.searchbox, 'keydown', function (e) { + if (e.key === 'Enter' && that.createOptionButton && !that.createOptionButton.hidden && !that.selectpicker.current.data.length) { + e.preventDefault(); + e.stopPropagation(); + that.createOpenOption(that.searchbox.value); + } + }); + + this._on(this.searchbox, 'input', function () { + var searchValue = that.searchbox.value; + + that.selectpicker.search.elements = []; + that.selectpicker.search.data = []; + + if (searchValue) { + that.selectpicker.search.previousValue = searchValue; + + if (that.options.source.search) { + that.fetchData(function () { + that.appendCreatedSearchResults(searchValue); + that.render(); + that.buildList(undefined, true); + that.noScroll = true; + that.menuInner.scrollTop = 0; + that.createView(true); + showNoResults.call(that, that.selectpicker.search.data, searchValue); + }, 'search', 0, searchValue); + } else { + var searchMatch = [], + q = searchValue.toUpperCase(), + cache = {}, + cacheArr = [], + searchStyle = that._searchStyle(), + normalizeSearch = that.options.liveSearchNormalize; + + if (normalizeSearch) q = normalizeToBase(q); + + for (var i = 0; i < that.selectpicker.main.data.length; i++) { + var li = that.selectpicker.main.data[i]; + + if (!cache[i]) { + cache[i] = stringSearch(li, q, searchStyle, normalizeSearch); + } + + if (cache[i] && li.headerIndex !== undefined && cacheArr.indexOf(li.headerIndex) === -1) { + if (li.headerIndex > 0) { + cache[li.headerIndex - 1] = true; + cacheArr.push(li.headerIndex - 1); + } + + cache[li.headerIndex] = true; + cacheArr.push(li.headerIndex); + + cache[li.lastIndex + 1] = true; + } + + if (cache[i] && li.type !== 'optgroup-label') cacheArr.push(i); + } + + for (var j = 0, cacheLen = cacheArr.length; j < cacheLen; j++) { + var index = cacheArr[j], + prevIndex = cacheArr[j - 1], + liData = that.selectpicker.main.data[index], + liPrev = that.selectpicker.main.data[prevIndex]; + + if (liData.type !== 'divider' || (liData.type === 'divider' && liPrev && liPrev.type !== 'divider' && cacheLen - 1 !== j)) { + that.selectpicker.search.data.push(liData); + searchMatch.push(that.selectpicker.main.elements[index]); + } + } + + that.activeElement = undefined; + that.noScroll = true; + that.menuInner.scrollTop = 0; + that.selectpicker.search.elements = searchMatch; + that.createView(true); + showNoResults.call(that, searchMatch, searchValue); + } + } else if (that.selectpicker.search.previousValue) { + that.menuInner.scrollTop = 0; + that.createView(false); + } + + that.syncOpenOptionButton(); + }); + } + + _searchStyle () { + return this.options.liveSearchStyle || 'contains'; + } + + getValue () { + var element = this.element; + + if (this.multiple) { + var values = []; + for (var i = 0; i < element.options.length; i++) { + if (element.options[i].selected) values.push(element.options[i].value); + } + return values; + } + + return element.value; + } + + + +/* eslint-disable no-undef */ +// Shared ordered source fragment consumed by the Grunt JS build. + val (value) { + var element = this.element; + + if (typeof value !== 'undefined') { + var selectedOptions = getSelectedOptions.call(this), + prevValue = getSelectValues.call(this, selectedOptions); + + changedArguments = [null, null, prevValue]; + + if (!Array.isArray(value)) value = [ value ]; + + value.map(String); + + for (var i = 0; i < selectedOptions.length; i++) { + var item = selectedOptions[i]; + + if (item && value.indexOf(String(item.value)) === -1) { + this.setSelected(item, false); + } + } + + // only update selected value if it matches an existing option + this.selectpicker.main.data.filter(function (item) { + if (value.indexOf(String(item.value)) !== -1) { + this.setSelected(item, true); + return true; + } + + return false; + }, this); + + if (this.options.source.data) element.appendChild(this.selectpicker.main.optionQueue); + + this._emit('changed', changedArguments ? { + clickedIndex: changedArguments[0], + isSelected: changedArguments[1], + previousValue: changedArguments[2] + } : null); + + if (this.newElement.classList.contains(classNames.SHOW)) { + if (this.multiple) { + this.setOptionStatus(true); + } else { + var liSelectedIndex = (element.options[element.selectedIndex] || {}).liIndex; + + if (typeof liSelectedIndex === 'number') { + this.setSelected(this.selectpicker.current.data[liSelectedIndex], true); + } + } + } + + this.render(); + + changedArguments = null; + + return this.element; + } else { + return this.getValue(); + } + } + + changeAll (status) { + if (!this.multiple) return; + if (typeof status === 'undefined') status = true; + + var element = this.element, + previousSelected = 0, + currentSelected = 0, + prevValue = getSelectValues.call(this); + + element.classList.add('bs-select-hidden'); + + for (var i = 0, data = this.selectpicker.current.data, len = data.length; i < len; i++) { + var liData = data[i], + option = liData.option; + + if (option && !liData.disabled && liData.type !== 'divider') { + if (liData.selected) previousSelected++; + option.selected = status; + liData.selected = status; + if (status === true) currentSelected++; + } + } + + element.classList.remove('bs-select-hidden'); + + if (previousSelected === currentSelected) return; + + this.setOptionStatus(); + + changedArguments = [null, null, prevValue]; + + triggerNative(this.element, 'change'); + } + + selectAll () { + return this.changeAll(true); + } + + deselectAll () { + return this.changeAll(false); + } + + toggle (e, state) { + var isActive, + triggerToggle = state === undefined; + + if (e && e.stopPropagation) e.stopPropagation(); + + if (triggerToggle === false) { + isActive = this.newElement.classList.contains(classNames.SHOW); + triggerToggle = (state === true && isActive === false) || (state === false && isActive === true); + } + + if (triggerToggle) this.dropdown.toggle(); + } + + open (e) { + this.toggle(e, true); + } + + close (e) { + this.toggle(e, false); + } + + _keydown (e, el) { + var that = this, + which = e.which || e.keyCode, + isToggle = el.classList.contains('dropdown-toggle'), + items = that.findLis(), + index, + isActive, + liActive, + activeLi, + offsetVal, + updateScroll = false, + downOnTab = which === keyCodes.TAB && !isToggle && !that.options.selectOnTab, + isArrowKey = REGEXP_ARROW.test(which) || downOnTab, + scrollTop = that.menuInner.scrollTop, + isVirtual = that.isVirtual(), + position0 = isVirtual === true ? that.selectpicker.view.position0 : 0; + + // do nothing if a function key is pressed + if (which >= 112 && which <= 123) return; + + isActive = that.menu.classList.contains(classNames.SHOW); + + if ( + !isActive && + ( + isArrowKey || + (which >= 48 && which <= 57) || + (which >= 96 && which <= 105) || + (which >= 65 && which <= 90) + ) + ) { + that.dropdown.show(); + + if (that.options.liveSearch) { + that.searchbox.focus(); + return; + } + } + + if (which === keyCodes.ESCAPE && isActive) { + e.preventDefault(); + that.dropdown.hide(); + that.button.focus(); + } + + if (isArrowKey) { // if up or down + if (!items.length) return; + + liActive = that.activeElement; + index = liActive ? Array.prototype.indexOf.call(liActive.parentElement.children, liActive) : -1; + + if (index !== -1) { + that.defocusItem(liActive); + } + + if (which === keyCodes.ARROW_UP) { // up + if (index !== -1) index--; + if (index + position0 < 0) index += items.length; + + if (!that.selectpicker.view.canHighlight[index + position0]) { + index = that.selectpicker.view.canHighlight.slice(0, index + position0).lastIndexOf(true) - position0; + if (index === -1) index = items.length - 1; + } + } else if (which === keyCodes.ARROW_DOWN || downOnTab) { // down + index++; + if (index + position0 >= that.selectpicker.view.canHighlight.length) index = that.selectpicker.view.firstHighlightIndex; + + if (!that.selectpicker.view.canHighlight[index + position0]) { + index = index + 1 + that.selectpicker.view.canHighlight.slice(index + position0 + 1).indexOf(true); + } + } + + e.preventDefault(); + + var liActiveIndex = position0 + index; + + if (which === keyCodes.ARROW_UP) { // up + // scroll to bottom and highlight last option + if (position0 === 0 && index === items.length - 1) { + that.menuInner.scrollTop = that.menuInner.scrollHeight; + + liActiveIndex = that.selectpicker.current.elements.length - 1; + } else { + activeLi = that.selectpicker.current.data[liActiveIndex]; + + // could be undefined if no results exist + if (activeLi) { + offsetVal = activeLi.position - activeLi.height; + + updateScroll = offsetVal < scrollTop; + } + } + } else if (which === keyCodes.ARROW_DOWN || downOnTab) { // down + // scroll to top and highlight first option + if (index === that.selectpicker.view.firstHighlightIndex) { + that.menuInner.scrollTop = 0; + + liActiveIndex = that.selectpicker.view.firstHighlightIndex; + } else { + activeLi = that.selectpicker.current.data[liActiveIndex]; + + // could be undefined if no results exist + if (activeLi) { + offsetVal = activeLi.position - that.sizeInfo.menuInnerHeight; + + updateScroll = offsetVal > scrollTop; + } + } + } + + liActive = that.selectpicker.current.elements[liActiveIndex]; + + that.activeElement = (that.selectpicker.current.data[liActiveIndex] || {}).element; + + that.focusItem(liActive); + + that.selectpicker.view.currentActive = liActive; + + if (updateScroll) that.menuInner.scrollTop = offsetVal; + + if (that.options.liveSearch) { + that.searchbox.focus(); + } else { + el.focus(); + } + } else if ( + (!el.matches('input') && !REGEXP_TAB_OR_ESCAPE.test(which)) || + (which === keyCodes.SPACE && that.selectpicker.keydown.keyHistory) + ) { + var matches = [], + keyHistory; + + e.preventDefault(); + + that.selectpicker.keydown.keyHistory += keyCodeMap[which]; + + if (that.selectpicker.keydown.resetKeyHistory.cancel) clearTimeout(that.selectpicker.keydown.resetKeyHistory.cancel); + that.selectpicker.keydown.resetKeyHistory.cancel = that.selectpicker.keydown.resetKeyHistory.start(); + + keyHistory = that.selectpicker.keydown.keyHistory; + + // if all letters are the same, set keyHistory to just the first character when searching + if (/^(.)\1+$/.test(keyHistory)) { + keyHistory = keyHistory.charAt(0); + } + + // find matches + for (var i = 0; i < that.selectpicker.current.data.length; i++) { + var li = that.selectpicker.current.data[i], + hasMatch; + + hasMatch = stringSearch(li, keyHistory, 'startsWith', true); + + if (hasMatch && that.selectpicker.view.canHighlight[i]) { + matches.push(li.element); + } + } + + if (matches.length) { + var matchIndex = 0; + + Array.prototype.forEach.call(items, function (item) { + item.classList.remove('active'); + if (item.firstChild) item.firstChild.classList.remove('active'); + }); + + // either only one key has been pressed or they are all the same key + if (keyHistory.length === 1) { + matchIndex = matches.indexOf(that.activeElement); + + if (matchIndex === -1 || matchIndex === matches.length - 1) { + matchIndex = 0; + } else { + matchIndex++; + } + } + + activeLi = that.selectpicker.main.data[that.selectpicker.main.elements.indexOf(matches[matchIndex])]; + + if (activeLi) { + if (scrollTop - activeLi.position > 0) { + offsetVal = activeLi.position - activeLi.height; + updateScroll = true; + } else { + offsetVal = activeLi.position - that.sizeInfo.menuInnerHeight; + // if the option is already visible at the current scroll position, just keep it the same + updateScroll = activeLi.position > scrollTop + that.sizeInfo.menuInnerHeight; + } + } + + liActive = matches[matchIndex]; + + that.activeElement = liActive; + + that.focusItem(liActive); + + if (liActive) liActive.firstChild.focus(); + + if (updateScroll) that.menuInner.scrollTop = offsetVal; + + el.focus(); + } + } + + // Select focused option if "Enter", "Spacebar" or "Tab" (when selectOnTab is true) are pressed inside the menu. + if ( + isActive && + ( + (which === keyCodes.SPACE && !that.selectpicker.keydown.keyHistory) || + which === keyCodes.ENTER || + (which === keyCodes.TAB && that.options.selectOnTab) + ) + ) { + if (which !== keyCodes.SPACE) e.preventDefault(); + + if (!that.options.liveSearch || which !== keyCodes.SPACE) { + var activeAnchor = that.menuInner.querySelector('.active a'); + if (activeAnchor) that.onOptionClick(activeAnchor, e, true); // retain active class + el.focus(); + + if (!that.options.liveSearch) { + // Prevent screen from scrolling if the user hits the spacebar + e.preventDefault(); + // Fixes spacebar selection of dropdown items in FF & IE + spaceSelectFlag = true; + } + } + } + } + + mobile () { + // ensure mobile is set to true if mobile function is called after init + this.options.mobile = true; + this.element.classList.add('mobile-device'); + } + + resetMenuData () { + this.selectpicker.main.data = []; + this.selectpicker.main.elements = []; + this.selectpicker.main.hasMore = false; + this.selectpicker.search.data = []; + this.selectpicker.search.elements = []; + this.selectpicker.search.hasMore = false; + this.selectpicker.current.data = this.selectpicker.main.data; + this.selectpicker.current.elements = this.selectpicker.main.elements; + this.selectpicker.current.hasMore = false; + this.selectpicker.isSearching = false; + } + + refresh () { + var that = this; + // update options if data attributes have been changed + var config = stripRemovedOptions(Object.assign({}, this.options, getAttributesObject(this.element), getDataset(this.element))); + this.options = config; + + if (this.options.source.data) { + this.render(); + this.buildList(); + } else { + this.resetMenuData(); + this.fetchData(function () { + that.render(); + that.buildList(); + }); + } + + this.checkDisabled(); + this.setStyle(); + this.setWidth(); + + this.setSize(true); + + this._emit('refreshed'); + } + + hide () { + this.newElement.style.display = 'none'; + } + + show () { + this.newElement.style.display = ''; + } + + remove () { + if (this.newElement.parentNode) this.newElement.parentNode.removeChild(this.newElement); + instanceMap.delete(this.element); + } + + destroy () { + // move the select back out of newElement, then remove newElement + if (this.newElement.parentNode) { + this.newElement.parentNode.insertBefore(this.element, this.newElement); + this.newElement.parentNode.removeChild(this.newElement); + } + + if (this.bsContainer) { + if (this.bsContainer.parentNode) this.bsContainer.parentNode.removeChild(this.bsContainer); + } else if (this.menu && this.menu.parentNode) { + this.menu.parentNode.removeChild(this.menu); + } + + if (this.selectpicker.view.titleOption && this.selectpicker.view.titleOption.parentNode) { + this.selectpicker.view.titleOption.parentNode.removeChild(this.selectpicker.view.titleOption); + } + + // remove all tracked event listeners + for (var i = 0; i < this._listeners.length; i++) { + var l = this._listeners[i]; + l.el.removeEventListener(l.type, l.handler, l.options); + } + this._listeners = []; + + for (var key in this._named) { + if (Object.prototype.hasOwnProperty.call(this._named, key)) { + this._removeNamed(key); + } + } + + if (this.dropdown && typeof this.dropdown.dispose === 'function') { + this.dropdown.dispose(); + } + + this.element.classList.remove('bs-select-hidden', 'selectpicker', 'mobile-device'); + + instanceMap.delete(this.element); + } +} + +// stores element -> Selectpicker instance +var instanceMap = new WeakMap(); + +Selectpicker.NAME = 'selectpicker'; +Selectpicker.VERSION = '1.2.0'; + +// user-provided global defaults (set via Selectpicker.setDefaults, used by i18n files) +Selectpicker.defaults = null; + +// part of this is duplicated in i18n/defaults-en_US.js. Make sure to update both. +Selectpicker.DEFAULTS = { + noneSelectedText: 'Nothing selected', + noneResultsText: 'No results matched {0}', + countSelectedText: function (numSelected) { + return (numSelected == 1) ? '{0} item selected' : '{0} items selected'; + }, + maxOptionsText: function (numAll, numGroup) { + return [ + (numAll == 1) ? 'Limit reached ({n} item max)' : 'Limit reached ({n} items max)', + (numGroup == 1) ? 'Group limit reached ({n} item max)' : 'Group limit reached ({n} items max)' + ]; + }, + selectAllText: 'Select All', + deselectAllText: 'Deselect All', + source: { + pageSize: 40, + create: null + }, + chunkSize: 40, + doneButton: false, + doneButtonText: 'Close', + multipleSeparator: ', ', + style: classNames.BUTTONCLASS, + size: 'auto', + placeholder: null, + allowClear: false, + selectedTextFormat: 'values', + width: false, + hideDisabled: false, + showSubtext: false, + showIcon: true, + showContent: true, + dropupAuto: true, + header: false, + liveSearch: false, + liveSearchPlaceholder: null, + liveSearchNormalize: false, + liveSearchStyle: 'contains', + openOptions: false, + openOptionsText: 'Create "{0}"', + selectionIndicator: 'checkmark', + actionsBox: false, + iconBase: classNames.ICONBASE, + tickIcon: classNames.TICKICON, + showTick: false, + showSelectedTags: false, + selectedItemsStyle: 'tags', + selectedTagRemoveLabel: 'Remove', + template: { + caret: '' + }, + maxOptions: false, + selectOnTab: true, + dropdownAlignRight: false, + virtualScroll: 600, + sanitize: true, + sanitizeFn: null, + whiteList: DefaultWhitelist +}; + +Selectpicker._buildConfig = function (element, options) { + options = stripRemovedOptions(options || {}); + + var dataAttributes = stripRemovedOptions(getDataset(element)); + + for (var dataAttr in dataAttributes) { + if (Object.prototype.hasOwnProperty.call(dataAttributes, dataAttr) && DISALLOWED_ATTRIBUTES.indexOf(dataAttr) !== -1) { + delete dataAttributes[dataAttr]; + } + } + + var userDefaults = stripRemovedOptions(Selectpicker.defaults || {}); + + var config = Object.assign({}, Selectpicker.DEFAULTS, userDefaults, getAttributesObject(element), dataAttributes, options); + config.template = Object.assign({}, Selectpicker.DEFAULTS.template, userDefaults.template || {}, dataAttributes.template, options.template); + config.source = Object.assign({}, Selectpicker.DEFAULTS.source, userDefaults.source || {}, options.source); + + return applyLegacyOptions(element, config); +}; + +Selectpicker.setDefaults = function (newDefaults) { + Selectpicker.defaults = stripRemovedOptions(Object.assign({}, Selectpicker.defaults, newDefaults)); +}; + +Selectpicker.getInstance = function (element) { + if (typeof element === 'string') element = document.querySelector(element); + return instanceMap.get(element) || null; +}; + +Selectpicker.getOrCreateInstance = function (element, options) { + if (typeof element === 'string') element = document.querySelector(element); + if (!element || element.tagName !== 'SELECT') return null; + + var instance = instanceMap.get(element); + + if (instance) { + options = stripRemovedOptions(options); + + if (options && typeof options === 'object') { + for (var i in options) { + if (Object.prototype.hasOwnProperty.call(options, i)) { + instance.options[i] = options[i]; + } + } + } + + return instance; + } + + return new Selectpicker(element, typeof options === 'object' ? options : {}); +}; + +// Runtime wiring lives in js/bootstrap-select.runtime.js so each distribution +// can choose whether it should expose a browser global or stay module-scoped. + + +/* global Selector, __SELECTPICKER_EXPOSE_GLOBAL__ */ + +// +var KEYDOWN_SELECTOR = '.bootstrap-select [' + Selector.DATA_TOGGLE + '], .bootstrap-select [role="listbox"], .bootstrap-select .bs-searchbox input'; + +function initSelectpickerRuntime (Selectpicker, exposeGlobal) { + if (typeof window === 'undefined' || typeof document === 'undefined') return; + + // Handle keyboard navigation ourselves. This listener runs in the capture + // phase on `window` so it executes before Bootstrap's `document`-level + // (capture-phase, delegated) dropdown keydown handler and prevents it from + // processing bootstrap-select's custom menu (which would otherwise error on + // relocated/container menus and conflict with our own navigation). This + // replaces the upstream approach of unbinding Bootstrap's global handler. + window.addEventListener('keydown', function (e) { + var target = e.target; + if (!target || !target.closest) return; + + // Any keydown originating inside a bootstrap-select widget (or its + // relocated menu container) must not reach Bootstrap's dropdown keydown + // handler. + var widget = target.closest('.bootstrap-select, .bs-container'); + if (!widget) return; + + e.stopImmediatePropagation(); + + var trigger = target.closest(KEYDOWN_SELECTOR); + if (!trigger) return; + + var instance; + for (var node = trigger; node; node = node.parentElement) { + if (node.bootstrapSelectInstance) { + instance = node.bootstrapSelectInstance; + break; + } + } + + if (instance) instance._keydown(e, trigger); + }, true); + + document.addEventListener('focusin', function (e) { + var target = e.target; + if (target && target.closest && target.closest(KEYDOWN_SELECTOR)) { + e.stopPropagation(); + } + }); + + function initAll () { + var selects = document.querySelectorAll('.selectpicker'); + Array.prototype.forEach.call(selects, function (select) { + Selectpicker.getOrCreateInstance(select); + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initAll); + } else { + initAll(); + } + + if (exposeGlobal) { + window.Selectpicker = Selectpicker; + } +} + +initSelectpickerRuntime( + Selectpicker, + typeof __SELECTPICKER_EXPOSE_GLOBAL__ === 'undefined' ? true : __SELECTPICKER_EXPOSE_GLOBAL__ +); +// + +return Selectpicker; + + +})); +//# sourceMappingURL=bootstrap-select.js.map diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js new file mode 100644 index 000000000..d9f32779c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js @@ -0,0 +1,10 @@ +/*! + * Bootstrap-select v1.2.0 (https://github.com/CrestApps/bootstrap-select) + * + * CrestApps fork (vanilla JavaScript, Bootstrap 5+) of snapappointments/bootstrap-select + * Copyright 2012-2018 SnapAppointments, LLC (original work) + * Fork modifications Copyright 2024-2026 CrestApps + * Licensed under MIT (https://github.com/CrestApps/bootstrap-select/blob/main/LICENSE) + */ +!function(e){if("function"==typeof define&&define.amd)define(["bootstrap"],e);else if("object"==typeof module&&module.exports){var t;try{t=require("bootstrap")}catch(e){t=void 0}module.exports=e(t)}else e("undefined"!=typeof window?window.bootstrap:void 0)}(function(e){var t=!0;function i(){var t=e||("undefined"!=typeof window?window.bootstrap:void 0);return t&&t.Dropdown||("undefined"!=typeof window?window.Dropdown:void 0)}function s(e){var t=document.createElement("div");return t.innerHTML=e.trim(),t.firstChild}function n(e){return parseInt(e,10)||0}function o(e){var t=e.getBoundingClientRect();return{top:t.top+window.pageYOffset,left:t.left+window.pageXOffset}}function l(e,t){var i=e.offsetHeight;if(t){var s=window.getComputedStyle(e);i+=n(s.marginTop)+n(s.marginBottom)}return i}function r(e,t){for(var i in t)Object.prototype.hasOwnProperty.call(t,i)&&(e.style[i]=t[i])}function a(e,t){e.dispatchEvent(new Event(t,{bubbles:!0}))}function c(e){return e.replace(/-([a-z])/g,function(e,t){return t.toUpperCase()})}function h(e){if("true"===e)return!0;if("false"===e)return!1;if("null"===e)return null;if(e===+e+"")return+e;if(/^(?:\{[\w\W]*\}|\[[\w\W]*\])$/.test(e))try{return JSON.parse(e)}catch(t){return e}return e}function d(e){for(var t={},i=e.attributes,s=0;s]+>/g,"")),s&&(a=z(a)),a=a.toUpperCase(),o="function"==typeof i?i(a,t):"contains"===i?a.indexOf(t)>=0:a.startsWith(t)))break}return o}function y(e,t){return null==e&&(e=""),e=e.toString().trim(),t&&e&&(e=z(e)),e.toUpperCase()}function I(e){return e&&(e.title||e.text||e.value)||""}var S={"\xc0":"A","\xc1":"A","\xc2":"A","\xc3":"A","\xc4":"A","\xc5":"A","\xe0":"a","\xe1":"a","\xe2":"a","\xe3":"a","\xe4":"a","\xe5":"a","\xc7":"C","\xe7":"c","\xd0":"D","\xf0":"d","\xc8":"E","\xc9":"E","\xca":"E","\xcb":"E","\xe8":"e","\xe9":"e","\xea":"e","\xeb":"e","\xcc":"I","\xcd":"I","\xce":"I","\xcf":"I","\xec":"i","\xed":"i","\xee":"i","\xef":"i","\xd1":"N","\xf1":"n","\xd2":"O","\xd3":"O","\xd4":"O","\xd5":"O","\xd6":"O","\xd8":"O","\xf2":"o","\xf3":"o","\xf4":"o","\xf5":"o","\xf6":"o","\xf8":"o","\xd9":"U","\xda":"U","\xdb":"U","\xdc":"U","\xf9":"u","\xfa":"u","\xfb":"u","\xfc":"u","\xdd":"Y","\xfd":"y","\xff":"y","\xc6":"Ae","\xe6":"ae","\xde":"Th","\xfe":"th","\xdf":"ss","\u0100":"A","\u0102":"A","\u0104":"A","\u0101":"a","\u0103":"a","\u0105":"a","\u0106":"C","\u0108":"C","\u010a":"C","\u010c":"C","\u0107":"c","\u0109":"c","\u010b":"c","\u010d":"c","\u010e":"D","\u0110":"D","\u010f":"d","\u0111":"d","\u0112":"E","\u0114":"E","\u0116":"E","\u0118":"E","\u011a":"E","\u0113":"e","\u0115":"e","\u0117":"e","\u0119":"e","\u011b":"e","\u011c":"G","\u011e":"G","\u0120":"G","\u0122":"G","\u011d":"g","\u011f":"g","\u0121":"g","\u0123":"g","\u0124":"H","\u0126":"H","\u0125":"h","\u0127":"h","\u0128":"I","\u012a":"I","\u012c":"I","\u012e":"I","\u0130":"I","\u0129":"i","\u012b":"i","\u012d":"i","\u012f":"i","\u0131":"i","\u0134":"J","\u0135":"j","\u0136":"K","\u0137":"k","\u0138":"k","\u0139":"L","\u013b":"L","\u013d":"L","\u013f":"L","\u0141":"L","\u013a":"l","\u013c":"l","\u013e":"l","\u0140":"l","\u0142":"l","\u0143":"N","\u0145":"N","\u0147":"N","\u014a":"N","\u0144":"n","\u0146":"n","\u0148":"n","\u014b":"n","\u014c":"O","\u014e":"O","\u0150":"O","\u014d":"o","\u014f":"o","\u0151":"o","\u0154":"R","\u0156":"R","\u0158":"R","\u0155":"r","\u0157":"r","\u0159":"r","\u015a":"S","\u015c":"S","\u015e":"S","\u0160":"S","\u015b":"s","\u015d":"s","\u015f":"s","\u0161":"s","\u0162":"T","\u0164":"T","\u0166":"T","\u0163":"t","\u0165":"t","\u0167":"t","\u0168":"U","\u016a":"U","\u016c":"U","\u016e":"U","\u0170":"U","\u0172":"U","\u0169":"u","\u016b":"u","\u016d":"u","\u016f":"u","\u0171":"u","\u0173":"u","\u0174":"W","\u0175":"w","\u0176":"Y","\u0177":"y","\u0178":"Y","\u0179":"Z","\u017b":"Z","\u017d":"Z","\u017a":"z","\u017c":"z","\u017e":"z","\u0132":"IJ","\u0133":"ij","\u0152":"Oe","\u0153":"oe","\u0149":"'n","\u017f":"s"},E=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,C=RegExp("[\\u0300-\\u036f\\ufe20-\\ufe2f\\u20d0-\\u20ff\\u1ab0-\\u1aff\\u1dc0-\\u1dff]","g");function O(e){return S[e]}function z(e){return(e=e.toString())&&e.replace(E,O).replace(C,"")}var L,A,N,T,D,H=(L={"&":"&","<":"<",">":">",'"':""","'":"'","`":"`"},A=function(e){return L[e]},N="(?:"+Object.keys(L).join("|")+")",T=RegExp(N),D=RegExp(N,"g"),function(e){return e=null==e?"":""+e,T.test(e)?e.replace(D,A):e}),_={32:" ",48:"0",49:"1",50:"2",51:"3",52:"4",53:"5",54:"6",55:"7",56:"8",57:"9",59:";",65:"A",66:"B",67:"C",68:"D",69:"E",70:"F",71:"G",72:"H",73:"I",74:"J",75:"K",76:"L",77:"M",78:"N",79:"O",80:"P",81:"Q",82:"R",83:"S",84:"T",85:"U",86:"V",87:"W",88:"X",89:"Y",90:"Z",96:"0",97:"1",98:"2",99:"3",100:"4",101:"5",102:"6",103:"7",104:"8",105:"9"},B=27,M=13,W=32,V=9,P=38,R=40,q=0,j=".bs.select",F="disabled",U="dropdown-divider",G="show",Q="dropup",K="dropdown-menu",Y="dropdown-menu-end",Z="btn-light",$="popover-header",J="",X="bs-ok-default",ee="."+K,te='data-bs-toggle="dropdown"',ie={div:document.createElement("div"),span:document.createElement("span"),i:document.createElement("i"),subtext:document.createElement("small"),a:document.createElement("a"),li:document.createElement("li"),whitespace:document.createTextNode("\xa0"),fragment:document.createDocumentFragment(),option:document.createElement("option")};ie.selectedOption=ie.option.cloneNode(!1),ie.selectedOption.setAttribute("selected",!0),ie.noResults=ie.li.cloneNode(!1),ie.noResults.className="no-results",ie.a.setAttribute("role","option"),ie.a.className="dropdown-item",ie.subtext.className="text-muted",ie.text=ie.span.cloneNode(!1),ie.text.className="text",ie.checkMark=ie.span.cloneNode(!1);var se=new RegExp(P+"|"+R),ne=new RegExp("^"+V+"$|"+B),oe={li:function(e,t,i){var s=ie.li.cloneNode(!1);return e&&(1===e.nodeType||11===e.nodeType?s.appendChild(e):s.innerHTML=e),void 0!==t&&""!==t&&(s.className=t),null!=i&&s.classList.add("optgroup-"+i),s},a:function(e,t,i){var s=ie.a.cloneNode(!0);return e&&(11===e.nodeType?s.appendChild(e):s.insertAdjacentHTML("beforeend",e)),void 0!==t&&""!==t&&s.classList.add.apply(s.classList,t.split(/\s+/)),i&&s.setAttribute("style",i),s},text:function(e,t){var i,s,n=ie.text.cloneNode(!1);if(e.content)n.innerHTML=e.content;else{if(n.textContent=e.text,e.icon){var o=ie.whitespace.cloneNode(!1);(s=(!0===t?ie.i:ie.span).cloneNode(!1)).className=this.options.iconBase+" "+e.icon,ie.fragment.appendChild(s),ie.fragment.appendChild(o)}e.subtext&&((i=ie.subtext.cloneNode(!1)).textContent=e.subtext,n.appendChild(i))}if(!0===t)for(;n.childNodes.length>0;)ie.fragment.appendChild(n.childNodes[0]);else ie.fragment.appendChild(n);return ie.fragment},label:function(e){var t,i,s=ie.text.cloneNode(!1);if(s.innerHTML=e.display,e.icon){var n=ie.whitespace.cloneNode(!1);(i=ie.span.cloneNode(!1)).className=this.options.iconBase+" "+e.icon,ie.fragment.appendChild(i),ie.fragment.appendChild(n)}return e.subtext&&((t=ie.subtext.cloneNode(!1)).textContent=e.subtext,s.appendChild(t)),ie.fragment.appendChild(s),ie.fragment}},le={fromOption:function(e,t){var i;switch(t){case"divider":i="true"===e.getAttribute("data-divider");break;case"text":i=e.textContent;break;case"label":i=e.label;break;case"style":i=e.style.cssText;break;case"title":i=e.title;break;default:i=e.getAttribute("data-"+t.replace(/[A-Z]+(?![a-z])|[A-Z]/g,function(e,t){return(t?"-":"")+e.toLowerCase()}))}return i},fromDataSource:function(e,t){var i;switch(t){case"text":case"label":i=e.text||e.value||"";break;default:i=e[t]}return i}};function re(e,t){e.length||(ie.noResults.innerHTML=this.options.noneResultsText.replace("{0}",'"'+H(t)+'"'),this.menuInner.firstChild.appendChild(ie.noResults))}function ae(e){return!(e.hidden||this.options.hideDisabled&&e.disabled)}function ce(){var e=this.selectpicker.main.data;(this.options.source.data||this.options.source.search)&&(e=Object.values(this.selectpicker.optionValuesDataMap));var t=e.filter(function(e){return!!e.selected&&(!this.options.hideDisabled||!e.disabled)},this);if(this.options.source.data&&!this.multiple&&t.length>1){for(var i=0;isetTimeout(()=>{this.selectpicker.keydown.keyHistory=""},800)}}},this.sizeInfo={},this.init(),ve.set(e,this)}_on(e,t,i,s){return e.addEventListener(t,i,s),this._listeners.push({el:e,type:t,handler:i,options:s}),i}_delegate(e,t,i,s,n){return this._on(e,t,function(t){var n=t.target.closest(i);n&&e.contains(n)&&s.call(n,t)},n)}_emit(e,t){var i=new CustomEvent(e+j,{bubbles:!0,cancelable:!0,detail:t||null});return this.element.dispatchEvent(i),i}_replace(e,t,i,s,n){this._removeNamed(e),t.addEventListener(i,s,n),this._named[e]={el:t,type:i,handler:s,options:n}}_removeNamed(e){var t=this._named[e];t&&(t.el.removeEventListener(t.type,t.handler,t.options),delete this._named[e])}init(){var e=this,t=this.element.getAttribute("id"),s=this.element,n=s.form;q++,this.selectId="bs-select-"+q,s.classList.add("bs-select-hidden"),this.multiple=this.element.multiple,this.autofocus=this.element.autofocus,s.classList.contains("show-tick")&&(this.options.showTick=!0),this.newElement=this.createDropdown(),s.parentNode.insertBefore(this.newElement,s.nextSibling),this.newElement.insertBefore(s,this.newElement.firstChild),n&&null===s.form&&(n.id||(n.id="form-"+this.selectId),s.setAttribute("form",n.id)),this.button=this.newElement.querySelector(":scope > button"),this.options.allowClear&&(this.clearButton=this.button.querySelector(".bs-select-clear-selected")),this.menu=this.newElement.querySelector(":scope > "+ee),this.menuInner=this.menu.querySelector(".inner"),this.searchbox=this.menu.querySelector("input"),this.selectedItems=this.newElement.querySelector(":scope > .bs-selected-items-external")||this.menu.querySelector(".bs-selected-items"),this.createOptionButton=this.menu.querySelector(".bs-create-option"),s.classList.remove("bs-select-hidden"),this.fetchData(function(){e.render(!0),e.buildList(),requestAnimationFrame(function(){e._emit("loaded")})}),!0===this.options.dropdownAlignRight&&this.menu.classList.add(Y),null!=t&&this.button.setAttribute("data-id",t),this.checkDisabled(),this.clickListener();var o=i();this.dropdown=new o(this.button),this.newElement.bootstrapSelectInstance=this,this.menu.bootstrapSelectInstance=this,this.options.liveSearch?(this.liveSearchListener(),this.focusedParent=this.searchbox):this.focusedParent=this.menuInner,this.setStyle(),this.setWidth(),this._on(this.element,"hide"+j,function(){if(e.isVirtual()){var t=e.menuInner,i=t.firstChild.cloneNode(!1);t.replaceChild(i,t.firstChild),t.scrollTop=0}}),this._on(this.newElement,"hide.bs.dropdown",function(t){e._emit("hide",{bsEvent:t})}),this._on(this.newElement,"hidden.bs.dropdown",function(t){e._emit("hidden",{bsEvent:t})}),this._on(this.newElement,"show.bs.dropdown",function(t){e.onShow(t),e._emit("show",{bsEvent:t})}),this._on(this.newElement,"shown.bs.dropdown",function(t){e._emit("shown",{bsEvent:t})}),s.hasAttribute("required")&&this._on(this.element,"invalid",function(){e.button.classList.add("bs-invalid");var t=function(){a(e.element,"change"),e.element.removeEventListener("shown"+j,t)};e._on(e.element,"shown"+j,t);var i=function(){e.element.validity.valid&&e.button.classList.remove("bs-invalid"),e.element.removeEventListener("rendered"+j,i)};e._on(e.element,"rendered"+j,i);var s=function(){e.element.focus(),e.element.blur(),e.button.removeEventListener("blur"+j,s)};e._on(e.button,"blur"+j,s)}),n&&this._on(n,"reset",function(){requestAnimationFrame(function(){e.render()})})}createDropdown(){var e="checkbox"===this.options.selectionIndicator,t=this.multiple||this.options.showTick||e?" show-tick":"",i=this.options.showSelectedTags?" show-selected-tags":"",n="list"===this.options.selectedItemsStyle?" selected-items-style-list":"",o=e?this.multiple?" selection-indicator-checkbox":" selection-indicator-radio":"",l=this.multiple?' aria-multiselectable="true"':"",r=this.autofocus?" autofocus":"",a=this.options.liveSearchPlaceholder;null===a&&(this.options.showSelectedTags||this.options.openOptions)&&(a=this.options.placeholder||"Search");var c="",h="",d="",p="",u="";return this.options.header&&(c='
    '+this.options.header+'
    '),this.options.liveSearch&&(h='"),this.multiple&&this.options.actionsBox&&(d='
    "),this.multiple&&this.options.doneButton&&(p='
    "),this.options.allowClear&&(u='×'),s('")}onShow(){this.options.liveSearch&&this.searchbox.value&&(this.searchbox.value="",this.selectpicker.search.previousValue=void 0),this.newElement.classList.contains(G)||this.setSize()}setPositionData(){this.selectpicker.view.canHighlight=[],this.selectpicker.view.size=0,this.selectpicker.view.firstHighlightIndex=!1;for(var e=0;e=this.options.virtualScroll||!0===this.options.virtualScroll}createView(e,t,i){var s=this,n=0;if(this.selectpicker.isSearching=e,this.selectpicker.current=e?this.selectpicker.search:this.selectpicker.main,this.setPositionData(),t)if(i)n=this.menuInner.scrollTop;else if(!s.multiple){var o=s.element,l=(o.options[o.selectedIndex]||{}).liIndex;if("number"==typeof l&&!1!==s.options.size){var r=s.selectpicker.main.data[l],a=r&&r.position;a&&(n=a-(s.sizeInfo.menuInnerHeight+s.sizeInfo.liHeight)/2)}}function c(t,i){var n,o,l,r,a,h,d,p,u,m,f=s.selectpicker.current.data.length,v=[],g=!0,b=s.isVirtual();s.selectpicker.view.scrollTop=t,n=s.options.chunkSize,o=Math.ceil(f/n)||1;for(var k=0;kf-1?0:s.selectpicker.current.data[f-1].position-s.selectpicker.current.data[s.selectpicker.view.position1-1].position,S.firstChild.style.marginTop=y+"px",S.firstChild.style.marginBottom=I+"px"):(S.firstChild.style.marginTop=0,S.firstChild.style.marginBottom=0),S.firstChild.appendChild(E),!0===b&&s.sizeInfo.hasScrollBar){var D=S.firstChild.offsetWidth;if(i&&Ds.sizeInfo.selectWidth)S.firstChild.style.minWidth=s.sizeInfo.menuInnerInnerWidth+"px";else if(D>s.sizeInfo.menuInnerInnerWidth){s.menu.style.minWidth=0;var H=S.firstChild.offsetWidth;H>s.sizeInfo.menuInnerInnerWidth&&(s.sizeInfo.menuInnerInnerWidth=H,S.firstChild.style.minWidth=s.sizeInfo.menuInnerInnerWidth+"px"),s.menu.style.minWidth=""}}}if((!e&&s.options.source.data||e&&s.options.source.search)&&s.selectpicker.current.hasMore&&a===o-1&&t>0){var _=Math.floor(a*s.options.chunkSize/s.options.source.pageSize)+2;s.fetchData(function(){s.render(),s.buildList(f,e),s.setPositionData(),c(t)},e?"search":"data",_,e?s.selectpicker.search.previousValue:void 0)}}if(s.prevActiveElement=s.activeElement,s.options.liveSearch){if(e&&i){var B,M=0;s.selectpicker.view.canHighlight[M]||(M=1+s.selectpicker.view.canHighlight.slice(1).indexOf(!0)),B=s.selectpicker.view.visibleElements[M],s.defocusItem(s.selectpicker.view.currentActive),s.activeElement=(s.selectpicker.current.data[M]||{}).element,s.focusItem(B)}}else s.menuInner.focus()}c(n,!0),this._replace("createViewScroll",this.menuInner,"scroll",function(){s.noScroll||c(s.menuInner.scrollTop),s.noScroll=!1}),this._replace("createViewResize",window,"resize",function(){s.newElement.classList.contains(G)&&c(s.menuInner.scrollTop)})}focusItem(e,t,i){if(e){t=t||this.selectpicker.current.data[this.selectpicker.current.elements.indexOf(this.activeElement)];var s=e.firstChild;s&&(s.setAttribute("aria-setsize",this.selectpicker.view.size),s.setAttribute("aria-posinset",t.posinset),!0!==i&&(this.focusedParent.setAttribute("aria-activedescendant",s.id),e.classList.add("active"),s.classList.add("active")))}}defocusItem(e){e&&(e.classList.remove("active"),e.firstChild&&e.firstChild.classList.remove("active"))}setPlaceholder(){var e=this,t=!1;if((this.options.placeholder||this.options.allowClear)&&!this.multiple){this.selectpicker.view.titleOption||(this.selectpicker.view.titleOption=document.createElement("option")),t=!0;var i=this.element,s=!1,n=!this.selectpicker.view.titleOption.parentNode,o=i.selectedIndex,l=i.options[o],r=i.querySelector("select > *:not(:disabled)"),a=r?r.index:0,c=window.performance&&window.performance.getEntriesByType("navigation"),h=c&&c.length?"back_forward"!==c[0].type:2!==window.performance.navigation.type;n&&(this.selectpicker.view.titleOption.className="bs-title-option",this.selectpicker.view.titleOption.value="",s=!l||o===a&&!1===l.defaultSelected),(n||0!==this.selectpicker.view.titleOption.index)&&i.insertBefore(this.selectpicker.view.titleOption,i.firstChild),s&&h?i.selectedIndex=0:"complete"!==document.readyState&&window.addEventListener("pageshow",function(){e.selectpicker.view.displayedValue!==i.value&&e.render()})}return t}fetchData(e,t,i,s){i=i||1,t=t||"data";var n,o=this,l=this.options.source[t];l?(this.options.virtualScroll=!0,"function"==typeof l?l.call(this,function(i,s,l){var r=o.selectpicker["search"===t?"search":"main"];r.hasMore=s,r.totalItems=l,n=o.buildData(i,t),e.call(o,n),o._emit("fetched")},i,s):Array.isArray(l)&&(n=o.buildData(l,t),e.call(o,n))):(n=this.buildData(!1,t),e.call(o,n))}buildData(e,t){var i=this,s=!1===e?le.fromOption:le.fromDataSource,n=':not([hidden]):not([data-hidden="true"]):not([style*="display: none"])',o=[],l=this.selectpicker.main.data?this.selectpicker.main.data.length:0,r=0,a=this.setPlaceholder()&&!e?1:0;"search"===t&&(l=this.selectpicker.search.data.length),this.options.hideDisabled&&(n+=":not(:disabled)");var c,h=e?e.filter(ae,this):this.element.querySelectorAll("select > *"+n);function d(e){var t=o[o.length-1];t&&"divider"===t.type&&(t.optID||e.optID)||((e=e||{}).type="divider",o.push(e))}function p(t,n){if((n=n||{}).divider=s(t,"divider"),!0===n.divider)d({optID:n.optID});else{var r=o.length+l,a=s(t,"style"),c=a?H(a):"",h=(t.className||"")+(n.optgroupClass||"");n.optID&&(h="opt "+h),n.optionClass=h.trim(),n.inlineStyle=c,n.text=s(t,"text"),n.title=s(t,"title"),n.content=s(t,"content"),n.tokens=s(t,"tokens"),n.subtext=s(t,"subtext"),n.icon=s(t,"icon"),n.display=n.content||n.text,n.value=void 0===t.value?t.text:t.value,n.type="option",n.index=r,n.option=t.option?t.option:t,n.option.liIndex=r,n.selected=!!t.selected,n.disabled=n.disabled||!!t.disabled,!1!==e&&(i.selectpicker.optionValuesDataMap[n.value]?n=Object.assign(i.selectpicker.optionValuesDataMap[n.value],n):i.selectpicker.optionValuesDataMap[n.value]=n),o.push(n)}}function u(t,i){var l=i[t],h=!(t-1o&&(o=n,i.selectpicker.view.widestOption=e[e.length-1])}(i.options.showTick||i.multiple||"checkbox"===i.options.selectionIndicator)&&(ie.checkMark.className="checkbox"===this.options.selectionIndicator?"check-mark bs-selection-indicator":this.options.iconBase+" "+i.options.tickIcon+" check-mark",ie.checkMark.parentNode||ie.a.appendChild(ie.checkMark));for(var r=e||0,a=s.length,c=r;c li")}render(e){var t,i,s=this,n=this.element,o=this.setPlaceholder()&&0===n.selectedIndex,l=ce.call(this),r=l.length,a=he.call(this,l),c=this.button,h=c.querySelector(".filter-option-inner-inner"),d=document.createTextNode(this.options.multipleSeparator),p=ie.fragment.cloneNode(!1),u=this.multiple&&this.options.showSelectedTags&&r>0,m=!1;if(this.options.source.data&&e&&(l.map(function e(t){t.selected?s.createOption(t,!0):t.children&&t.children.length&&t.children.map(e)}),n.appendChild(this.selectpicker.main.optionQueue),o&&(o=0===n.selectedIndex)),c.classList.toggle("bs-placeholder",s.multiple?!r:!a&&0!==a),s.multiple||1!==l.length||(s.selectpicker.view.displayedValue=a),"static"===this.options.selectedTextFormat)p=oe.text.call(this,{text:this.options.placeholder},!0);else if((t=u||this.multiple&&-1!==this.options.selectedTextFormat.indexOf("count")&&r>0)&&!u&&(t=(i=this.options.selectedTextFormat.split(">")).length>1&&r>i[1]||1===i.length&&r>=2),!1===t){if(!o){for(var f=0;f0&&p.appendChild(d.cloneNode(!1)),v.title?g.text=v.title:v.content&&s.options.showContent?(g.content=v.content.toString(),m=!0):(s.options.showIcon&&(g.icon=v.icon),s.options.showSubtext&&!s.multiple&&v.subtext&&(g.subtext=" "+v.subtext),g.text=v.text.trim()),p.appendChild(oe.text.call(this,g,!0)))}r>49&&p.appendChild(document.createTextNode("..."))}}else{var b=':not([hidden]):not([data-hidden="true"]):not([data-divider="true"]):not([style*="display: none"])';this.options.hideDisabled&&(b+=":not(:disabled)");var k=this.element.querySelectorAll("select > option"+b+", optgroup"+b+" option"+b).length,x="function"==typeof this.options.countSelectedText?this.options.countSelectedText(r,k):this.options.countSelectedText;p=oe.text.call(this,{text:x.replace("{0}",r.toString()).replace("{1}",k.toString())},!0)}p.childNodes.length||(p=oe.text.call(this,{text:this.options.placeholder?this.options.placeholder:this.options.noneSelectedText},!0)),c.title=p.textContent.replace(/<[^>]*>?/g,"").trim(),this.options.sanitize&&m&&w([p],s.options.whiteList,s.options.sanitizeFn),h.innerHTML="",h.appendChild(p),this.syncTagEditor(),this._emit("rendered")}usesTagEditor(){return this.options.liveSearch&&(this.options.showSelectedTags||this.options.openOptions)}syncTagEditor(){if(this.usesTagEditor()){if(this.selectedItems){var e=ce.call(this),t="list"===this.options.selectedItemsStyle;this.selectedItems.innerHTML="",this.selectedItems.hidden=!e.length,this.selectedItems.classList.toggle("list-group",t);for(var i=0;i0?this.menu.querySelector("."+$).cloneNode(!0):null,u=this.options.liveSearch&&this.menu.querySelector(".bs-searchbox")?this.menu.querySelector(".bs-searchbox").cloneNode(!0):null,m=this.options.actionsBox&&this.multiple&&this.menu.querySelectorAll(".bs-actionsbox").length>0?this.menu.querySelector(".bs-actionsbox").cloneNode(!0):null,f=this.options.doneButton&&this.multiple&&this.menu.querySelectorAll(".bs-donebutton").length>0?this.menu.querySelector(".bs-donebutton").cloneNode(!0):null,v=this.element.options[0];if(this.sizeInfo.selectWidth=this.newElement.offsetWidth,d.className="text",h.className="dropdown-item "+(v?v.className:""),i.className=this.menu.parentNode.className+" "+G,i.style.width=0,s.className=K+" "+G,o.className="inner "+G,r.className=K+" inner "+G,a.className=U,c.className="dropdown-header",d.appendChild(document.createTextNode("\u200b")),this.selectpicker.current.data.length)for(var g=0;gthis.sizeInfo.menuExtras.vert&&a+this.sizeInfo.menuExtras.vert+50>this.sizeInfo.selectOffsetBot,!0===this.selectpicker.isSearching&&(c=this.selectpicker.dropup),this.newElement.classList.toggle(Q,c),this.selectpicker.dropup=c),"auto"===this.options.size)n=this.selectpicker.current.data.length>3?3*this.sizeInfo.liHeight+this.sizeInfo.menuExtras.vert-2:0,i=this.sizeInfo.selectOffsetBot-this.sizeInfo.menuExtras.vert,s=n+p+u+m+f,l=Math.max(n-g.vert,0),this.newElement.classList.contains(Q)&&(i=this.sizeInfo.selectOffsetTop-this.sizeInfo.menuExtras.vert),o=i,t=i-p-u-m-f-g.vert;else if(this.options.size&&"auto"!==this.options.size&&this.selectpicker.current.elements.length>this.options.size){for(var w=0;wthis.sizeInfo.menuInnerHeight&&(this.sizeInfo.hasScrollBar=!0,this.sizeInfo.totalMenuWidth=this.sizeInfo.menuWidth+this.sizeInfo.scrollBarWidth),"auto"===this.options.dropdownAlignRight&&this.menu.classList.toggle(Y,this.sizeInfo.selectOffsetLeft>this.sizeInfo.selectOffsetRight&&this.sizeInfo.selectOffsetRightthis.options.size&&(this._removeNamed("setMenuSizeResize"),this._removeNamed("setMenuSizeScroll"))}this.createView(!1,!0,e)}setWidth(){this.menu.style.minWidth="",this.newElement.style.width="",this.newElement.classList.remove("fit-width"),"fit"!==this.options.width?this.options.width&&"auto"!==this.options.width&&(this.newElement.style.width=this.options.width):this.newElement.classList.add("fit-width")}selectPosition(){this.bsContainer=s('
    ');var e,t,a,c=this,h=function(e){return e?"string"==typeof e?document.querySelector(e):e:null}(this.options.container),d=function(s){var l=i(),d={},p=c.options.display||!!l.Default&&l.Default.display,u=s.getAttribute("class").replace(/form-control|fit-width/gi,"").trim();if(u&&c.bsContainer.classList.add.apply(c.bsContainer.classList,u.split(/\s+/)),c.bsContainer.classList.toggle(Q,s.classList.contains(Q)),e=o(s),h!==document.body){t=o(h);var m=window.getComputedStyle(h);t.top+=n(m.borderTopWidth)-h.scrollTop,t.left+=n(m.borderLeftWidth)-h.scrollLeft}else t={top:0,left:0};a=s.classList.contains(Q)?0:s.offsetHeight,"static"===p&&(d.top=e.top-t.top+a,d.left=e.left-t.left),d.width=s.offsetWidth,r(c.bsContainer,{top:void 0!==d.top?d.top+"px":"",left:void 0!==d.left?d.left+"px":"",width:d.width+"px"})};this._on(this.button,"click",function(){c.isDisabled()||(d(c.newElement),h.appendChild(c.bsContainer),c.bsContainer.classList.toggle(G,!c.button.classList.contains(G)),c.bsContainer.appendChild(c.menu))});var p=function(){c.newElement.classList.contains(G)&&d(c.newElement)};this._replace("selectPositionResize",window,"resize",p),this._replace("selectPositionScroll",window,"scroll",p),this._on(this.element,"hide"+j,function(){c._menuHeight=l(c.menu),c.bsContainer.parentNode&&c.bsContainer.parentNode.removeChild(c.bsContainer)})}createOption(e,t){var i=e.option?e.option:e;if(i&&1!==i.nodeType){var s=(t?ie.selectedOption:ie.option).cloneNode(!0);void 0!==i.value&&(s.value=i.value),s.textContent=i.text,s.selected=!0,void 0!==i.liIndex?s.liIndex=i.liIndex:t||(s.liIndex=e.index),e.option=s,this.selectpicker.main.optionQueue.appendChild(s)}}setOptionStatus(e){var t=this;if(t.noScroll=!1,t.selectpicker.view.visibleElements&&t.selectpicker.view.visibleElements.length){for(var i=0;i
    ');n.menu.appendChild(H),y&&S&&(H.appendChild(s("
    "+T+"
    ")),f=!1,n._emit("maxReached")),I&&O&&(H.appendChild(s("
    "+D+"
    ")),f=!1,n._emit("maxReachedGrp")),setTimeout(function(){n.setSelected(c,!1)},10),H.classList.add("fadeOut"),setTimeout(function(){H.remove()},1050)}}}else m&&n.setSelected(m,!1),n.setSelected(c,!0);n.options.source.data&&n.element.appendChild(n.selectpicker.main.optionQueue),!n.multiple||n.multiple&&1===n.options.maxOptions?n.button.focus():n.options.liveSearch&&n.searchbox.focus(),f&&(n.multiple||p!==o.selectedIndex)&&(de=[v.index,v.selected,d],a(n.element,"change"))}}liveSearchListener(){var e=this;this._on(this.searchbox,"click",function(e){e.stopPropagation()}),this._on(this.searchbox,"focus",function(e){e.stopPropagation()}),this._on(this.searchbox,"touchend",function(e){e.stopPropagation()}),this._on(this.searchbox,"keydown",function(t){"Enter"!==t.key||!e.createOptionButton||e.createOptionButton.hidden||e.selectpicker.current.data.length||(t.preventDefault(),t.stopPropagation(),e.createOpenOption(e.searchbox.value))}),this._on(this.searchbox,"input",function(){var t=e.searchbox.value;if(e.selectpicker.search.elements=[],e.selectpicker.search.data=[],t)if(e.selectpicker.search.previousValue=t,e.options.source.search)e.fetchData(function(){e.appendCreatedSearchResults(t),e.render(),e.buildList(void 0,!0),e.noScroll=!0,e.menuInner.scrollTop=0,e.createView(!0),re.call(e,e.selectpicker.search.data,t)},"search",0,t);else{var i=[],s=t.toUpperCase(),n={},o=[],l=e._searchStyle(),r=e.options.liveSearchNormalize;r&&(s=z(s));for(var a=0;a0&&(n[c.headerIndex-1]=!0,o.push(c.headerIndex-1)),n[c.headerIndex]=!0,o.push(c.headerIndex),n[c.lastIndex+1]=!0),n[a]&&"optgroup-label"!==c.type&&o.push(a)}for(var h=0,d=o.length;h=112&&a<=123))if(!(s=r.menu.classList.contains(G))&&(u||a>=48&&a<=57||a>=96&&a<=105||a>=65&&a<=90)&&(r.dropdown.show(),r.options.liveSearch))r.searchbox.focus();else{if(a===B&&s&&(e.preventDefault(),r.dropdown.hide(),r.button.focus()),u){if(!h.length)return;-1!==(i=(n=r.activeElement)?Array.prototype.indexOf.call(n.parentElement.children,n):-1)&&r.defocusItem(n),a===P?(-1!==i&&i--,i+f<0&&(i+=h.length),r.selectpicker.view.canHighlight[i+f]||-1===(i=r.selectpicker.view.canHighlight.slice(0,i+f).lastIndexOf(!0)-f)&&(i=h.length-1)):(a===R||p)&&(++i+f>=r.selectpicker.view.canHighlight.length&&(i=r.selectpicker.view.firstHighlightIndex),r.selectpicker.view.canHighlight[i+f]||(i=i+1+r.selectpicker.view.canHighlight.slice(i+f+1).indexOf(!0))),e.preventDefault();var v=f+i;a===P?0===f&&i===h.length-1?(r.menuInner.scrollTop=r.menuInner.scrollHeight,v=r.selectpicker.current.elements.length-1):(o=r.selectpicker.current.data[v])&&(d=(l=o.position-o.height)m)),n=r.selectpicker.current.elements[v],r.activeElement=(r.selectpicker.current.data[v]||{}).element,r.focusItem(n),r.selectpicker.view.currentActive=n,d&&(r.menuInner.scrollTop=l),r.options.liveSearch?r.searchbox.focus():t.focus()}else if(!t.matches("input")&&!ne.test(a)||a===W&&r.selectpicker.keydown.keyHistory){var g,b=[];e.preventDefault(),r.selectpicker.keydown.keyHistory+=_[a],r.selectpicker.keydown.resetKeyHistory.cancel&&clearTimeout(r.selectpicker.keydown.resetKeyHistory.cancel),r.selectpicker.keydown.resetKeyHistory.cancel=r.selectpicker.keydown.resetKeyHistory.start(),g=r.selectpicker.keydown.keyHistory,/^(.)\1+$/.test(g)&&(g=g.charAt(0));for(var w=0;w0?(l=o.position-o.height,d=!0):(l=o.position-r.sizeInfo.menuInnerHeight,d=o.position>m+r.sizeInfo.menuInnerHeight)),n=b[y],r.activeElement=n,r.focusItem(n),n&&n.firstChild.focus(),d&&(r.menuInner.scrollTop=l),t.focus()}}if(s&&(a===W&&!r.selectpicker.keydown.keyHistory||a===M||a===V&&r.options.selectOnTab)&&(a!==W&&e.preventDefault(),!r.options.liveSearch||a!==W)){var I=r.menuInner.querySelector(".active a");I&&r.onOptionClick(I,e,!0),t.focus(),r.options.liveSearch||(e.preventDefault(),pe=!0)}}}mobile(){this.options.mobile=!0,this.element.classList.add("mobile-device")}resetMenuData(){this.selectpicker.main.data=[],this.selectpicker.main.elements=[],this.selectpicker.main.hasMore=!1,this.selectpicker.search.data=[],this.selectpicker.search.elements=[],this.selectpicker.search.hasMore=!1,this.selectpicker.current.data=this.selectpicker.main.data,this.selectpicker.current.elements=this.selectpicker.main.elements,this.selectpicker.current.hasMore=!1,this.selectpicker.isSearching=!1}refresh(){var e=this,t=me(Object.assign({},this.options,k(this.element),d(this.element)));this.options=t,this.options.source.data?(this.render(),this.buildList()):(this.resetMenuData(),this.fetchData(function(){e.render(),e.buildList()})),this.checkDisabled(),this.setStyle(),this.setWidth(),this.setSize(!0),this._emit("refreshed")}hide(){this.newElement.style.display="none"}show(){this.newElement.style.display=""}remove(){this.newElement.parentNode&&this.newElement.parentNode.removeChild(this.newElement),ve.delete(this.element)}destroy(){this.newElement.parentNode&&(this.newElement.parentNode.insertBefore(this.element,this.newElement),this.newElement.parentNode.removeChild(this.newElement)),this.bsContainer?this.bsContainer.parentNode&&this.bsContainer.parentNode.removeChild(this.bsContainer):this.menu&&this.menu.parentNode&&this.menu.parentNode.removeChild(this.menu),this.selectpicker.view.titleOption&&this.selectpicker.view.titleOption.parentNode&&this.selectpicker.view.titleOption.parentNode.removeChild(this.selectpicker.view.titleOption);for(var e=0;e
    '},maxOptions:!1,selectOnTab:!0,dropdownAlignRight:!1,virtualScroll:600,sanitize:!0,sanitizeFn:null,whiteList:m},fe._buildConfig=function(e,t){t=me(t||{});var i=me(d(e));for(var s in i)Object.prototype.hasOwnProperty.call(i,s)&&-1!==p.indexOf(s)&&delete i[s];var n=me(fe.defaults||{}),o=Object.assign({},fe.DEFAULTS,n,k(e),i,t);return o.template=Object.assign({},fe.DEFAULTS.template,n.template||{},i.template,t.template),o.source=Object.assign({},fe.DEFAULTS.source,n.source||{},t.source),function(e,t){if(!t.placeholder){var i=e.getAttribute("title");i&&(t.placeholder=i)}return t}(e,o)},fe.setDefaults=function(e){fe.defaults=me(Object.assign({},fe.defaults,e))},fe.getInstance=function(e){return"string"==typeof e&&(e=document.querySelector(e)),ve.get(e)||null},fe.getOrCreateInstance=function(e,t){if("string"==typeof e&&(e=document.querySelector(e)),!e||"SELECT"!==e.tagName)return null;var i=ve.get(e);if(i){if((t=me(t))&&"object"==typeof t)for(var s in t)Object.prototype.hasOwnProperty.call(t,s)&&(i.options[s]=t[s]);return i}return new fe(e,"object"==typeof t?t:{})};var ge=".bootstrap-select ["+te+'], .bootstrap-select [role="listbox"], .bootstrap-select .bs-searchbox input';return function(e,t){function i(){var t=document.querySelectorAll(".selectpicker");Array.prototype.forEach.call(t,function(t){e.getOrCreateInstance(t)})}"undefined"!=typeof window&&"undefined"!=typeof document&&(window.addEventListener("keydown",function(e){var t=e.target;if(t&&t.closest&&t.closest(".bootstrap-select, .bs-container")){e.stopImmediatePropagation();var i=t.closest(ge);if(i){for(var s,n=i;n;n=n.parentElement)if(n.bootstrapSelectInstance){s=n.bootstrapSelectInstance;break}s&&s._keydown(e,i)}}},!0),document.addEventListener("focusin",function(e){var t=e.target;t&&t.closest&&t.closest(ge)&&e.stopPropagation()}),"loading"===document.readyState?document.addEventListener("DOMContentLoaded",i):i(),t&&(window.Selectpicker=e))}(fe,t),fe}); +//# sourceMappingURL=bootstrap-select.min.js.map diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs index 6623e2662..9625cc0a9 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerServiceTests.cs @@ -25,8 +25,8 @@ public async Task RunCycleAsync_WhenProviderFails_CancelsReservationAndMarksInte var interactionManager = CreateInteractionManager(interaction); var activity = new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }; var activityManager = CreateActivityManager(activity); - var provider = CreateProvider(DialerDialResult.Failure("provider_failed", "Provider rejected the request.")); - var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, provider); + var voiceCallRouter = CreateVoiceCallRouter(Failure("provider_failed", "Provider rejected the request.")); + var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, voiceCallRouter); // Act var started = await service.RunCycleAsync(CreateProfile(), TestContext.Current.CancellationToken); @@ -53,8 +53,8 @@ public async Task RunCycleAsync_WhenMaxAttemptsReached_CancelsReservationWithout }; var activityManager = CreateActivityManager(activity); - var provider = CreateProvider(DialerDialResult.Success("call1")); - var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, provider); + var voiceCallRouter = CreateVoiceCallRouter(Success("call1")); + var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, voiceCallRouter); // Act var started = await service.RunCycleAsync(CreateProfile(), TestContext.Current.CancellationToken); @@ -62,7 +62,7 @@ public async Task RunCycleAsync_WhenMaxAttemptsReached_CancelsReservationWithout // Assert Assert.Equal(0, started); Assert.Equal(ActivityStatus.Failed, activity.Status); - provider.Verify(p => p.PlaceCallAsync(It.IsAny(), It.IsAny()), Times.Never); + voiceCallRouter.Verify(p => p.RouteOutboundAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); reservationService.Verify(s => s.CancelAsync("r1", It.IsAny()), Times.Once); } @@ -82,8 +82,8 @@ public async Task RunCycleAsync_WhenPowerMode_StopsAtCallsPerAgentPacingLimit() var reservationService = new Mock(); var interactionManager = CreateInteractionManager(new Interaction { ItemId = "int1" }); var activityManager = CreateActivityManager(new OmnichannelActivity { ItemId = "act1", PreferredDestination = "+15551112222" }); - var provider = CreateProvider(DialerDialResult.Success("call1")); - var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, provider); + var voiceCallRouter = CreateVoiceCallRouter(Success("call1")); + var service = CreateService(assignmentService, reservationService, interactionManager, activityManager, voiceCallRouter); var profile = CreateProfile(); profile.CallsPerAgent = 1; @@ -140,16 +140,20 @@ private static Mock CreateActivityManager(Omnichann return activityManager; } - private static Mock CreateProvider(DialerDialResult result) + private static Mock CreateVoiceCallRouter(ContactCenterVoiceProviderResult result) { - var provider = new Mock(); - provider.SetupGet(p => p.TechnicalName).Returns("test"); - provider.SetupGet(p => p.Capabilities).Returns(DialerProviderCapabilities.Outbound); - provider - .Setup(p => p.PlaceCallAsync(It.IsAny(), It.IsAny())) + var voiceCallRouter = new Mock(); + voiceCallRouter + .Setup(router => router.CanRouteOutbound("test")) + .Returns(true); + voiceCallRouter + .Setup(router => router.GetOutboundProviderName("test")) + .Returns("test"); + voiceCallRouter + .Setup(router => router.RouteOutboundAsync(It.IsAny(), "test", It.IsAny())) .ReturnsAsync(result); - return provider; + return voiceCallRouter; } private static DialerService CreateService( @@ -157,10 +161,8 @@ private static DialerService CreateService( Mock reservationService, Mock interactionManager, Mock activityManager, - Mock provider) + Mock voiceCallRouter) { - var providerResolver = new Mock(); - providerResolver.Setup(r => r.Get("test")).Returns(provider.Object); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); @@ -169,9 +171,28 @@ private static DialerService CreateService( reservationService.Object, interactionManager.Object, activityManager.Object, - providerResolver.Object, + voiceCallRouter.Object, new Mock().Object, clock.Object, new Mock>().Object); } + + private static ContactCenterVoiceProviderResult Success(string providerCallId) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = true, + ProviderCallId = providerCallId, + }; + } + + private static ContactCenterVoiceProviderResult Failure(string errorCode, string errorMessage) + { + return new ContactCenterVoiceProviderResult + { + Succeeded = false, + ErrorCode = errorCode, + ErrorMessage = errorMessage, + }; + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index 9fc88decd..3a6b031a6 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using CrestApps.OrchardCore.ContactCenter; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; @@ -212,6 +213,8 @@ private sealed class Harness public Mock IncomingCallDispatcher { get; } = new(); + public Mock VoiceProviderResolver { get; } = new(); + public void SetupNoContext() { ChannelEndpointManager @@ -235,12 +238,12 @@ public void SetupNoContext() .ReturnsAsync(new Interaction { ItemId = "int1" }); } - public InboundVoiceService CreateService() + public VoiceContactCenterCallRouter CreateService() { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); - return new InboundVoiceService( + return new VoiceContactCenterCallRouter( ChannelEndpointManager.Object, SubjectFlowSettingsService.Object, ActivityManager.Object, @@ -253,6 +256,7 @@ public InboundVoiceService CreateService() AgentManager.Object, ContactLookup.Object, IncomingCallDispatcher.Object, + VoiceProviderResolver.Object, clock.Object); } } From 79bf91cc335120ece48d3b5813619a073d64b6cc Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 14:12:21 -0700 Subject: [PATCH 08/56] [Fix] Add Contact Center skills and presence cleanup Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> AI-Model: gpt-5.5 --- .github/contact-center/PLAN.md | 2 +- .../Models/SoftPhoneWidgetExtensionContext.cs | 12 + .../ISoftPhoneWidgetExtensionProvider.cs | 17 ++ .../ContactCenterPermissions.cs | 5 + .../Indexes/ContactCenterSkillIndex.cs | 24 ++ .../Models/ContactCenterSkill.cs | 35 +++ .../Services/ContactCenterSkillManager.cs | 54 ++++ .../Services/ContactCenterSkillStore.cs | 44 +++ .../Services/IContactCenterSkillManager.cs | 25 ++ .../Services/IContactCenterSkillStore.cs | 25 ++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 +- .../contact-center/agents-queues-dialer.md | 30 +- .../docs/contact-center/index.md | 14 +- src/CrestApps.Docs/docs/telephony/index.md | 6 +- .../Controllers/AgentWorkspaceController.cs | 12 +- .../Controllers/SkillsController.cs | 289 ++++++++++++++++++ .../ContactCenterSkillDisplayDriver.cs | 67 ++++ .../Handlers/ContactCenterSkillHandler.cs | 55 ++++ .../ContactCenterSkillIndexProvider.cs | 32 ++ .../ContactCenterSkillIndexMigrations.cs | 32 ++ .../ContactCenterAdminFormOptionsProvider.cs | 33 +- .../Services/ContactCenterAdminMenu.cs | 6 + .../ContactCenterPermissionProvider.cs | 1 + ...tCenterSoftPhoneWidgetExtensionProvider.cs | 71 +++++ .../Startup.cs | 10 +- .../ViewModels/AgentWorkspaceViewModel.cs | 5 +- .../ViewModels/ContactCenterSkillViewModel.cs | 30 ++ .../Views/ActivityQueue.Edit.cshtml | 4 + .../Views/AgentWorkspace/Index.cshtml | 127 ++++---- .../Views/ContactCenterSkill.Edit.cshtml | 4 + .../ContactCenterSkill.SummaryAdmin.cshtml | 29 ++ .../ContactCenterSkillFields.Edit.cshtml | 28 ++ .../ContactCenterSoftPhonePresence.cshtml | 25 ++ .../Views/DialerProfile.Edit.cshtml | 4 + ...actCenterSkill.Buttons.SummaryAdmin.cshtml | 14 + ...enterSkill.DefaultMeta.SummaryAdmin.cshtml | 17 ++ ...tactCenterSkill.Fields.SummaryAdmin.cshtml | 18 ++ .../Views/Skills/Create.cshtml | 19 ++ .../Views/Skills/Edit.cshtml | 19 ++ .../Views/Skills/Index.cshtml | 57 ++++ .../CrestApps.OrchardCore.DialPad/Manifest.cs | 6 +- .../Filters/SoftPhoneWidgetFilter.cs | 18 ++ .../Manifest.cs | 6 +- .../Models/SoftPhoneWidgetSettings.cs | 2 +- .../Views/SoftPhoneWidget.cshtml | 6 + 45 files changed, 1219 insertions(+), 124 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterSkillIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterSkill.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterSkillStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterSkillStore.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SkillsController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSkillDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSkillHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterSkillIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterSkillIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterSkillViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkillFields.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Buttons.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.DefaultMeta.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Fields.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Create.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Index.cshtml diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 2427d5757..b559fc012 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1409,4 +1409,4 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing initially used `IDialerProvider`/`IDialerProviderResolver`, power/progressive pacing, dialer batch sources, and a `DialPad.Dialer` provider; this was later corrected so outbound voice calls route through `IVoiceContactCenterCallRouter` and `IContactCenterVoiceProvider`. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). - 2026-06-29: Phase 3 routing advanced with an extensible `IActivityRoutingStrategy` pipeline, required-skills eligibility, longest-idle scoring, and `RoutingDecisionMade` audit events that capture candidate scores and reasons. Contact Center voice-provider resolution was added through `IContactCenterVoiceProviderResolver`; outbound dialer failure paths now cancel reservations and enforce max-attempt boundaries; inbound offer failures release reservations immediately; agent sign-in clears stale reservations and serializes profile creation. Queue, dialer, and agent workspace admin UI now uses Orchard `ocat-*` layout and exposes routing skills, inbound endpoint mapping, retry/do-not-call settings, and agent skill sign-in. Added routing/dialer/reservation tests and updated docs/changelog. Next: sticky-agent and business-hours routing, then wrap-up timers and required-disposition policies before Phase 7 real-time desktop work. - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). -- 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent Workspace campaign and skill sign-in fields now use searchable multi-select lists backed by the CrestApps bootstrap-select resource; campaigns come from the Interaction Center campaign catalog. Queue and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. +- 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent Workspace campaign and skill sign-in fields now use searchable multi-select lists backed by the CrestApps bootstrap-select resource; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; Agent Workspace and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. Agent Workspace now shows either sign-in or sign-out based on signed-in state, and Contact Center presence controls moved to the soft phone widget through a Telephony soft-phone extension point. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs new file mode 100644 index 000000000..0bf2493ca --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs @@ -0,0 +1,12 @@ +namespace CrestApps.OrchardCore.Telephony.Models; + +/// +/// Represents the extension context used by modules that contribute shapes to the soft phone widget. +/// +public sealed class SoftPhoneWidgetExtensionContext +{ + /// + /// Gets the shapes contributed to the soft phone widget. + /// + public IList Shapes { get; } = []; +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs new file mode 100644 index 000000000..9de6d4cc2 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Telephony.Models; + +namespace CrestApps.OrchardCore.Telephony.Services; + +/// +/// Provides extension shapes for the floating soft phone widget. +/// +public interface ISoftPhoneWidgetExtensionProvider +{ + /// + /// Builds extension shapes for the floating soft phone widget. + /// + /// The extension context. + /// The token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. + Task BuildAsync(SoftPhoneWidgetExtensionContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs index 5c9f61042..45acbe7b3 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs @@ -32,6 +32,11 @@ public static class ContactCenterPermissions /// public static readonly Permission ManageQueues = new("ManageContactCenterQueues", "Manage Contact Center queues", [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. /// 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/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/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/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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 5aa61af09..975f73eb9 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -181,7 +181,8 @@ At a high level, the platform changes are: - Adds the `CrestApps.OrchardCore.DialPad.Dialer` 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 admin entries now live under **Interaction Center**. Queue and dialer profile CRUD screens follow the Omnichannel Campaigns display-driver UI pattern, and the Agent Workspace uses searchable multi-select lists for Omnichannel campaigns and routing skills instead of raw identifier fields. +- Contact Center admin entries now live under **Interaction Center**. Adds a managed **Skills** CRUD screen, queue and dialer profile CRUD screens follow the Omnichannel Campaigns display-driver UI pattern with root edit wrappers, and the Agent Workspace uses searchable multi-select lists for Omnichannel campaigns and managed routing skills instead of raw identifier fields. +- Agent presence controls moved from the Agent Workspace to the floating soft phone widget through a soft-phone extension point, so sign-in/out stays on the workspace while availability changes stay near call handling. - See [Agents, Queues & Dialer](../contact-center/agents-queues-dialer.md). #### Inbound voice routing and the incoming-call modal @@ -394,6 +395,7 @@ At a high level, the platform changes are: - The new `CrestApps.OrchardCore.Telephony.Abstractions` package defines the `ITelephonyProvider`, `ITelephonyService`, `ITelephonyProviderResolver`, `ITelephonyClient`, `ITelephonyAuthenticationProvider`, `ITelephonyAuthenticationService`, and `ITelephonyUserTokenStore` contracts together with the call models, provider options, settings, and permissions, so provider modules stay decoupled from the hub and UI. - The hub routes soft phone requests (dial, hang up, hold/resume, mute, transfer, merge, send DTMF digits, answer, and reject) to the configured provider, runs each invocation in its own shell scope, and authorizes against the `Use the telephony soft phone` permission. - The optional **Telephony Soft Phone** feature (`CrestApps.OrchardCore.Telephony.SoftPhone`) can display the floating soft phone on the admin dashboard, the front end, or both, and the `SoftPhoneWidget` shape can be placed manually in a theme or template. +- The soft phone is shown on the admin dashboard by default, and modules can contribute extension shapes to the widget. - The soft phone supports multiple per-user authentication scenarios: providers that use shared, account-level credentials work without a per-user step, while providers that require OAuth 2.0 show a **Connect to provider** button that runs the authorization code flow and stores the user's tokens encrypted on their account. The widget authentication experience is extensible through `window.telephonySoftPhone.authHandlers`. - The widget reports a live connection status and only enables the dial pad and call controls when the configured provider is available, connected, and authenticated. When no provider is enabled the header status reads **Not Ready** instead of a misleading **Ready** status, and during an active call the toggle button turns red and switches to a hang-up icon. - The soft phone is draggable and can be moved anywhere on the screen, including the far right edge and on top of other widgets. Its position and open/closed state are persisted in `localStorage` and restored without a visible flash on the next page load, and by default it offsets itself to sit side by side with the AI chat widget when both are present. diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 45df6cf20..bf3e1bd0a 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -15,7 +15,7 @@ module so tenants enable only what they need. | Feature | Feature ID | Purpose | | --- | --- | --- | | Contact Center Agents | `CrestApps.OrchardCore.ContactCenter.Agents` | Agent profiles, presence, capacity, skills, and queue/campaign sign-in. | -| Contact Center Queues | `CrestApps.OrchardCore.ContactCenter.Queues` | Work queues, queue items, reservations, and availability-based assignment. | +| Contact Center Queues | `CrestApps.OrchardCore.ContactCenter.Queues` | Managed skills, work queues, queue items, reservations, and availability-based assignment. | | Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Outbound profiles, pacing, and dialer activity batches routed through Contact Center Voice. | | DialPad Contact Center Voice | `CrestApps.OrchardCore.DialPad.Dialer` | DialPad implementation of the Contact Center voice provider boundary. | @@ -27,9 +27,24 @@ skills, queue membership, campaign membership, and live presence. Presence state Agents sign in from **Interaction Center → Agent Workspace**, selecting the queues and campaigns they want to receive work from. Campaigns come from Omnichannel Management and are shown in a searchable -multi-select list; skills are also selected from a searchable multi-select list. Signing in sets +multi-select list; skills are selected from the managed Contact Center skill catalog. Signing in sets presence to `Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission grants -self-service sign-in and presence changes. +self-service sign-in. + +Agents change presence from the floating soft phone widget. When the Contact Center queues feature is +enabled and an agent has a profile, the soft phone shows a **Contact Center presence** selector so +availability changes stay close to call handling instead of the queue/campaign sign-in screen. + +## Skills + +Administrators manage routeable capabilities from **Interaction Center → Skills**. A skill has a +unique name, description, and enabled state. Enabled skills appear in the Agent Workspace and queue +editor selectors; disabled skills remain on existing agents and queues but are hidden from new +selections. + +Queues can require one or more skills. Agents must select every required skill to be eligible for +that queue, and the default routing strategy filters out agents missing any required skill before +longest-idle scoring runs. ## Queues, reservations, and assignment @@ -73,10 +88,11 @@ call assignment, and voice-specific orchestration without coupling Contact Cente ## Admin UX and extensibility -Contact Center admin entries live under **Interaction Center**. Queue and dialer profile CRUD screens -match the Omnichannel Campaigns UI: searchable list pages render summary shapes, and create/edit -screens render display-driver editor shapes. This keeps the UI extensible for provider panels, -compliance fields, routing strategies, and future supervisor controls. +Contact Center admin entries live under **Interaction Center**. Skills, queues, and dialer profile +CRUD screens match the Omnichannel Campaigns UI: searchable list pages render summary shapes, and +create/edit screens render display-driver editor shapes with the required root edit wrapper templates. +This keeps the UI extensible for provider panels, compliance fields, routing strategies, and future +supervisor controls. ## Enable via recipe diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index a50988c22..133b9db7f 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -106,11 +106,13 @@ Managers configure queue membership, campaign assignment, dialer mode, priority, compliance rules. Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all offer Activities through the same real-time workspace model. -The current workspace lets agents choose queues, campaigns, skills, and presence using the standard -Orchard admin layout. Campaign and skill selection use searchable multi-select lists instead of raw +The current workspace lets agents choose queues, campaigns, and skills using the standard Orchard +admin layout. Campaign and skill selection use searchable multi-select lists instead of raw identifier fields; campaigns come from the Omnichannel Management **Interaction Center** campaign -catalog. 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. +catalog, and skills come from **Interaction Center → Skills**. Presence changes are exposed in the +floating soft phone widget so availability stays near call handling. 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 @@ -191,8 +193,8 @@ outbound calls are wrapped up through the same subject workflow. ## UI extensibility All Contact Center UI is built with Orchard Core display management: shapes, display drivers, -placement, templates, and shape alternates. The queue and dialer profile CRUD screens follow the -Omnichannel Campaigns UI pattern: controllers load catalog entries through managers and build +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. diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index f839695ef..e4bb898d0 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -99,7 +99,8 @@ 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. +- **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. - **Accent color** controls the widget's button and control colors. @@ -107,6 +108,9 @@ You can enable the soft phone on the admin, the front end, or both. The widget i 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 extension shapes to the widget. Contact Center uses this extension point to +show agent presence controls in the soft phone when the current user has an agent profile. + ### Moving and persisting the widget The soft phone is draggable by its header. Its position and open/closed state are saved to the diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs index f03d4b028..b2fa6ea87 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs @@ -12,7 +12,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; /// -/// Provides the agent workspace where agents sign in to queues and campaigns and change presence. +/// Provides the agent workspace where agents sign in to queues and campaigns. /// [Admin] [Feature(ContactCenterConstants.Feature.Queues)] @@ -72,7 +72,6 @@ public async Task Index() SelectedCampaignIds = selectedCampaignIds, SkillOptions = await _optionsProvider.GetSkillOptionsAsync(selectedSkills), SelectedSkills = selectedSkills, - PresenceReason = profile?.PresenceReason, }); } @@ -122,11 +121,11 @@ public async Task SignOutAgent() } /// - /// Sets the agent presence state. + /// Sets the agent presence state from the soft phone widget. /// /// The presence state. /// The optional reason code. - /// A redirect to the workspace. + /// A redirect to the referring page or the workspace. [HttpPost] [ValidateAntiForgeryToken] public async Task SetPresence(AgentPresenceStatus status, string presenceReason) @@ -138,6 +137,11 @@ public async Task SetPresence(AgentPresenceStatus status, string await _presenceManager.SetPresenceAsync(GetUserId(), status, presenceReason); + if (Request.Headers.Referer.Count > 0) + { + return Redirect(Request.Headers.Referer.ToString()); + } + return RedirectToAction(nameof(Index)); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SkillsController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SkillsController.cs new file mode 100644 index 000000000..374b80d1f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SkillsController.cs @@ -0,0 +1,289 @@ +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using QueryContext = CrestApps.Core.Models.QueryContext; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides administration of Contact Center skills. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Queues)] +public sealed class SkillsController : Controller +{ + private const string _optionsSearch = "Options.Search"; + + private readonly IContactCenterSkillManager _manager; + private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The skill manager. + /// The authorization service. + /// The update model accessor. + /// The display manager. + /// The notifier. + /// The HTML localizer. + /// The string localizer. + public SkillsController( + IContactCenterSkillManager manager, + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _manager = manager; + _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + /// + /// Lists the skills. + /// + /// The catalog entry options. + /// The pager parameters. + /// The pager options. + /// The shape factory. + /// The skills list view. + [Admin("contact-center/skills", "ContactCenterSkillsIndex")] + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Name = options.Search, + }); + + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListCatalogEntryViewModel> + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayManager.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } + + return View(viewModel); + } + + /// + /// Applies the skills list filter. + /// + /// The submitted list model. + /// A redirect to the filtered list. + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("contact-center/skills", "ContactCenterSkillsIndex")] + public async Task IndexFilterPost(ListCatalogEntryViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); + } + + /// + /// Displays the skill create form. + /// + /// The create view. + [Admin("contact-center/skills/create", "ContactCenterSkillsCreate")] + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["Skill"], + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); + } + + /// + /// Persists a new skill. + /// + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Create))] + [Admin("contact-center/skills/create", "ContactCenterSkillsCreate")] + public async Task CreatePost() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["New Skill"], + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + if (ModelState.IsValid) + { + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new skill has been created successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Displays the skill edit form. + /// + /// The skill identifier. + /// The edit view. + [Admin("contact-center/skills/edit/{id}", "ContactCenterSkillsEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); + } + + /// + /// Persists changes to a skill. + /// + /// The skill identifier. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Edit))] + [Admin("contact-center/skills/edit/{id}", "ContactCenterSkillsEdit")] + public async Task EditPost(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _manager.UpdateAsync(model); + await _notifier.SuccessAsync(H["The skill has been updated successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Deletes a skill. + /// + /// The skill identifier. + /// A redirect to the list. + [HttpPost] + [Admin("contact-center/skills/delete/{id}", "ContactCenterSkillsDelete")] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageSkills)) + { + return Forbid(); + } + + var skill = await _manager.FindByIdAsync(id); + + if (skill is not null) + { + await _manager.DeleteAsync(skill); + await _notifier.SuccessAsync(H["The skill has been deleted successfully."]); + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSkillDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSkillDisplayDriver.cs new file mode 100644 index 000000000..83af6aac1 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSkillDisplayDriver.cs @@ -0,0 +1,67 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.Extensions.Localization; +using OrchardCore; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class ContactCenterSkillDisplayDriver : DisplayDriver +{ + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ContactCenterSkillDisplayDriver(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + public override Task DisplayAsync(ContactCenterSkill skill, BuildDisplayContext context) + { + return CombineAsync( + View("ContactCenterSkill_Fields_SummaryAdmin", skill) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Content:1"), + View("ContactCenterSkill_Buttons_SummaryAdmin", skill) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Actions:5"), + View("ContactCenterSkill_DefaultMeta_SummaryAdmin", skill) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Meta:5") + ); + } + + /// + public override IDisplayResult Edit(ContactCenterSkill skill, BuildEditorContext context) + { + return Initialize("ContactCenterSkillFields_Edit", model => + { + model.Id = skill.ItemId; + model.Name = skill.Name; + model.Description = skill.Description; + model.Enabled = skill.Enabled; + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(ContactCenterSkill skill, UpdateEditorContext context) + { + var model = new ContactCenterSkillViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is a required field."]); + } + + skill.Name = model.Name?.Trim(); + skill.Description = model.Description?.Trim(); + skill.Enabled = model.Enabled; + + return Edit(skill, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSkillHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSkillHandler.cs new file mode 100644 index 000000000..1ad5186fe --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterSkillHandler.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +internal sealed class ContactCenterSkillHandler : CatalogEntryHandlerBase +{ + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The clock used to stamp audit times. + /// The string localizer. + public ContactCenterSkillHandler( + IClock clock, + IStringLocalizer stringLocalizer) + { + _clock = clock; + S = stringLocalizer; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(ContactCenterSkill.Name)])); + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterSkillIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterSkillIndexProvider.cs new file mode 100644 index 000000000..336cec554 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterSkillIndexProvider.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class ContactCenterSkillIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public ContactCenterSkillIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(skill => new ContactCenterSkillIndex + { + ItemId = skill.ItemId, + Name = skill.Name, + Enabled = skill.Enabled, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterSkillIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterSkillIndexMigrations.cs new file mode 100644 index 000000000..1248a9337 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterSkillIndexMigrations.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class ContactCenterSkillIndexMigrations : DataMigration +{ + /// + /// Creates the skill index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_ContactCenterSkillIndex_DocumentId", "DocumentId", "ItemId", "Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs index 6656a9184..6c6514b3a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs @@ -16,7 +16,7 @@ public sealed class ContactCenterAdminFormOptionsProvider { private readonly ICatalogManager _campaignManager; private readonly IActivityQueueManager _queueManager; - private readonly IAgentProfileManager _agentProfileManager; + private readonly IContactCenterSkillManager _skillManager; private readonly IOmnichannelChannelEndpointManager _channelEndpointManager; private readonly IEnumerable _voiceProviders; @@ -25,19 +25,19 @@ public sealed class ContactCenterAdminFormOptionsProvider /// /// The omnichannel campaign manager. /// The activity queue manager. - /// The agent profile manager. + /// The Contact Center skill manager. /// The omnichannel channel endpoint manager. /// The registered voice call providers. public ContactCenterAdminFormOptionsProvider( ICatalogManager campaignManager, IActivityQueueManager queueManager, - IAgentProfileManager agentProfileManager, + IContactCenterSkillManager skillManager, IOmnichannelChannelEndpointManager channelEndpointManager, IEnumerable voiceProviders) { _campaignManager = campaignManager; _queueManager = queueManager; - _agentProfileManager = agentProfileManager; + _skillManager = skillManager; _channelEndpointManager = channelEndpointManager; _voiceProviders = voiceProviders; } @@ -75,21 +75,16 @@ internal async Task> GetQueueOptionsAsync(string selectedQ internal async Task> GetSkillOptionsAsync(IEnumerable selectedSkills) { var selected = CreateSelectedSet(selectedSkills, StringComparer.OrdinalIgnoreCase); - var queues = await _queueManager.GetAllAsync(); - var agents = await _agentProfileManager.GetAllAsync(); - - var skills = queues - .SelectMany(queue => queue.RequiredSkills) - .Concat(agents.SelectMany(agent => agent.Skills)) - .Concat(selected) - .Where(skill => !string.IsNullOrWhiteSpace(skill)) - .Select(skill => skill.Trim()) - .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(skill => skill, StringComparer.CurrentCultureIgnoreCase); - - return skills - .Select(skill => new SelectListItem(skill, skill, selected.Contains(skill))) - .ToArray(); + var skills = await _skillManager.ListEnabledAsync(); + + var options = skills + .OrderBy(skill => skill.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(skill => new SelectListItem(skill.Name, skill.Name, selected.Contains(skill.Name))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.OrdinalIgnoreCase); + + return options; } internal async Task> GetInboundChannelEndpointOptionsAsync(string selectedEndpointId) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs index 6420b7eef..e6619c710 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs @@ -38,6 +38,12 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("contactCenterQueues") .Action("Index", "Queues", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.ManageQueues) + .LocalNav()) + .Add(S["Skills"], S["Skills"].PrefixPosition(), skills => skills + .AddClass("contact-center-skills") + .Id("contactCenterSkills") + .Action("Index", "Skills", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageSkills) .LocalNav()), priority: 1); diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs index ac55032a7..de520a5fd 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs @@ -16,6 +16,7 @@ internal sealed class ContactCenterPermissionProvider : IPermissionProvider ContactCenterPermissions.ViewInteractions, ContactCenterPermissions.ManageAgents, ContactCenterPermissions.ManageQueues, + ContactCenterPermissions.ManageSkills, ContactCenterPermissions.ManageDialer, ContactCenterPermissions.SignIntoQueues, ]; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs new file mode 100644 index 000000000..72c38c3e5 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs @@ -0,0 +1,71 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Telephony.Models; +using CrestApps.OrchardCore.Telephony.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Adds Contact Center agent presence controls to the floating soft phone widget. +/// +public sealed class ContactCenterSoftPhoneWidgetExtensionProvider : ISoftPhoneWidgetExtensionProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IAgentProfileManager _agentProfileManager; + private readonly IShapeFactory _shapeFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP context accessor. + /// The authorization service. + /// The agent profile manager. + /// The shape factory. + public ContactCenterSoftPhoneWidgetExtensionProvider( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IAgentProfileManager agentProfileManager, + IShapeFactory shapeFactory) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _agentProfileManager = agentProfileManager; + _shapeFactory = shapeFactory; + } + + /// + public async Task BuildAsync(SoftPhoneWidgetExtensionContext context, CancellationToken cancellationToken = default) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (user?.Identity?.IsAuthenticated != true || + !await _authorizationService.AuthorizeAsync(user, ContactCenterPermissions.SignIntoQueues)) + { + return; + } + + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + + if (string.IsNullOrEmpty(userId)) + { + return; + } + + var profile = await _agentProfileManager.FindByUserIdAsync(userId, cancellationToken); + + if (profile is null) + { + return; + } + + var shape = await _shapeFactory.CreateAsync("ContactCenterSoftPhonePresence"); + shape.Properties["Profile"] = profile; + + context.Shapes.Add(shape); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 3cd07221e..f9792db6e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -10,6 +10,7 @@ using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.Models; using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Services; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using OrchardCore.BackgroundTasks; @@ -80,6 +81,8 @@ public override void ConfigureServices(IServiceCollection services) services .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -90,13 +93,18 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped(); services .AddDisplayDriver() + .AddDisplayDriver() .AddScoped, ActivityQueueHandler>() + .AddScoped, ContactCenterSkillHandler>() .AddIndexProvider() .AddDataMigration() + .AddIndexProvider() + .AddDataMigration() .AddIndexProvider() .AddDataMigration() .AddIndexProvider() diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs index 9063020d1..44b112d7c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; using Microsoft.AspNetCore.Mvc.Rendering; namespace CrestApps.OrchardCore.ContactCenter.ViewModels; @@ -45,7 +46,7 @@ public class AgentWorkspaceViewModel public IList SelectedSkills { get; set; } = []; /// - /// Gets or sets the optional presence reason submitted by the agent. + /// Gets a value indicating whether the agent is currently signed in. /// - public string PresenceReason { get; set; } + public bool IsSignedIn => Profile is not null && Profile.PresenceStatus != AgentPresenceStatus.Offline; } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterSkillViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterSkillViewModel.cs new file mode 100644 index 000000000..c1992483d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/ContactCenterSkillViewModel.cs @@ -0,0 +1,30 @@ +using System.ComponentModel.DataAnnotations; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the edit view model for a Contact Center skill. +/// +public class ContactCenterSkillViewModel +{ + /// + /// Gets or sets the skill identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the unique skill name. + /// + [Required] + 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 is enabled. + /// + public bool Enabled { get; set; } = true; +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.Edit.cshtml new file mode 100644 index 000000000..be403296b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueue.Edit.cshtml @@ -0,0 +1,4 @@ +@if (Model.Content != null) +{ + @await DisplayAsync(Model.Content) +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml index 87df0288a..0156aa94f 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml @@ -5,93 +5,70 @@

    @T["Agent workspace"]

    -@if (Model.Profile is not null) +@if (Model.IsSignedIn) { -
    - @T["Current presence: {0}", Model.Profile.PresenceStatus] +
    + @T["You are signed in to Contact Center work."]
    } -
    -
    -
    -

    @T["Select the queues you want to receive work from, then sign in."]

    -
    -
    - -
    - -
    - @foreach (var queue in Model.AvailableQueues) - { - var selected = Model.SelectedQueueIds.Contains(queue.ItemId); - -
    - - -
    - } - @T["Only selected queues can reserve and offer work to you."] -
    -
    - -
    - -
    - - - @T["Select the outbound campaigns you can receive dialer work from."] +@if (!Model.IsSignedIn) +{ + +
    +
    +

    @T["Select the queues, campaigns, and skills you want to receive work from, then sign in."]

    +
    -
    -
    - -
    - - - @T["Select the routing skills that match the work you can handle."] -
    -
    +
    + +
    + @foreach (var queue in Model.AvailableQueues) + { + var selected = Model.SelectedQueueIds.Contains(queue.ItemId); -
    -
    - +
    + + +
    + } + @T["Only selected queues can reserve and offer work to you."] +
    -
    - -
    -
    -
    - +
    + +
    + + + @T["Select the outbound campaigns you can receive dialer work from."] +
    -
    - -
    -
    - -
    - +
    + +
    + + + @T["Select the routing skills that match the work you can handle."] +
    -
    -
    - -
    - - +
    +
    + +
    -
    - -
    -
    - + +} +else +{ +
    +
    +
    + +
    -
    - + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.Edit.cshtml new file mode 100644 index 000000000..be403296b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.Edit.cshtml @@ -0,0 +1,4 @@ +@if (Model.Content != null) +{ + @await DisplayAsync(Model.Content) +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.SummaryAdmin.cshtml new file mode 100644 index 000000000..d07548c93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkill.SummaryAdmin.cshtml @@ -0,0 +1,29 @@ +
    +
    +
    +
    +
    + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
    + + @if (Model.Meta != null) + { + + } +
    +
    +
    +
    +
    + @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkillFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkillFields.Edit.cshtml new file mode 100644 index 000000000..26910ec60 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSkillFields.Edit.cshtml @@ -0,0 +1,28 @@ +@model ContactCenterSkillViewModel + +
    + +
    + + + @T["Use a clear routeable capability name, such as Spanish, Billing, or Tier 2 Support."] +
    +
    + +
    + +
    + + +
    +
    + +
    +
    +
    + + +
    + @T["Disabled skills remain on existing agents and queues but are hidden from new selections."] +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml new file mode 100644 index 000000000..3f317cfd7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml @@ -0,0 +1,25 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using CrestApps.OrchardCore.ContactCenter.Models + +@{ + var profile = Model.Profile as AgentProfile; +} + +@if (profile is not null) +{ +
    +
    + + +
    +
    + +
    + +
    +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.Edit.cshtml new file mode 100644 index 000000000..be403296b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfile.Edit.cshtml @@ -0,0 +1,4 @@ +@if (Model.Content != null) +{ + @await DisplayAsync(Model.Content) +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Buttons.SummaryAdmin.cshtml new file mode 100644 index 000000000..57d2e06b8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,14 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@T["Edit"] +@T["Delete"] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 000000000..554f03af1 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,17 @@ +@using System.Globalization +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var modifiedAt = Model.Value.ModifiedUtc ?? Model.Value.CreatedUtc; +} + +@if (modifiedAt != default) +{ + + + + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Fields.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Fields.SummaryAdmin.cshtml new file mode 100644 index 000000000..41484436b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSkill.Fields.SummaryAdmin.cshtml @@ -0,0 +1,18 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +
    @Model.Value.Name
    + +@if (!string.IsNullOrWhiteSpace(Model.Value.Description)) +{ +
    @Model.Value.Description
    +} + +@if (!Model.Value.Enabled) +{ +
    + @T["Disabled"] +
    +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Create.cshtml new file mode 100644 index 000000000..2e36df86f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Create.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["New '{0}' Skill", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Edit.cshtml new file mode 100644 index 000000000..df2314ab7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Edit.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["Edit '{0}' Skill", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Index.cshtml new file mode 100644 index 000000000..7e50da300 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Skills/Index.cshtml @@ -0,0 +1,57 @@ +@using CrestApps.Core.Models +@using CrestApps.OrchardCore.Core.Models + +@model ListCatalogEntryViewModel> + +

    @RenderTitleSegments(T["Skills"])

    + +@* The form is necessary to generate an antiforgery token for delete actions. *@ +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
      + @if (Model.Models.Count > 0) + { +
    • + @(Model.Models.Count == 1 ? T["1 item"] : T["{0} items", Model.Models.Count]) +
    • + + @foreach (var entry in Model.Models) + { +
    • +
      + @await DisplayAsync(entry.Shape) +
      +
    • + } + } + else + { +
    • + @T["Nothing here! There are no skills at the moment."] +
    • + } +
    + + +
    + +@await DisplayAsync(Model.Pager) diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs index fc954e7cf..c09d403ba 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs @@ -10,14 +10,14 @@ Website = CrestAppsManifestConstants.Website, Version = CrestAppsManifestConstants.Version, Description = "Integrates the DialPad telephony platform with the Telephony soft phone.", - Category = "Communications" + Category = "Communication" )] [assembly: Feature( Id = DialPadConstants.Feature.Area, Name = "DialPad", Description = "Provides the DialPad telephony provider and its settings.", - Category = "Communications", + Category = "Communication", Dependencies = [ TelephonyConstants.Feature.Area, @@ -28,7 +28,7 @@ Id = DialPadConstants.Feature.Dialer, Name = "DialPad Contact Center Voice", Description = "Implements the Contact Center voice provider boundary over DialPad so the Voice Contact Center Call Router can place outbound calls through DialPad.", - Category = "Communications", + Category = "Communication", Dependencies = [ DialPadConstants.Feature.Area, diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs index 1e37605e4..898d6c301 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs @@ -1,4 +1,5 @@ using CrestApps.OrchardCore.Telephony.Models; +using CrestApps.OrchardCore.Telephony.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Filters; @@ -23,6 +24,7 @@ public sealed class SoftPhoneWidgetFilter : IAsyncResultFilter private readonly ISiteService _siteService; private readonly IAuthorizationService _authorizationService; private readonly ITelephonyProviderResolver _providerResolver; + private readonly IEnumerable _extensionProviders; private readonly IResourceManager _resourceManager; private readonly AdminOptions _adminOptions; @@ -34,6 +36,7 @@ public sealed class SoftPhoneWidgetFilter : IAsyncResultFilter /// The site service. /// The authorization service. /// The telephony provider resolver. + /// The soft phone widget extension providers. /// The resource manager. /// The admin options. public SoftPhoneWidgetFilter( @@ -42,6 +45,7 @@ public SoftPhoneWidgetFilter( ISiteService siteService, IAuthorizationService authorizationService, ITelephonyProviderResolver providerResolver, + IEnumerable extensionProviders, IResourceManager resourceManager, IOptions adminOptions) { @@ -50,6 +54,7 @@ public SoftPhoneWidgetFilter( _siteService = siteService; _authorizationService = authorizationService; _providerResolver = providerResolver; + _extensionProviders = extensionProviders; _resourceManager = resourceManager; _adminOptions = adminOptions.Value; } @@ -103,6 +108,7 @@ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultE ? SoftPhoneWidgetSettings.DefaultAccentColor : settings.AccentColor; shape.Properties["Capabilities"] = capabilities; + shape.Properties["Extensions"] = await BuildExtensionsAsync(context.HttpContext.RequestAborted); var layout = await _layoutAccessor.GetLayoutAsync(); @@ -115,4 +121,16 @@ private bool IsAdminPage(ResultExecutingContext context) { return context.HttpContext.Request.Path.StartsWithSegments('/' + _adminOptions.AdminUrlPrefix, StringComparison.OrdinalIgnoreCase); } + + private async Task> BuildExtensionsAsync(CancellationToken cancellationToken) + { + var extensionContext = new SoftPhoneWidgetExtensionContext(); + + foreach (var provider in _extensionProviders) + { + await provider.BuildAsync(extensionContext, cancellationToken); + } + + return extensionContext.Shapes; + } } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Manifest.cs index 5c7acfc76..3f5c974c1 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Manifest.cs @@ -8,14 +8,14 @@ Website = CrestAppsManifestConstants.Website, Version = CrestAppsManifestConstants.Version, Description = "Provides a provider-agnostic soft phone and SignalR hub for integrating telephony providers.", - Category = "Communications" + Category = "Communication" )] [assembly: Feature( Id = TelephonyConstants.Feature.Area, Name = "Telephony", Description = "Provides the provider-agnostic telephony services, SignalR hub, and site settings.", - Category = "Communications", + Category = "Communication", Dependencies = [ "OrchardCore.Users", @@ -27,7 +27,7 @@ Id = TelephonyConstants.Feature.SoftPhone, Name = "Telephony Soft Phone", Description = "Injects the floating soft phone experience into the admin dashboard, front end, or both.", - Category = "Communications", + Category = "Communication", Dependencies = [ TelephonyConstants.Feature.Area, diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Models/SoftPhoneWidgetSettings.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Models/SoftPhoneWidgetSettings.cs index 248b81009..a5c3a5de7 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Models/SoftPhoneWidgetSettings.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Models/SoftPhoneWidgetSettings.cs @@ -13,7 +13,7 @@ public sealed class SoftPhoneWidgetSettings /// /// Gets or sets a value indicating whether the floating soft phone widget is shown on the admin dashboard. /// - public bool DisplayOnAdmin { get; set; } + public bool DisplayOnAdmin { get; set; } = true; /// /// Gets or sets a value indicating whether the floating soft phone widget is shown on the front end. diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml index a1d69190d..9d1dd097d 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml @@ -8,6 +8,7 @@ var hubUrl = HubRouteManager.GetPathByHub(); var accentColor = Model.AccentColor as string ?? "#2f6fed"; var capabilities = Model.Capabilities is int caps ? caps : 0; + var extensions = Model.Extensions as IEnumerable ?? []; var connectUrl = Url.RouteUrl(TelephonyConstants.RouteNames.OAuthConnect); var antiForgeryToken = Antiforgery.GetAndStoreTokens(Context).RequestToken; @@ -84,6 +85,11 @@ + @foreach (var extension in extensions) + { + @await DisplayAsync(extension) + } +
    From 0b4dbc09bf841bc3cd3dcc10fde8673181938e54 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 29 Jun 2026 19:56:51 -0700 Subject: [PATCH 09/56] Use Soft Phone for agent workspace --- .github/contact-center/PLAN.md | 31 +++---- .../Models/SoftPhoneWidget.cs | 17 ++++ .../Models/SoftPhoneWidgetExtensionContext.cs | 12 --- .../ISoftPhoneWidgetExtensionProvider.cs | 17 ---- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 8 +- .../contact-center/agents-queues-dialer.md | 29 +++---- .../docs/contact-center/index.md | 31 +++---- src/CrestApps.Docs/docs/telephony/index.md | 23 +++-- ...troller.cs => AgentSoftPhoneController.cs} | 81 +++++++----------- ...ntactCenterSoftPhoneWidgetDisplayDriver.cs | 84 +++++++++++++++++++ .../Services/ContactCenterAdminMenu.cs | 8 +- ...tCenterSoftPhoneWidgetExtensionProvider.cs | 71 ---------------- .../Startup.cs | 6 +- ...iewModel.cs => AgentSoftPhoneViewModel.cs} | 4 +- .../Views/AgentWorkspace/Index.cshtml | 74 ---------------- .../ContactCenterSoftPhonePresence.cshtml | 25 ------ .../ContactCenterSoftPhoneWork.Tab.cshtml | 6 ++ .../ContactCenterSoftPhoneWork.View.cshtml | 82 ++++++++++++++++++ .../Assets/js/soft-phone.js | 61 ++++++++++---- .../Assets/scss/soft-phone.scss | 6 ++ .../Filters/SoftPhoneWidgetFilter.cs | 45 +++++----- .../Views/SoftPhoneWidget.cshtml | 28 +++++-- .../wwwroot/scripts/soft-phone.js | 52 +++++++++--- .../wwwroot/scripts/soft-phone.min.js | 2 +- .../wwwroot/styles/soft-phone.css | 5 ++ .../wwwroot/styles/soft-phone.min.css | 2 +- .../Infrastructure/SoftPhoneTestServer.cs | 4 + .../SoftPhoneWidgetTests.cs | 19 +++++ 28 files changed, 451 insertions(+), 382 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs delete mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs delete mode 100644 src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs rename src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/{AgentWorkspaceController.cs => AgentSoftPhoneController.cs} (58%) create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs delete mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs rename src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/{AgentWorkspaceViewModel.cs => AgentSoftPhoneViewModel.cs} (94%) delete mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml delete mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index b559fc012..6de1119f9 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -60,7 +60,7 @@ Required CRM alignment: - Activities need classification metadata such as kind (call, email, SMS, meeting, task) and source (manual, preview dial, power dial, progressive dial, predictive dial, callback, inbound, workflow, API). Workflows must ignore source and react only to the activity and final disposition. - Activity batches select an activity source before showing the editor. Manual batches require user assignment while loading; dialer batches hide user selection and load unassigned activities with an available assignment status so the dialer can reserve and assign them later. - Dispositions belong to activities, not interactions. Provider, agent, AI, and workflow outcomes must converge through a single activity disposition service before subject actions/workflows run. -- Contact Center must provide the agent workspace where agents receive work. Agents sign in to queues and/or campaign assignments, set presence, accept or reject offers, handle the active activity, use injected Telephony controls, review interaction history, and complete wrap-up from the CRM UI. +- Contact Center must provide CRM-integrated agent surfaces where agents receive work. Queue/campaign sign-in, sign-out, and presence belong in the Telephony soft phone through display-driver tabs; broader desktop surfaces handle offers, active activity context, interaction history, wrap-up, required disposition, and supervisor/AI assistance from the CRM UI. - PBX/voice providers that can do more than soft-phone call control may implement Contact Center voice-provider abstractions for dialer dialing, call assignment, provider-side queues, queue events, and PBX presence synchronization. ### Existing real-time boundary @@ -78,7 +78,7 @@ Design implication: Contact Center should add its own real-time event stream for - No real-time agent presence or capacity model. - No inbound routing orchestration. - No outbound dialer orchestration beyond manual CRM activities and automated SMS processing. -- No Contact Center agent workspace for queue/campaign sign-in, current offer handling, active activity, injected phone controls, and wrap-up. +- No CRM-integrated Contact Center agent surfaces for soft-phone sign-in/presence, current offer handling, active activity, injected phone controls, and wrap-up. - No contact-center-level call session mapping between provider calls and business interactions. - No Contact Center voice-provider abstraction that lets PBX providers participate in dialer dialing, call assignment, and provider-side queue behavior. - No supervisor dashboard or live queue metric projection. @@ -144,9 +144,10 @@ Design implication: Contact Center should add its own real-time event stream for - Supervisor live monitoring, agent monitoring, queue controls, coaching/assist metadata, SLA alerts, and operational command permissions. - Live call-control primitives: silent monitor, whisper coaching, barge-in, and take-over, expressed as orchestration intents that Telephony/providers execute. -9. `CrestApps.OrchardCore.ContactCenter.AgentWorkspace` - - Agent workspace UI where agents sign in/out of queues and campaigns, change presence, receive activity offers, accept or reject work, see the active activity, use injected Telephony call controls, review interaction history, and complete wrap-up. - - Uses shapes, display drivers, placement, and SignalR snapshots/events so modules can inject provider controls, campaign panels, compliance warnings, AI assist, and supervisor coaching without replacing the workspace. +9. `CrestApps.OrchardCore.ContactCenter.AgentDesktop` + - Agent desktop surfaces where agents receive activity offers, accept or reject work, see the active activity, use injected Telephony call controls, review interaction history, and complete wrap-up. + - Queue/campaign sign-in, sign-out, and presence are contributed to the Telephony soft phone as display-driver tabs instead of a standalone navigation page. + - Uses shapes, display drivers, placement, and SignalR snapshots/events so modules can inject provider controls, campaign panels, compliance warnings, AI assist, and supervisor coaching without replacing the desktop. 10. `CrestApps.OrchardCore.ContactCenter.EntryPoints` - Inbound entry points, DID/number-to-entry-point mapping, IVR/self-service decision flows, business-hours/holiday gating, queue selection, announcements, and screen-pop context. @@ -338,13 +339,14 @@ Capacity model: - Capacity is per channel and per agent profile. - Routing must reserve capacity before offering work. -Agent workspace: +Agent desktop and soft-phone controls: -- Agents use the Contact Center Agent Workspace in the CRM admin experience to receive work. -- The workspace shows queue sign-in, campaign sign-in, presence/reason selection, current reservation/offer, active activity, customer/contact summary, interaction history, Telephony call controls, wrap-up, required disposition, and supervisor/AI assistance. +- Agents receive Contact Center work through CRM-integrated agent surfaces rather than a standalone sign-in navigation page. +- The Telephony soft phone is the home for queue sign-in, campaign sign-in, sign-out, and presence/reason selection. Contact Center contributes these controls as soft-phone display-driver tabs when its features are enabled. +- Future agent desktop surfaces show current reservation/offer, active activity, customer/contact summary, interaction history, wrap-up, required disposition, and supervisor/AI assistance. - Agents can opt into queues and campaigns they are permitted to handle. Managers configure queue membership, campaign assignment, dialing mode, priority, and capacity rules. -- Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all deliver activity offers through the same workspace offer model. -- The workspace is shape-driven: Telephony injects call controls, Contact Center injects reservation/wrap-up/presence, providers inject provider-specific call state, and optional modules inject AI assist, compliance, or supervisor coaching. +- Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all deliver activity offers through the same agent-offer model. +- The agent desktop and soft phone are shape-driven: Telephony owns the soft phone and call controls, Contact Center injects sign-in, reservation, wrap-up, and presence tabs or panels, providers inject provider-specific call state, and optional modules inject AI assist, compliance, or supervisor coaching. ### 6. Campaign Dialer @@ -1383,7 +1385,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] 13 unit tests (event envelope, event publisher dispatch/idempotency/resilience, interaction manager lifecycle, entity metadata extensibility) - [x] Docs landing page + `v2.0.0` changelog entry - [x] **Phase 2 — Agent, presence, queue, and reservation foundation** - - [x] `Agents` feature: AgentProfile (presence, capacity, skills, queue/campaign membership), store/manager/index, presence manager, Agent Workspace sign-in/out + - [x] `Agents` feature: AgentProfile (presence, capacity, skills, queue/campaign membership), store/manager/index, presence manager, soft-phone queue/campaign sign-in/out - [x] `Queues` feature: ActivityQueue, QueueItem, ActivityReservation models/stores/managers/indexes; queue + reservation lifecycle (reserve/accept/reject/expire); reservation-expiry background task - [x] Availability-based assignment service (longest-idle agent ↔ highest-priority item); agent/queue/dialer permissions; admin menu + CRUD UI; unit tests - [~] Phase 3 — Routing MVP (availability + priority + required-skills filtering + longest-idle assignment + auditable routing decision events shipped; sticky-agent and business-hours strategies pending) @@ -1406,7 +1408,8 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 0 started: promoted the session plan to this durable repo-tracked document, added the copilot-instructions pointer, and created the public Contact Center docs landing page. - Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). - Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. -- Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing initially used `IDialerProvider`/`IDialerProviderResolver`, power/progressive pacing, dialer batch sources, and a `DialPad.Dialer` provider; this was later corrected so outbound voice calls route through `IVoiceContactCenterCallRouter` and `IContactCenterVoiceProvider`. Added admin CRUD for queues/dialer profiles + agent sign-in workspace, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). -- 2026-06-29: Phase 3 routing advanced with an extensible `IActivityRoutingStrategy` pipeline, required-skills eligibility, longest-idle scoring, and `RoutingDecisionMade` audit events that capture candidate scores and reasons. Contact Center voice-provider resolution was added through `IContactCenterVoiceProviderResolver`; outbound dialer failure paths now cancel reservations and enforce max-attempt boundaries; inbound offer failures release reservations immediately; agent sign-in clears stale reservations and serializes profile creation. Queue, dialer, and agent workspace admin UI now uses Orchard `ocat-*` layout and exposes routing skills, inbound endpoint mapping, retry/do-not-call settings, and agent skill sign-in. Added routing/dialer/reservation tests and updated docs/changelog. Next: sticky-agent and business-hours routing, then wrap-up timers and required-disposition policies before Phase 7 real-time desktop work. +- Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing initially used `IDialerProvider`/`IDialerProviderResolver`, power/progressive pacing, dialer batch sources, and a `DialPad.Dialer` provider; this was later corrected so outbound voice calls route through `IVoiceContactCenterCallRouter` and `IContactCenterVoiceProvider`. Added admin CRUD for queues/dialer profiles, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). +- 2026-06-29: Phase 3 routing advanced with an extensible `IActivityRoutingStrategy` pipeline, required-skills eligibility, longest-idle scoring, and `RoutingDecisionMade` audit events that capture candidate scores and reasons. Contact Center voice-provider resolution was added through `IContactCenterVoiceProviderResolver`; outbound dialer failure paths now cancel reservations and enforce max-attempt boundaries; inbound offer failures release reservations immediately; agent sign-in clears stale reservations and serializes profile creation. Queue and dialer admin UI now uses Orchard `ocat-*` layout and exposes routing skills, inbound endpoint mapping, and retry/do-not-call settings. Added routing/dialer/reservation tests and updated docs/changelog. Next: sticky-agent and business-hours routing, then wrap-up timers and required-disposition policies before Phase 7 real-time desktop work. - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). -- 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent Workspace campaign and skill sign-in fields now use searchable multi-select lists backed by the CrestApps bootstrap-select resource; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; Agent Workspace and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. Agent Workspace now shows either sign-in or sign-out based on signed-in state, and Contact Center presence controls moved to the soft phone widget through a Telephony soft-phone extension point. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. +- 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent sign-in campaign and skill fields now use managed catalog data; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; agent sign-in and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. +- 2026-06-29: The standalone agent sign-in admin navigation item was removed from the Contact Center menu. Telephony soft-phone extensibility now uses `DisplayDriver` zones for contributed tabs/views, and Contact Center contributes a **Work** tab for queue/campaign sign-in, sign-out, and presence so agent availability controls stay inside the soft phone instead of a separate navigation page. 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..04bdde85c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs @@ -0,0 +1,17 @@ +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 telephony operations supported by the active provider. + /// + public TelephonyCapabilities Capabilities { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs deleted file mode 100644 index 0bf2493ca..000000000 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidgetExtensionContext.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace CrestApps.OrchardCore.Telephony.Models; - -/// -/// Represents the extension context used by modules that contribute shapes to the soft phone widget. -/// -public sealed class SoftPhoneWidgetExtensionContext -{ - /// - /// Gets the shapes contributed to the soft phone widget. - /// - public IList Shapes { get; } = []; -} diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs deleted file mode 100644 index 9de6d4cc2..000000000 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Services/ISoftPhoneWidgetExtensionProvider.cs +++ /dev/null @@ -1,17 +0,0 @@ -using CrestApps.OrchardCore.Telephony.Models; - -namespace CrestApps.OrchardCore.Telephony.Services; - -/// -/// Provides extension shapes for the floating soft phone widget. -/// -public interface ISoftPhoneWidgetExtensionProvider -{ - /// - /// Builds extension shapes for the floating soft phone widget. - /// - /// The extension context. - /// The token to monitor for cancellation requests. - /// A task that represents the asynchronous operation. - Task BuildAsync(SoftPhoneWidgetExtensionContext context, CancellationToken cancellationToken = default); -} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 975f73eb9..ceff0b1ca 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -175,14 +175,14 @@ At a high level, the platform changes are: #### 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 an Agent Workspace. +- 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. - 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 activity batches. 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.Dialer` 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 admin entries now live under **Interaction Center**. Adds a managed **Skills** CRUD screen, queue and dialer profile CRUD screens follow the Omnichannel Campaigns display-driver UI pattern with root edit wrappers, and the Agent Workspace uses searchable multi-select lists for Omnichannel campaigns and managed routing skills instead of raw identifier fields. -- Agent presence controls moved from the Agent Workspace to the floating soft phone widget through a soft-phone extension point, so sign-in/out stays on the workspace while availability changes stay near call handling. +- 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. +- Telephony soft-phone extensibility now uses `DisplayDriver` contributed tabs/views. Contact Center injects a **Work** tab for queue/campaign sign-in, sign-out, skill selection, and presence, so these agent controls stay with the soft phone instead of a separate admin navigation page. - See [Agents, Queues & Dialer](../contact-center/agents-queues-dialer.md). #### Inbound voice routing and the incoming-call modal @@ -395,7 +395,7 @@ At a high level, the platform changes are: - The new `CrestApps.OrchardCore.Telephony.Abstractions` package defines the `ITelephonyProvider`, `ITelephonyService`, `ITelephonyProviderResolver`, `ITelephonyClient`, `ITelephonyAuthenticationProvider`, `ITelephonyAuthenticationService`, and `ITelephonyUserTokenStore` contracts together with the call models, provider options, settings, and permissions, so provider modules stay decoupled from the hub and UI. - The hub routes soft phone requests (dial, hang up, hold/resume, mute, transfer, merge, send DTMF digits, answer, and reject) to the configured provider, runs each invocation in its own shell scope, and authorizes against the `Use the telephony soft phone` permission. - The optional **Telephony Soft Phone** feature (`CrestApps.OrchardCore.Telephony.SoftPhone`) can display the floating soft phone on the admin dashboard, the front end, or both, and the `SoftPhoneWidget` shape can be placed manually in a theme or template. -- The soft phone is shown on the admin dashboard by default, and modules can contribute extension shapes to the widget. +- The soft phone is shown on the admin dashboard by default, and modules can contribute Display Management tabs/views to the widget. - The soft phone supports multiple per-user authentication scenarios: providers that use shared, account-level credentials work without a per-user step, while providers that require OAuth 2.0 show a **Connect to provider** button that runs the authorization code flow and stores the user's tokens encrypted on their account. The widget authentication experience is extensible through `window.telephonySoftPhone.authHandlers`. - The widget reports a live connection status and only enables the dial pad and call controls when the configured provider is available, connected, and authenticated. When no provider is enabled the header status reads **Not Ready** instead of a misleading **Ready** status, and during an active call the toggle button turns red and switches to a hang-up icon. - The soft phone is draggable and can be moved anywhere on the screen, including the far right edge and on top of other widgets. Its position and open/closed state are persisted in `localStorage` and restored without a visible flash on the next page load, and by default it offsets itself to sit side by side with the AI chat widget when both are present. diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index bf3e1bd0a..11e01d0ec 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -25,21 +25,17 @@ An **agent profile** links an Orchard user to Contact Center configuration: disp skills, queue membership, campaign membership, and live presence. Presence states are `Offline`, `Available`, `Reserved`, `Busy`, `WrapUp`, and `Break`. -Agents sign in from **Interaction Center → Agent Workspace**, selecting the queues and campaigns they -want to receive work from. Campaigns come from Omnichannel Management and are shown in a searchable -multi-select list; skills are selected from the managed Contact Center skill catalog. Signing in sets -presence to `Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission grants -self-service sign-in. - -Agents change presence from the floating soft phone widget. When the Contact Center queues feature is -enabled and an agent has a profile, the soft phone shows a **Contact Center presence** selector so -availability changes stay close to call handling instead of the queue/campaign sign-in screen. +Agents sign in from the floating Telephony soft phone. When the Contact Center queues feature is +enabled, Contact Center contributes a **Work** tab where agents select the queues and campaigns they +want to receive work from, choose routing skills, sign out, and change presence/reason codes. Signing +in sets presence to `Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission +grants self-service sign-in. ## Skills Administrators manage routeable capabilities from **Interaction Center → Skills**. A skill has a -unique name, description, and enabled state. Enabled skills appear in the Agent Workspace and queue -editor selectors; disabled skills remain on existing agents and queues but are hidden from new +unique name, description, and enabled state. Enabled skills appear in the soft-phone **Work** tab and +queue editor selectors; disabled skills remain on existing agents and queues but are hidden from new selections. Queues can require one or more skills. Agents must select every required skill to be eligible for @@ -88,11 +84,12 @@ call assignment, and voice-specific orchestration without coupling Contact Cente ## Admin UX and extensibility -Contact Center admin entries live under **Interaction Center**. Skills, queues, and dialer profile -CRUD screens match the Omnichannel Campaigns UI: searchable list pages render summary shapes, and -create/edit screens render display-driver editor shapes with the required root edit wrapper templates. -This keeps the UI extensible for provider panels, compliance fields, routing strategies, and future -supervisor controls. +Contact Center management entries live under **Interaction Center**. Skills, queues, and dialer +profile CRUD screens match the Omnichannel Campaigns UI: searchable list pages render summary shapes, +and create/edit screens render display-driver editor shapes with the required root edit wrapper +templates. Agent sign-in and presence are injected into the Telephony soft phone through +`DisplayDriver`, so the operational controls stay with the phone while management +screens remain catalog-focused. ## Enable via recipe diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 133b9db7f..2928b780a 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -94,25 +94,26 @@ supervisor dashboards, and queue monitors. It does not reuse the Telephony soft- routing, queue, or supervisor data; voice call state continues to flow through Telephony and is projected into the interaction. -## Agent workspace +## Agent soft-phone work controls -Agents receive Contact Center work in the **Interaction Center → Agent Workspace** screen inside the -CRM admin experience. The workspace is the place where agents sign in to allowed queues and outbound -campaigns, set presence and reason codes, receive activity offers, accept or reject reservations, -work the active CRM activity, use injected Telephony call controls, review interaction history, and -complete wrap-up/disposition. +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, +sign out, and set presence/reason codes. This avoids a separate sign-in navigation page and keeps +availability changes next to call handling. + +Future agent desktop surfaces handle activity offers, accept/reject actions, active CRM activity +context, injected Telephony call controls, interaction history, wrap-up, and required disposition. 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 workspace model. - -The current workspace lets agents choose queues, campaigns, and skills using the standard Orchard -admin layout. Campaign and skill selection use searchable multi-select lists instead of raw -identifier fields; campaigns come from the Omnichannel Management **Interaction Center** campaign -catalog, and skills come from **Interaction Center → Skills**. Presence changes are exposed in the -floating soft phone widget so availability stays near call handling. 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. +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, campaigns, and skills. Campaigns come +from the Omnichannel Management **Interaction Center** campaign catalog, and skills come from +**Interaction Center → Skills**. 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 diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index e4bb898d0..5aa4b75f3 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -108,8 +108,10 @@ You can enable the soft phone on the admin, the front end, or both. The widget i 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 extension shapes to the widget. Contact Center uses this extension point to -show agent presence controls in the soft phone when the current user has an agent profile. +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 @@ -133,17 +135,19 @@ call controls when the provider is **available, connected, and authenticated**: - During an active call the main toggle button turns red and switches to a hang-up icon, and the widget exposes mute, hold, transfer, and merge controls based on the provider's capabilities. -### Keypad and recent calls tabs +### Keypad, recent calls, and extension tabs -The widget's footer is a tab bar that switches the panel between two views: +The widget's footer is a tab bar that switches the panel between built-in and contributed views: - **Keypad** – the number field, dial pad, and call controls. - **Recent** – the call history, listing active calls, recent inbound and outbound interactions, and missed calls (highlighted in red with a direction icon). Selecting a recent call dials it again. +- **Contributed tabs** – modules can add their own views through Display Management. For example, + Contact Center adds a **Work** tab for queue/campaign sign-in and presence. -The footer is designed to be extensible, so additional tabs can be added in the future. The history -is read from the hub's `GetInteractions` method and is backed by the persisted interaction store -described below, so it survives page reloads and is available independently of the provider. +The history is read from the hub's `GetInteractions` method and is backed by the persisted +interaction store described below, so it survives page reloads and is available independently of the +provider. ## Incoming calls @@ -210,8 +214,9 @@ There are two ways to add the soft phone to your site: - **Automatically** – turn on **Show the soft phone on the front end** (and/or the admin) on the telephony settings. The widget is injected into the layout's `Footer` zone for authorized users. - **Manually** – render the `SoftPhoneWidget` shape wherever you want it (for example in a theme - layout or a template) and register its resources. This is useful when your theme has no `Footer` - zone or you want precise placement: + layout or a template) and register its resources. This shows the base phone controls. If you need + contributed Display Management tabs, build the widget through `IDisplayManager` the + same way the automatic filter does. ```html diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs similarity index 58% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs rename to src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs index b2fa6ea87..7fa7001a8 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs @@ -2,7 +2,6 @@ using CrestApps.OrchardCore.ContactCenter.Core; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; -using CrestApps.OrchardCore.ContactCenter.Services; using CrestApps.OrchardCore.ContactCenter.ViewModels; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -12,82 +11,47 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; /// -/// Provides the agent workspace where agents sign in to queues and campaigns. +/// Provides Contact Center agent work actions used by the soft phone widget. /// [Admin] [Feature(ContactCenterConstants.Feature.Queues)] -public sealed class AgentWorkspaceController : Controller +public sealed class AgentSoftPhoneController : Controller { private readonly IAgentPresenceManager _presenceManager; private readonly IAgentProfileManager _agentManager; - private readonly IActivityQueueManager _queueManager; - private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; private readonly IAuthorizationService _authorizationService; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The agent presence manager. /// The agent profile manager. - /// The queue manager. - /// The admin form options provider. /// The authorization service. - public AgentWorkspaceController( + public AgentSoftPhoneController( IAgentPresenceManager presenceManager, IAgentProfileManager agentManager, - IActivityQueueManager queueManager, - ContactCenterAdminFormOptionsProvider optionsProvider, IAuthorizationService authorizationService) { _presenceManager = presenceManager; _agentManager = agentManager; - _queueManager = queueManager; - _optionsProvider = optionsProvider; _authorizationService = authorizationService; } - /// - /// Displays the agent workspace. - /// - /// The workspace view. - [Admin("contact-center/workspace", "ContactCenterWorkspace")] - public async Task Index() - { - if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) - { - return Forbid(); - } - - var queues = await _queueManager.ListEnabledAsync(); - var profile = await _agentManager.FindByUserIdAsync(GetUserId()); - var selectedCampaignIds = profile?.CampaignIds ?? []; - var selectedSkills = profile?.Skills ?? []; - - return View(new AgentWorkspaceViewModel - { - Profile = profile, - AvailableQueues = [.. queues], - SelectedQueueIds = profile?.QueueIds ?? [], - CampaignOptions = await _optionsProvider.GetCampaignOptionsAsync(selectedCampaignIds), - SelectedCampaignIds = selectedCampaignIds, - SkillOptions = await _optionsProvider.GetSkillOptionsAsync(selectedSkills), - SelectedSkills = selectedSkills, - }); - } - /// /// Signs the agent in to the selected queues and campaigns. /// /// The queues to sign in to. /// The campaigns to sign in to. /// The skills to keep on the agent profile. - /// A redirect to the workspace. + /// The local URL to return to after sign-in. + /// A redirect to the current page. [HttpPost] [ValidateAntiForgeryToken] public async Task SignIn( IEnumerable selectedQueueIds, IEnumerable selectedCampaignIds, - IEnumerable selectedSkills) + IEnumerable selectedSkills, + string returnUrl) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) { @@ -99,16 +63,17 @@ public async Task SignIn( profile.Skills = ContactCenterFormHelpers.NormalizeList(selectedSkills); await _agentManager.UpdateAsync(profile); - return RedirectToAction(nameof(Index)); + return RedirectToReturnLocation(returnUrl); } /// /// Signs the agent out. /// - /// A redirect to the workspace. + /// The local URL to return to after sign-out. + /// A redirect to the current page. [HttpPost] [ValidateAntiForgeryToken] - public async Task SignOutAgent() + public async Task SignOutAgent(string returnUrl) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) { @@ -117,7 +82,7 @@ public async Task SignOutAgent() await _presenceManager.SignOutAsync(GetUserId()); - return RedirectToAction(nameof(Index)); + return RedirectToReturnLocation(returnUrl); } /// @@ -125,10 +90,14 @@ public async Task SignOutAgent() /// /// The presence state. /// The optional reason code. - /// A redirect to the referring page or the workspace. + /// The local URL to return to after updating presence. + /// A redirect to the current page. [HttpPost] [ValidateAntiForgeryToken] - public async Task SetPresence(AgentPresenceStatus status, string presenceReason) + public async Task SetPresence( + AgentPresenceStatus status, + string presenceReason, + string returnUrl) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) { @@ -137,12 +106,22 @@ public async Task SetPresence(AgentPresenceStatus status, string await _presenceManager.SetPresenceAsync(GetUserId(), status, presenceReason); + return RedirectToReturnLocation(returnUrl); + } + + private IActionResult RedirectToReturnLocation(string returnUrl) + { + if (!string.IsNullOrWhiteSpace(returnUrl) && Url.IsLocalUrl(returnUrl)) + { + return LocalRedirect(returnUrl); + } + if (Request.Headers.Referer.Count > 0) { return Redirect(Request.Headers.Referer.ToString()); } - return RedirectToAction(nameof(Index)); + return LocalRedirect("~/"); } private string GetUserId() diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs new file mode 100644 index 000000000..6b2dd02d7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs @@ -0,0 +1,84 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class ContactCenterSoftPhoneWidgetDisplayDriver : DisplayDriver +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IAgentProfileManager _agentProfileManager; + private readonly IActivityQueueManager _queueManager; + private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP context accessor. + /// The authorization service. + /// The agent profile manager. + /// The queue manager. + /// The admin form options provider. + public ContactCenterSoftPhoneWidgetDisplayDriver( + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IAgentProfileManager agentProfileManager, + IActivityQueueManager queueManager, + ContactCenterAdminFormOptionsProvider optionsProvider) + { + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _agentProfileManager = agentProfileManager; + _queueManager = queueManager; + _optionsProvider = optionsProvider; + } + + /// + public override async Task DisplayAsync(SoftPhoneWidget widget, BuildDisplayContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (user?.Identity?.IsAuthenticated != true || + !await _authorizationService.AuthorizeAsync(user, ContactCenterPermissions.SignIntoQueues)) + { + return null; + } + + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + + if (string.IsNullOrEmpty(userId)) + { + return null; + } + + var profile = await _agentProfileManager.FindByUserIdAsync(userId); + var selectedCampaignIds = profile?.CampaignIds ?? []; + var selectedSkills = profile?.Skills ?? []; + var queues = await _queueManager.ListEnabledAsync(); + var viewModel = new AgentSoftPhoneViewModel + { + Profile = profile, + AvailableQueues = [.. queues], + SelectedQueueIds = profile?.QueueIds ?? [], + CampaignOptions = await _optionsProvider.GetCampaignOptionsAsync(selectedCampaignIds), + SelectedCampaignIds = selectedCampaignIds, + SkillOptions = await _optionsProvider.GetSkillOptionsAsync(selectedSkills), + SelectedSkills = selectedSkills, + }; + + return Combine( + View("ContactCenterSoftPhoneWork_Tab", viewModel) + .Location("Detail", "Tabs:10"), + View("ContactCenterSoftPhoneWork_View", viewModel) + .Location("Detail", "Views:10") + ); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs index e6619c710..0aceb4bd4 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs @@ -5,7 +5,7 @@ namespace CrestApps.OrchardCore.ContactCenter.Services; /// -/// Adds the Contact Center entries to the Interaction Center admin navigation. +/// Adds the Contact Center management entries to the Interaction Center admin navigation. /// public sealed class ContactCenterAdminMenu : AdminNavigationProvider { @@ -27,12 +27,6 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Add(S["Interaction Center"], "80", interactionCenter => interactionCenter .AddClass("interaction-center") .Id("interactionCenter") - .Add(S["Agent Workspace"], S["Agent Workspace"].PrefixPosition(), workspace => workspace - .AddClass("agent-workspace") - .Id("agentWorkspace") - .Action("Index", "AgentWorkspace", "CrestApps.OrchardCore.ContactCenter") - .Permission(ContactCenterPermissions.SignIntoQueues) - .LocalNav()) .Add(S["Queues"], S["Queues"].PrefixPosition(), queues => queues .AddClass("contact-center-queues") .Id("contactCenterQueues") diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs deleted file mode 100644 index 72c38c3e5..000000000 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterSoftPhoneWidgetExtensionProvider.cs +++ /dev/null @@ -1,71 +0,0 @@ -using System.Security.Claims; -using CrestApps.OrchardCore.ContactCenter.Core; -using CrestApps.OrchardCore.ContactCenter.Core.Services; -using CrestApps.OrchardCore.Telephony.Models; -using CrestApps.OrchardCore.Telephony.Services; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Http; -using OrchardCore.DisplayManagement; - -namespace CrestApps.OrchardCore.ContactCenter.Services; - -/// -/// Adds Contact Center agent presence controls to the floating soft phone widget. -/// -public sealed class ContactCenterSoftPhoneWidgetExtensionProvider : ISoftPhoneWidgetExtensionProvider -{ - private readonly IHttpContextAccessor _httpContextAccessor; - private readonly IAuthorizationService _authorizationService; - private readonly IAgentProfileManager _agentProfileManager; - private readonly IShapeFactory _shapeFactory; - - /// - /// Initializes a new instance of the class. - /// - /// The HTTP context accessor. - /// The authorization service. - /// The agent profile manager. - /// The shape factory. - public ContactCenterSoftPhoneWidgetExtensionProvider( - IHttpContextAccessor httpContextAccessor, - IAuthorizationService authorizationService, - IAgentProfileManager agentProfileManager, - IShapeFactory shapeFactory) - { - _httpContextAccessor = httpContextAccessor; - _authorizationService = authorizationService; - _agentProfileManager = agentProfileManager; - _shapeFactory = shapeFactory; - } - - /// - public async Task BuildAsync(SoftPhoneWidgetExtensionContext context, CancellationToken cancellationToken = default) - { - var user = _httpContextAccessor.HttpContext?.User; - - if (user?.Identity?.IsAuthenticated != true || - !await _authorizationService.AuthorizeAsync(user, ContactCenterPermissions.SignIntoQueues)) - { - return; - } - - var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); - - if (string.IsNullOrEmpty(userId)) - { - return; - } - - var profile = await _agentProfileManager.FindByUserIdAsync(userId, cancellationToken); - - if (profile is null) - { - return; - } - - var shape = await _shapeFactory.CreateAsync("ContactCenterSoftPhonePresence"); - shape.Properties["Profile"] = profile; - - context.Shapes.Add(shape); - } -} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index f9792db6e..92c67d663 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -10,7 +10,7 @@ using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.Models; using CrestApps.OrchardCore.Telephony; -using CrestApps.OrchardCore.Telephony.Services; +using CrestApps.OrchardCore.Telephony.Models; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using OrchardCore.BackgroundTasks; @@ -93,12 +93,12 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() - .AddScoped() - .AddScoped(); + .AddScoped(); services .AddDisplayDriver() .AddDisplayDriver() + .AddDisplayDriver() .AddScoped, ActivityQueueHandler>() .AddScoped, ContactCenterSkillHandler>() .AddIndexProvider() diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs similarity index 94% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs rename to src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs index 44b112d7c..48f87a23e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs @@ -6,9 +6,9 @@ namespace CrestApps.OrchardCore.ContactCenter.ViewModels; /// -/// Represents the agent workspace sign-in view model. +/// Represents the agent soft phone sign-in view model. /// -public class AgentWorkspaceViewModel +public class AgentSoftPhoneViewModel { /// /// Gets or sets the agent profile, when the agent has signed in before. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml deleted file mode 100644 index 0156aa94f..000000000 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml +++ /dev/null @@ -1,74 +0,0 @@ -@model AgentWorkspaceViewModel - - - - -

    @T["Agent workspace"]

    - -@if (Model.IsSignedIn) -{ -
    - @T["You are signed in to Contact Center work."] -
    -} - -@if (!Model.IsSignedIn) -{ -
    -
    -
    -

    @T["Select the queues, campaigns, and skills you want to receive work from, then sign in."]

    -
    -
    - -
    - -
    - @foreach (var queue in Model.AvailableQueues) - { - var selected = Model.SelectedQueueIds.Contains(queue.ItemId); - -
    - - -
    - } - @T["Only selected queues can reserve and offer work to you."] -
    -
    - -
    - -
    - - - @T["Select the outbound campaigns you can receive dialer work from."] -
    -
    - -
    - -
    - - - @T["Select the routing skills that match the work you can handle."] -
    -
    - -
    -
    - -
    -
    -
    -} -else -{ -
    -
    -
    - -
    -
    -
    -} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml deleted file mode 100644 index 3f317cfd7..000000000 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterSoftPhonePresence.cshtml +++ /dev/null @@ -1,25 +0,0 @@ -@using CrestApps.OrchardCore.ContactCenter.Core.Models -@using CrestApps.OrchardCore.ContactCenter.Models - -@{ - var profile = Model.Profile as AgentProfile; -} - -@if (profile is not null) -{ -
    -
    - - -
    -
    - -
    - -
    -} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml new file mode 100644 index 000000000..cf5c20d0c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml @@ -0,0 +1,6 @@ +@model AgentSoftPhoneViewModel + + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml new file mode 100644 index 000000000..39c73bd81 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml @@ -0,0 +1,82 @@ +@model AgentSoftPhoneViewModel + +@{ + var returnUrl = $"{Context.Request.PathBase}{Context.Request.Path}{Context.Request.QueryString}"; +} + + diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js index feaa0bbe6..c93d6ad8e 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js @@ -104,6 +104,7 @@ historyList: rootElement.querySelector('[data-telephony-history-list]'), footer: rootElement.querySelector('[data-telephony-footer]'), tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')), + views: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-view]')), incoming: rootElement.querySelector('[data-telephony-incoming]'), incomingCaller: rootElement.querySelector('[data-telephony-incoming-caller]'), incomingQueue: rootElement.querySelector('[data-telephony-incoming-queue]'), @@ -153,6 +154,36 @@ } } + function showView(name) { + dom.views.forEach(function (view) { + show(view, view.getAttribute('data-telephony-view') === name); + }); + } + + function activeTabExists() { + return dom.tabs.some(function (tab) { + return tab.getAttribute('data-telephony-tab') === activeTab; + }); + } + + function ensureActiveTab() { + if (activeTabExists()) { + return; + } + + activeTab = dom.tabs.length ? dom.tabs[0].getAttribute('data-telephony-tab') : 'keypad'; + } + + function isTelephonyTab(tab) { + return tab === 'keypad' || tab === 'history'; + } + + function hasExtensionTabs() { + return dom.tabs.some(function (tab) { + return !isTelephonyTab(tab.getAttribute('data-telephony-tab')); + }); + } + function statusTextForState(stateName) { var key = stateName.charAt(0).toLowerCase() + stateName.slice(1); @@ -376,6 +407,7 @@ function render() { renderIncoming(); + ensureActiveTab(); var stateName = currentCall ? normalizeState(currentCall.state) : 'Idle'; var active = isActive(stateName); @@ -389,17 +421,19 @@ var notAvailable = !isAvailable; var needsConnect = isAvailable && requiresAuthentication && !isConnected; - // Unavailable: no provider configured. Hide the body and the tab footer. + // Unavailable: no provider configured. Keep contributed tabs reachable. if (notAvailable && !active) { + var showUnavailable = isTelephonyTab(activeTab); + if (dom.unavailableText) { dom.unavailableText.textContent = strings.notConfigured || 'No telephony provider is configured.'; } - show(dom.unavailable, true); + show(dom.unavailable, showUnavailable); show(dom.connectPanel, false); - show(dom.keypadView, false); - show(dom.history, false); - show(dom.footer, false); + showView(showUnavailable ? null : activeTab); + show(dom.footer, hasExtensionTabs()); + updateTabs(); setStatus(strings.notReady || 'Not Ready'); return; @@ -407,12 +441,14 @@ show(dom.unavailable, false); - // Needs a per-user connection (for example OAuth). Hide the body and the tab footer. + // Needs a per-user connection (for example OAuth). Keep contributed tabs reachable. if (needsConnect && !active) { - show(dom.connectPanel, true); - show(dom.keypadView, false); - show(dom.history, false); - show(dom.footer, false); + var showConnect = isTelephonyTab(activeTab); + + show(dom.connectPanel, showConnect); + showView(showConnect ? null : activeTab); + show(dom.footer, hasExtensionTabs()); + updateTabs(); setStatus(strings.notConnected || 'Not connected'); return; @@ -423,10 +459,7 @@ // Operating state: show the footer tabs and the selected view (keypad or recent calls). show(dom.footer, true); updateTabs(); - - var showHistory = activeTab === 'history'; - show(dom.history, showHistory); - show(dom.keypadView, !showHistory); + showView(activeTab); setStatus(currentCall ? statusTextForState(stateName) : (strings.idle || 'Ready')); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss index b939c7c35..2cfd2ddeb 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss @@ -99,6 +99,12 @@ padding: 0.35rem 0.5rem 0.65rem; } + &__extension-view { + max-height: 18rem; + overflow-y: auto; + padding: 0.75rem 0.85rem; + } + &__history-empty { padding: 1rem; text-align: center; diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs b/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs index 898d6c301..1920f7ced 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Filters/SoftPhoneWidgetFilter.cs @@ -8,6 +8,7 @@ using OrchardCore.Admin; using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.Layout; +using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.ResourceManagement; using OrchardCore.Settings; @@ -20,11 +21,11 @@ namespace CrestApps.OrchardCore.Telephony.Filters; public sealed class SoftPhoneWidgetFilter : IAsyncResultFilter { private readonly ILayoutAccessor _layoutAccessor; - private readonly IShapeFactory _shapeFactory; private readonly ISiteService _siteService; private readonly IAuthorizationService _authorizationService; private readonly ITelephonyProviderResolver _providerResolver; - private readonly IEnumerable _extensionProviders; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; private readonly IResourceManager _resourceManager; private readonly AdminOptions _adminOptions; @@ -32,29 +33,29 @@ public sealed class SoftPhoneWidgetFilter : IAsyncResultFilter /// Initializes a new instance of the class. ///
    /// The layout accessor. - /// The shape factory. /// The site service. /// The authorization service. /// The telephony provider resolver. - /// The soft phone widget extension providers. + /// The update model accessor. + /// The soft phone widget display manager. /// The resource manager. /// The admin options. public SoftPhoneWidgetFilter( ILayoutAccessor layoutAccessor, - IShapeFactory shapeFactory, ISiteService siteService, IAuthorizationService authorizationService, ITelephonyProviderResolver providerResolver, - IEnumerable extensionProviders, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, IResourceManager resourceManager, IOptions adminOptions) { _layoutAccessor = layoutAccessor; - _shapeFactory = shapeFactory; _siteService = siteService; _authorizationService = authorizationService; _providerResolver = providerResolver; - _extensionProviders = extensionProviders; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; _resourceManager = resourceManager; _adminOptions = adminOptions.Value; } @@ -102,13 +103,17 @@ public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultE _resourceManager.RegisterResource("script", "telephony-soft-phone").AtFoot(); _resourceManager.RegisterResource("script", "telephony-phone-field").AtFoot(); - var shape = await _shapeFactory.CreateAsync("SoftPhoneWidget"); - - shape.Properties["AccentColor"] = string.IsNullOrWhiteSpace(settings.AccentColor) - ? SoftPhoneWidgetSettings.DefaultAccentColor - : settings.AccentColor; + var widget = new SoftPhoneWidget + { + AccentColor = string.IsNullOrWhiteSpace(settings.AccentColor) + ? SoftPhoneWidgetSettings.DefaultAccentColor + : settings.AccentColor, + Capabilities = provider?.Capabilities ?? TelephonyCapabilities.None, + }; + + var shape = await _displayManager.BuildDisplayAsync(widget, _updateModelAccessor.ModelUpdater, "Detail"); + shape.Properties["AccentColor"] = widget.AccentColor; shape.Properties["Capabilities"] = capabilities; - shape.Properties["Extensions"] = await BuildExtensionsAsync(context.HttpContext.RequestAborted); var layout = await _layoutAccessor.GetLayoutAsync(); @@ -121,16 +126,4 @@ private bool IsAdminPage(ResultExecutingContext context) { return context.HttpContext.Request.Path.StartsWithSegments('/' + _adminOptions.AdminUrlPrefix, StringComparison.OrdinalIgnoreCase); } - - private async Task> BuildExtensionsAsync(CancellationToken cancellationToken) - { - var extensionContext = new SoftPhoneWidgetExtensionContext(); - - foreach (var provider in _extensionProviders) - { - await provider.BuildAsync(extensionContext, cancellationToken); - } - - return extensionContext.Shapes; - } } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml index 9d1dd097d..1f7b41b76 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml @@ -1,5 +1,6 @@ @using CrestApps.OrchardCore.Telephony @using CrestApps.OrchardCore.Telephony.Hubs +@using CrestApps.OrchardCore.Telephony.Models @using CrestApps.Core.SignalR.Services @using System.Text.Json @inject HubRouteManager HubRouteManager @@ -7,8 +8,17 @@ @{ var hubUrl = HubRouteManager.GetPathByHub(); var accentColor = Model.AccentColor as string ?? "#2f6fed"; - var capabilities = Model.Capabilities is int caps ? caps : 0; - var extensions = Model.Extensions as IEnumerable ?? []; + var capabilities = 0; + + if (Model.Capabilities is TelephonyCapabilities caps) + { + capabilities = (int)caps; + } + else if (Model.Capabilities is int intCaps) + { + capabilities = intCaps; + } + var connectUrl = Url.RouteUrl(TelephonyConstants.RouteNames.OAuthConnect); var antiForgeryToken = Antiforgery.GetAndStoreTokens(Context).RequestToken; @@ -85,11 +95,6 @@ - @foreach (var extension in extensions) - { - @await DisplayAsync(extension) - } -
    @@ -141,6 +146,11 @@ + + @if (Model.Views != null) + { + @await DisplayAsync(Model.Views) + }
    diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js index ed6d5dfc9..1bb215570 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.js @@ -102,6 +102,7 @@ historyList: rootElement.querySelector('[data-telephony-history-list]'), footer: rootElement.querySelector('[data-telephony-footer]'), tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')), + views: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-view]')), incoming: rootElement.querySelector('[data-telephony-incoming]'), incomingCaller: rootElement.querySelector('[data-telephony-incoming-caller]'), incomingQueue: rootElement.querySelector('[data-telephony-incoming-queue]'), @@ -144,6 +145,30 @@ dom.error.hidden = true; } } + function showView(name) { + dom.views.forEach(function (view) { + show(view, view.getAttribute('data-telephony-view') === name); + }); + } + function activeTabExists() { + return dom.tabs.some(function (tab) { + return tab.getAttribute('data-telephony-tab') === activeTab; + }); + } + function ensureActiveTab() { + if (activeTabExists()) { + return; + } + activeTab = dom.tabs.length ? dom.tabs[0].getAttribute('data-telephony-tab') : 'keypad'; + } + function isTelephonyTab(tab) { + return tab === 'keypad' || tab === 'history'; + } + function hasExtensionTabs() { + return dom.tabs.some(function (tab) { + return !isTelephonyTab(tab.getAttribute('data-telephony-tab')); + }); + } function statusTextForState(stateName) { var key = stateName.charAt(0).toLowerCase() + stateName.slice(1); return strings[key] || stateName; @@ -332,6 +357,7 @@ } function render() { renderIncoming(); + ensureActiveTab(); var stateName = currentCall ? normalizeState(currentCall.state) : 'Idle'; var active = isActive(stateName); rootElement.classList.toggle('telephony-soft-phone--in-call', active); @@ -341,27 +367,29 @@ var notAvailable = !isAvailable; var needsConnect = isAvailable && requiresAuthentication && !isConnected; - // Unavailable: no provider configured. Hide the body and the tab footer. + // Unavailable: no provider configured. Keep contributed tabs reachable. if (notAvailable && !active) { + var showUnavailable = isTelephonyTab(activeTab); if (dom.unavailableText) { dom.unavailableText.textContent = strings.notConfigured || 'No telephony provider is configured.'; } - show(dom.unavailable, true); + show(dom.unavailable, showUnavailable); show(dom.connectPanel, false); - show(dom.keypadView, false); - show(dom.history, false); - show(dom.footer, false); + showView(showUnavailable ? null : activeTab); + show(dom.footer, hasExtensionTabs()); + updateTabs(); setStatus(strings.notReady || 'Not Ready'); return; } show(dom.unavailable, false); - // Needs a per-user connection (for example OAuth). Hide the body and the tab footer. + // Needs a per-user connection (for example OAuth). Keep contributed tabs reachable. if (needsConnect && !active) { - show(dom.connectPanel, true); - show(dom.keypadView, false); - show(dom.history, false); - show(dom.footer, false); + var showConnect = isTelephonyTab(activeTab); + show(dom.connectPanel, showConnect); + showView(showConnect ? null : activeTab); + show(dom.footer, hasExtensionTabs()); + updateTabs(); setStatus(strings.notConnected || 'Not connected'); return; } @@ -370,9 +398,7 @@ // Operating state: show the footer tabs and the selected view (keypad or recent calls). show(dom.footer, true); updateTabs(); - var showHistory = activeTab === 'history'; - show(dom.history, showHistory); - show(dom.keypadView, !showHistory); + showView(activeTab); setStatus(currentCall ? statusTextForState(stateName) : strings.idle || 'Ready'); if (dom.peer) { dom.peer.textContent = active && currentCall ? currentCall.to || currentCall.from || '' : ''; diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js index a4e729a18..0553d7f5c 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/scripts/soft-phone.min.js @@ -1 +1 @@ -!function(){"use strict";var e=2,n=4,t=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function s(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function u(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var n=document.createElement("div");return n.textContent=null==e?"":String(e),n.innerHTML}function p(e,n,t){return Math.min(Math.max(e,n),t)}function h(c,h){h=h||{};var f=function(e){var n=e.getAttribute("data-config");if(!n)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(n)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),g=f.strings||{},y=f.capabilities||0,m=(f.storageKey||"telephony-soft-phone")+"-layout",v=h.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,S=null,C=null,k=!1,L=!1,_=!1,q=null,I="keypad",E=!1;function A(e){return(y&e)===e}function R(e,n){e&&(e.hidden=!n)}function x(e){b.status&&(b.status.textContent=e)}function M(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function P(){try{return JSON.parse(localStorage.getItem(m))||{}}catch(e){return{}}}function H(e){try{var n=P();Object.assign(n,e),localStorage.setItem(m,JSON.stringify(n))}catch(e){}}function T(e,n){c.style.left=e+"px",c.style.top=n+"px",c.style.right="auto",c.style.bottom="auto"}function N(e,n){var t=c.getBoundingClientRect(),o=t.width||56,i=t.height||56,r=Math.max(8,window.innerWidth-o-8),a=Math.max(8,window.innerHeight-i-8),l=8,s=8;if(b.panel&&!b.panel.hidden){var u=b.panel.getBoundingClientRect(),d=u.width||o,h=u.height||0;l=Math.min(r,Math.max(8,d-o+8)),s=Math.min(a,h+20)}return{left:p(e,l,r),top:p(n,s,a)}}function U(){if(c.style.left){var e=c.getBoundingClientRect(),n=N(e.left,e.top);T(n.left,n.top)}}function O(e,n){if(e){n=n||{};var t=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||n.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();T(d.left,d.top),t=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",s),document.addEventListener("pointerup",u),document.addEventListener("pointercancel",u)}})}function s(e){if(null!==t&&e.pointerId===t){var n=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(n,c)<4)){l=!0;var s=N(r+n,a+c);T(s.left,s.top)}}}function u(){var e;null!==t&&(document.removeEventListener("pointermove",s),document.removeEventListener("pointerup",u),document.removeEventListener("pointercancel",u),c.classList.remove("telephony-soft-phone--dragging"),t=null,l&&(H({position:{left:(e=c.getBoundingClientRect()).left,top:e.top,leftRatio:e.left/Math.max(1,window.innerWidth),topRatio:e.top/Math.max(1,window.innerHeight)}}),n.suppressClick&&(E=!0)))}}function B(e){I=e,V(),"history"===e&&function(){if(!w)return void ce([]);w.invoke("GetInteractions",50).then(function(e){ce(e||[])}).catch(function(){ce([])})}()}function V(){!function(){var e=function(){if(!S)return!1;var e=1===S.direction||"Inbound"===S.direction;return"Ringing"===s(S.state)&&e}();if(R(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return void(C=null);b.incomingCaller&&(b.incomingCaller.textContent=S&&(S.from||S.to)||g.incomingCall||"Incoming call");var n=C&&C.properties?C.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=n||"",b.incomingQueue.hidden=!n);R(b.incomingVoicemail,A(l)),function(){if(!b.incomingCards)return;var e=C&&C.cards?C.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var n="",t=C&&C.heading||g.matchedRecords;t&&(n+='
    '+d(t)+"
    ");e.forEach(function(e){n+=function(e){var n=e.icon?'':"",t='
    '+d(e.title||"")+"
    ";e.subtitle&&(t+='
    '+d(e.subtitle)+"
    ");e.description&&(t+='
    '+d(e.description)+"
    ");e.badges&&e.badges.length&&(t+='
    ',e.badges.forEach(function(e){t+=''+d(e)+""}),t+="
    ");e.links&&e.links.length&&(t+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(g.open||"Open")+""}return'
    '+n+'
    '+t+"
    "+(o?'
    '+o+"
    ":"")+"
    "}(e)}),b.incomingCards.innerHTML=n,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ne(e.getAttribute("data-url"))})})}()}();var a=S?s(S.state):"Idle",p=u(a);c.classList.toggle("telephony-soft-phone--in-call",p),b.toggleIcon&&(b.toggleIcon.className=p?"fa-solid fa-phone-slash":"fa-solid fa-phone");var h=_&&k&&!L;if(!_&&!p)return b.unavailableText&&(b.unavailableText.textContent=g.notConfigured||"No telephony provider is configured."),R(b.unavailable,!0),R(b.connectPanel,!1),R(b.keypadView,!1),R(b.history,!1),R(b.footer,!1),void x(g.notReady||"Not Ready");if(R(b.unavailable,!1),h&&!p)return R(b.connectPanel,!0),R(b.keypadView,!1),R(b.history,!1),R(b.footer,!1),void x(g.notConnected||"Not connected");R(b.connectPanel,!1),R(b.footer,!0),b.tabs.forEach(function(e){var n=e.getAttribute("data-telephony-tab")===I;e.classList.toggle("is-active",n),e.setAttribute("aria-selected",n?"true":"false")});var f="history"===I;R(b.history,f),R(b.keypadView,!f),x(S?function(e){var n=e.charAt(0).toLowerCase()+e.slice(1);return g[n]||e}(a):g.idle||"Ready"),b.peer&&(b.peer.textContent=p&&S&&(S.to||S.from)||""),R(b.dial,!p),R(b.hangup,p&&A(e)),R(b.hold,p&&"Connected"===a&&A(n)),R(b.resume,p&&"OnHold"===a&&A(t));var y=S&&S.isMuted;R(b.mute,p&&!y&&A(o)),R(b.unmute,p&&y&&A(o)),R(b.transfer,p&&A(i)),R(b.merge,p&&A(r)),b.number&&(b.number.disabled=p)}function D(e,n){return w?w.invoke(e,n).then(function(e){return function(e){!!e&&(!1===e.succeeded?M(e.error||g.failed||"Call failed"):(M(null),e.call&&("Disconnected"!==s((S=e.call).state)&&"Failed"!==s(S.state)||(S=null)),V()))}(e),e}).catch(function(e){throw M(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function F(){return S?S.callId:null}function j(){var e=b.number?b.number.value.trim():"";e?D("Dial",{to:e}):M(g.invalidNumber||"Enter a phone number to call.")}function J(e){e&&(I="keypad",$(!0),b.number&&(b.number.value=e),D("Dial",{to:e}))}function Q(){var e=F();e&&D("Hangup",{callId:e})}function G(){var e=F();e&&D("Hold",{callId:e})}function W(){var e=F();e&&D("Resume",{callId:e})}function z(){var e=F();e&&D("Mute",{callId:e})}function K(){var e=F();e&&D("Unmute",{callId:e})}function X(){var e=F();if(e){var n=window.prompt(g.transferPrompt||"Transfer to number");n&&D("Transfer",{callId:e,to:n,mode:0})}}function Y(){var e=F();if(e){var n=window.prompt(g.mergePrompt||"Second call id to merge");n&&D("Merge",{primaryCallId:e,secondaryCallId:n})}}function Z(e){var n=S?s(S.state):"Idle";"Connected"===n&&A(a)?D("SendDigits",{callId:F(),digits:e}):!u(n)&&b.number&&(b.number.value+=e)}function $(e){if(b.panel){var n="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!n,H({open:n}),U(),V()}}function ee(e){if(C&&C.properties){var n=C.properties[e];if(n){var t={"Content-Type":"application/json"};f.antiForgeryToken&&(t.RequestVerificationToken=f.antiForgeryToken);try{fetch(n,{method:"POST",credentials:"same-origin",headers:t,body:JSON.stringify({callId:F()})}).catch(function(){})}catch(e){}}}}function ne(e){var n=F();e&&window.open(e,"_blank","noopener"),ee("acceptUrl"),n&&($(!0),D("Answer",{callId:n}))}function te(){var e=F();ee("declineUrl"),e&&D("Voicemail",{callId:e})}function oe(){var e=F();ee("declineUrl"),e?D("Reject",{callId:e}):(S=null,V())}function ie(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(_=!!e.isAvailable,k=!!e.requiresAuthentication,L=!!e.isConnected,q=e.authenticationScheme||"oauth2",V())}).catch(function(){}):Promise.resolve()}function re(){if(f.connectUrl){var e=f.connectUrl.indexOf("?")>=0?"&":"?",n=f.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(n,"telephony-oauth","width=520,height=640")||(window.location.href=n)}}function ae(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,n=e&&(e[q]||e.oauth2),t={scheme:q,connectUrl:f.connectUrl,startOAuth:re,refreshStatus:ie};"function"==typeof n?n(t):re()}function le(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&ie()}function ce(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var n=function(e){return 1===e.direction||"Inbound"===e.direction}(e),t=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=n?"fa-arrow-down-left":"fa-arrow-up-right",r=n?e.from||"":e.to||"",a=t?g.missed||"Missed":n?g.incoming||"Incoming":g.outgoing||"Outgoing",l=function(e){try{var n=new Date(e);return isNaN(n.getTime())?"":n.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(t?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),s=d(a)+(o?" • "+d(g.inProgress||"In progress"):"")+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var n=e.getAttribute("data-telephony-history-number");n&&(I="keypad",J(n))})})):b.historyList.innerHTML='
    '+d(g.noInteractions||"No recent calls.")+"
    ")}b.toggle&&b.toggle.addEventListener("click",function(){E?E=!1:$()}),b.close&&b.close.addEventListener("click",function(){$(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){B(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",j),b.hangup&&b.hangup.addEventListener("click",Q),b.hold&&b.hold.addEventListener("click",G),b.resume&&b.resume.addEventListener("click",W),b.mute&&b.mute.addEventListener("click",z),b.unmute&&b.unmute.addEventListener("click",K),b.transfer&&b.transfer.addEventListener("click",X),b.merge&&b.merge.addEventListener("click",Y),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ne(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",te),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",oe),b.connect&&b.connect.addEventListener("click",ae),b.keys.forEach(function(e){e.addEventListener("click",function(){Z(e.getAttribute("data-telephony-key"))})}),O(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),O(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",le),window.addEventListener("resize",U),function(){var e=P();if(e.open&&b.panel&&(b.panel.hidden=!1),e.position&&isFinite(e.position.left)){var n=e.position.left,t=e.position.top;isFinite(e.position.leftRatio)&&(n=e.position.leftRatio*window.innerWidth,t=e.position.topRatio*window.innerHeight);var o=N(n,t);T(o.left,o.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var n=e.getBoundingClientRect(),t=c.getBoundingClientRect().width||56,o=n.left-t-14;o<8&&(o=n.right+14);var i=N(o,n.top);T(i.left,i.top)}}()}(),V(),c.style.visibility="";var se=v&&f.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(f.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){S=e,"Disconnected"!==s(e.state)&&"Failed"!==s(e.state)||(S=null),V()}),w.on("IncomingCall",function(e,n){S=e,C=n||null,I="keypad",V()}),w.on("ReceiveError",function(e){M(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){x(g.disconnectedHub||"Disconnected")})),w.start().then(function(){return M(null),Promise.all([w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(y=e,V())}).catch(function(){}):Promise.resolve(),ie()])}).then(function(){V()}).catch(function(e){M(e&&e.message?e.message:String(e))})):(V(),Promise.resolve());return{element:c,config:f,dial:j,dialNumber:J,hangup:Q,hold:G,resume:W,mute:z,unmute:K,transfer:X,merge:Y,pressKey:Z,togglePanel:$,open:function(){$(!0)},getCurrentCall:function(){return S},getConnection:function(){return w},started:se}}function f(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=h(e))})}function g(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:h,initializeAll:f,getInstance:g,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var n=g();n&&n.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",f):f()}(); +!function(){"use strict";var e=2,t=4,n=8,o=16,i=32,r=64,a=128,l=512,c=["Idle","Connecting","Ringing","Connected","OnHold","Disconnected","Failed"];function u(e){return"number"==typeof e?c[e]||"Idle":"string"==typeof e&&e.length?e:"Idle"}function s(e){return"Connecting"===e||"Ringing"===e||"Connected"===e||"OnHold"===e}function d(e){var t=document.createElement("div");return t.textContent=null==e?"":String(e),t.innerHTML}function p(e,t,n){return Math.min(Math.max(e,t),n)}function h(c,h){h=h||{};var f=function(e){var t=e.getAttribute("data-config");if(!t)return{hubUrl:"",capabilities:0,strings:{}};try{return JSON.parse(t)}catch(e){return{hubUrl:"",capabilities:0,strings:{}}}}(c),g=f.strings||{},y=f.capabilities||0,m=(f.storageKey||"telephony-soft-phone")+"-layout",v=h.signalRFactory||("undefined"!=typeof signalR?signalR:null),b={toggle:c.querySelector("[data-telephony-toggle]"),toggleIcon:c.querySelector("[data-telephony-toggle-icon]"),panel:c.querySelector("[data-telephony-panel]"),dragHandle:c.querySelector("[data-telephony-drag-handle]"),close:c.querySelector("[data-telephony-close]"),status:c.querySelector("[data-telephony-status]"),number:c.querySelector("[data-telephony-number]"),peer:c.querySelector("[data-telephony-peer]"),error:c.querySelector("[data-telephony-error]"),keys:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-key]")),dial:c.querySelector("[data-telephony-dial]"),hold:c.querySelector("[data-telephony-hold]"),resume:c.querySelector("[data-telephony-resume]"),mute:c.querySelector("[data-telephony-mute]"),unmute:c.querySelector("[data-telephony-unmute]"),transfer:c.querySelector("[data-telephony-transfer]"),merge:c.querySelector("[data-telephony-merge]"),hangup:c.querySelector("[data-telephony-hangup]"),connectPanel:c.querySelector("[data-telephony-connect-panel]"),connect:c.querySelector("[data-telephony-connect]"),unavailable:c.querySelector("[data-telephony-unavailable]"),unavailableText:c.querySelector("[data-telephony-unavailable-text]"),keypadView:c.querySelector('[data-telephony-view="keypad"]'),history:c.querySelector("[data-telephony-history]"),historyList:c.querySelector("[data-telephony-history-list]"),footer:c.querySelector("[data-telephony-footer]"),tabs:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-tab]")),views:Array.prototype.slice.call(c.querySelectorAll("[data-telephony-view]")),incoming:c.querySelector("[data-telephony-incoming]"),incomingCaller:c.querySelector("[data-telephony-incoming-caller]"),incomingQueue:c.querySelector("[data-telephony-incoming-queue]"),incomingCards:c.querySelector("[data-telephony-incoming-cards]"),incomingAnswer:c.querySelector("[data-telephony-incoming-answer]"),incomingVoicemail:c.querySelector("[data-telephony-incoming-voicemail]"),incomingIgnore:c.querySelector("[data-telephony-incoming-ignore]")},w=null,S=null,C=null,k=!1,L=!1,q=!1,_=null,I="keypad",E=!1;function A(e){return(y&e)===e}function R(e,t){e&&(e.hidden=!t)}function x(e){b.status&&(b.status.textContent=e)}function M(e){b.error&&(e?(b.error.textContent=e,b.error.hidden=!1):(b.error.textContent="",b.error.hidden=!0))}function P(e){b.views.forEach(function(t){R(t,t.getAttribute("data-telephony-view")===e)})}function H(){b.tabs.some(function(e){return e.getAttribute("data-telephony-tab")===I})||(I=b.tabs.length?b.tabs[0].getAttribute("data-telephony-tab"):"keypad")}function T(e){return"keypad"===e||"history"===e}function N(){return b.tabs.some(function(e){return!T(e.getAttribute("data-telephony-tab"))})}function U(){try{return JSON.parse(localStorage.getItem(m))||{}}catch(e){return{}}}function O(e){try{var t=U();Object.assign(t,e),localStorage.setItem(m,JSON.stringify(t))}catch(e){}}function B(e,t){c.style.left=e+"px",c.style.top=t+"px",c.style.right="auto",c.style.bottom="auto"}function D(e,t){var n=c.getBoundingClientRect(),o=n.width||56,i=n.height||56,r=Math.max(8,window.innerWidth-o-8),a=Math.max(8,window.innerHeight-i-8),l=8,u=8;if(b.panel&&!b.panel.hidden){var s=b.panel.getBoundingClientRect(),d=s.width||o,h=s.height||0;l=Math.min(r,Math.max(8,d-o+8)),u=Math.min(a,h+20)}return{left:p(e,l,r),top:p(t,u,a)}}function F(){if(c.style.left){var e=c.getBoundingClientRect(),t=D(e.left,e.top);B(t.left,t.top)}}function V(e,t){if(e){t=t||{};var n=null,o=0,i=0,r=0,a=0,l=!1;e.addEventListener("pointerdown",function(e){if(!(0!==e.button||t.ignoreButtons&&e.target.closest("button, a, input, textarea, select"))){var d=c.getBoundingClientRect();B(d.left,d.top),n=e.pointerId,l=!1,o=e.clientX,i=e.clientY,r=d.left,a=d.top,c.classList.add("telephony-soft-phone--dragging"),document.addEventListener("pointermove",u),document.addEventListener("pointerup",s),document.addEventListener("pointercancel",s)}})}function u(e){if(null!==n&&e.pointerId===n){var t=e.clientX-o,c=e.clientY-i;if(l||!(Math.hypot(t,c)<4)){l=!0;var u=D(r+t,a+c);B(u.left,u.top)}}}function s(){var e;null!==n&&(document.removeEventListener("pointermove",u),document.removeEventListener("pointerup",s),document.removeEventListener("pointercancel",s),c.classList.remove("telephony-soft-phone--dragging"),n=null,l&&(O({position:{left:(e=c.getBoundingClientRect()).left,top:e.top,leftRatio:e.left/Math.max(1,window.innerWidth),topRatio:e.top/Math.max(1,window.innerHeight)}}),t.suppressClick&&(E=!0)))}}function j(){b.tabs.forEach(function(e){var t=e.getAttribute("data-telephony-tab")===I;e.classList.toggle("is-active",t),e.setAttribute("aria-selected",t?"true":"false")})}function J(e){I=e,Q(),"history"===e&&function(){if(!w)return void he([]);w.invoke("GetInteractions",50).then(function(e){he(e||[])}).catch(function(){he([])})}()}function Q(){!function(){var e=function(){if(!S)return!1;var e=1===S.direction||"Inbound"===S.direction;return"Ringing"===u(S.state)&&e}();if(R(b.incoming,e),c.classList.toggle("telephony-soft-phone--incoming",e),!e)return void(C=null);b.incomingCaller&&(b.incomingCaller.textContent=S&&(S.from||S.to)||g.incomingCall||"Incoming call");var t=C&&C.properties?C.properties.queue:"";b.incomingQueue&&(b.incomingQueue.textContent=t||"",b.incomingQueue.hidden=!t);R(b.incomingVoicemail,A(l)),function(){if(!b.incomingCards)return;var e=C&&C.cards?C.cards:[];if(!e.length)return b.incomingCards.innerHTML="",void(b.incomingCards.hidden=!0);var t="",n=C&&C.heading||g.matchedRecords;n&&(t+='
    '+d(n)+"
    ");e.forEach(function(e){t+=function(e){var t=e.icon?'':"",n='
    '+d(e.title||"")+"
    ";e.subtitle&&(n+='
    '+d(e.subtitle)+"
    ");e.description&&(n+='
    '+d(e.description)+"
    ");e.badges&&e.badges.length&&(n+='
    ',e.badges.forEach(function(e){n+=''+d(e)+""}),n+="
    ");e.links&&e.links.length&&(n+='");var o="";if(e.url){var i=e.openInNewTab?' target="_blank" rel="noopener"':"";o+='",o+=' '+d(g.open||"Open")+""}return'
    '+t+'
    '+n+"
    "+(o?'
    '+o+"
    ":"")+"
    "}(e)}),b.incomingCards.innerHTML=t,b.incomingCards.hidden=!1,Array.prototype.forEach.call(b.incomingCards.querySelectorAll("[data-telephony-card-answer]"),function(e){e.addEventListener("click",function(){ae(e.getAttribute("data-url"))})})}()}(),H();var a=S?u(S.state):"Idle",p=s(a);c.classList.toggle("telephony-soft-phone--in-call",p),b.toggleIcon&&(b.toggleIcon.className=p?"fa-solid fa-phone-slash":"fa-solid fa-phone");var h=q&&k&&!L;if(!q&&!p){var f=T(I);return b.unavailableText&&(b.unavailableText.textContent=g.notConfigured||"No telephony provider is configured."),R(b.unavailable,f),R(b.connectPanel,!1),P(f?null:I),R(b.footer,N()),j(),void x(g.notReady||"Not Ready")}if(R(b.unavailable,!1),h&&!p){var y=T(I);return R(b.connectPanel,y),P(y?null:I),R(b.footer,N()),j(),void x(g.notConnected||"Not connected")}R(b.connectPanel,!1),R(b.footer,!0),j(),P(I),x(S?function(e){var t=e.charAt(0).toLowerCase()+e.slice(1);return g[t]||e}(a):g.idle||"Ready"),b.peer&&(b.peer.textContent=p&&S&&(S.to||S.from)||""),R(b.dial,!p),R(b.hangup,p&&A(e)),R(b.hold,p&&"Connected"===a&&A(t)),R(b.resume,p&&"OnHold"===a&&A(n));var m=S&&S.isMuted;R(b.mute,p&&!m&&A(o)),R(b.unmute,p&&m&&A(o)),R(b.transfer,p&&A(i)),R(b.merge,p&&A(r)),b.number&&(b.number.disabled=p)}function G(e,t){return w?w.invoke(e,t).then(function(e){return function(e){!!e&&(!1===e.succeeded?M(e.error||g.failed||"Call failed"):(M(null),e.call&&("Disconnected"!==u((S=e.call).state)&&"Failed"!==u(S.state)||(S=null)),Q()))}(e),e}).catch(function(e){throw M(e&&e.message?e.message:String(e)),e}):Promise.reject(new Error("Not connected."))}function W(){return S?S.callId:null}function z(){var e=b.number?b.number.value.trim():"";e?G("Dial",{to:e}):M(g.invalidNumber||"Enter a phone number to call.")}function K(e){e&&(I="keypad",ie(!0),b.number&&(b.number.value=e),G("Dial",{to:e}))}function X(){var e=W();e&&G("Hangup",{callId:e})}function Y(){var e=W();e&&G("Hold",{callId:e})}function Z(){var e=W();e&&G("Resume",{callId:e})}function $(){var e=W();e&&G("Mute",{callId:e})}function ee(){var e=W();e&&G("Unmute",{callId:e})}function te(){var e=W();if(e){var t=window.prompt(g.transferPrompt||"Transfer to number");t&&G("Transfer",{callId:e,to:t,mode:0})}}function ne(){var e=W();if(e){var t=window.prompt(g.mergePrompt||"Second call id to merge");t&&G("Merge",{primaryCallId:e,secondaryCallId:t})}}function oe(e){var t=S?u(S.state):"Idle";"Connected"===t&&A(a)?G("SendDigits",{callId:W(),digits:e}):!s(t)&&b.number&&(b.number.value+=e)}function ie(e){if(b.panel){var t="boolean"==typeof e?e:b.panel.hidden;b.panel.hidden=!t,O({open:t}),F(),Q()}}function re(e){if(C&&C.properties){var t=C.properties[e];if(t){var n={"Content-Type":"application/json"};f.antiForgeryToken&&(n.RequestVerificationToken=f.antiForgeryToken);try{fetch(t,{method:"POST",credentials:"same-origin",headers:n,body:JSON.stringify({callId:W()})}).catch(function(){})}catch(e){}}}}function ae(e){var t=W();e&&window.open(e,"_blank","noopener"),re("acceptUrl"),t&&(ie(!0),G("Answer",{callId:t}))}function le(){var e=W();re("declineUrl"),e&&G("Voicemail",{callId:e})}function ce(){var e=W();re("declineUrl"),e?G("Reject",{callId:e}):(S=null,Q())}function ue(){return w?w.invoke("GetConnectionStatus").then(function(e){e&&(q=!!e.isAvailable,k=!!e.requiresAuthentication,L=!!e.isConnected,_=e.authenticationScheme||"oauth2",Q())}).catch(function(){}):Promise.resolve()}function se(){if(f.connectUrl){var e=f.connectUrl.indexOf("?")>=0?"&":"?",t=f.connectUrl+e+"returnUrl="+encodeURIComponent(window.location.pathname);window.open(t,"telephony-oauth","width=520,height=640")||(window.location.href=t)}}function de(){var e=window.telephonySoftPhone&&window.telephonySoftPhone.authHandlers,t=e&&(e[_]||e.oauth2),n={scheme:_,connectUrl:f.connectUrl,startOAuth:se,refreshStatus:ue};"function"==typeof t?t(n):se()}function pe(e){e.origin===window.location.origin&&e.data&&"telephony-oauth"===e.data.type&&e.data.success&&ue()}function he(e){b.historyList&&(e.length?(b.historyList.innerHTML=e.map(function(e){var t=function(e){return 1===e.direction||"Inbound"===e.direction}(e),n=function(e){return 2===e.outcome||"Missed"===e.outcome||3===e.outcome||"Rejected"===e.outcome}(e),o=function(e){return 0===e.outcome||"InProgress"===e.outcome}(e),i=t?"fa-arrow-down-left":"fa-arrow-up-right",r=t?e.from||"":e.to||"",a=n?g.missed||"Missed":t?g.incoming||"Incoming":g.outgoing||"Outgoing",l=function(e){try{var t=new Date(e);return isNaN(t.getTime())?"":t.toLocaleString()}catch(e){return""}}(e.startedUtc),c="telephony-soft-phone__history-item"+(n?" telephony-soft-phone__history-item--missed":"")+(o?" telephony-soft-phone__history-item--active":""),u=d(a)+(o?" • "+d(g.inProgress||"In progress"):"")+(l?" • "+d(l):"");return'"}).join(""),Array.prototype.forEach.call(b.historyList.querySelectorAll("[data-telephony-history-number]"),function(e){e.addEventListener("click",function(){var t=e.getAttribute("data-telephony-history-number");t&&(I="keypad",K(t))})})):b.historyList.innerHTML='
    '+d(g.noInteractions||"No recent calls.")+"
    ")}b.toggle&&b.toggle.addEventListener("click",function(){E?E=!1:ie()}),b.close&&b.close.addEventListener("click",function(){ie(!1)}),b.tabs.forEach(function(e){e.addEventListener("click",function(){J(e.getAttribute("data-telephony-tab"))})}),b.dial&&b.dial.addEventListener("click",z),b.hangup&&b.hangup.addEventListener("click",X),b.hold&&b.hold.addEventListener("click",Y),b.resume&&b.resume.addEventListener("click",Z),b.mute&&b.mute.addEventListener("click",$),b.unmute&&b.unmute.addEventListener("click",ee),b.transfer&&b.transfer.addEventListener("click",te),b.merge&&b.merge.addEventListener("click",ne),b.incomingAnswer&&b.incomingAnswer.addEventListener("click",function(){ae(null)}),b.incomingVoicemail&&b.incomingVoicemail.addEventListener("click",le),b.incomingIgnore&&b.incomingIgnore.addEventListener("click",ce),b.connect&&b.connect.addEventListener("click",de),b.keys.forEach(function(e){e.addEventListener("click",function(){oe(e.getAttribute("data-telephony-key"))})}),V(b.dragHandle,{ignoreButtons:!0,suppressClick:!1}),V(b.toggle,{ignoreButtons:!1,suppressClick:!0}),window.addEventListener("message",pe),window.addEventListener("resize",F),function(){var e=U();if(e.open&&b.panel&&(b.panel.hidden=!1),e.position&&isFinite(e.position.left)){var t=e.position.left,n=e.position.top;isFinite(e.position.leftRatio)&&(t=e.position.leftRatio*window.innerWidth,n=e.position.topRatio*window.innerHeight);var o=D(t,n);B(o.left,o.top)}else!function(){var e=document.querySelector(".ai-chat-widget-toggle");if(e){var t=e.getBoundingClientRect(),n=c.getBoundingClientRect().width||56,o=t.left-n-14;o<8&&(o=t.right+14);var i=D(o,t.top);B(i.left,i.top)}}()}(),Q(),c.style.visibility="";var fe=v&&f.hubUrl?((w=(new v.HubConnectionBuilder).withUrl(f.hubUrl).withAutomaticReconnect().build())&&(w.on("CallStateChanged",function(e){S=e,"Disconnected"!==u(e.state)&&"Failed"!==u(e.state)||(S=null),Q()}),w.on("IncomingCall",function(e,t){S=e,C=t||null,I="keypad",Q()}),w.on("ReceiveError",function(e){M(e)}),w.on("CredentialsIssued",function(){}),w.onclose(function(){x(g.disconnectedHub||"Disconnected")})),w.start().then(function(){return M(null),Promise.all([w?w.invoke("GetCapabilities").then(function(e){"number"==typeof e&&(y=e,Q())}).catch(function(){}):Promise.resolve(),ue()])}).then(function(){Q()}).catch(function(e){M(e&&e.message?e.message:String(e))})):(Q(),Promise.resolve());return{element:c,config:f,dial:z,dialNumber:K,hangup:X,hold:Y,resume:Z,mute:$,unmute:ee,transfer:te,merge:ne,pressKey:oe,togglePanel:ie,open:function(){ie(!0)},getCurrentCall:function(){return S},getConnection:function(){return w},started:fe}}function f(){var e=document.querySelectorAll("#telephony-soft-phone, .telephony-soft-phone");Array.prototype.forEach.call(e,function(e){e.__telephonySoftPhone||(e.__telephonySoftPhone=h(e))})}function g(){var e=document.querySelector("#telephony-soft-phone, .telephony-soft-phone");return e?e.__telephonySoftPhone:null}window.telephonySoftPhone={create:h,initializeAll:f,getInstance:g,authHandlers:{oauth2:function(e){e.startOAuth()}},dial:function(e){var t=g();t&&t.dialNumber(e)}},"loading"===document.readyState?document.addEventListener("DOMContentLoaded",f):f()}(); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css index 29111e82e..5b27cc12f 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.css @@ -87,6 +87,11 @@ overflow-y: auto; padding: 0.35rem 0.5rem 0.65rem; } +.telephony-soft-phone__extension-view { + max-height: 18rem; + overflow-y: auto; + padding: 0.75rem 0.85rem; +} .telephony-soft-phone__history-empty { padding: 1rem; text-align: center; diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css index c97ca3253..a57a1867a 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css +++ b/src/Modules/CrestApps.OrchardCore.Telephony/wwwroot/styles/soft-phone.min.css @@ -1 +1 @@ -.telephony-soft-phone{--telephony-accent:#2f6fed;position:fixed;right:1.5rem;bottom:1.5rem;z-index:1080;font-size:.9rem}.telephony-soft-phone__toggle{width:3.25rem;height:3.25rem;border:none;border-radius:50%;color:#fff;background-color:var(--telephony-accent);box-shadow:0 .25rem .75rem rgba(0,0,0,.25);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.1rem;transition:transform .15s ease-in-out}.telephony-soft-phone__toggle:hover{transform:scale(1.05)}.telephony-soft-phone__panel{position:absolute;right:0;bottom:4rem;width:18rem;background-color:#fff;border-radius:.75rem;box-shadow:0 .5rem 1.5rem rgba(0,0,0,.25);overflow:hidden}.telephony-soft-phone__header{display:flex;align-items:center;gap:.5rem;padding:.65rem .85rem;color:#fff;background-color:var(--telephony-accent);cursor:move;touch-action:none;user-select:none}.telephony-soft-phone__title{font-weight:600}.telephony-soft-phone__status{margin-left:auto;font-size:.78rem;opacity:.9}.telephony-soft-phone__icon-btn{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1;padding:.15rem .25rem}.telephony-soft-phone__close{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1}.telephony-soft-phone__unavailable{padding:1.25rem 1rem;text-align:center;color:#6c757d}.telephony-soft-phone__unavailable i{font-size:1.5rem;margin-bottom:.5rem;color:#adb5bd}.telephony-soft-phone__unavailable p{margin:0;font-size:.85rem}.telephony-soft-phone__history{max-height:18rem;overflow-y:auto;padding:.35rem .5rem .65rem}.telephony-soft-phone__history-empty{padding:1rem;text-align:center;color:#6c757d;font-size:.85rem}.telephony-soft-phone__history-item{display:flex;align-items:center;gap:.6rem;width:100%;border:none;background:0 0;text-align:left;padding:.45rem .5rem;border-radius:.5rem;cursor:pointer}.telephony-soft-phone__history-item:hover{background-color:#f2f4f7}.telephony-soft-phone__history-item--missed{color:#d23f3f}.telephony-soft-phone__history-item--missed .telephony-soft-phone__history-dir{color:#d23f3f}.telephony-soft-phone__history-item--active .telephony-soft-phone__history-dir{color:#1f9d55}.telephony-soft-phone__history-dir{flex:0 0 1.25rem;color:#6c757d;text-align:center}.telephony-soft-phone__history-body{display:flex;flex-direction:column;min-width:0}.telephony-soft-phone__history-number{font-weight:600;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.telephony-soft-phone__history-meta{font-size:.72rem;color:#8a929b}.telephony-soft-phone__footer{display:flex;border-top:1px solid #e9ecef;background-color:#f8f9fb}.telephony-soft-phone__tab{flex:1 1 0;border:none;background:0 0;padding:.5rem .5rem .55rem;font-size:.74rem;color:#6c757d;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.2rem}.telephony-soft-phone__tab i{font-size:.95rem}.telephony-soft-phone__tab:hover{color:#343a40}.telephony-soft-phone__tab.is-active{color:var(--telephony-accent);box-shadow:inset 0 -2px 0 var(--telephony-accent);font-weight:600}.telephony-soft-phone--dragging{user-select:none}.telephony-soft-phone--in-call .telephony-soft-phone__toggle{background-color:#d23f3f;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-soft-phone__display{padding:.85rem .85rem .35rem}.telephony-soft-phone__number{width:100%;border:1px solid #d4d8dd;border-radius:.5rem;padding:.5rem .65rem;font-size:1.1rem;letter-spacing:.05em}.telephony-soft-phone__peer{min-height:1rem;margin-top:.35rem;font-size:.8rem;color:#6c757d;text-align:center}.telephony-soft-phone__connect{padding:1rem .85rem;text-align:center}.telephony-soft-phone__connect-text{margin-bottom:.75rem;font-size:.85rem;color:#495057}.telephony-soft-phone__error{margin:0 .85rem .5rem;padding:.4rem .6rem;font-size:.78rem}.telephony-soft-phone__keypad{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;padding:0 .85rem .5rem}.telephony-soft-phone__key{border:1px solid #e3e6ea;border-radius:.5rem;background-color:#f7f8fa;padding:.5rem 0;font-size:1.05rem;cursor:pointer}.telephony-soft-phone__key:hover{background-color:#eef1f5}.telephony-soft-phone__actions{display:flex;flex-wrap:wrap;gap:.4rem;justify-content:center;padding:.5rem .85rem .95rem}.telephony-soft-phone__btn{width:2.6rem;height:2.6rem;border:none;border-radius:50%;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;background-color:#6c757d}.telephony-soft-phone__btn--call{background-color:#1f9d55}.telephony-soft-phone__btn--hangup{background-color:#d23f3f}.telephony-soft-phone__btn--hold,.telephony-soft-phone__btn--merge,.telephony-soft-phone__btn--mute,.telephony-soft-phone__btn--resume,.telephony-soft-phone__btn--transfer,.telephony-soft-phone__btn--unmute{background-color:var(--telephony-accent)}.telephony-phone-dial-btn{margin-inline-start:.5rem;line-height:1}.phone-field-dial{display:inline-flex;align-items:center}@keyframes telephony-soft-phone-pulse{0%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}50%{box-shadow:0 .25rem 1.25rem rgba(210,63,63,.85)}100%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}}.telephony-incoming{position:fixed;inset:0;z-index:1090;display:flex;align-items:center;justify-content:center}.telephony-incoming__backdrop{position:absolute;inset:0;background-color:rgba(0,0,0,.45)}.telephony-incoming__dialog{position:relative;width:min(26rem,100vw - 2rem);max-height:calc(100vh - 2rem);overflow-y:auto;background-color:#fff;border-radius:.75rem;box-shadow:0 1rem 2.5rem rgba(0,0,0,.35);padding:1.25rem}.telephony-incoming__header{display:flex;align-items:center;gap:.85rem;margin-bottom:1rem}.telephony-incoming__pulse{flex:0 0 auto;width:3rem;height:3rem;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;background-color:#198754;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-incoming__heading{display:flex;flex-direction:column;min-width:0}.telephony-incoming__label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#6c757d}.telephony-incoming__caller{font-size:1.15rem;font-weight:600;word-break:break-word}.telephony-incoming__queue{font-size:.8rem;color:#6c757d}.telephony-incoming__cards{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1rem;max-height:14rem;overflow-y:auto}.telephony-incoming__cards-heading{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#6c757d}.telephony-incoming__card{display:flex;gap:.65rem;padding:.6rem .7rem;border:1px solid #e3e6ea;border-radius:.6rem;background-color:#f8f9fa}.telephony-incoming__card-icon{flex:0 0 auto;color:var(--telephony-accent,#2f6fed);font-size:1.1rem}.telephony-incoming__card-body{flex:1 1 auto;min-width:0}.telephony-incoming__card-title{font-weight:600;word-break:break-word}.telephony-incoming__card-desc,.telephony-incoming__card-subtitle{font-size:.8rem;color:#6c757d;word-break:break-word}.telephony-incoming__card-badges{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.35rem}.telephony-incoming__card-links{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.35rem;font-size:.8rem}.telephony-incoming__card-actions{display:flex;flex-direction:column;gap:.35rem;justify-content:center}.telephony-incoming__actions{display:flex;gap:.5rem}.telephony-incoming__actions .telephony-incoming__btn{flex:1 1 0} +.telephony-soft-phone{--telephony-accent:#2f6fed;position:fixed;right:1.5rem;bottom:1.5rem;z-index:1080;font-size:.9rem}.telephony-soft-phone__toggle{width:3.25rem;height:3.25rem;border:none;border-radius:50%;color:#fff;background-color:var(--telephony-accent);box-shadow:0 .25rem .75rem rgba(0,0,0,.25);cursor:pointer;display:flex;align-items:center;justify-content:center;font-size:1.1rem;transition:transform .15s ease-in-out}.telephony-soft-phone__toggle:hover{transform:scale(1.05)}.telephony-soft-phone__panel{position:absolute;right:0;bottom:4rem;width:18rem;background-color:#fff;border-radius:.75rem;box-shadow:0 .5rem 1.5rem rgba(0,0,0,.25);overflow:hidden}.telephony-soft-phone__header{display:flex;align-items:center;gap:.5rem;padding:.65rem .85rem;color:#fff;background-color:var(--telephony-accent);cursor:move;touch-action:none;user-select:none}.telephony-soft-phone__title{font-weight:600}.telephony-soft-phone__status{margin-left:auto;font-size:.78rem;opacity:.9}.telephony-soft-phone__icon-btn{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1;padding:.15rem .25rem}.telephony-soft-phone__close{border:none;background:0 0;color:#fff;cursor:pointer;line-height:1}.telephony-soft-phone__unavailable{padding:1.25rem 1rem;text-align:center;color:#6c757d}.telephony-soft-phone__unavailable i{font-size:1.5rem;margin-bottom:.5rem;color:#adb5bd}.telephony-soft-phone__unavailable p{margin:0;font-size:.85rem}.telephony-soft-phone__history{max-height:18rem;overflow-y:auto;padding:.35rem .5rem .65rem}.telephony-soft-phone__extension-view{max-height:18rem;overflow-y:auto;padding:.75rem .85rem}.telephony-soft-phone__history-empty{padding:1rem;text-align:center;color:#6c757d;font-size:.85rem}.telephony-soft-phone__history-item{display:flex;align-items:center;gap:.6rem;width:100%;border:none;background:0 0;text-align:left;padding:.45rem .5rem;border-radius:.5rem;cursor:pointer}.telephony-soft-phone__history-item:hover{background-color:#f2f4f7}.telephony-soft-phone__history-item--missed{color:#d23f3f}.telephony-soft-phone__history-item--missed .telephony-soft-phone__history-dir{color:#d23f3f}.telephony-soft-phone__history-item--active .telephony-soft-phone__history-dir{color:#1f9d55}.telephony-soft-phone__history-dir{flex:0 0 1.25rem;color:#6c757d;text-align:center}.telephony-soft-phone__history-body{display:flex;flex-direction:column;min-width:0}.telephony-soft-phone__history-number{font-weight:600;font-size:.85rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.telephony-soft-phone__history-meta{font-size:.72rem;color:#8a929b}.telephony-soft-phone__footer{display:flex;border-top:1px solid #e9ecef;background-color:#f8f9fb}.telephony-soft-phone__tab{flex:1 1 0;border:none;background:0 0;padding:.5rem .5rem .55rem;font-size:.74rem;color:#6c757d;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:.2rem}.telephony-soft-phone__tab i{font-size:.95rem}.telephony-soft-phone__tab:hover{color:#343a40}.telephony-soft-phone__tab.is-active{color:var(--telephony-accent);box-shadow:inset 0 -2px 0 var(--telephony-accent);font-weight:600}.telephony-soft-phone--dragging{user-select:none}.telephony-soft-phone--in-call .telephony-soft-phone__toggle{background-color:#d23f3f;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-soft-phone__display{padding:.85rem .85rem .35rem}.telephony-soft-phone__number{width:100%;border:1px solid #d4d8dd;border-radius:.5rem;padding:.5rem .65rem;font-size:1.1rem;letter-spacing:.05em}.telephony-soft-phone__peer{min-height:1rem;margin-top:.35rem;font-size:.8rem;color:#6c757d;text-align:center}.telephony-soft-phone__connect{padding:1rem .85rem;text-align:center}.telephony-soft-phone__connect-text{margin-bottom:.75rem;font-size:.85rem;color:#495057}.telephony-soft-phone__error{margin:0 .85rem .5rem;padding:.4rem .6rem;font-size:.78rem}.telephony-soft-phone__keypad{display:grid;grid-template-columns:repeat(3,1fr);gap:.4rem;padding:0 .85rem .5rem}.telephony-soft-phone__key{border:1px solid #e3e6ea;border-radius:.5rem;background-color:#f7f8fa;padding:.5rem 0;font-size:1.05rem;cursor:pointer}.telephony-soft-phone__key:hover{background-color:#eef1f5}.telephony-soft-phone__actions{display:flex;flex-wrap:wrap;gap:.4rem;justify-content:center;padding:.5rem .85rem .95rem}.telephony-soft-phone__btn{width:2.6rem;height:2.6rem;border:none;border-radius:50%;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center;background-color:#6c757d}.telephony-soft-phone__btn--call{background-color:#1f9d55}.telephony-soft-phone__btn--hangup{background-color:#d23f3f}.telephony-soft-phone__btn--hold,.telephony-soft-phone__btn--merge,.telephony-soft-phone__btn--mute,.telephony-soft-phone__btn--resume,.telephony-soft-phone__btn--transfer,.telephony-soft-phone__btn--unmute{background-color:var(--telephony-accent)}.telephony-phone-dial-btn{margin-inline-start:.5rem;line-height:1}.phone-field-dial{display:inline-flex;align-items:center}@keyframes telephony-soft-phone-pulse{0%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}50%{box-shadow:0 .25rem 1.25rem rgba(210,63,63,.85)}100%{box-shadow:0 .25rem .75rem rgba(210,63,63,.5)}}.telephony-incoming{position:fixed;inset:0;z-index:1090;display:flex;align-items:center;justify-content:center}.telephony-incoming__backdrop{position:absolute;inset:0;background-color:rgba(0,0,0,.45)}.telephony-incoming__dialog{position:relative;width:min(26rem,100vw - 2rem);max-height:calc(100vh - 2rem);overflow-y:auto;background-color:#fff;border-radius:.75rem;box-shadow:0 1rem 2.5rem rgba(0,0,0,.35);padding:1.25rem}.telephony-incoming__header{display:flex;align-items:center;gap:.85rem;margin-bottom:1rem}.telephony-incoming__pulse{flex:0 0 auto;width:3rem;height:3rem;border-radius:50%;display:flex;align-items:center;justify-content:center;color:#fff;background-color:#198754;animation:telephony-soft-phone-pulse 1.4s ease-in-out infinite}.telephony-incoming__heading{display:flex;flex-direction:column;min-width:0}.telephony-incoming__label{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#6c757d}.telephony-incoming__caller{font-size:1.15rem;font-weight:600;word-break:break-word}.telephony-incoming__queue{font-size:.8rem;color:#6c757d}.telephony-incoming__cards{display:flex;flex-direction:column;gap:.5rem;margin-bottom:1rem;max-height:14rem;overflow-y:auto}.telephony-incoming__cards-heading{font-size:.75rem;text-transform:uppercase;letter-spacing:.05em;color:#6c757d}.telephony-incoming__card{display:flex;gap:.65rem;padding:.6rem .7rem;border:1px solid #e3e6ea;border-radius:.6rem;background-color:#f8f9fa}.telephony-incoming__card-icon{flex:0 0 auto;color:var(--telephony-accent,#2f6fed);font-size:1.1rem}.telephony-incoming__card-body{flex:1 1 auto;min-width:0}.telephony-incoming__card-title{font-weight:600;word-break:break-word}.telephony-incoming__card-desc,.telephony-incoming__card-subtitle{font-size:.8rem;color:#6c757d;word-break:break-word}.telephony-incoming__card-badges{display:flex;flex-wrap:wrap;gap:.25rem;margin-top:.35rem}.telephony-incoming__card-links{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:.35rem;font-size:.8rem}.telephony-incoming__card-actions{display:flex;flex-direction:column;gap:.35rem;justify-content:center}.telephony-incoming__actions{display:flex;gap:.5rem}.telephony-incoming__actions .telephony-incoming__btn{flex:1 1 0} diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs index 80c6bd1b1..3c97bda0c 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/Infrastructure/SoftPhoneTestServer.cs @@ -118,10 +118,14 @@ private static string BuildHtml() +
    diff --git a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs index c65682baf..ff853ce87 100644 --- a/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs +++ b/tests/CrestApps.OrchardCore.Telephony.PlaywrightTests/SoftPhoneWidgetTests.cs @@ -123,6 +123,25 @@ public async Task RecentTab_ShowsCallHistory_AndHidesKeypad() Assert.True(await page.Locator("[data-telephony-view=\"history\"]").IsHiddenAsync()); } + [Fact] + public async Task ExtensionTab_ShowsExtensionView_AndHidesBuiltInViews() + { + // Arrange + var page = await _browser.NewPageAsync(); + await page.GotoAsync(_server.BaseUrl); + await WaitForConnectedAsync(page); + + await page.ClickAsync("[data-telephony-toggle]"); + + // Act + await page.ClickAsync("[data-telephony-tab=\"contact-center\"]"); + + // Assert + await page.Locator("[data-telephony-view=\"contact-center\"]").WaitForAsync(new LocatorWaitForOptions { State = WaitForSelectorState.Visible }); + Assert.True(await page.Locator("[data-telephony-view=\"keypad\"]").IsHiddenAsync()); + Assert.True(await page.Locator("[data-telephony-view=\"history\"]").IsHiddenAsync()); + } + private static async Task WaitForConnectedAsync(IPage page) { await page.WaitForFunctionAsync( From de7113c6f7b435410c5e4b22a0efd09b06547582 Mon Sep 17 00:00:00 2001 From: MikeAlhayek Date: Mon, 29 Jun 2026 23:53:58 -0700 Subject: [PATCH 10/56] [Fix] Render contact center soft phone tab Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> AI-Model: gpt-5.5 --- .../ContactCenterSoftPhoneWork.Tab.cshtml | 2 +- .../ContactCenterSoftPhoneWork.View.cshtml | 47 +++++++++++-------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml index cf5c20d0c..b8a5171a3 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.Tab.cshtml @@ -1,4 +1,4 @@ -@model AgentSoftPhoneViewModel +@model ShapeViewModel @@ -48,12 +49,12 @@
    - @if (Model.AvailableQueues.Count > 0) + @if (viewModel.AvailableQueues.Count > 0) { - @foreach (var queue in Model.AvailableQueues) + @foreach (var queue in viewModel.AvailableQueues) {
    - +
    } @@ -64,16 +65,24 @@ }
    -
    - - - +
    + +
    -
    - - - +
    + +
    From 2c0396d78d01a9a21fd4f84ecea47ffd9975ea43 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 09:03:32 -0700 Subject: [PATCH 11/56] Fix softphone --- .github/contact-center/PLAN.md | 5 +- .../Models/AgentPresenceStatus.cs | 25 +++++++ .../Services/AgentPresenceManagerService.cs | 6 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 2 +- .../contact-center/agents-queues-dialer.md | 42 ++++++----- .../docs/contact-center/index.md | 16 +++-- .../Controllers/AgentSoftPhoneController.cs | 10 +-- ...ntactCenterSoftPhoneWidgetDisplayDriver.cs | 3 - .../ViewModels/AgentSoftPhoneViewModel.cs | 10 --- .../ContactCenterSoftPhoneWork.View.cshtml | 69 ++++++++----------- .../AgentPresenceManagerServiceTests.cs | 22 ++++++ 11 files changed, 121 insertions(+), 89 deletions(-) diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 6de1119f9..7450a5a7e 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -344,7 +344,7 @@ Agent desktop and soft-phone controls: - Agents receive Contact Center work through CRM-integrated agent surfaces rather than a standalone sign-in navigation page. - The Telephony soft phone is the home for queue sign-in, campaign sign-in, sign-out, and presence/reason selection. Contact Center contributes these controls as soft-phone display-driver tabs when its features are enabled. - Future agent desktop surfaces show current reservation/offer, active activity, customer/contact summary, interaction history, wrap-up, required disposition, and supervisor/AI assistance. -- Agents can opt into queues and campaigns they are permitted to handle. Managers configure queue membership, campaign assignment, dialing mode, priority, and capacity rules. +- Agents can opt into queues and campaigns they are permitted to handle. Managers configure routing skills, queue membership, campaign assignment, dialing mode, priority, and capacity rules. Agents must not self-select routing skills from the soft phone because skills are supervisor/admin-owned eligibility data. - Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all deliver activity offers through the same agent-offer model. - The agent desktop and soft phone are shape-driven: Telephony owns the soft phone and call controls, Contact Center injects sign-in, reservation, wrap-up, and presence tabs or panels, providers inject provider-specific call state, and optional modules inject AI assist, compliance, or supervisor coaching. @@ -1019,7 +1019,7 @@ Goals: Deliverables: -- Dialer profiles. +- Dialer profiles as execution policies over CRM campaign/activity inventory, not as a replacement for campaigns, subjects, activity batches, or activity configuration. - Dialer run and attempt projections. - Preview dialing agent UX. - Power dialing service. @@ -1413,3 +1413,4 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). - 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent sign-in campaign and skill fields now use managed catalog data; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; agent sign-in and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. - 2026-06-29: The standalone agent sign-in admin navigation item was removed from the Contact Center menu. Telephony soft-phone extensibility now uses `DisplayDriver` zones for contributed tabs/views, and Contact Center contributes a **Work** tab for queue/campaign sign-in, sign-out, and presence so agent availability controls stay inside the soft phone instead of a separate navigation page. +- 2026-06-30: Agent routing skills were clarified as administrator-owned eligibility data, not agent-selected sign-in data. The soft-phone **Work** tab should only let agents choose queues/campaigns and set presence/reason state. Agent state/reason-code CRUD should be added as a catalog-backed admin surface with recipe and deployment steps, seeded by executing a module recipe during tenant setup. Dialer Profile remains useful as an outbound execution policy tying campaign inventory to queue, dialing mode, provider, pacing, and retry/compliance settings; it must not become the source of activity/business workflow configuration. diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs index e509d415a..7985b8901 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs @@ -34,4 +34,29 @@ public enum AgentPresenceStatus /// The agent is signed in but temporarily not ready for work. /// Break, + + /// + /// 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/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs index 836418b03..6da6751aa 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -106,14 +106,16 @@ public async Task SetPresenceAsync(string userId, AgentPresenceSta if (profile is null) { - return null; + profile = await _agentManager.NewAsync(cancellationToken: cancellationToken); + profile.UserId = userId; + profile.Name = userId; } profile.PresenceStatus = status; profile.PresenceReason = reason; profile.PresenceChangedUtc = _clock.UtcNow; - await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); + await SaveAsync(profile, cancellationToken); await PublishAsync(ContactCenterConstants.Events.AgentPresenceChanged, profile, cancellationToken); return profile; diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index ceff0b1ca..7acc3de55 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -182,7 +182,7 @@ At a high level, the platform changes are: - 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. -- Telephony soft-phone extensibility now uses `DisplayDriver` contributed tabs/views. Contact Center injects a **Work** tab for queue/campaign sign-in, sign-out, skill selection, and presence, so these agent controls stay with the soft phone instead of a separate admin navigation page. +- Telephony soft-phone extensibility now uses `DisplayDriver` contributed tabs/views. Contact Center injects a **Work** tab for queue/campaign sign-in, sign-out, and presence, so these agent controls stay with the soft phone instead of a separate admin navigation page. 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 diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 11e01d0ec..35252c83d 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -22,25 +22,27 @@ module so tenants enable only what they need. ## Agents and presence An **agent profile** links an Orchard user to Contact Center configuration: display name, capacity, -skills, queue membership, campaign membership, and live presence. Presence states are `Offline`, -`Available`, `Reserved`, `Busy`, `WrapUp`, and `Break`. +administrator-assigned skills, queue membership, campaign membership, and live presence. Presence +states include `Offline`, `Available`, `Break`, `Away`, `DoNotDisturb`, `Meeting`, `Training`, +`AfterHoursUnavailable`, and system-managed states such as `Reserved`, `Busy`, and `WrapUp`. Agents sign in from the floating Telephony soft phone. When the Contact Center queues feature is enabled, Contact Center contributes a **Work** tab where agents select the queues and campaigns they -want to receive work from, choose routing skills, sign out, and change presence/reason codes. Signing -in sets presence to `Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission -grants self-service sign-in. +want to receive work from, sign out, and change presence/reason codes. Signing in sets presence to +`Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission grants self-service +sign-in. ## Skills Administrators manage routeable capabilities from **Interaction Center → Skills**. A skill has a -unique name, description, and enabled state. Enabled skills appear in the soft-phone **Work** tab and +unique name, description, and enabled state. Enabled skills appear in admin assignment surfaces and queue editor selectors; disabled skills remain on existing agents and queues but are hidden from new -selections. +selections. Agents do not self-select skills from the soft phone because skills are routing +eligibility data owned by supervisors/administrators. -Queues can require one or more skills. Agents must select every required skill to be eligible for -that queue, and the default routing strategy filters out agents missing any required skill before -longest-idle scoring runs. +Queues can require one or more skills. Agents must have every required skill assigned on their agent +profile to be eligible for that queue, and the default routing strategy filters out agents missing any +required skill before longest-idle scoring runs. ## Queues, reservations, and assignment @@ -62,12 +64,15 @@ background task expires stale reservations and assigns waiting work every minute ## Dialer -A **dialer profile** ties an Interaction Center campaign and queue to a dialing mode (`Manual`, -`Preview`, `Power`, `Progressive`, `Predictive`), a Contact Center voice provider, calls-per-agent -pacing, and attempt limits. Power and progressive profiles run automatically each minute: the Contact -Center reserves an available agent, creates an outbound interaction, and asks the Voice Contact Center -Call Router to place the call. Manual and preview profiles wait for agent action. Dialer activity -batches load **unassigned** inventory the dialer reserves later. +A **dialer profile** is an execution policy, not the source of CRM work. Activities, campaigns, +subjects, batches, dispositions, and contact context still come from Omnichannel. The profile tells +the Contact Center how a specific outbound campaign should be dialed: which queue supplies agents, +which dialing mode (`Manual`, `Preview`, `Power`, `Progressive`, `Predictive`) is used, which Contact +Center voice provider places calls, how pacing works, and how attempts/retries are bounded. Power and +progressive profiles run automatically each minute: the Contact Center reserves an available agent, +creates an outbound interaction, and asks the Voice Contact Center Call Router to place the call. +Manual and preview profiles wait for agent action. Dialer activity batches load **unassigned** +inventory the dialer reserves later. ## Voice Contact Center Call Router @@ -91,6 +96,11 @@ templates. Agent sign-in and presence are injected into the Telephony soft phone `DisplayDriver`, so the operational controls stay with the phone while management screens remain catalog-focused. +Agent state/reason-code management is planned as a catalog-backed admin surface, not as a +provider-specific dialer setting. When added, it should include recipe and deployment steps so +presence states/reason codes can move between tenants, and its migration should seed standard values +by executing the module recipe during tenant setup. + ## Enable via recipe ```json diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 2928b780a..123dc6aef 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -99,8 +99,9 @@ projected into the interaction. 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, -sign out, and set presence/reason codes. This avoids a separate sign-in navigation page and keeps -availability changes next to call handling. +sign out, and set presence/reason codes such as available, break, away, meeting, training, do not +disturb, and offline. This avoids a separate sign-in navigation page and keeps availability changes +next to call handling. Future agent desktop surfaces handle activity offers, accept/reject actions, active CRM activity context, injected Telephony call controls, interaction history, wrap-up, and required disposition. @@ -109,11 +110,12 @@ Managers configure queue membership, campaign assignment, dialer mode, priority, 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, campaigns, and skills. Campaigns come -from the Omnichannel Management **Interaction Center** campaign catalog, and skills come from -**Interaction Center → Skills**. 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. +The current soft-phone **Work** tab lets agents choose queues and campaigns. Campaigns come from the +Omnichannel Management **Interaction Center** campaign catalog. Routing skills come from +**Interaction Center → 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 diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs index 7fa7001a8..25b624249 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentSoftPhoneController.cs @@ -18,22 +18,18 @@ namespace CrestApps.OrchardCore.ContactCenter.Controllers; public sealed class AgentSoftPhoneController : Controller { private readonly IAgentPresenceManager _presenceManager; - private readonly IAgentProfileManager _agentManager; private readonly IAuthorizationService _authorizationService; /// /// Initializes a new instance of the class. /// /// The agent presence manager. - /// The agent profile manager. /// The authorization service. public AgentSoftPhoneController( IAgentPresenceManager presenceManager, - IAgentProfileManager agentManager, IAuthorizationService authorizationService) { _presenceManager = presenceManager; - _agentManager = agentManager; _authorizationService = authorizationService; } @@ -42,7 +38,6 @@ public AgentSoftPhoneController( /// /// The queues to sign in to. /// The campaigns to sign in to. - /// The skills to keep on the agent profile. /// The local URL to return to after sign-in. /// A redirect to the current page. [HttpPost] @@ -50,7 +45,6 @@ public AgentSoftPhoneController( public async Task SignIn( IEnumerable selectedQueueIds, IEnumerable selectedCampaignIds, - IEnumerable selectedSkills, string returnUrl) { if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.SignIntoQueues)) @@ -59,9 +53,7 @@ public async Task SignIn( } var campaigns = ContactCenterFormHelpers.NormalizeList(selectedCampaignIds); - var profile = await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); - profile.Skills = ContactCenterFormHelpers.NormalizeList(selectedSkills); - await _agentManager.UpdateAsync(profile); + await _presenceManager.SignInAsync(GetUserId(), selectedQueueIds ?? [], campaigns); return RedirectToReturnLocation(returnUrl); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs index 6b2dd02d7..2481c34e6 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs @@ -61,7 +61,6 @@ public override async Task DisplayAsync(SoftPhoneWidget widget, var profile = await _agentProfileManager.FindByUserIdAsync(userId); var selectedCampaignIds = profile?.CampaignIds ?? []; - var selectedSkills = profile?.Skills ?? []; var queues = await _queueManager.ListEnabledAsync(); var viewModel = new AgentSoftPhoneViewModel { @@ -70,8 +69,6 @@ public override async Task DisplayAsync(SoftPhoneWidget widget, SelectedQueueIds = profile?.QueueIds ?? [], CampaignOptions = await _optionsProvider.GetCampaignOptionsAsync(selectedCampaignIds), SelectedCampaignIds = selectedCampaignIds, - SkillOptions = await _optionsProvider.GetSkillOptionsAsync(selectedSkills), - SelectedSkills = selectedSkills, }; return Combine( diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs index 48f87a23e..41081bc4a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs @@ -35,16 +35,6 @@ public class AgentSoftPhoneViewModel /// public IList SelectedCampaignIds { get; set; } = []; - /// - /// Gets or sets the skills the agent can select for routing. - /// - public IList SkillOptions { get; set; } = []; - - /// - /// Gets or sets the selected skill names. - /// - public IList SelectedSkills { get; set; } = []; - /// /// Gets a value indicating whether the agent is currently signed in. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml index e261990c1..edcb63cfb 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml @@ -1,7 +1,12 @@ @model ShapeViewModel + + + @{ var viewModel = Model.Value; + var presenceStatus = viewModel.Profile?.PresenceStatus ?? AgentPresenceStatus.Offline; + var presenceReason = viewModel.Profile?.PresenceReason; var returnUrl = $"{Context.Request.PathBase}{Context.Request.Path}{Context.Request.QueryString}"; } @@ -18,25 +23,29 @@ }
    +
    + +
    + + +
    +
    + +
    + +
    + @if (viewModel.IsSignedIn) { -
    - -
    - - -
    -
    - -
    - -
    -
    @@ -49,25 +58,17 @@
    - @if (viewModel.AvailableQueues.Count > 0) - { + - -
    + } - } - else - { -
    @T["No enabled queues are available."]
    - } +
    - @foreach (var option in viewModel.CampaignOptions) { @@ -75,16 +76,6 @@
    -
    - - -
    - } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index cd84d524c..5bd526ba3 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -52,6 +52,28 @@ public async Task SignOutAsync_SetsOffline() Assert.Equal(AgentPresenceStatus.Offline, profile.PresenceStatus); } + [Fact] + public async Task SetPresenceAsync_WhenProfileDoesNotExist_CreatesProfile() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync((AgentProfile)null); + agentManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new AgentProfile { ItemId = "a1" }); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync((AgentProfile)null); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService(agentManager.Object, new Mock().Object, CreateDistributedLock().Object, clock.Object); + + // Act + var profile = await service.SetPresenceAsync("u1", AgentPresenceStatus.DoNotDisturb, "Focus time", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("u1", profile.UserId); + Assert.Equal(AgentPresenceStatus.DoNotDisturb, profile.PresenceStatus); + Assert.Equal("Focus time", profile.PresenceReason); + agentManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + } + private static Mock CreateDistributedLock() { var distributedLock = new Mock(); From db2359d1b7a8daac05643349da67fd89e502b044 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 10:16:07 -0700 Subject: [PATCH 12/56] Fix softphone --- .github/contact-center/PLAN.md | 5 +- .../Models/AgentPresenceStatus.cs | 5 ++ .../Models/AgentProfile.cs | 5 ++ .../Services/ActivityReservationService.cs | 13 ++++- .../Services/AgentPresenceManagerService.cs | 29 ++++++++++- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 2 +- .../contact-center/agents-queues-dialer.md | 11 +++-- .../docs/contact-center/index.md | 15 ++++-- ...ntactCenterSoftPhoneWidgetDisplayDriver.cs | 2 + .../ViewModels/AgentSoftPhoneViewModel.cs | 4 +- ...ntactCenterSoftPhonePresence.Header.cshtml | 46 +++++++++++++++++ .../ContactCenterSoftPhoneWork.View.cshtml | 23 --------- .../Assets/scss/soft-phone.scss | 8 ++- .../Views/SoftPhoneWidget.cshtml | 4 ++ .../wwwroot/styles/soft-phone.css | 7 ++- .../wwwroot/styles/soft-phone.min.css | 2 +- .../ActivityReservationServiceTests.cs | 49 +++++++++++++++++++ .../AgentPresenceManagerServiceTests.cs | 46 +++++++++++++++++ 18 files changed, 237 insertions(+), 39 deletions(-) create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 7450a5a7e..1ef51a980 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -342,7 +342,8 @@ Capacity model: Agent desktop and soft-phone controls: - Agents receive Contact Center work through CRM-integrated agent surfaces rather than a standalone sign-in navigation page. -- The Telephony soft phone is the home for queue sign-in, campaign sign-in, sign-out, and presence/reason selection. Contact Center contributes these controls as soft-phone display-driver tabs when its features are enabled. +- The Telephony soft phone is the home for queue sign-in, campaign sign-in, sign-out, and presence/reason selection. Contact Center contributes sign-in controls as soft-phone display-driver tabs and presence as a soft-phone header dropdown when its features are enabled. +- Request break is system-approved, not manager-approved. If no route or reservation is in progress, the request is granted immediately as Break. If a routing decision or reservation is already in progress, the assignment continues and Break is granted automatically when the in-flight work releases. - Future agent desktop surfaces show current reservation/offer, active activity, customer/contact summary, interaction history, wrap-up, required disposition, and supervisor/AI assistance. - Agents can opt into queues and campaigns they are permitted to handle. Managers configure routing skills, queue membership, campaign assignment, dialing mode, priority, and capacity rules. Agents must not self-select routing skills from the soft phone because skills are supervisor/admin-owned eligibility data. - Inbound queues, callback queues, preview dial queues, power/progressive/predictive campaigns, and future channels all deliver activity offers through the same agent-offer model. @@ -1413,4 +1414,4 @@ Keep this section current. Use the checklist below to track phase-level progress - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). - 2026-06-29: Contact Center admin entries were moved under the Omnichannel Management **Interaction Center** menu, and the Contact Center base feature now depends on `CrestApps.OrchardCore.Omnichannel.Managements`. Agent sign-in campaign and skill fields now use managed catalog data; campaigns come from the Interaction Center campaign catalog. Added a managed Contact Center Skills catalog and **Interaction Center → Skills** CRUD UI; agent sign-in and queue selectors now read from that catalog. Skill, Queue, and Dialer Profile admin CRUD now follows the Omnichannel Campaigns display-driver pattern with catalog summary/editor shapes and the required root `*.Edit.cshtml` wrapper templates. The Telephony soft phone is shown on admin pages by default. The Voice feature now exposes `IVoiceContactCenterCallRouter` as the inbound and outbound voice routing boundary, the dialer routes outbound calls through that router, and DialPad registers an `IContactCenterVoiceProvider` implementation while Telephony remains the media execution layer. - 2026-06-29: The standalone agent sign-in admin navigation item was removed from the Contact Center menu. Telephony soft-phone extensibility now uses `DisplayDriver` zones for contributed tabs/views, and Contact Center contributes a **Work** tab for queue/campaign sign-in, sign-out, and presence so agent availability controls stay inside the soft phone instead of a separate navigation page. -- 2026-06-30: Agent routing skills were clarified as administrator-owned eligibility data, not agent-selected sign-in data. The soft-phone **Work** tab should only let agents choose queues/campaigns and set presence/reason state. Agent state/reason-code CRUD should be added as a catalog-backed admin surface with recipe and deployment steps, seeded by executing a module recipe during tenant setup. Dialer Profile remains useful as an outbound execution policy tying campaign inventory to queue, dialing mode, provider, pacing, and retry/compliance settings; it must not become the source of activity/business workflow configuration. +- 2026-06-30: Agent routing skills were clarified as administrator-owned eligibility data, not agent-selected sign-in data. The soft-phone **Work** tab should only let agents choose queues/campaigns, while presence belongs in a soft-phone header dropdown. Request break is system-approved: new routing decisions must not target agents in request-break/break states, but an already-made assignment continues and grants Break after release. Agent state/reason-code CRUD should be added as a catalog-backed admin surface with recipe and deployment steps, seeded by executing a module recipe during tenant setup. Dialer Profile remains useful as an outbound execution policy tying campaign inventory to queue, dialing mode, provider, pacing, and retry/compliance settings; it must not become the source of activity/business workflow configuration. diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs index 7985b8901..48363b391 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceStatus.cs @@ -35,6 +35,11 @@ public enum AgentPresenceStatus /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs index 4fdde2148..14acbf5b8 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AgentProfile.cs @@ -44,6 +44,11 @@ public sealed class AgentProfile : CatalogItem, INameAwareModel, IModifiedUtcAwa /// 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. /// diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs index a5ed606fc..31815128a 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -67,6 +67,16 @@ public async Task ReserveAsync(QueueItem queueItem, AgentPr queueItem.AgentId = agent.ItemId; await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); + agent = await _agentManager.FindByIdAsync(agent.ItemId, cancellationToken) ?? agent; + + 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; @@ -202,7 +212,8 @@ private async Task ReleaseAsync(ActivityReservation reservation, ReservationStat if (agent is not null) { - agent.PresenceStatus = AgentPresenceStatus.Available; + agent.PresenceStatus = agent.RequestedPresenceStatus ?? AgentPresenceStatus.Available; + agent.RequestedPresenceStatus = null; agent.ActiveReservationId = null; agent.PresenceChangedUtc = _clock.UtcNow; await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs index 6da6751aa..ba52fe6ab 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -67,6 +67,7 @@ public async Task SignInAsync(string userId, IEnumerable q profile.QueueIds = queueIds?.Distinct().ToList() ?? []; profile.CampaignIds = campaignIds?.Distinct().ToList() ?? []; profile.PresenceStatus = AgentPresenceStatus.Available; + profile.RequestedPresenceStatus = null; profile.PresenceChangedUtc = _clock.UtcNow; profile.ActiveReservationId = null; @@ -89,6 +90,7 @@ public async Task SignOutAsync(string userId, CancellationToken ca } profile.PresenceStatus = AgentPresenceStatus.Offline; + profile.RequestedPresenceStatus = null; profile.PresenceChangedUtc = _clock.UtcNow; await _agentManager.UpdateAsync(profile, cancellationToken: cancellationToken); @@ -111,7 +113,26 @@ public async Task SetPresenceAsync(string userId, AgentPresenceSta profile.Name = userId; } - profile.PresenceStatus = status; + 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; @@ -121,6 +142,12 @@ public async Task SetPresenceAsync(string userId, AgentPresenceSta 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); diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 7acc3de55..6ae558e04 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -182,7 +182,7 @@ At a high level, the platform changes are: - 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. -- Telephony soft-phone extensibility now uses `DisplayDriver` contributed tabs/views. Contact Center injects a **Work** tab for queue/campaign sign-in, sign-out, and presence, so these agent controls stay with the soft phone instead of a separate admin navigation page. Routing skills are administrator-owned eligibility data and are no longer self-selected by agents from the soft phone. +- 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 diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 35252c83d..cb6c05b7f 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -28,9 +28,14 @@ states include `Offline`, `Available`, `Break`, `Away`, `DoNotDisturb`, `Meeting Agents sign in from the floating Telephony soft phone. When the Contact Center queues feature is enabled, Contact Center contributes a **Work** tab where agents select the queues and campaigns they -want to receive work from, sign out, and change presence/reason codes. Signing in sets presence to -`Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission grants self-service -sign-in. +want to receive work from and sign out. Signing in sets presence to `Available`; signing out sets it +to `Offline`. The `SignIntoQueues` permission grants self-service sign-in. + +Presence is a dropdown in the soft-phone header so agents can change availability without switching +tabs. **Request break** is system-approved: if no assignment is in progress, the request is granted +immediately and the agent enters `Break`; if a route/reservation is already in progress, the request is +kept pending while the call continues, and the system grants `Break` automatically when that in-flight +work is released. Agents in `RequestBreak` or `Break` are not eligible for new routing decisions. ## Skills diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 123dc6aef..c1560f969 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -98,10 +98,17 @@ projected into the interaction. 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, -sign out, and set presence/reason codes such as available, break, away, meeting, training, do not -disturb, and offline. This avoids a separate sign-in navigation page and keeps availability changes -next to call handling. +**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. Future agent desktop surfaces handle activity offers, accept/reject actions, active CRM activity context, injected Telephony call controls, interaction history, wrap-up, and required disposition. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs index 2481c34e6..56584e58f 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs @@ -72,6 +72,8 @@ public override async Task DisplayAsync(SoftPhoneWidget widget, }; return Combine( + View("ContactCenterSoftPhonePresence_Header", viewModel) + .Location("Detail", "HeaderActions:5"), View("ContactCenterSoftPhoneWork_Tab", viewModel) .Location("Detail", "Tabs:10"), View("ContactCenterSoftPhoneWork_View", viewModel) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs index 41081bc4a..d3c02b248 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs @@ -38,5 +38,7 @@ public class AgentSoftPhoneViewModel /// /// Gets a value indicating whether the agent is currently signed in. /// - public bool IsSignedIn => Profile is not null && Profile.PresenceStatus != AgentPresenceStatus.Offline; + public bool IsSignedIn => Profile is not null && + Profile.PresenceStatus != AgentPresenceStatus.Offline && + (Profile.QueueIds.Count > 0 || Profile.CampaignIds.Count > 0); } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml new file mode 100644 index 000000000..c56f42e27 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml @@ -0,0 +1,46 @@ +@model ShapeViewModel + +@{ + var viewModel = Model.Value; + var presenceStatus = viewModel.Profile?.PresenceStatus ?? AgentPresenceStatus.Offline; + var pendingBreak = viewModel.Profile?.RequestedPresenceStatus == AgentPresenceStatus.Break; + var returnUrl = $"{Context.Request.PathBase}{Context.Request.Path}{Context.Request.QueryString}"; + + var presenceText = presenceStatus switch + { + AgentPresenceStatus.Available => T["Available"], + AgentPresenceStatus.Reserved => T["Reserved"], + AgentPresenceStatus.Busy => T["Busy"], + AgentPresenceStatus.WrapUp => T["Wrap-up"], + AgentPresenceStatus.Break => T["Break"], + AgentPresenceStatus.RequestBreak => T["Break pending"], + AgentPresenceStatus.Away => T["Away"], + AgentPresenceStatus.DoNotDisturb => T["Do not disturb"], + AgentPresenceStatus.Meeting => T["Meeting"], + AgentPresenceStatus.Training => T["Training"], + AgentPresenceStatus.AfterHoursUnavailable => T["After-hours unavailable"], + _ => T["Offline"], + }; +} + + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml index edcb63cfb..808cba701 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml @@ -5,8 +5,6 @@ @{ var viewModel = Model.Value; - var presenceStatus = viewModel.Profile?.PresenceStatus ?? AgentPresenceStatus.Offline; - var presenceReason = viewModel.Profile?.PresenceReason; var returnUrl = $"{Context.Request.PathBase}{Context.Request.Path}{Context.Request.QueryString}"; } @@ -23,27 +21,6 @@ } -
    - -
    - - -
    -
    - -
    - -
    - @if (viewModel.IsSignedIn) {
    diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss index 2cfd2ddeb..32504b5e6 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss @@ -53,8 +53,14 @@ font-weight: 600; } - &__status { + &__header-actions { margin-left: auto; + display: flex; + align-items: center; + gap: 0.35rem; + } + + &__status { font-size: 0.78rem; opacity: 0.9; } diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml index 1f7b41b76..20617bdf2 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml @@ -77,6 +77,10 @@ @@ -71,6 +71,7 @@
    + @T["Power dialing is capped at {0} calls per agent until predictive pacing is available.", Model.MaxCallsPerAgent]
    @@ -104,7 +105,44 @@ - @T["Suppression enforcement is shared with the outbound compliance phase."] + @T["When enabled, contacts that opted out of phone calls and destinations listed on a national do-not-call registry are suppressed before dialing."] + + + +
    +
    +
    + + +
    + @T["When enabled, the contact is only dialed while their local time is within the window below."] +
    +
    + +
    + +
    + + + @T["The first local hour (0-23) at which the contact may be dialed."] +
    +
    + +
    + +
    + + + @T["The local hour (1-24), exclusive, after which the contact may no longer be dialed."] +
    +
    + +
    + +
    + + + @T["The time zone used to evaluate the calling window when the contact has no time zone of its own."]
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index 4a46e5a8a..638ec4a6f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -534,7 +534,7 @@ public async Task CompleteAsync(string id) // Disposition the activity through the source-neutral path so the configured subject flow runs. activity.Subject = subject; - await _activityDispositionService.ApplyAsync(new ActivityDispositionRequest + var result = await _activityDispositionService.ApplyAsync(new ActivityDispositionRequest { Activity = activity, DispositionId = activity.DispositionId, @@ -543,9 +543,14 @@ await _activityDispositionService.ApplyAsync(new ActivityDispositionRequest ActorDisplayName = User.Identity?.Name, }); - await _notifier.SuccessAsync(H["The activity has been completed successfully."]); + if (result.Succeeded) + { + await _notifier.SuccessAsync(H["The activity has been completed successfully."]); + + return RedirectToAction(nameof(Activities)); + } - return RedirectToAction(nameof(Activities)); + await _notifier.ErrorAsync(H["A disposition is required to complete this activity."]); } return View(model); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectFlowSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectFlowSettingsDisplayDriver.cs index cc1c151a6..54aae046f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectFlowSettingsDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectFlowSettingsDisplayDriver.cs @@ -41,6 +41,7 @@ public override IDisplayResult Edit(SubjectFlowSettings flowSettings, BuildEdito model.InteractionType = flowSettings.InteractionType; model.Channel = flowSettings.Channel; model.ChannelEndpointId = flowSettings.ChannelEndpointId; + model.RequireDisposition = flowSettings.RequireDisposition; model.Campaigns = (await _campaignCatalog.GetAllAsync()) .Select(c => new SelectListItem(c.DisplayText, c.ItemId)) @@ -91,6 +92,7 @@ public override async Task UpdateAsync(SubjectFlowSettings flowS flowSettings.InteractionType = model.InteractionType; flowSettings.Channel = model.Channel; flowSettings.ChannelEndpointId = model.ChannelEndpointId; + flowSettings.RequireDisposition = model.RequireDisposition; return Edit(flowSettings, context); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs index 2d5aac789..48cad104e 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityDispositionService.cs @@ -18,6 +18,7 @@ public sealed class DefaultActivityDispositionService : IActivityDispositionServ private readonly INamedCatalog _dispositionsCatalog; private readonly IContentManager _contentManager; private readonly ISubjectActionExecutor _subjectActionExecutor; + private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; private readonly IClock _clock; /// @@ -27,18 +28,21 @@ public sealed class DefaultActivityDispositionService : IActivityDispositionServ /// The dispositions catalog used to resolve the selected disposition. /// The content manager used to load the contact for subject actions. /// The subject action executor that runs the subject flow. + /// The subject flow settings service used to resolve the required-disposition policy. /// The clock used to stamp completion times. public DefaultActivityDispositionService( IOmnichannelActivityManager activityManager, INamedCatalog dispositionsCatalog, IContentManager contentManager, ISubjectActionExecutor subjectActionExecutor, + ISubjectFlowSettingsService subjectFlowSettingsService, IClock clock) { _activityManager = activityManager; _dispositionsCatalog = dispositionsCatalog; _contentManager = contentManager; _subjectActionExecutor = subjectActionExecutor; + _subjectFlowSettingsService = subjectFlowSettingsService; _clock = clock; } @@ -54,6 +58,16 @@ public async Task ApplyAsync(ActivityDispositionReque return ActivityDispositionResult.Failure("An activity is required to apply a disposition."); } + var effectiveDispositionId = !string.IsNullOrEmpty(request.DispositionId) + ? request.DispositionId + : activity.DispositionId; + + if (string.IsNullOrEmpty(effectiveDispositionId) && + await RequiresDispositionAsync(activity, cancellationToken)) + { + return ActivityDispositionResult.Failure("A disposition is required to complete this activity."); + } + if (!string.IsNullOrEmpty(request.DispositionId)) { activity.DispositionId = request.DispositionId; @@ -77,23 +91,38 @@ public async Task ApplyAsync(ActivityDispositionReque ? null : await _dispositionsCatalog.FindByIdAsync(activity.DispositionId, cancellationToken); - ContentItem contact = null; - - if (!string.IsNullOrEmpty(activity.ContactContentItemId)) + if (disposition is not null) { - contact = await _contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); + ContentItem contact = null; + + if (!string.IsNullOrEmpty(activity.ContactContentItemId)) + { + contact = await _contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); + } + + var executionContext = new SubjectActionExecutionContext + { + Activity = activity, + Contact = contact, + Subject = activity.Subject, + Disposition = disposition, + }; + + await _subjectActionExecutor.ExecuteAsync(executionContext, cancellationToken); } - var executionContext = new SubjectActionExecutionContext + return ActivityDispositionResult.Success(activity); + } + + private async Task RequiresDispositionAsync(OmnichannelActivity activity, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(activity.SubjectContentType)) { - Activity = activity, - Contact = contact, - Subject = activity.Subject, - Disposition = disposition, - }; + return false; + } - await _subjectActionExecutor.ExecuteAsync(executionContext, cancellationToken); + var flowSettings = await _subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(activity.SubjectContentType, cancellationToken); - return ActivityDispositionResult.Success(activity); + return flowSettings?.RequireDisposition == true; } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/SubjectFlowSettingsViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/SubjectFlowSettingsViewModel.cs index 0d12d7151..647d4d8ed 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/SubjectFlowSettingsViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/SubjectFlowSettingsViewModel.cs @@ -29,6 +29,11 @@ public class SubjectFlowSettingsViewModel /// public string ChannelEndpointId { get; set; } + /// + /// Gets or sets a value indicating whether a disposition must be selected before an activity using this subject can be completed. + /// + public bool RequireDisposition { get; set; } + /// /// Gets or sets the initial outbound prompt pattern. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/SubjectFlowSettingsFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/SubjectFlowSettingsFields.Edit.cshtml index 1ea5d4ccd..5b8e82582 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/SubjectFlowSettingsFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/SubjectFlowSettingsFields.Edit.cshtml @@ -43,6 +43,16 @@ +
    +
    +
    + + +
    + @T["When enabled, an activity using this subject cannot be completed until a disposition is selected. Applies to both inbound and outbound activities."] +
    +
    + + +``` + +The agent desktop and supervisor dashboard UIs build on top of this layer and are the next deliverables +in the agent-experience phase. + +## 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, then hands it to +`IContactCenterOutbox`, which runs every handler inline for low latency. If a handler throws, the outbox +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 every handler for the event, so **handlers must be idempotent** — the +existing handlers (such as the real-time broadcaster) already are, and provider-sourced events carry an +idempotency key so duplicate or out-of-order deliveries are ignored. + +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. ## Agent soft-phone work controls @@ -284,6 +348,8 @@ controls, wrap-up, and supervisor decorations. The Contact Center is under active, phased development. The first milestone is a voice MVP that proves agents can run inbound and outbound voice work entirely inside the CRM while preserving the Telephony boundary. Inbound voice routing and the soft-phone incoming-call modal now ship in the -[Inbound voice](#inbound-voice) feature. This documentation will expand as each capability ships. See -[Agents, Queues & Dialer](agents-queues-dialer.md) for the agent, queue, reservation, and dialer -features. +[Inbound voice](#inbound-voice) feature, and the [real-time SignalR layer](#real-time-experience) +(hub, live agent sessions, heartbeat/stale cleanup, reconnect snapshots, and presence/offer/queue +broadcasts) now ships in the **Contact Center Real-Time** feature. This documentation will expand as +each capability ships. See [Agents, Queues & Dialer](agents-queues-dialer.md) for the agent, queue, +reservation, and dialer features. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/AgentSessionCleanupBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/AgentSessionCleanupBackgroundTask.cs new file mode 100644 index 000000000..85e573cd2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/AgentSessionCleanupBackgroundTask.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Signs out agents whose real-time session heartbeat has gone stale so routing stops targeting a client +/// that is no longer connected. Acts as the safety net behind the SignalR disconnect handler. +/// +[BackgroundTask( + Title = "Contact Center Agent Session Cleanup", + Schedule = "* * * * *", + Description = "Expires agent sessions whose heartbeat has gone stale and signs the agent out.", + LockTimeout = 5_000, + LockExpiration = 60_000)] +public sealed class AgentSessionCleanupBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var sessionService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + try + { + var expired = await sessionService.ExpireStaleAsync(cancellationToken); + + if (expired > 0 && logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Expired {Count} stale Contact Center agent session(s).", expired); + } + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while expiring stale Contact Center agent sessions."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/OutboxDispatchBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/OutboxDispatchBackgroundTask.cs new file mode 100644 index 000000000..169a39424 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/OutboxDispatchBackgroundTask.cs @@ -0,0 +1,41 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Redelivers Contact Center domain events whose handler dispatch previously failed. It is the durable +/// retry mechanism behind , so a transient handler failure no longer +/// silently drops an event. +/// +[BackgroundTask( + Title = "Contact Center Event Outbox Dispatch", + Schedule = "* * * * *", + Description = "Retries Contact Center domain events whose handler dispatch failed, with exponential back-off and dead-lettering.", + LockTimeout = 5_000, + LockExpiration = 120_000)] +public sealed class OutboxDispatchBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var outbox = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + try + { + var redelivered = await outbox.DispatchDueAsync(cancellationToken); + + if (redelivered > 0 && logger.IsEnabled(LogLevel.Debug)) + { + logger.LogDebug("Redelivered {Count} Contact Center event(s) from the outbox.", redelivered); + } + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while dispatching the Contact Center event outbox."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs index 2dc4b6be1..2439a0e39 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ReservationExpiryBackgroundTask.cs @@ -21,6 +21,7 @@ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToke { var reservationService = serviceProvider.GetRequiredService(); var assignmentService = serviceProvider.GetRequiredService(); + var queueService = serviceProvider.GetRequiredService(); var queueManager = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService>(); @@ -32,6 +33,7 @@ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToke { try { + await queueService.OverflowDueAsync(queue, cancellationToken); await assignmentService.AssignQueueAsync(queue.ItemId, cancellationToken); } catch (Exception ex) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentStateReasonCodesController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentStateReasonCodesController.cs new file mode 100644 index 000000000..c3ce8c774 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentStateReasonCodesController.cs @@ -0,0 +1,289 @@ +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using QueryContext = CrestApps.Core.Models.QueryContext; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides administration of Contact Center agent state reason codes. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Agents)] +public sealed class AgentStateReasonCodesController : Controller +{ + private const string _optionsSearch = "Options.Search"; + + private readonly IAgentStateReasonCodeManager _manager; + private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The reason code manager. + /// The authorization service. + /// The update model accessor. + /// The display manager. + /// The notifier. + /// The HTML localizer. + /// The string localizer. + public AgentStateReasonCodesController( + IAgentStateReasonCodeManager manager, + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _manager = manager; + _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + /// + /// Lists the reason codes. + /// + /// The catalog entry options. + /// The pager parameters. + /// The pager options. + /// The shape factory. + /// The reason codes list view. + [Admin("contact-center/agent-states", "ContactCenterAgentStatesIndex")] + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Name = options.Search, + }); + + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListCatalogEntryViewModel> + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayManager.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } + + return View(viewModel); + } + + /// + /// Applies the reason codes list filter. + /// + /// The submitted list model. + /// A redirect to the filtered list. + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("contact-center/agent-states", "ContactCenterAgentStatesIndex")] + public async Task IndexFilterPost(ListCatalogEntryViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); + } + + /// + /// Displays the reason code create form. + /// + /// The create view. + [Admin("contact-center/agent-states/create", "ContactCenterAgentStatesCreate")] + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["Agent state reason code"], + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); + } + + /// + /// Persists a new reason code. + /// + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Create))] + [Admin("contact-center/agent-states/create", "ContactCenterAgentStatesCreate")] + public async Task CreatePost() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["New agent state reason code"], + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + if (ModelState.IsValid) + { + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new agent state reason code has been created successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Displays the reason code edit form. + /// + /// The reason code identifier. + /// The edit view. + [Admin("contact-center/agent-states/edit/{id}", "ContactCenterAgentStatesEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); + } + + /// + /// Persists changes to a reason code. + /// + /// The reason code identifier. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Edit))] + [Admin("contact-center/agent-states/edit/{id}", "ContactCenterAgentStatesEdit")] + public async Task EditPost(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _manager.UpdateAsync(model); + await _notifier.SuccessAsync(H["The agent state reason code has been updated successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Deletes a reason code. + /// + /// The reason code identifier. + /// A redirect to the list. + [HttpPost] + [Admin("contact-center/agent-states/delete/{id}", "ContactCenterAgentStatesDelete")] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageAgents)) + { + return Forbid(); + } + + var reasonCode = await _manager.FindByIdAsync(id); + + if (reasonCode is not null) + { + await _manager.DeleteAsync(reasonCode); + await _notifier.SuccessAsync(H["The agent state reason code has been deleted successfully."]); + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/BusinessHoursCalendarsController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/BusinessHoursCalendarsController.cs new file mode 100644 index 000000000..83bbb9556 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/BusinessHoursCalendarsController.cs @@ -0,0 +1,289 @@ +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using QueryContext = CrestApps.Core.Models.QueryContext; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides administration of Contact Center business-hours calendars. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Queues)] +public sealed class BusinessHoursCalendarsController : Controller +{ + private const string _optionsSearch = "Options.Search"; + + private readonly IBusinessHoursCalendarManager _manager; + private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The calendar manager. + /// The authorization service. + /// The update model accessor. + /// The display manager. + /// The notifier. + /// The HTML localizer. + /// The string localizer. + public BusinessHoursCalendarsController( + IBusinessHoursCalendarManager manager, + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _manager = manager; + _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + /// + /// Lists the calendars. + /// + /// The catalog entry options. + /// The pager parameters. + /// The pager options. + /// The shape factory. + /// The calendars list view. + [Admin("contact-center/business-hours", "ContactCenterBusinessHoursIndex")] + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Name = options.Search, + }); + + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListCatalogEntryViewModel> + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayManager.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } + + return View(viewModel); + } + + /// + /// Applies the calendars list filter. + /// + /// The submitted list model. + /// A redirect to the filtered list. + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("contact-center/business-hours", "ContactCenterBusinessHoursIndex")] + public async Task IndexFilterPost(ListCatalogEntryViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); + } + + /// + /// Displays the calendar create form. + /// + /// The create view. + [Admin("contact-center/business-hours/create", "ContactCenterBusinessHoursCreate")] + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["Business hours calendar"], + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); + } + + /// + /// Persists a new calendar. + /// + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Create))] + [Admin("contact-center/business-hours/create", "ContactCenterBusinessHoursCreate")] + public async Task CreatePost() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["New business hours calendar"], + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + if (ModelState.IsValid) + { + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new business hours calendar has been created successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Displays the calendar edit form. + /// + /// The calendar identifier. + /// The edit view. + [Admin("contact-center/business-hours/edit/{id}", "ContactCenterBusinessHoursEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); + } + + /// + /// Persists changes to a calendar. + /// + /// The calendar identifier. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Edit))] + [Admin("contact-center/business-hours/edit/{id}", "ContactCenterBusinessHoursEdit")] + public async Task EditPost(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _manager.UpdateAsync(model); + await _notifier.SuccessAsync(H["The business hours calendar has been updated successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Deletes a calendar. + /// + /// The calendar identifier. + /// A redirect to the list. + [HttpPost] + [Admin("contact-center/business-hours/delete/{id}", "ContactCenterBusinessHoursDelete")] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var calendar = await _manager.FindByIdAsync(id); + + if (calendar is not null) + { + await _manager.DeleteAsync(calendar); + await _notifier.SuccessAsync(H["The business hours calendar has been deleted successfully."]); + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index abc058b3c..7774ff730 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -24,6 +24,8 @@ + + @@ -35,4 +37,12 @@ + + + + + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs index d9da50dce..f4e274e69 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ActivityQueueDisplayDriver.cs @@ -50,10 +50,17 @@ public override async Task EditAsync(ActivityQueue queue, BuildE Name = queue.Name, Description = queue.Description, DefaultPriority = queue.DefaultPriority, + RoutingStrategy = queue.RoutingStrategy, + PreferStickyAgent = queue.PreferStickyAgent, + EnableSlaAging = queue.EnableSlaAging, SlaThresholdSeconds = queue.SlaThresholdSeconds, ReservationTimeoutSeconds = queue.ReservationTimeoutSeconds, RequiredSkills = queue.RequiredSkills, InboundChannelEndpointId = queue.InboundChannelEndpointId, + BusinessHoursCalendarId = queue.BusinessHoursCalendarId, + AfterHoursAction = queue.AfterHoursAction, + OverflowQueueId = queue.OverflowQueueId, + OverflowAfterSeconds = queue.OverflowAfterSeconds, Enabled = queue.Enabled, }; @@ -65,12 +72,21 @@ public override async Task EditAsync(ActivityQueue queue, BuildE model.Name = viewModel.Name; model.Description = viewModel.Description; model.DefaultPriority = viewModel.DefaultPriority; + model.RoutingStrategy = viewModel.RoutingStrategy; + model.PreferStickyAgent = viewModel.PreferStickyAgent; + model.EnableSlaAging = viewModel.EnableSlaAging; model.SlaThresholdSeconds = viewModel.SlaThresholdSeconds; model.ReservationTimeoutSeconds = viewModel.ReservationTimeoutSeconds; model.RequiredSkills = viewModel.RequiredSkills; model.SkillOptions = viewModel.SkillOptions; model.InboundChannelEndpointId = viewModel.InboundChannelEndpointId; model.InboundChannelEndpointOptions = viewModel.InboundChannelEndpointOptions; + model.BusinessHoursCalendarId = viewModel.BusinessHoursCalendarId; + model.BusinessHoursCalendarOptions = viewModel.BusinessHoursCalendarOptions; + model.AfterHoursAction = viewModel.AfterHoursAction; + model.OverflowQueueId = viewModel.OverflowQueueId; + model.OverflowQueueOptions = viewModel.OverflowQueueOptions; + model.OverflowAfterSeconds = viewModel.OverflowAfterSeconds; model.Enabled = viewModel.Enabled; }).Location("Content:1"); } @@ -90,12 +106,23 @@ public override async Task UpdateAsync(ActivityQueue queue, Upda queue.Name = model.Name?.Trim(); queue.Description = model.Description?.Trim(); queue.DefaultPriority = model.DefaultPriority; + queue.RoutingStrategy = model.RoutingStrategy; + queue.PreferStickyAgent = model.PreferStickyAgent; + queue.EnableSlaAging = model.EnableSlaAging; queue.SlaThresholdSeconds = model.SlaThresholdSeconds; queue.ReservationTimeoutSeconds = model.ReservationTimeoutSeconds; queue.RequiredSkills = ContactCenterFormHelpers.NormalizeList(model.RequiredSkills); queue.InboundChannelEndpointId = string.IsNullOrWhiteSpace(model.InboundChannelEndpointId) ? null : model.InboundChannelEndpointId.Trim(); + queue.BusinessHoursCalendarId = string.IsNullOrWhiteSpace(model.BusinessHoursCalendarId) + ? null + : model.BusinessHoursCalendarId.Trim(); + queue.AfterHoursAction = model.AfterHoursAction; + queue.OverflowQueueId = string.IsNullOrWhiteSpace(model.OverflowQueueId) || string.Equals(model.OverflowQueueId, queue.ItemId, StringComparison.Ordinal) + ? null + : model.OverflowQueueId.Trim(); + queue.OverflowAfterSeconds = Math.Max(0, model.OverflowAfterSeconds); queue.Enabled = model.Enabled; return await EditAsync(queue, context); diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/AgentStateReasonCodeDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/AgentStateReasonCodeDisplayDriver.cs new file mode 100644 index 000000000..c9079d0ba --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/AgentStateReasonCodeDisplayDriver.cs @@ -0,0 +1,92 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.Localization; +using OrchardCore; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class AgentStateReasonCodeDisplayDriver : DisplayDriver +{ + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public AgentStateReasonCodeDisplayDriver(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + public override Task DisplayAsync(AgentStateReasonCode reasonCode, BuildDisplayContext context) + { + return CombineAsync( + View("AgentStateReasonCode_Fields_SummaryAdmin", reasonCode) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Content:1"), + View("AgentStateReasonCode_Buttons_SummaryAdmin", reasonCode) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Actions:5"), + View("AgentStateReasonCode_DefaultMeta_SummaryAdmin", reasonCode) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Meta:5") + ); + } + + /// + public override IDisplayResult Edit(AgentStateReasonCode reasonCode, BuildEditorContext context) + { + return Initialize("AgentStateReasonCodeFields_Edit", model => + { + model.Id = reasonCode.ItemId; + model.Name = reasonCode.Name; + model.Description = reasonCode.Description; + model.AppliesTo = reasonCode.AppliesTo; + model.SortOrder = reasonCode.SortOrder; + model.Enabled = reasonCode.Enabled; + model.AppliesToOptions = GetAppliesToOptions(reasonCode.AppliesTo); + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(AgentStateReasonCode reasonCode, UpdateEditorContext context) + { + var model = new AgentStateReasonCodeViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is a required field."]); + } + + reasonCode.Name = model.Name?.Trim(); + reasonCode.Description = model.Description?.Trim(); + reasonCode.AppliesTo = model.AppliesTo; + reasonCode.SortOrder = model.SortOrder; + reasonCode.Enabled = model.Enabled; + + return Edit(reasonCode, context); + } + + private List GetAppliesToOptions(AgentPresenceStatus selected) + { + return + [ + CreateOption(S["Break"], AgentPresenceStatus.Break, selected), + CreateOption(S["Away"], AgentPresenceStatus.Away, selected), + CreateOption(S["Do not disturb"], AgentPresenceStatus.DoNotDisturb, selected), + CreateOption(S["Meeting"], AgentPresenceStatus.Meeting, selected), + CreateOption(S["Training"], AgentPresenceStatus.Training, selected), + CreateOption(S["After-hours unavailable"], AgentPresenceStatus.AfterHoursUnavailable, selected), + ]; + } + + private static SelectListItem CreateOption(LocalizedString text, AgentPresenceStatus status, AgentPresenceStatus selected) + { + return new SelectListItem(text.Value, status.ToString(), status == selected); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/BusinessHoursCalendarDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/BusinessHoursCalendarDisplayDriver.cs new file mode 100644 index 000000000..acbd66b2b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/BusinessHoursCalendarDisplayDriver.cs @@ -0,0 +1,191 @@ +using System.Globalization; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.Extensions.Localization; +using OrchardCore; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class BusinessHoursCalendarDisplayDriver : DisplayDriver +{ + private const int _defaultOpenMinute = 540; + private const int _defaultCloseMinute = 1020; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public BusinessHoursCalendarDisplayDriver(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + public override Task DisplayAsync(BusinessHoursCalendar calendar, BuildDisplayContext context) + { + return CombineAsync( + View("BusinessHoursCalendar_Fields_SummaryAdmin", calendar) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Content:1"), + View("BusinessHoursCalendar_Buttons_SummaryAdmin", calendar) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Actions:5"), + View("BusinessHoursCalendar_DefaultMeta_SummaryAdmin", calendar) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Meta:5") + ); + } + + /// + public override IDisplayResult Edit(BusinessHoursCalendar calendar, BuildEditorContext context) + { + return Initialize("BusinessHoursCalendarFields_Edit", model => + { + model.Id = calendar.ItemId; + model.Name = calendar.Name; + model.Description = calendar.Description; + model.TimeZoneId = calendar.TimeZoneId; + model.Enabled = calendar.Enabled; + model.Days = BuildDays(calendar.WeeklySchedule); + model.HolidaysText = calendar.Holidays is { Count: > 0 } + ? string.Join(Environment.NewLine, calendar.Holidays.OrderBy(date => date).Select(date => date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))) + : null; + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(BusinessHoursCalendar calendar, UpdateEditorContext context) + { + var model = new BusinessHoursCalendarViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is a required field."]); + } + + calendar.Name = model.Name?.Trim(); + calendar.Description = model.Description?.Trim(); + calendar.TimeZoneId = string.IsNullOrWhiteSpace(model.TimeZoneId) ? null : model.TimeZoneId.Trim(); + calendar.Enabled = model.Enabled; + calendar.WeeklySchedule = BuildSchedule(model.Days); + calendar.Holidays = ParseHolidays(model.HolidaysText); + + return Edit(calendar, context); + } + + private static List BuildDays(IList schedule) + { + var hasSchedule = schedule is { Count: > 0 }; + var days = new List(); + + foreach (var day in Enum.GetValues()) + { + var existing = schedule?.FirstOrDefault(entry => entry.Day == day); + + bool isOpen; + int openMinute; + int closeMinute; + + if (existing is not null) + { + isOpen = existing.IsOpen; + openMinute = existing.OpenMinute; + closeMinute = existing.CloseMinute; + } + else if (!hasSchedule) + { + isOpen = day is not DayOfWeek.Saturday and not DayOfWeek.Sunday; + openMinute = _defaultOpenMinute; + closeMinute = _defaultCloseMinute; + } + else + { + isOpen = false; + openMinute = _defaultOpenMinute; + closeMinute = _defaultCloseMinute; + } + + days.Add(new BusinessHoursDayViewModel + { + Day = day, + DayName = CultureInfo.CurrentCulture.DateTimeFormat.GetDayName(day), + IsOpen = isOpen, + OpenTime = FormatMinutes(openMinute), + CloseTime = FormatMinutes(closeMinute), + }); + } + + return days; + } + + private static List BuildSchedule(IList days) + { + var schedule = new List(); + + if (days is null) + { + return schedule; + } + + foreach (var day in days) + { + schedule.Add(new BusinessHoursDay + { + Day = day.Day, + IsOpen = day.IsOpen, + OpenMinute = ParseMinutes(day.OpenTime, _defaultOpenMinute), + CloseMinute = ParseMinutes(day.CloseTime, _defaultCloseMinute), + }); + } + + return schedule; + } + + private static List ParseHolidays(string text) + { + var holidays = new List(); + + if (string.IsNullOrWhiteSpace(text)) + { + return holidays; + } + + foreach (var line in text.Split('\n')) + { + var trimmed = line.Trim(); + + if (trimmed.Length == 0) + { + continue; + } + + if (DateOnly.TryParse(trimmed, CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) && !holidays.Contains(date)) + { + holidays.Add(date); + } + } + + return holidays; + } + + private static int ParseMinutes(string value, int fallback) + { + if (TimeOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var time)) + { + return (time.Hour * 60) + time.Minute; + } + + return fallback; + } + + private static string FormatMinutes(int minutes) + { + var clamped = Math.Clamp(minutes, 0, 1439); + + return $"{clamped / 60:D2}:{clamped % 60:D2}"; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs index 56584e58f..7532b2c1d 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterSoftPhoneWidgetDisplayDriver.cs @@ -18,6 +18,7 @@ internal sealed class ContactCenterSoftPhoneWidgetDisplayDriver : DisplayDriver< private readonly IAgentProfileManager _agentProfileManager; private readonly IActivityQueueManager _queueManager; private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; + private readonly IAgentStateReasonCodeManager _reasonCodeManager; /// /// Initializes a new instance of the class. @@ -27,18 +28,21 @@ internal sealed class ContactCenterSoftPhoneWidgetDisplayDriver : DisplayDriver< /// The agent profile manager. /// The queue manager. /// The admin form options provider. + /// The optional agent state reason code managers, available when the Agents feature is enabled. public ContactCenterSoftPhoneWidgetDisplayDriver( IHttpContextAccessor httpContextAccessor, IAuthorizationService authorizationService, IAgentProfileManager agentProfileManager, IActivityQueueManager queueManager, - ContactCenterAdminFormOptionsProvider optionsProvider) + ContactCenterAdminFormOptionsProvider optionsProvider, + IEnumerable reasonCodeManagers) { _httpContextAccessor = httpContextAccessor; _authorizationService = authorizationService; _agentProfileManager = agentProfileManager; _queueManager = queueManager; _optionsProvider = optionsProvider; + _reasonCodeManager = reasonCodeManagers.FirstOrDefault(); } /// @@ -62,6 +66,10 @@ public override async Task DisplayAsync(SoftPhoneWidget widget, var profile = await _agentProfileManager.FindByUserIdAsync(userId); var selectedCampaignIds = profile?.CampaignIds ?? []; var queues = await _queueManager.ListEnabledAsync(); + var reasonCodes = _reasonCodeManager is null + ? [] + : await _reasonCodeManager.ListEnabledAsync(); + var viewModel = new AgentSoftPhoneViewModel { Profile = profile, @@ -69,6 +77,7 @@ public override async Task DisplayAsync(SoftPhoneWidget widget, SelectedQueueIds = profile?.QueueIds ?? [], CampaignOptions = await _optionsProvider.GetCampaignOptionsAsync(selectedCampaignIds), SelectedCampaignIds = selectedCampaignIds, + ReasonCodes = [.. reasonCodes], }; return Combine( diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/AgentStateReasonCodeHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/AgentStateReasonCodeHandler.cs new file mode 100644 index 000000000..53743689d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/AgentStateReasonCodeHandler.cs @@ -0,0 +1,97 @@ +using System.ComponentModel.DataAnnotations; +using System.Text.Json.Nodes; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +internal sealed class AgentStateReasonCodeHandler : CatalogEntryHandlerBase +{ + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The clock used to stamp audit times. + /// The string localizer. + public AgentStateReasonCodeHandler( + IClock clock, + IStringLocalizer stringLocalizer) + { + _clock = clock; + S = stringLocalizer; + } + + /// + public override Task InitializingAsync(InitializingContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + return PopulateAsync(context.Model, context.Data); + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return PopulateAsync(context.Model, context.Data); + } + + /// + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(AgentStateReasonCode.Name)])); + } + + return Task.CompletedTask; + } + + private static Task PopulateAsync(AgentStateReasonCode model, JsonNode data) + { + var name = data[nameof(AgentStateReasonCode.Name)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(name)) + { + model.Name = name; + } + + var description = data[nameof(AgentStateReasonCode.Description)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(description)) + { + model.Description = description; + } + + var appliesTo = data[nameof(AgentStateReasonCode.AppliesTo)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(appliesTo) && Enum.TryParse(appliesTo, ignoreCase: true, out var status)) + { + model.AppliesTo = status; + } + + var sortOrder = data[nameof(AgentStateReasonCode.SortOrder)]?.GetValue(); + + if (sortOrder.HasValue) + { + model.SortOrder = sortOrder.Value; + } + + var enabled = data[nameof(AgentStateReasonCode.Enabled)]?.GetValue(); + + if (enabled.HasValue) + { + model.Enabled = enabled.Value; + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/BusinessHoursCalendarHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/BusinessHoursCalendarHandler.cs new file mode 100644 index 000000000..fae724d74 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/BusinessHoursCalendarHandler.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +internal sealed class BusinessHoursCalendarHandler : CatalogEntryHandlerBase +{ + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The clock used to stamp audit times. + /// The string localizer. + public BusinessHoursCalendarHandler( + IClock clock, + IStringLocalizer stringLocalizer) + { + _clock = clock; + S = stringLocalizer; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(BusinessHoursCalendar.Name)])); + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs new file mode 100644 index 000000000..91b5dad6f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs @@ -0,0 +1,199 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Hubs; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +/// +/// Projects the durable Contact Center domain events onto the real-time SignalR layer so the agent +/// desktop and supervisor dashboards stay live. The handler is read-only with respect to domain state; it +/// only enriches events and forwards them to . +/// +public sealed class ContactCenterRealTimeEventHandler : IContactCenterEventHandler +{ + private readonly IContactCenterRealTimeNotifier _notifier; + private readonly IAgentProfileManager _agentManager; + private readonly IActivityReservationManager _reservationManager; + private readonly IQueueItemStore _queueItemStore; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The real-time notifier used to broadcast updates. + /// The agent profile manager used to resolve agents. + /// The reservation manager used to resolve offers. + /// The queue item store used to compute queue depth. + /// The clock used to stamp notifications. + public ContactCenterRealTimeEventHandler( + IContactCenterRealTimeNotifier notifier, + IAgentProfileManager agentManager, + IActivityReservationManager reservationManager, + IQueueItemStore queueItemStore, + IClock clock) + { + _notifier = notifier; + _agentManager = agentManager; + _reservationManager = reservationManager; + _queueItemStore = queueItemStore; + _clock = clock; + } + + /// + public async Task HandleAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + switch (interactionEvent.EventType) + { + case ContactCenterConstants.Events.AgentSignedIn: + case ContactCenterConstants.Events.AgentSignedOut: + case ContactCenterConstants.Events.AgentPresenceChanged: + await BroadcastPresenceAsync(interactionEvent, cancellationToken); + break; + + case ContactCenterConstants.Events.AgentReserved: + await BroadcastOfferReceivedAsync(interactionEvent, cancellationToken); + break; + + case ContactCenterConstants.Events.AgentReleased: + await BroadcastOfferRevokedAsync(interactionEvent, AgentOfferRevokedReason.Released, cancellationToken); + break; + + case ContactCenterConstants.Events.QueueItemAssigned: + await BroadcastOfferRevokedAsync(interactionEvent, AgentOfferRevokedReason.Accepted, cancellationToken); + break; + + case ContactCenterConstants.Events.QueueItemAdded: + case ContactCenterConstants.Events.QueueItemDequeued: + await BroadcastQueueStatsForItemAsync(interactionEvent.AggregateId, cancellationToken); + break; + } + } + + private async Task BroadcastPresenceAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(interactionEvent.AggregateId)) + { + return; + } + + var profile = await _agentManager.FindByIdAsync(interactionEvent.AggregateId, cancellationToken); + + if (profile is null) + { + return; + } + + await _notifier.NotifyPresenceChangedAsync(new AgentPresenceNotification + { + UserId = profile.UserId, + AgentId = profile.ItemId, + DisplayName = profile.DisplayName ?? profile.UserName, + Status = profile.PresenceStatus.ToString(), + Reason = profile.PresenceReason, + QueueIds = [.. profile.QueueIds], + ChangedUtc = profile.PresenceChangedUtc ?? interactionEvent.OccurredUtc, + }, cancellationToken); + } + + private async Task BroadcastOfferReceivedAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken) + { + var reservation = await ResolveReservationAsync(interactionEvent.AggregateId, cancellationToken); + + if (reservation is null) + { + return; + } + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + await _notifier.NotifyOfferReceivedAsync(new AgentOfferNotification + { + UserId = agent?.UserId, + AgentId = reservation.AgentId, + ReservationId = reservation.ItemId, + ActivityItemId = reservation.ActivityItemId, + QueueItemId = reservation.QueueItemId, + QueueId = reservation.QueueId, + ExpiresUtc = reservation.ExpiresUtc, + ServerTimeUtc = _clock.UtcNow, + }, cancellationToken); + + await BroadcastQueueStatsAsync(reservation.QueueId, cancellationToken); + } + + private async Task BroadcastOfferRevokedAsync(InteractionEvent interactionEvent, AgentOfferRevokedReason reason, CancellationToken cancellationToken) + { + var reservation = await ResolveReservationAsync(interactionEvent.AggregateId, cancellationToken); + + if (reservation is null) + { + return; + } + + var resolvedReason = reservation.Status == ReservationStatus.Expired + ? AgentOfferRevokedReason.Expired + : reason; + + var agent = await _agentManager.FindByIdAsync(reservation.AgentId, cancellationToken); + + await _notifier.NotifyOfferRevokedAsync(new AgentOfferRevokedNotification + { + UserId = agent?.UserId, + AgentId = reservation.AgentId, + ReservationId = reservation.ItemId, + QueueId = reservation.QueueId, + Reason = resolvedReason, + }, cancellationToken); + + await BroadcastQueueStatsAsync(reservation.QueueId, cancellationToken); + } + + private async Task ResolveReservationAsync(string reservationId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(reservationId)) + { + return null; + } + + return await _reservationManager.FindByIdAsync(reservationId, cancellationToken); + } + + private async Task BroadcastQueueStatsForItemAsync(string queueItemId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(queueItemId)) + { + return; + } + + var item = await _queueItemStore.FindByIdAsync(queueItemId, cancellationToken); + + if (item is null) + { + return; + } + + await BroadcastQueueStatsAsync(item.QueueId, cancellationToken); + } + + private async Task BroadcastQueueStatsAsync(string queueId, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(queueId)) + { + return; + } + + var waiting = await _queueItemStore.ListWaitingAsync(queueId, cancellationToken); + + await _notifier.NotifyQueueStatsChangedAsync(new QueueStatsNotification + { + QueueId = queueId, + WaitingCount = waiting.Count, + ChangedUtc = _clock.UtcNow, + }, cancellationToken); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferNotification.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferNotification.cs new file mode 100644 index 000000000..14532f14e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferNotification.cs @@ -0,0 +1,48 @@ +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// Describes a work item being offered to a specific agent in real time so the agent desktop can render +/// the offer (with its accept/decline countdown) 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 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/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedNotification.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedNotification.cs new file mode 100644 index 000000000..18ffbb1c8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedNotification.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// 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/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedReason.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedReason.cs new file mode 100644 index 000000000..36d629189 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedReason.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// 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/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentPresenceNotification.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentPresenceNotification.cs new file mode 100644 index 000000000..ee9bdd924 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentPresenceNotification.cs @@ -0,0 +1,43 @@ +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// 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/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs new file mode 100644 index 000000000..d981db03e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs @@ -0,0 +1,220 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.Environment.Shell.Scope; +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// SignalR hub that powers the real-time Contact Center experience. Agents connect to receive presence, +/// offer, and queue updates, while supervisors connect to monitor queues and agents. Each invocation runs +/// in its own OrchardCore shell scope and is authorized against Contact Center permissions. +/// +[Authorize] +public sealed class ContactCenterHub : Hub +{ + /// + /// The name of the SignalR group that receives supervisor-wide updates. + /// + public const string SupervisorsGroup = "cc:supervisors"; + + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger. + public ContactCenterHub(ILogger logger) + { + _logger = logger; + } + + /// + /// Builds the SignalR group name that receives updates for a single queue. + /// + /// The queue identifier. + /// The queue group name. + public static string QueueGroup(string queueId) + { + return $"cc:queue:{queueId}"; + } + + /// + public override async Task OnConnectedAsync() + { + var userId = Context.UserIdentifier; + + if (string.IsNullOrEmpty(userId)) + { + Context.Abort(); + + return; + } + + var authorized = false; + + await ShellScope.UsingChildScopeAsync(async scope => + { + var services = scope.ServiceProvider; + + if (await AuthorizeAsync(services, ContactCenterPermissions.SignIntoQueues)) + { + authorized = true; + + try + { + var sessionService = services.GetRequiredService(); + var userName = Context.User?.Identity?.Name; + + var session = await sessionService.ConnectAsync(userId, Context.ConnectionId, userName, userName, Context.ConnectionAborted); + + foreach (var queueId in session.QueueIds) + { + await Groups.AddToGroupAsync(Context.ConnectionId, QueueGroup(queueId), Context.ConnectionAborted); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while registering the Contact Center connection for user '{UserId}'.", userId); + } + } + + if (await AuthorizeAsync(services, ContactCenterPermissions.MonitorContactCenter)) + { + authorized = true; + + await Groups.AddToGroupAsync(Context.ConnectionId, SupervisorsGroup, Context.ConnectionAborted); + } + }); + + if (!authorized) + { + Context.Abort(); + + return; + } + + await base.OnConnectedAsync(); + } + + /// + public override async Task OnDisconnectedAsync(Exception exception) + { + var userId = Context.UserIdentifier; + + if (!string.IsNullOrEmpty(userId)) + { + await ShellScope.UsingChildScopeAsync(async scope => + { + var sessionService = scope.ServiceProvider.GetRequiredService(); + + try + { + await sessionService.DisconnectAsync(userId, Context.ConnectionId); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while removing the Contact Center connection for user '{UserId}'.", userId); + } + }); + } + + await base.OnDisconnectedAsync(exception); + } + + /// + /// Records a heartbeat so the cleanup pass does not consider the agent session stale. + /// + public Task Heartbeat() + { + var userId = Context.UserIdentifier; + + if (string.IsNullOrEmpty(userId)) + { + return Task.CompletedTask; + } + + return ShellScope.UsingChildScopeAsync(async scope => + { + var sessionService = scope.ServiceProvider.GetRequiredService(); + await sessionService.HeartbeatAsync(userId, Context.ConnectionAborted); + }); + } + + /// + /// Gets the reconnect snapshot the agent desktop needs to restore its state. + /// + /// The agent desktop snapshot. + public async Task GetSnapshot() + { + var userId = Context.UserIdentifier; + + if (string.IsNullOrEmpty(userId)) + { + return null; + } + + AgentDesktopSnapshot snapshot = null; + + await ShellScope.UsingChildScopeAsync(async scope => + { + var sessionService = scope.ServiceProvider.GetRequiredService(); + snapshot = await sessionService.BuildSnapshotAsync(userId, Context.ConnectionAborted); + }); + + return snapshot; + } + + /// + /// Subscribes a supervisor connection to live updates for a single queue. + /// + /// The queue identifier to watch. + public async Task WatchQueue(string queueId) + { + if (string.IsNullOrEmpty(queueId)) + { + return; + } + + await ShellScope.UsingChildScopeAsync(async scope => + { + if (await AuthorizeAsync(scope.ServiceProvider, ContactCenterPermissions.MonitorContactCenter)) + { + await Groups.AddToGroupAsync(Context.ConnectionId, QueueGroup(queueId), Context.ConnectionAborted); + } + }); + } + + /// + /// Unsubscribes a supervisor connection from live updates for a single queue. + /// + /// The queue identifier to stop watching. + public async Task UnwatchQueue(string queueId) + { + if (string.IsNullOrEmpty(queueId)) + { + return; + } + + await Groups.RemoveFromGroupAsync(Context.ConnectionId, QueueGroup(queueId), Context.ConnectionAborted); + } + + private async Task AuthorizeAsync(IServiceProvider services, Permission permission) + { + var httpContext = Context.GetHttpContext(); + + if (httpContext?.User is null) + { + return false; + } + + var authorizationService = services.GetRequiredService(); + + return await authorizationService.AuthorizeAsync(httpContext.User, permission); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs new file mode 100644 index 000000000..81051abde --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// Defines the strongly-typed methods the Contact Center hub invokes on connected clients. The agent +/// desktop and supervisor dashboards implement these callbacks to react to live contact center activity. +/// +public interface IContactCenterHubClient +{ + /// + /// Notifies the client that an agent's presence changed. + /// + /// The presence change. + Task PresenceChanged(AgentPresenceNotification notification); + + /// + /// Notifies the client that a work item is being offered to the agent. + /// + /// The offer. + Task OfferReceived(AgentOfferNotification notification); + + /// + /// Notifies the client that an offer is no longer presented to the agent. + /// + /// The revoked offer. + Task OfferRevoked(AgentOfferRevokedNotification notification); + + /// + /// Notifies the client that a queue's waiting depth changed. + /// + /// The updated queue statistics. + Task QueueStatsChanged(QueueStatsNotification notification); +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/QueueStatsNotification.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/QueueStatsNotification.cs new file mode 100644 index 000000000..5cd454bc5 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/QueueStatsNotification.cs @@ -0,0 +1,23 @@ +namespace CrestApps.OrchardCore.ContactCenter.Hubs; + +/// +/// 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/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentSessionIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentSessionIndexProvider.cs new file mode 100644 index 000000000..cab464c5b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentSessionIndexProvider.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class AgentSessionIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public AgentSessionIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(session => new AgentSessionIndex + { + ItemId = session.ItemId, + UserId = session.UserId, + IsOnline = session.IsOnline, + LastHeartbeatUtc = session.LastHeartbeatUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentStateReasonCodeIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentStateReasonCodeIndexProvider.cs new file mode 100644 index 000000000..74e313888 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/AgentStateReasonCodeIndexProvider.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class AgentStateReasonCodeIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public AgentStateReasonCodeIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(reasonCode => new AgentStateReasonCodeIndex + { + ItemId = reasonCode.ItemId, + Name = reasonCode.Name, + SortOrder = reasonCode.SortOrder, + Enabled = reasonCode.Enabled, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/BusinessHoursCalendarIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/BusinessHoursCalendarIndexProvider.cs new file mode 100644 index 000000000..9c24caf6b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/BusinessHoursCalendarIndexProvider.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class BusinessHoursCalendarIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public BusinessHoursCalendarIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(calendar => new BusinessHoursCalendarIndex + { + ItemId = calendar.ItemId, + Name = calendar.Name, + Enabled = calendar.Enabled, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterOutboxMessageIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterOutboxMessageIndexProvider.cs new file mode 100644 index 000000000..509c2a6e9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterOutboxMessageIndexProvider.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class ContactCenterOutboxMessageIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public ContactCenterOutboxMessageIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(message => new ContactCenterOutboxMessageIndex + { + ItemId = message.ItemId, + EventId = message.EventId, + Status = message.Status, + NextAttemptUtc = message.NextAttemptUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs index cd1629b29..68ff5e668 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -68,3 +68,15 @@ "CrestApps.OrchardCore.Telephony.SoftPhone", ] )] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.RealTime, + Name = "Contact Center Real-Time", + Description = "Adds the SignalR hub, live agent sessions with heartbeat and stale-session cleanup, and real-time presence, offer, and queue broadcasts for the agent desktop and supervisor dashboards.", + Category = "Communication", + Dependencies = + [ + ContactCenterConstants.Feature.Queues, + "CrestApps.OrchardCore.SignalR", + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentSessionIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentSessionIndexMigrations.cs new file mode 100644 index 000000000..662c23a3d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentSessionIndexMigrations.cs @@ -0,0 +1,38 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class AgentSessionIndexMigrations : DataMigration +{ + /// + /// Creates the agent session index table and its supporting index. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("UserId", column => column.WithLength(26)) + .Column("IsOnline") + .Column("LastHeartbeatUtc"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_AgentSessionIndex_DocumentId", + "DocumentId", + "ItemId", + "UserId", + "IsOnline", + "LastHeartbeatUtc"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentStateReasonCodeIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentStateReasonCodeIndexMigrations.cs new file mode 100644 index 000000000..4a4651ba6 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/AgentStateReasonCodeIndexMigrations.cs @@ -0,0 +1,48 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using OrchardCore.Recipes; +using OrchardCore.Recipes.Services; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the and seeds the standard reason codes. +/// +internal sealed class AgentStateReasonCodeIndexMigrations : DataMigration +{ + private readonly IRecipeMigrator _recipeMigrator; + + /// + /// Initializes a new instance of the class. + /// + /// The recipe migrator used to seed the standard reason codes. + public AgentStateReasonCodeIndexMigrations(IRecipeMigrator recipeMigrator) + { + _recipeMigrator = recipeMigrator; + } + + /// + /// Creates the reason code index table and seeds the standard reason codes. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("SortOrder") + .Column("Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_AgentStateReasonCodeIndex_DocumentId", "DocumentId", "ItemId", "Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await _recipeMigrator.ExecuteAsync($"agent-state-reason-codes{RecipesConstants.RecipeExtension}", this); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/BusinessHoursCalendarIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/BusinessHoursCalendarIndexMigrations.cs new file mode 100644 index 000000000..e9aac530f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/BusinessHoursCalendarIndexMigrations.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class BusinessHoursCalendarIndexMigrations : DataMigration +{ + /// + /// Creates the business-hours calendar index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_BusinessHoursCalendarIndex_DocumentId", "DocumentId", "ItemId", "Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterOutboxMessageIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterOutboxMessageIndexMigrations.cs new file mode 100644 index 000000000..9912ea022 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterOutboxMessageIndexMigrations.cs @@ -0,0 +1,36 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class ContactCenterOutboxMessageIndexMigrations : DataMigration +{ + /// + /// Creates the outbox message index table and its supporting index. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("EventId", column => column.WithLength(26)) + .Column("Status", column => column.WithLength(50)) + .Column("NextAttemptUtc", column => column.NotNull()), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_ContactCenterOutboxMessageIndex_Due", + "DocumentId", + "Status", + "NextAttemptUtc"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/agent-state-reason-codes.recipe.json b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/agent-state-reason-codes.recipe.json new file mode 100644 index 000000000..5834a48cb --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/agent-state-reason-codes.recipe.json @@ -0,0 +1,61 @@ +{ + "name": "ContactCenterAgentStateReasonCodes", + "displayName": "Contact Center agent state reason codes", + "description": "Seeds the standard Contact Center agent state reason codes.", + "steps": [ + { + "name": "AgentStateReasonCode", + "ReasonCodes": [ + { + "Name": "Short break", + "Description": "A short personal break away from active work.", + "AppliesTo": "Break", + "SortOrder": 10, + "Enabled": true + }, + { + "Name": "Lunch", + "Description": "Scheduled meal break.", + "AppliesTo": "Break", + "SortOrder": 20, + "Enabled": true + }, + { + "Name": "Away from desk", + "Description": "Temporarily away from the desk.", + "AppliesTo": "Away", + "SortOrder": 30, + "Enabled": true + }, + { + "Name": "Team meeting", + "Description": "Attending a team or one-on-one meeting.", + "AppliesTo": "Meeting", + "SortOrder": 40, + "Enabled": true + }, + { + "Name": "Training", + "Description": "Attending training or onboarding.", + "AppliesTo": "Training", + "SortOrder": 50, + "Enabled": true + }, + { + "Name": "Coaching", + "Description": "In a coaching or quality review session.", + "AppliesTo": "Meeting", + "SortOrder": 60, + "Enabled": true + }, + { + "Name": "System issue", + "Description": "Unable to take work due to a system or tooling issue.", + "AppliesTo": "DoNotDisturb", + "SortOrder": 70, + "Enabled": true + } + ] + } + ] +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Recipes/AgentStateReasonCodeStep.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Recipes/AgentStateReasonCodeStep.cs new file mode 100644 index 000000000..5b8494815 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Recipes/AgentStateReasonCodeStep.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; +using CrestApps.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Localization; +using OrchardCore.Recipes.Models; +using OrchardCore.Recipes.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Recipes; + +internal sealed class AgentStateReasonCodeStep : NamedRecipeStepHandler +{ + public const string StepKey = "AgentStateReasonCode"; + + private readonly IAgentStateReasonCodeManager _manager; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The reason code manager. + /// The string localizer. + public AgentStateReasonCodeStep( + IAgentStateReasonCodeManager manager, + IStringLocalizer stringLocalizer) + : base(StepKey) + { + _manager = manager; + S = stringLocalizer; + } + + protected override async Task HandleAsync(RecipeExecutionContext context) + { + var model = context.Step.ToObject(); + var tokens = model.ReasonCodes?.Cast() ?? []; + + foreach (var token in tokens) + { + AgentStateReasonCode reasonCode = null; + var isNew = false; + + var id = token[nameof(AgentStateReasonCode.ItemId)]?.GetValue(); + var hasId = !string.IsNullOrEmpty(id); + + if (hasId) + { + reasonCode = await _manager.FindByIdAsync(id); + } + + if (reasonCode is null) + { + var name = token[nameof(AgentStateReasonCode.Name)]?.GetValue()?.Trim(); + + if (!string.IsNullOrEmpty(name)) + { + reasonCode = await _manager.FindByNameAsync(name); + } + } + + if (reasonCode is not null) + { + await _manager.UpdateAsync(reasonCode, token); + } + else + { + isNew = true; + reasonCode = await _manager.NewAsync(token); + + if (hasId && UniqueId.IsValid(id)) + { + reasonCode.ItemId = id; + } + } + + var validationResult = await _manager.ValidateAsync(reasonCode); + + if (!validationResult.Succeeded) + { + foreach (var error in validationResult.Errors) + { + context.Errors.Add(error.ErrorMessage); + } + + continue; + } + + if (isNew) + { + await _manager.CreateAsync(reasonCode); + } + } + } + + private sealed class AgentStateReasonCodesStepModel + { + /// + /// Gets or sets the collection of reason codes to import. + /// + public JsonArray ReasonCodes { get; set; } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs index 6c6514b3a..bf7e59e36 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs @@ -17,6 +17,7 @@ public sealed class ContactCenterAdminFormOptionsProvider private readonly ICatalogManager _campaignManager; private readonly IActivityQueueManager _queueManager; private readonly IContactCenterSkillManager _skillManager; + private readonly IBusinessHoursCalendarManager _calendarManager; private readonly IOmnichannelChannelEndpointManager _channelEndpointManager; private readonly IEnumerable _voiceProviders; @@ -26,18 +27,21 @@ public sealed class ContactCenterAdminFormOptionsProvider /// The omnichannel campaign manager. /// The activity queue manager. /// The Contact Center skill manager. + /// The business-hours calendar manager. /// The omnichannel channel endpoint manager. /// The registered voice call providers. public ContactCenterAdminFormOptionsProvider( ICatalogManager campaignManager, IActivityQueueManager queueManager, IContactCenterSkillManager skillManager, + IBusinessHoursCalendarManager calendarManager, IOmnichannelChannelEndpointManager channelEndpointManager, IEnumerable voiceProviders) { _campaignManager = campaignManager; _queueManager = queueManager; _skillManager = skillManager; + _calendarManager = calendarManager; _channelEndpointManager = channelEndpointManager; _voiceProviders = voiceProviders; } @@ -102,6 +106,37 @@ internal async Task> GetInboundChannelEndpointOptionsAsync return options; } + internal async Task> GetBusinessHoursCalendarOptionsAsync(string selectedCalendarId) + { + var selected = CreateSelectedSet([selectedCalendarId], StringComparer.Ordinal); + var calendars = await _calendarManager.GetAllAsync(); + + var options = calendars + .OrderBy(calendar => calendar.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(calendar => new SelectListItem(calendar.Name ?? calendar.ItemId, calendar.ItemId, selected.Contains(calendar.ItemId))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.Ordinal); + + return options; + } + + internal async Task> GetOverflowQueueOptionsAsync(string selectedQueueId, string excludeQueueId) + { + var selected = CreateSelectedSet([selectedQueueId], StringComparer.Ordinal); + var queues = await _queueManager.GetAllAsync(); + + var options = queues + .Where(queue => !string.Equals(queue.ItemId, excludeQueueId, StringComparison.Ordinal)) + .OrderBy(queue => queue.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(queue => new SelectListItem(queue.Name ?? queue.ItemId, queue.ItemId, selected.Contains(queue.ItemId))) + .ToList(); + + AddMissingSelectedOptions(options, selected, StringComparer.Ordinal); + + return options; + } + internal IList GetVoiceProviderOptions(string selectedProviderName) { var selected = CreateSelectedSet([selectedProviderName], StringComparer.OrdinalIgnoreCase); @@ -120,6 +155,8 @@ internal async Task PopulateQueueEditorAsync(QueueViewModel model) model.RequiredSkills = ContactCenterFormHelpers.NormalizeList(model.RequiredSkills); model.SkillOptions = await GetSkillOptionsAsync(model.RequiredSkills); model.InboundChannelEndpointOptions = await GetInboundChannelEndpointOptionsAsync(model.InboundChannelEndpointId); + model.BusinessHoursCalendarOptions = await GetBusinessHoursCalendarOptionsAsync(model.BusinessHoursCalendarId); + model.OverflowQueueOptions = await GetOverflowQueueOptionsAsync(model.OverflowQueueId, model.Id); } internal async Task PopulateDialerProfileEditorAsync(DialerProfileViewModel model) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs index 0aceb4bd4..7719c99d3 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs @@ -38,6 +38,12 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("contactCenterSkills") .Action("Index", "Skills", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.ManageSkills) + .LocalNav()) + .Add(S["Business hours"], S["Business hours"].PrefixPosition(), businessHours => businessHours + .AddClass("contact-center-business-hours") + .Id("contactCenterBusinessHours") + .Action("Index", "BusinessHoursCalendars", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageQueues) .LocalNav()), priority: 1); diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs new file mode 100644 index 000000000..21be3db0a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Adds the Contact Center agent configuration entries to the Interaction Center admin navigation. +/// +public sealed class ContactCenterAgentsAdminMenu : AdminNavigationProvider +{ + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ContactCenterAgentsAdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder + .Add(S["Interaction Center"], "80", interactionCenter => interactionCenter + .AddClass("interaction-center") + .Id("interactionCenter") + .Add(S["Agent states"], S["Agent states"].PrefixPosition(), agentStates => agentStates + .AddClass("contact-center-agent-states") + .Id("contactCenterAgentStates") + .Action("Index", "AgentStateReasonCodes", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageAgents) + .LocalNav()), + priority: 1); + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs index de520a5fd..1e5379edc 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs @@ -19,6 +19,7 @@ internal sealed class ContactCenterPermissionProvider : IPermissionProvider ContactCenterPermissions.ManageSkills, ContactCenterPermissions.ManageDialer, ContactCenterPermissions.SignIntoQueues, + ContactCenterPermissions.MonitorContactCenter, ]; /// @@ -39,6 +40,15 @@ public IEnumerable GetDefaultStereotypes() ContactCenterPermissions.SignIntoQueues, ], }, + new PermissionStereotype + { + Name = "Supervisor", + Permissions = + [ + ContactCenterPermissions.ViewInteractions, + ContactCenterPermissions.MonitorContactCenter, + ], + }, ]; /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs new file mode 100644 index 000000000..522761f7e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs @@ -0,0 +1,84 @@ +using CrestApps.OrchardCore.ContactCenter.Hubs; +using Microsoft.AspNetCore.SignalR; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Provides the default implementation over the +/// strongly-typed hub context. +/// +public sealed class ContactCenterRealTimeNotifier : IContactCenterRealTimeNotifier +{ + private readonly IHubContext _hubContext; + + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center hub context used to push events to connected clients. + public ContactCenterRealTimeNotifier(IHubContext hubContext) + { + _hubContext = hubContext; + } + + /// + public async Task NotifyPresenceChangedAsync(AgentPresenceNotification notification, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + if (!string.IsNullOrEmpty(notification.UserId)) + { + await _hubContext.Clients.User(notification.UserId).PresenceChanged(notification); + } + + await _hubContext.Clients.Group(ContactCenterHub.SupervisorsGroup).PresenceChanged(notification); + } + + /// + public async Task NotifyOfferReceivedAsync(AgentOfferNotification notification, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + if (!string.IsNullOrEmpty(notification.UserId)) + { + await _hubContext.Clients.User(notification.UserId).OfferReceived(notification); + } + + if (!string.IsNullOrEmpty(notification.QueueId)) + { + await _hubContext.Clients.Group(ContactCenterHub.QueueGroup(notification.QueueId)).OfferReceived(notification); + } + + await _hubContext.Clients.Group(ContactCenterHub.SupervisorsGroup).OfferReceived(notification); + } + + /// + public async Task NotifyOfferRevokedAsync(AgentOfferRevokedNotification notification, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + if (!string.IsNullOrEmpty(notification.UserId)) + { + await _hubContext.Clients.User(notification.UserId).OfferRevoked(notification); + } + + if (!string.IsNullOrEmpty(notification.QueueId)) + { + await _hubContext.Clients.Group(ContactCenterHub.QueueGroup(notification.QueueId)).OfferRevoked(notification); + } + + await _hubContext.Clients.Group(ContactCenterHub.SupervisorsGroup).OfferRevoked(notification); + } + + /// + public async Task NotifyQueueStatsChangedAsync(QueueStatsNotification notification, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(notification); + + if (!string.IsNullOrEmpty(notification.QueueId)) + { + await _hubContext.Clients.Group(ContactCenterHub.QueueGroup(notification.QueueId)).QueueStatsChanged(notification); + } + + await _hubContext.Clients.Group(ContactCenterHub.SupervisorsGroup).QueueStatsChanged(notification); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeResourceConfiguration.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeResourceConfiguration.cs new file mode 100644 index 000000000..e6bc77047 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeResourceConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.Extensions.Options; +using OrchardCore.ResourceManagement; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Registers the Contact Center real-time client script as a named resource that depends on the SignalR +/// client library. +/// +internal sealed class ContactCenterRealTimeResourceConfiguration : IConfigureOptions +{ + private static readonly ResourceManifest _manifest; + + static ContactCenterRealTimeResourceConfiguration() + { + _manifest = new ResourceManifest(); + + _manifest + .DefineScript("contact-center-realtime") + .SetUrl("~/CrestApps.OrchardCore.ContactCenter/scripts/contact-center-realtime.js") + .SetDependencies("signalr") + .SetVersion("1.0.0"); + } + + /// + public void Configure(ResourceManagementOptions options) + { + options.ResourceManifests.Add(_manifest); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IContactCenterRealTimeNotifier.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IContactCenterRealTimeNotifier.cs new file mode 100644 index 000000000..047c08413 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IContactCenterRealTimeNotifier.cs @@ -0,0 +1,38 @@ +using CrestApps.OrchardCore.ContactCenter.Hubs; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Broadcasts Contact Center real-time updates to the appropriate SignalR audiences (the affected agent, +/// queue watchers, and supervisors) without exposing the underlying hub or group naming 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); +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index b86b90c55..6db1435ed 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -1,16 +1,21 @@ using CrestApps.Core.Services; +using CrestApps.Core.SignalR.Services; using CrestApps.OrchardCore.ContactCenter.BackgroundTasks; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Drivers; using CrestApps.OrchardCore.ContactCenter.Handlers; +using CrestApps.OrchardCore.ContactCenter.Hubs; using CrestApps.OrchardCore.ContactCenter.Indexes; using CrestApps.OrchardCore.ContactCenter.Migrations; +using CrestApps.OrchardCore.ContactCenter.Recipes; using CrestApps.OrchardCore.ContactCenter.Services; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.Models; using CrestApps.OrchardCore.Telephony; using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using OrchardCore.BackgroundTasks; @@ -19,6 +24,7 @@ using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Modules; using OrchardCore.Navigation; +using OrchardCore.Recipes; using OrchardCore.Security.Permissions; namespace CrestApps.OrchardCore.ContactCenter; @@ -36,6 +42,8 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped, InteractionHandler>(); @@ -51,10 +59,15 @@ public override void ConfigureServices(IServiceCollection services) .AddIndexProvider() .AddDataMigration(); + services + .AddIndexProvider() + .AddDataMigration(); + services .AddIndexProvider() .AddDataMigration(); + services.AddSingleton(); services.AddPermissionProvider(); } } @@ -70,11 +83,34 @@ public override void ConfigureServices(IServiceCollection services) services .AddScoped() .AddScoped() - .AddScoped(); + .AddScoped() + .AddScoped() + .AddScoped(); services .AddIndexProvider() .AddDataMigration(); + + services + .AddDisplayDriver() + .AddScoped, AgentStateReasonCodeHandler>() + .AddIndexProvider() + .AddDataMigration(); + + services.AddNavigationProvider(); + } +} + +/// +/// Registers recipe execution support for the agent feature. +/// +[Feature(ContactCenterConstants.Feature.Agents)] +[RequireFeatures("OrchardCore.Recipes.Core")] +public sealed class AgentsRecipesStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddRecipeExecutionStep(); } } @@ -91,6 +127,9 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -100,20 +139,27 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped(); services .AddDisplayDriver() .AddDisplayDriver() + .AddDisplayDriver() .AddDisplayDriver() .AddScoped, ActivityQueueHandler>() .AddScoped, ContactCenterSkillHandler>() + .AddScoped, BusinessHoursCalendarHandler>() .AddIndexProvider() .AddDataMigration() .AddIndexProvider() .AddDataMigration() + .AddIndexProvider() + .AddDataMigration() .AddIndexProvider() .AddDataMigration() .AddIndexProvider() @@ -198,3 +244,34 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped(); } } + +/// +/// Registers the real-time agent and supervisor experience: the SignalR hub, the live agent session +/// store, the heartbeat-driven stale-session cleanup, and the event projection that broadcasts presence, +/// offer, and queue updates to connected clients. +/// +[Feature(ContactCenterConstants.Feature.RealTime)] +public sealed class RealTimeStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + + services + .AddIndexProvider() + .AddDataMigration(); + + services.AddSingleton(); + services.AddResourceConfiguration(); + } + + public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder routes, IServiceProvider serviceProvider) + { + HubRouteManager.MapHub(routes); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs index d3c02b248..eb3618319 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentSoftPhoneViewModel.cs @@ -35,6 +35,11 @@ public class AgentSoftPhoneViewModel /// public IList SelectedCampaignIds { get; set; } = []; + /// + /// Gets or sets the enabled agent state reason codes the agent can choose when going not ready. + /// + public IList ReasonCodes { get; set; } = []; + /// /// Gets a value indicating whether the agent is currently signed in. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentStateReasonCodeViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentStateReasonCodeViewModel.cs new file mode 100644 index 000000000..1b6259bc3 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentStateReasonCodeViewModel.cs @@ -0,0 +1,47 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the edit view model for an agent state reason code. +/// +public class AgentStateReasonCodeViewModel +{ + /// + /// Gets or sets the reason code identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the unique reason code name. + /// + [Required] + public string Name { get; set; } + + /// + /// Gets or sets the reason code description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the presence state the agent enters when selecting this reason code. + /// + public AgentPresenceStatus AppliesTo { get; set; } = AgentPresenceStatus.Break; + + /// + /// Gets or sets the presence states a reason code may map to. + /// + public IList AppliesToOptions { 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; } = true; +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/BusinessHoursCalendarViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/BusinessHoursCalendarViewModel.cs new file mode 100644 index 000000000..1498f3e4a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/BusinessHoursCalendarViewModel.cs @@ -0,0 +1,45 @@ +using System.ComponentModel.DataAnnotations; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the edit view model for a business-hours calendar. +/// +public class BusinessHoursCalendarViewModel +{ + /// + /// Gets or sets the calendar identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the unique calendar name. + /// + [Required] + public string Name { get; set; } + + /// + /// Gets or sets the calendar description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the time zone the schedule is evaluated in. + /// + public string TimeZoneId { get; set; } + + /// + /// Gets or sets the per-day open windows. + /// + public IList Days { get; set; } = []; + + /// + /// Gets or sets the holiday dates, one ISO date (yyyy-MM-dd) per line. + /// + public string HolidaysText { get; set; } + + /// + /// Gets or sets a value indicating whether the calendar is enabled. + /// + public bool Enabled { get; set; } = true; +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/BusinessHoursDayViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/BusinessHoursDayViewModel.cs new file mode 100644 index 000000000..eb74cccd1 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/BusinessHoursDayViewModel.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the open window for a single day of the week in the business-hours calendar editor. +/// +public class BusinessHoursDayViewModel +{ + /// + /// Gets or sets the day of the week. + /// + public DayOfWeek Day { get; set; } + + /// + /// Gets or sets the localized display name of the day. + /// + public string DayName { 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 open time, formatted as HH:mm. + /// + public string OpenTime { get; set; } + + /// + /// Gets or sets the local close time, formatted as HH:mm. + /// + public string CloseTime { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs index f880f4b73..22c9e3d1c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/QueueViewModel.cs @@ -30,6 +30,21 @@ public class QueueViewModel /// 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. + /// + public bool PreferStickyAgent { get; set; } + + /// + /// Gets or sets a value indicating whether waiting items age in priority past the SLA threshold. + /// + public bool EnableSlaAging { get; set; } + /// /// Gets or sets the SLA threshold in seconds. /// @@ -42,6 +57,37 @@ public class QueueViewModel [Range(1, int.MaxValue)] public int ReservationTimeoutSeconds { get; set; } = 30; + /// + /// Gets or sets the identifier of the business-hours calendar that gates when the queue routes work. + /// + public string BusinessHoursCalendarId { get; set; } + + /// + /// Gets or sets the available business-hours calendars. + /// + public IList BusinessHoursCalendarOptions { get; set; } = []; + + /// + /// Gets or sets the action taken for waiting items while the queue is closed. + /// + public QueueAfterHoursAction AfterHoursAction { get; set; } = QueueAfterHoursAction.HoldInQueue; + + /// + /// Gets or sets the identifier of the queue that receives overflowed items. + /// + public string OverflowQueueId { get; set; } + + /// + /// Gets or sets the available overflow queues. + /// + public IList OverflowQueueOptions { get; set; } = []; + + /// + /// Gets or sets the seconds an item may wait before it overflows to the overflow queue. + /// + [Range(0, int.MaxValue)] + public int OverflowAfterSeconds { get; set; } + /// /// Gets or sets the selected skills required to receive work from the queue. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml index bb103117d..86bf5b55c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml @@ -33,6 +33,29 @@ +
    + +
    + + + @T["How the queue chooses which available agent receives the next item."] +
    +
    + +
    +
    +
    + + +
    + @T["When available and eligible, returning work prefers the agent who last owned the activity."] +
    +
    +
    @@ -41,6 +64,16 @@
    +
    +
    +
    + + +
    + @T["When enabled, items waiting beyond the SLA threshold gain priority so aging work is routed first."] +
    +
    +
    @@ -69,6 +102,49 @@
    +
    + +
    + + + @T["Routing pauses while the calendar reports the queue closed. Leave empty to route around the clock."] +
    +
    + +
    + +
    + + + @T["What happens to waiting items while the queue is closed."] +
    +
    + +
    + +
    + + + @T["Items move to this queue when they wait too long or while the queue is closed and set to overflow."] +
    +
    + +
    + +
    + + + @T["How long an item may wait before it overflows. Set to 0 to disable wait-time overflow."] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCode.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCode.Edit.cshtml new file mode 100644 index 000000000..be403296b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCode.Edit.cshtml @@ -0,0 +1,4 @@ +@if (Model.Content != null) +{ + @await DisplayAsync(Model.Content) +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCode.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCode.SummaryAdmin.cshtml new file mode 100644 index 000000000..d07548c93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCode.SummaryAdmin.cshtml @@ -0,0 +1,29 @@ +
    +
    +
    +
    +
    + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
    + + @if (Model.Meta != null) + { + + } +
    +
    +
    +
    +
    + @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodeFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodeFields.Edit.cshtml new file mode 100644 index 000000000..9873599f9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodeFields.Edit.cshtml @@ -0,0 +1,46 @@ +@model AgentStateReasonCodeViewModel + +
    + +
    + + + @T["A clear reason agents pick when they go not ready, such as Lunch, Team meeting, or Training."] +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + @T["The presence state the agent enters when they choose this reason."] +
    +
    + +
    + +
    + + + @T["Lower values are listed first in the agent presence menu."] +
    +
    + +
    +
    +
    + + +
    + @T["Disabled reason codes are hidden from the agent presence menu."] +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Create.cshtml new file mode 100644 index 000000000..e4566492c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Create.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["New agent state reason code"])

    +
    + + + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Edit.cshtml new file mode 100644 index 000000000..f691d467b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Edit.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["Edit '{0}' reason code", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Index.cshtml new file mode 100644 index 000000000..c59db4ad4 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentStateReasonCodes/Index.cshtml @@ -0,0 +1,57 @@ +@using CrestApps.Core.Models +@using CrestApps.OrchardCore.Core.Models + +@model ListCatalogEntryViewModel> + +

    @RenderTitleSegments(T["Agent states"])

    + +@* The form is necessary to generate an antiforgery token for delete actions. *@ +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
      + @if (Model.Models.Count > 0) + { +
    • + @(Model.Models.Count == 1 ? T["1 item"] : T["{0} items", Model.Models.Count]) +
    • + + @foreach (var entry in Model.Models) + { +
    • +
      + @await DisplayAsync(entry.Shape) +
      +
    • + } + } + else + { +
    • + @T["Nothing here! There are no agent state reason codes at the moment."] +
    • + } +
    + + +
    + +@await DisplayAsync(Model.Pager) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendar.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendar.Edit.cshtml new file mode 100644 index 000000000..be403296b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendar.Edit.cshtml @@ -0,0 +1,4 @@ +@if (Model.Content != null) +{ + @await DisplayAsync(Model.Content) +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendar.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendar.SummaryAdmin.cshtml new file mode 100644 index 000000000..d07548c93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendar.SummaryAdmin.cshtml @@ -0,0 +1,29 @@ +
    +
    +
    +
    +
    + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
    + + @if (Model.Meta != null) + { + + } +
    +
    +
    +
    +
    + @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendarFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendarFields.Edit.cshtml new file mode 100644 index 000000000..d8c55ad8e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendarFields.Edit.cshtml @@ -0,0 +1,85 @@ +@model BusinessHoursCalendarViewModel + +
    + +
    + + + @T["A clear calendar name, such as Support Hours or US Holidays."] +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + @T["The time zone the weekly schedule and holidays are evaluated in. Leave empty to use UTC."] +
    +
    + +
    + +
    + + + + + + + + + + + @for (var i = 0; i < Model.Days.Count; i++) + { + + + + + + + } + +
    @T["Day"]@T["Open"]@T["From"]@T["To"]
    + + @Model.Days[i].DayName + +
    + +
    +
    + + + +
    + @T["Uncheck a day to mark the queue closed. The close time is exclusive."] +
    +
    + +
    + +
    + + + @T["One date per line in yyyy-MM-dd format. The queue is closed all day on these dates."] +
    +
    + +
    +
    +
    + + +
    + @T["Disabled calendars do not gate routing; queues that use them route around the clock."] +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Create.cshtml new file mode 100644 index 000000000..839ab2fa9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Create.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["New '{0}'", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Edit.cshtml new file mode 100644 index 000000000..8c02e3de5 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Edit.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["Edit '{0}' Business Hours Calendar", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Index.cshtml new file mode 100644 index 000000000..0eb8b7555 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/BusinessHoursCalendars/Index.cshtml @@ -0,0 +1,57 @@ +@using CrestApps.Core.Models +@using CrestApps.OrchardCore.Core.Models + +@model ListCatalogEntryViewModel> + +

    @RenderTitleSegments(T["Business hours"])

    + +@* The form is necessary to generate an antiforgery token for delete actions. *@ +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
      + @if (Model.Models.Count > 0) + { +
    • + @(Model.Models.Count == 1 ? T["1 item"] : T["{0} items", Model.Models.Count]) +
    • + + @foreach (var entry in Model.Models) + { +
    • +
      + @await DisplayAsync(entry.Shape) +
      +
    • + } + } + else + { +
    • + @T["Nothing here! There are no business hours calendars at the moment."] +
    • + } +
    + + +
    + +@await DisplayAsync(Model.Pager) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.Buttons.SummaryAdmin.cshtml new file mode 100644 index 000000000..27fa8d2eb --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,14 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@T["Edit"] +@T["Delete"] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 000000000..25d7b53e2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,17 @@ +@using System.Globalization +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var modifiedAt = Model.Value.ModifiedUtc ?? Model.Value.CreatedUtc; +} + +@if (modifiedAt != default) +{ + + + + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.Fields.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.Fields.SummaryAdmin.cshtml new file mode 100644 index 000000000..fb7657b48 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/AgentStateReasonCode.Fields.SummaryAdmin.cshtml @@ -0,0 +1,34 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using CrestApps.OrchardCore.ContactCenter.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var appliesToText = Model.Value.AppliesTo switch + { + AgentPresenceStatus.Break => T["Break"], + AgentPresenceStatus.Away => T["Away"], + AgentPresenceStatus.DoNotDisturb => T["Do not disturb"], + AgentPresenceStatus.Meeting => T["Meeting"], + AgentPresenceStatus.Training => T["Training"], + AgentPresenceStatus.AfterHoursUnavailable => T["After-hours unavailable"], + _ => T[Model.Value.AppliesTo.ToString()], + }; +} + +
    @Model.Value.Name
    + +@if (!string.IsNullOrWhiteSpace(Model.Value.Description)) +{ +
    @Model.Value.Description
    +} + +
    + @T["Sets state: {0}", appliesToText] + + @if (!Model.Value.Enabled) + { + @T["Disabled"] + } +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.Buttons.SummaryAdmin.cshtml new file mode 100644 index 000000000..cdc4f50d9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,14 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@T["Edit"] +@T["Delete"] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 000000000..3be9aba6d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,17 @@ +@using System.Globalization +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var modifiedAt = Model.Value.ModifiedUtc ?? Model.Value.CreatedUtc; +} + +@if (modifiedAt != default) +{ + + + + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.Fields.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.Fields.SummaryAdmin.cshtml new file mode 100644 index 000000000..57574132f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/BusinessHoursCalendar.Fields.SummaryAdmin.cshtml @@ -0,0 +1,39 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +
    @Model.Value.Name
    + +@if (!string.IsNullOrWhiteSpace(Model.Value.Description)) +{ +
    @Model.Value.Description
    +} + +
    + @if (!string.IsNullOrWhiteSpace(Model.Value.TimeZoneId)) + { + @T["Time zone: {0}", Model.Value.TimeZoneId] + } + else + { + @T["Time zone: UTC"] + } + + @{ + var openDays = Model.Value.WeeklySchedule?.Count(day => day.IsOpen && day.CloseMinute > day.OpenMinute) ?? 0; + } + @(openDays == 1 ? T["1 open day"] : T["{0} open days", openDays]) + + @if (Model.Value.Holidays is { Count: > 0 }) + { + @(Model.Value.Holidays.Count == 1 ? T["1 holiday"] : T["{0} holidays", Model.Value.Holidays.Count]) + } +
    + +@if (!Model.Value.Enabled) +{ +
    + @T["Disabled"] +
    +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml index c56f42e27..affef086b 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhonePresence.Header.cshtml @@ -32,15 +32,44 @@ @T["Break pending"] } - +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-realtime.js b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-realtime.js new file mode 100644 index 000000000..94ccdf500 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/contact-center-realtime.js @@ -0,0 +1,112 @@ +/* + * Contact Center real-time client helper. + * + * A thin convenience wrapper around the Microsoft SignalR client for connecting to the Contact Center + * hub (`ContactCenterHub`). It manages the connection, sends periodic heartbeats so the server does not + * consider the agent session stale, retrieves the reconnect snapshot, and dispatches the strongly-typed + * server callbacks (`PresenceChanged`, `OfferReceived`, `OfferRevoked`, `QueueStatsChanged`). + * + * Usage: + * const client = window.contactCenterRealTime.connect({ + * hubUrl: '/path/to/hub', + * onSnapshot: (snapshot) => { ... }, + * onPresenceChanged: (n) => { ... }, + * onOfferReceived: (n) => { ... }, + * onOfferRevoked: (n) => { ... }, + * onQueueStatsChanged: (n) => { ... } + * }); + */ +(function (window) { + 'use strict'; + + var DEFAULT_HEARTBEAT_INTERVAL_MS = 30000; + + function noop() { } + + function connect(options) { + options = options || {}; + + if (!window.signalR) { + throw new Error('The SignalR client library is not loaded. Require the "signalr" resource first.'); + } + + if (!options.hubUrl) { + throw new Error('A "hubUrl" option is required to connect to the Contact Center hub.'); + } + + var connection = new window.signalR.HubConnectionBuilder() + .withUrl(options.hubUrl) + .withAutomaticReconnect() + .build(); + + connection.on('PresenceChanged', options.onPresenceChanged || noop); + connection.on('OfferReceived', options.onOfferReceived || noop); + connection.on('OfferRevoked', options.onOfferRevoked || noop); + connection.on('QueueStatsChanged', options.onQueueStatsChanged || noop); + + var heartbeatIntervalMs = options.heartbeatIntervalMs || DEFAULT_HEARTBEAT_INTERVAL_MS; + var heartbeatTimer = null; + + function startHeartbeat() { + stopHeartbeat(); + heartbeatTimer = window.setInterval(function () { + connection.invoke('Heartbeat').catch(function () { }); + }, heartbeatIntervalMs); + } + + function stopHeartbeat() { + if (heartbeatTimer) { + window.clearInterval(heartbeatTimer); + heartbeatTimer = null; + } + } + + function loadSnapshot() { + return connection.invoke('GetSnapshot').then(function (snapshot) { + if (typeof options.onSnapshot === 'function') { + options.onSnapshot(snapshot); + } + + return snapshot; + }); + } + + connection.onreconnected(function () { + startHeartbeat(); + loadSnapshot().catch(function () { }); + }); + + connection.onclose(function () { + stopHeartbeat(); + }); + + connection.start().then(function () { + startHeartbeat(); + loadSnapshot().catch(function () { }); + }).catch(function (error) { + if (typeof options.onError === 'function') { + options.onError(error); + } + }); + + return { + connection: connection, + getSnapshot: loadSnapshot, + watchQueue: function (queueId) { + return connection.invoke('WatchQueue', queueId); + }, + unwatchQueue: function (queueId) { + return connection.invoke('UnwatchQueue', queueId); + }, + stop: function () { + stopHeartbeat(); + + return connection.stop(); + } + }; + } + + window.contactCenterRealTime = { + connect: connect + }; +})(window); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs index a0431c843..e0e94614d 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs @@ -3,6 +3,7 @@ using CrestApps.OrchardCore.ContactCenter.Models; using Moq; using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; @@ -197,14 +198,24 @@ private static ActivityAssignmentService CreateService( Mock reservationService, Mock distributedLock) { + var businessHours = new Mock(); + businessHours + .Setup(b => b.IsOpenAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(true); + + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + return new ActivityAssignmentService( queueItemManager.Object, agentManager.Object, queueManager.Object, CreateRoutingService(), reservationService.Object, + businessHours.Object, new Mock().Object, - distributedLock.Object); + distributedLock.Object, + clock.Object); } private static Mock CreateDistributedLock(bool locked) diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs new file mode 100644 index 000000000..ab0eed61c --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs @@ -0,0 +1,129 @@ +using System.Text.Json.Nodes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ActivityQueueServiceTests +{ + private static readonly DateTime _now = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task EnqueueAsync_CapturesAssignedUserAsStickyHint() + { + // Arrange + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync((QueueItem)null); + queueItemManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new QueueItem()); + + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1", AssignedToId = "user-7" }); + + var service = CreateService(queueItemManager, queueManager, activityManager, new Mock()); + + // Act + var item = await service.EnqueueAsync("act-1", "q1", null, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("user-7", item.StickyAgentUserId); + Assert.Equal("q1", item.QueueId); + Assert.Equal(QueueItemStatus.Waiting, item.Status); + } + + [Fact] + public async Task OverflowDueAsync_WhenNoOverflowTarget_ReturnsZero() + { + // Arrange + var queueItemManager = new Mock(); + var service = CreateService(queueItemManager, new Mock(), new Mock(), new Mock()); + var queue = new ActivityQueue { ItemId = "q1", OverflowQueueId = null }; + + // Act + var moved = await service.OverflowDueAsync(queue, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, moved); + queueItemManager.Verify(m => m.ListWaitingAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task OverflowDueAsync_MovesOnlyItemsWaitingPastThreshold() + { + // Arrange + var overdue = new QueueItem { ItemId = "i1", QueueId = "q1", Status = QueueItemStatus.Waiting, EnqueuedUtc = _now.AddSeconds(-60) }; + var fresh = new QueueItem { ItemId = "i2", QueueId = "q1", Status = QueueItemStatus.Waiting, EnqueuedUtc = _now.AddSeconds(-10) }; + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.ListWaitingAsync("q1", It.IsAny())).ReturnsAsync([overdue, fresh]); + + var service = CreateService(queueItemManager, new Mock(), new Mock(), new Mock()); + var queue = new ActivityQueue { ItemId = "q1", OverflowQueueId = "q2", OverflowAfterSeconds = 30 }; + + // Act + var moved = await service.OverflowDueAsync(queue, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, moved); + Assert.Equal("q2", overdue.QueueId); + Assert.Equal("q1", overdue.OverflowedFromQueueId); + Assert.Equal("q1", fresh.QueueId); + } + + [Fact] + public async Task OverflowDueAsync_WhenClosedAndAfterHoursOverflow_MovesAllWaitingItems() + { + // Arrange + var item1 = new QueueItem { ItemId = "i1", QueueId = "q1", Status = QueueItemStatus.Waiting, EnqueuedUtc = _now.AddSeconds(-10) }; + var item2 = new QueueItem { ItemId = "i2", QueueId = "q1", Status = QueueItemStatus.Waiting, EnqueuedUtc = _now.AddSeconds(-5) }; + + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.ListWaitingAsync("q1", It.IsAny())).ReturnsAsync([item1, item2]); + + var businessHours = new Mock(); + businessHours.Setup(b => b.IsOpenAsync("cal", It.IsAny(), It.IsAny())).ReturnsAsync(false); + + var service = CreateService(queueItemManager, new Mock(), new Mock(), businessHours); + var queue = new ActivityQueue + { + ItemId = "q1", + OverflowQueueId = "q2", + OverflowAfterSeconds = 0, + BusinessHoursCalendarId = "cal", + AfterHoursAction = QueueAfterHoursAction.Overflow, + }; + + // Act + var moved = await service.OverflowDueAsync(queue, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, moved); + Assert.Equal("q2", item1.QueueId); + Assert.Equal("q2", item2.QueueId); + } + + private static ActivityQueueService CreateService( + Mock queueItemManager, + Mock queueManager, + Mock activityManager, + Mock businessHours) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new ActivityQueueService( + queueItemManager.Object, + queueManager.Object, + activityManager.Object, + businessHours.Object, + new Mock().Object, + clock.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSessionServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSessionServiceTests.cs new file mode 100644 index 000000000..a4e0b152e --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSessionServiceTests.cs @@ -0,0 +1,244 @@ +using System.Text.Json.Nodes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; +using OrchardCore.Locking.Distributed; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class AgentSessionServiceTests +{ + private static readonly DateTime _now = new(2026, 6, 30, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task ConnectAsync_WhenNoSession_CreatesOnlineSessionWithProfileMembership() + { + // Arrange + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync((AgentSession)null); + sessionManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new AgentSession { ItemId = "s1" }); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a1", UserId = "u1", DisplayName = "Agent One", QueueIds = ["q1", "q2"] }); + + var service = CreateService(sessionManager, agentManager); + + // Act + var session = await service.ConnectAsync("u1", "c1", "user1", "User One", TestContext.Current.CancellationToken); + + // Assert + Assert.True(session.IsOnline); + Assert.Contains("c1", session.ConnectionIds); + Assert.Equal(["q1", "q2"], session.QueueIds); + Assert.Equal(_now, session.LastHeartbeatUtc); + sessionManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task ConnectAsync_WhenSessionExists_AddsConnectionAndUpdates() + { + // Arrange + var existing = new AgentSession { ItemId = "s1", UserId = "u1", ConnectionIds = ["c1"], IsOnline = true }; + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync((AgentProfile)null); + + var service = CreateService(sessionManager, agentManager); + + // Act + var session = await service.ConnectAsync("u1", "c2", "user1", "User One", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(["c1", "c2"], session.ConnectionIds); + Assert.True(session.IsOnline); + sessionManager.Verify(m => m.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + sessionManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DisconnectAsync_WhenLastConnection_MarksOffline() + { + // Arrange + var existing = new AgentSession { ItemId = "s1", UserId = "u1", ConnectionIds = ["c1"], IsOnline = true }; + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + + var service = CreateService(sessionManager, new Mock()); + + // Act + var session = await service.DisconnectAsync("u1", "c1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(session.IsOnline); + Assert.Empty(session.ConnectionIds); + Assert.Equal(_now, session.LastDisconnectedUtc); + } + + [Fact] + public async Task DisconnectAsync_WhenOtherConnectionsRemain_StaysOnline() + { + // Arrange + var existing = new AgentSession { ItemId = "s1", UserId = "u1", ConnectionIds = ["c1", "c2"], IsOnline = true }; + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + + var service = CreateService(sessionManager, new Mock()); + + // Act + var session = await service.DisconnectAsync("u1", "c1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(session.IsOnline); + Assert.Equal(["c2"], session.ConnectionIds); + } + + [Fact] + public async Task HeartbeatAsync_UpdatesLastHeartbeat() + { + // Arrange + var existing = new AgentSession { ItemId = "s1", UserId = "u1", LastHeartbeatUtc = _now.AddMinutes(-5) }; + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + + var service = CreateService(sessionManager, new Mock()); + + // Act + var session = await service.HeartbeatAsync("u1", TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(_now, session.LastHeartbeatUtc); + sessionManager.Verify(m => m.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task HeartbeatAsync_WhenNoSession_ReturnsNull() + { + // Arrange + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync((AgentSession)null); + + var service = CreateService(sessionManager, new Mock()); + + // Act + var session = await service.HeartbeatAsync("u1", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(session); + } + + [Fact] + public async Task BuildSnapshotAsync_CombinesProfileAndSession() + { + // Arrange + var sessionManager = new Mock(); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())) + .ReturnsAsync(new AgentSession { ItemId = "s1", UserId = "u1", IsOnline = true, LastHeartbeatUtc = _now }); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + UserId = "u1", + DisplayName = "Agent One", + PresenceStatus = AgentPresenceStatus.Available, + PresenceReason = "Ready", + ActiveReservationId = "r1", + QueueIds = ["q1"], + }); + + var service = CreateService(sessionManager, agentManager); + + // Act + var snapshot = await service.BuildSnapshotAsync("u1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(snapshot.HasProfile); + Assert.Equal("Available", snapshot.PresenceStatus); + Assert.Equal("Ready", snapshot.PresenceReason); + Assert.Equal("r1", snapshot.ActiveReservationId); + Assert.Equal(["q1"], snapshot.QueueIds); + Assert.True(snapshot.IsOnline); + Assert.Equal(_now, snapshot.ServerTimeUtc); + } + + [Fact] + public async Task ExpireStaleAsync_SignsOutAndDeletesStaleSession() + { + // Arrange + var stale = new AgentSession { ItemId = "s1", UserId = "u1", IsOnline = true, LastHeartbeatUtc = _now.AddMinutes(-5) }; + var sessionManager = new Mock(); + sessionManager.Setup(m => m.ListStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([stale]); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(stale); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Available }); + + var presenceManager = new Mock(); + + var service = CreateService(sessionManager, agentManager, presenceManager); + + // Act + var count = await service.ExpireStaleAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, count); + presenceManager.Verify(m => m.SignOutAsync("u1", It.IsAny()), Times.Once); + sessionManager.Verify(m => m.DeleteAsync(stale, It.IsAny()), Times.Once); + } + + [Fact] + public async Task ExpireStaleAsync_WhenHeartbeatRefreshed_SkipsSession() + { + // Arrange + var staleCandidate = new AgentSession { ItemId = "s1", UserId = "u1", IsOnline = true, LastHeartbeatUtc = _now.AddMinutes(-5) }; + var refreshed = new AgentSession { ItemId = "s1", UserId = "u1", IsOnline = true, LastHeartbeatUtc = _now }; + var sessionManager = new Mock(); + sessionManager.Setup(m => m.ListStaleAsync(It.IsAny(), It.IsAny())).ReturnsAsync([staleCandidate]); + sessionManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(refreshed); + + var presenceManager = new Mock(); + + var service = CreateService(sessionManager, new Mock(), presenceManager); + + // Act + var count = await service.ExpireStaleAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, count); + presenceManager.Verify(m => m.SignOutAsync(It.IsAny(), It.IsAny()), Times.Never); + sessionManager.Verify(m => m.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + private static AgentSessionService CreateService( + Mock sessionManager, + Mock agentManager, + Mock presenceManager = null) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new AgentSessionService( + sessionManager.Object, + agentManager.Object, + (presenceManager ?? new Mock()).Object, + CreateDistributedLock().Object, + clock.Object); + } + + private static Mock CreateDistributedLock() + { + var distributedLock = new Mock(); + distributedLock + .Setup(l => l.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, true)); + + return distributedLock; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs new file mode 100644 index 000000000..970a652bd --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/BusinessHoursServiceTests.cs @@ -0,0 +1,163 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class BusinessHoursServiceTests +{ + private static readonly DateTime _mondayNoonUtc = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void IsOpen_WithinWeeklyWindow_ReturnsTrue() + { + // Arrange + var calendar = CreateMondayNineToFive(); + + // Act + var open = DefaultBusinessHoursService.IsOpen(calendar, _mondayNoonUtc); + + // Assert + Assert.True(open); + } + + [Fact] + public void IsOpen_OutsideWeeklyWindow_ReturnsFalse() + { + // Arrange + var calendar = CreateMondayNineToFive(); + + // Act + var open = DefaultBusinessHoursService.IsOpen(calendar, new DateTime(2026, 1, 5, 18, 0, 0, DateTimeKind.Utc)); + + // Assert + Assert.False(open); + } + + [Fact] + public void IsOpen_OnClosedDay_ReturnsFalse() + { + // Arrange + var calendar = CreateMondayNineToFive(); + + // Act + var open = DefaultBusinessHoursService.IsOpen(calendar, new DateTime(2026, 1, 4, 12, 0, 0, DateTimeKind.Utc)); + + // Assert + Assert.False(open); + } + + [Fact] + public void IsOpen_OnHoliday_ReturnsFalse() + { + // Arrange + var calendar = CreateMondayNineToFive(); + calendar.Holidays = [new DateOnly(2026, 1, 5)]; + + // Act + var open = DefaultBusinessHoursService.IsOpen(calendar, _mondayNoonUtc); + + // Assert + Assert.False(open); + } + + [Fact] + public void IsOpen_WithNoSchedule_ReturnsFalse() + { + // Arrange + var calendar = new BusinessHoursCalendar { ItemId = "cal1", TimeZoneId = "UTC", Enabled = true }; + + // Act + var open = DefaultBusinessHoursService.IsOpen(calendar, _mondayNoonUtc); + + // Assert + Assert.False(open); + } + + [Fact] + public void IsOpen_RespectsTimeZone() + { + // Arrange + var calendar = CreateMondayNineToFive("America/New_York"); + + // Act + var openLocalMorning = DefaultBusinessHoursService.IsOpen(calendar, new DateTime(2026, 1, 5, 14, 30, 0, DateTimeKind.Utc)); + var closedLocalEarly = DefaultBusinessHoursService.IsOpen(calendar, new DateTime(2026, 1, 5, 13, 30, 0, DateTimeKind.Utc)); + + // Assert + Assert.True(openLocalMorning); + Assert.False(closedLocalEarly); + } + + [Fact] + public async Task IsOpenAsync_WithEmptyCalendarId_ReturnsTrue() + { + // Arrange + var service = CreateService(new Mock()); + + // Act + var open = await service.IsOpenAsync(string.Empty, TestContext.Current.CancellationToken); + + // Assert + Assert.True(open); + } + + [Fact] + public async Task IsOpenAsync_WithDisabledCalendar_ReturnsTrue() + { + // Arrange + var manager = new Mock(); + manager + .Setup(m => m.FindByIdAsync("cal1", It.IsAny())) + .ReturnsAsync(new BusinessHoursCalendar { ItemId = "cal1", Enabled = false }); + + var service = CreateService(manager); + + // Act + var open = await service.IsOpenAsync("cal1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(open); + } + + [Fact] + public async Task IsOpenAsync_EvaluatesScheduleThroughManager() + { + // Arrange + var manager = new Mock(); + manager + .Setup(m => m.FindByIdAsync("cal1", It.IsAny())) + .ReturnsAsync(CreateMondayNineToFive()); + + var service = CreateService(manager, _mondayNoonUtc); + + // Act + var open = await service.IsOpenAsync("cal1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(open); + } + + private static DefaultBusinessHoursService CreateService(Mock manager, DateTime? now = null) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(now ?? _mondayNoonUtc); + + return new DefaultBusinessHoursService(manager.Object, clock.Object); + } + + private static BusinessHoursCalendar CreateMondayNineToFive(string timeZoneId = "UTC") + { + return new BusinessHoursCalendar + { + ItemId = "cal1", + TimeZoneId = timeZoneId, + Enabled = true, + WeeklySchedule = + [ + new BusinessHoursDay { Day = DayOfWeek.Monday, IsOpen = true, OpenMinute = 540, CloseMinute = 1020 }, + ], + }; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs new file mode 100644 index 000000000..0f5912c0f --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterOutboxTests.cs @@ -0,0 +1,185 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterOutboxTests +{ + private static readonly DateTime _now = new(2026, 6, 30, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task DispatchAsync_WhenAllHandlersSucceed_DoesNotScheduleRetry() + { + // Arrange + var handler = new Mock(); + handler.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + var outboxStore = new Mock(); + var outbox = CreateOutbox(outboxStore, new Mock(), [handler.Object]); + + var interactionEvent = new InteractionEvent { ItemId = "e1", EventType = ContactCenterConstants.Events.InteractionCreated }; + + // Act + await outbox.DispatchAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + outboxStore.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task DispatchAsync_WhenAHandlerFails_SchedulesAPendingRetry_AndStillRunsOtherHandlers() + { + // Arrange + var faulty = new Mock(); + faulty.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("boom")); + + var healthy = new Mock(); + healthy.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + var outboxStore = new Mock(); + ContactCenterOutboxMessage created = null; + outboxStore + .Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((m, _) => created = m) + .Returns(ValueTask.CompletedTask); + + var outbox = CreateOutbox(outboxStore, new Mock(), [faulty.Object, healthy.Object]); + + var interactionEvent = new InteractionEvent { ItemId = "e1", EventType = ContactCenterConstants.Events.InteractionCreated }; + + // Act + await outbox.DispatchAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + healthy.Verify(h => h.HandleAsync(interactionEvent, It.IsAny()), Times.Once); + Assert.NotNull(created); + Assert.Equal("e1", created.EventId); + Assert.Equal(OutboxMessageStatus.Pending, created.Status); + Assert.Equal(1, created.AttemptCount); + Assert.Equal(_now.AddSeconds(30), created.NextAttemptUtc); + Assert.Equal("boom", created.LastError); + } + + [Fact] + public async Task DispatchDueAsync_WhenRetrySucceeds_DeletesTheMessage() + { + // Arrange + var message = new ContactCenterOutboxMessage { ItemId = "m1", EventId = "e1", Status = OutboxMessageStatus.Pending, AttemptCount = 1 }; + var outboxStore = new Mock(); + outboxStore.Setup(s => s.ListDueAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([message]); + + var eventStore = new Mock(); + eventStore.Setup(s => s.FindByIdAsync("e1", It.IsAny())).ReturnsAsync(new InteractionEvent { ItemId = "e1" }); + + var handler = new Mock(); + handler.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).Returns(Task.CompletedTask); + + var outbox = CreateOutbox(outboxStore, eventStore, [handler.Object]); + + // Act + var redelivered = await outbox.DispatchDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, redelivered); + outboxStore.Verify(s => s.DeleteAsync(message, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DispatchDueAsync_WhenRetryFails_IncrementsAttemptAndAppliesBackoff() + { + // Arrange + var message = new ContactCenterOutboxMessage { ItemId = "m1", EventId = "e1", Status = OutboxMessageStatus.Pending, AttemptCount = 1 }; + var outboxStore = new Mock(); + outboxStore.Setup(s => s.ListDueAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([message]); + + var eventStore = new Mock(); + eventStore.Setup(s => s.FindByIdAsync("e1", It.IsAny())).ReturnsAsync(new InteractionEvent { ItemId = "e1" }); + + var handler = new Mock(); + handler.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("still failing")); + + var outbox = CreateOutbox(outboxStore, eventStore, [handler.Object]); + + // Act + var redelivered = await outbox.DispatchDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, redelivered); + Assert.Equal(2, message.AttemptCount); + Assert.Equal(OutboxMessageStatus.Pending, message.Status); + Assert.Equal(_now.AddSeconds(60), message.NextAttemptUtc); + outboxStore.Verify(s => s.UpdateAsync(message, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DispatchDueAsync_WhenMaxAttemptsReached_DeadLetters() + { + // Arrange + var message = new ContactCenterOutboxMessage + { + ItemId = "m1", + EventId = "e1", + Status = OutboxMessageStatus.Pending, + AttemptCount = ContactCenterOutbox.MaxAttempts - 1, + }; + + var outboxStore = new Mock(); + outboxStore.Setup(s => s.ListDueAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([message]); + + var eventStore = new Mock(); + eventStore.Setup(s => s.FindByIdAsync("e1", It.IsAny())).ReturnsAsync(new InteractionEvent { ItemId = "e1" }); + + var handler = new Mock(); + handler.Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())).ThrowsAsync(new InvalidOperationException("permanent")); + + var outbox = CreateOutbox(outboxStore, eventStore, [handler.Object]); + + // Act + await outbox.DispatchDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(OutboxMessageStatus.DeadLettered, message.Status); + outboxStore.Verify(s => s.UpdateAsync(message, It.IsAny()), Times.Once); + } + + [Fact] + public async Task DispatchDueAsync_WhenEventNoLongerExists_DeadLetters() + { + // Arrange + var message = new ContactCenterOutboxMessage { ItemId = "m1", EventId = "missing", Status = OutboxMessageStatus.Pending, AttemptCount = 1 }; + var outboxStore = new Mock(); + outboxStore.Setup(s => s.ListDueAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync([message]); + + var eventStore = new Mock(); + eventStore.Setup(s => s.FindByIdAsync("missing", It.IsAny())).ReturnsAsync((InteractionEvent)null); + + var outbox = CreateOutbox(outboxStore, eventStore, []); + + // Act + await outbox.DispatchDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(OutboxMessageStatus.DeadLettered, message.Status); + outboxStore.Verify(s => s.UpdateAsync(message, It.IsAny()), Times.Once); + } + + private static ContactCenterOutbox CreateOutbox( + Mock outboxStore, + Mock eventStore, + IEnumerable handlers) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new ContactCenterOutbox( + handlers, + outboxStore.Object, + eventStore.Object, + clock.Object, + NullLogger.Instance); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs new file mode 100644 index 000000000..5fc15bc00 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs @@ -0,0 +1,193 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Handlers; +using CrestApps.OrchardCore.ContactCenter.Hubs; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterRealTimeEventHandlerTests +{ + private static readonly DateTime _now = new(2026, 6, 30, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task HandleAsync_AgentPresenceChanged_BroadcastsPresence() + { + // Arrange + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile + { + ItemId = "a1", + UserId = "u1", + DisplayName = "Agent One", + PresenceStatus = AgentPresenceStatus.Available, + QueueIds = ["q1"], + }); + + var notifier = new Mock(); + var handler = CreateHandler(notifier, agentManager: agentManager); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.AgentPresenceChanged, + AggregateId = "a1", + OccurredUtc = _now, + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + notifier.Verify( + n => n.NotifyPresenceChangedAsync( + It.Is(p => p.UserId == "u1" && p.Status == "Available"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_AgentReserved_BroadcastsOfferAndQueueStats() + { + // Arrange + var reservationManager = new Mock(); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "r1", + AgentId = "a1", + ActivityItemId = "act1", + QueueItemId = "qi1", + QueueId = "q1", + ExpiresUtc = _now.AddSeconds(30), + }); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a1", UserId = "u1" }); + + var queueItemStore = new Mock(); + queueItemStore.Setup(m => m.ListWaitingAsync("q1", It.IsAny())) + .ReturnsAsync([new QueueItem { ItemId = "qi1" }, new QueueItem { ItemId = "qi2" }]); + + var notifier = new Mock(); + var handler = CreateHandler(notifier, reservationManager, agentManager, queueItemStore); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.AgentReserved, + AggregateId = "r1", + OccurredUtc = _now, + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + notifier.Verify( + n => n.NotifyOfferReceivedAsync( + It.Is(o => o.UserId == "u1" && o.QueueId == "q1" && o.ReservationId == "r1"), + It.IsAny()), + Times.Once); + + notifier.Verify( + n => n.NotifyQueueStatsChangedAsync( + It.Is(q => q.QueueId == "q1" && q.WaitingCount == 2), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_QueueItemAssigned_BroadcastsOfferRevokedAccepted() + { + // Arrange + var reservationManager = new Mock(); + reservationManager.Setup(m => m.FindByIdAsync("r1", It.IsAny())) + .ReturnsAsync(new ActivityReservation + { + ItemId = "r1", + AgentId = "a1", + QueueId = "q1", + Status = ReservationStatus.Accepted, + }); + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())) + .ReturnsAsync(new AgentProfile { ItemId = "a1", UserId = "u1" }); + + var queueItemStore = new Mock(); + queueItemStore.Setup(m => m.ListWaitingAsync("q1", It.IsAny())).ReturnsAsync([]); + + var notifier = new Mock(); + var handler = CreateHandler(notifier, reservationManager, agentManager, queueItemStore); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemAssigned, + AggregateId = "r1", + OccurredUtc = _now, + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + notifier.Verify( + n => n.NotifyOfferRevokedAsync( + It.Is(o => o.UserId == "u1" && o.Reason == AgentOfferRevokedReason.Accepted), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task HandleAsync_QueueItemAdded_BroadcastsQueueStats() + { + // Arrange + var queueItemStore = new Mock(); + queueItemStore.Setup(m => m.FindByIdAsync("qi1", It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi1", QueueId = "q1" }); + queueItemStore.Setup(m => m.ListWaitingAsync("q1", It.IsAny())) + .ReturnsAsync([new QueueItem { ItemId = "qi1" }]); + + var notifier = new Mock(); + var handler = CreateHandler(notifier, queueItemStore: queueItemStore); + + var interactionEvent = new InteractionEvent + { + EventType = ContactCenterConstants.Events.QueueItemAdded, + AggregateId = "qi1", + OccurredUtc = _now, + }; + + // Act + await handler.HandleAsync(interactionEvent, TestContext.Current.CancellationToken); + + // Assert + notifier.Verify( + n => n.NotifyQueueStatsChangedAsync( + It.Is(q => q.QueueId == "q1" && q.WaitingCount == 1), + It.IsAny()), + Times.Once); + } + + private static ContactCenterRealTimeEventHandler CreateHandler( + Mock notifier, + Mock reservationManager = null, + Mock agentManager = null, + Mock queueItemStore = null) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new ContactCenterRealTimeEventHandler( + notifier.Object, + (agentManager ?? new Mock()).Object, + (reservationManager ?? new Mock()).Object, + (queueItemStore ?? new Mock()).Object, + clock.Object); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs index 313f05234..3560f904a 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DefaultContactCenterEventPublisherTests.cs @@ -12,16 +12,12 @@ public sealed class DefaultContactCenterEventPublisherTests private static readonly DateTime _now = new(2026, 6, 28, 12, 0, 0, DateTimeKind.Utc); [Fact] - public async Task PublishAsync_PersistsTheEvent_AndDispatchesToHandlers() + public async Task PublishAsync_PersistsTheEvent_AndDispatchesThroughTheOutbox() { // Arrange var store = CreateStore(); - var handler = new Mock(); - handler - .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - var publisher = CreatePublisher(store.Object, [handler.Object]); + var outbox = new Mock(); + var publisher = CreatePublisher(store.Object, outbox.Object); var interactionEvent = new InteractionEvent { @@ -34,7 +30,7 @@ public async Task PublishAsync_PersistsTheEvent_AndDispatchesToHandlers() // Assert store.Verify(s => s.CreateAsync(interactionEvent, It.IsAny()), Times.Once); - handler.Verify(h => h.HandleAsync(interactionEvent, It.IsAny()), Times.Once); + outbox.Verify(o => o.DispatchAsync(interactionEvent, It.IsAny()), Times.Once); } [Fact] @@ -42,7 +38,7 @@ public async Task PublishAsync_WhenOccurredUtcIsDefault_StampsFromClock() { // Arrange var store = CreateStore(); - var publisher = CreatePublisher(store.Object, []); + var publisher = CreatePublisher(store.Object, new Mock().Object); var interactionEvent = new InteractionEvent { EventType = ContactCenterConstants.Events.InteractionCreated, @@ -56,11 +52,11 @@ public async Task PublishAsync_WhenOccurredUtcIsDefault_StampsFromClock() } [Fact] - public async Task PublishAsync_WhenActorIdIsEmpty_SetsTheSystemActor() + public async Task PublishAsync_WhenItemIdIsEmpty_AssignsAnId() { // Arrange var store = CreateStore(); - var publisher = CreatePublisher(store.Object, []); + var publisher = CreatePublisher(store.Object, new Mock().Object); var interactionEvent = new InteractionEvent { EventType = ContactCenterConstants.Events.InteractionCreated, @@ -70,64 +66,51 @@ public async Task PublishAsync_WhenActorIdIsEmpty_SetsTheSystemActor() await publisher.PublishAsync(interactionEvent, CancellationToken.None); // Assert - Assert.Equal(ContactCenterConstants.SystemActor, interactionEvent.ActorId); + Assert.False(string.IsNullOrEmpty(interactionEvent.ItemId)); } [Fact] - public async Task PublishAsync_WhenIdempotencyKeyAlreadyExists_SkipsPersistAndDispatch() + public async Task PublishAsync_WhenActorIdIsEmpty_SetsTheSystemActor() { // Arrange var store = CreateStore(); - store - .Setup(s => s.ExistsByIdempotencyKeyAsync("dup-key", It.IsAny())) - .ReturnsAsync(true); - - var handler = new Mock(); - var publisher = CreatePublisher(store.Object, [handler.Object]); - + var publisher = CreatePublisher(store.Object, new Mock().Object); var interactionEvent = new InteractionEvent { EventType = ContactCenterConstants.Events.InteractionCreated, - IdempotencyKey = "dup-key", }; // Act await publisher.PublishAsync(interactionEvent, CancellationToken.None); // Assert - store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); - handler.Verify(h => h.HandleAsync(It.IsAny(), It.IsAny()), Times.Never); + Assert.Equal(ContactCenterConstants.SystemActor, interactionEvent.ActorId); } [Fact] - public async Task PublishAsync_WhenAHandlerThrows_DoesNotThrow_AndStillRunsOtherHandlers() + public async Task PublishAsync_WhenIdempotencyKeyAlreadyExists_SkipsPersistAndDispatch() { // Arrange var store = CreateStore(); + store + .Setup(s => s.ExistsByIdempotencyKeyAsync("dup-key", It.IsAny())) + .ReturnsAsync(true); - var faultyHandler = new Mock(); - faultyHandler - .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())) - .ThrowsAsync(new InvalidOperationException("boom")); - - var healthyHandler = new Mock(); - healthyHandler - .Setup(h => h.HandleAsync(It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - - var publisher = CreatePublisher(store.Object, [faultyHandler.Object, healthyHandler.Object]); + var outbox = new Mock(); + var publisher = CreatePublisher(store.Object, outbox.Object); var interactionEvent = new InteractionEvent { EventType = ContactCenterConstants.Events.InteractionCreated, + IdempotencyKey = "dup-key", }; // Act await publisher.PublishAsync(interactionEvent, CancellationToken.None); // Assert - store.Verify(s => s.CreateAsync(interactionEvent, It.IsAny()), Times.Once); - healthyHandler.Verify(h => h.HandleAsync(interactionEvent, It.IsAny()), Times.Once); + store.Verify(s => s.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + outbox.Verify(o => o.DispatchAsync(It.IsAny(), It.IsAny()), Times.Never); } private static Mock CreateStore() @@ -145,14 +128,14 @@ private static Mock CreateStore() private static DefaultContactCenterEventPublisher CreatePublisher( IInteractionEventStore store, - IEnumerable handlers) + IContactCenterOutbox outbox) { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); return new DefaultContactCenterEventPublisher( store, - handlers, + outbox, clock.Object, NullLogger.Instance); } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/QueueItemPrioritizerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/QueueItemPrioritizerTests.cs new file mode 100644 index 000000000..559c84feb --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/QueueItemPrioritizerTests.cs @@ -0,0 +1,72 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class QueueItemPrioritizerTests +{ + private static readonly DateTime _now = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public void SelectNext_WithoutAging_PrefersHighestPriority() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1" }; + var older = new QueueItem { ItemId = "old", Priority = InteractionPriority.Normal, EnqueuedUtc = _now.AddMinutes(-10) }; + var higher = new QueueItem { ItemId = "high", Priority = InteractionPriority.High, EnqueuedUtc = _now }; + + // Act + var selected = QueueItemPrioritizer.SelectNext([older, higher], queue, _now); + + // Assert + Assert.Same(higher, selected); + } + + [Fact] + public void SelectNext_WithoutAging_PrefersOldestWithinSamePriority() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1" }; + var newer = new QueueItem { ItemId = "newer", Priority = InteractionPriority.Normal, EnqueuedUtc = _now.AddMinutes(-1) }; + var older = new QueueItem { ItemId = "older", Priority = InteractionPriority.Normal, EnqueuedUtc = _now.AddMinutes(-5) }; + + // Act + var selected = QueueItemPrioritizer.SelectNext([newer, older], queue, _now); + + // Assert + Assert.Same(older, selected); + } + + [Fact] + public void SelectNext_WithAging_AgedLowPriorityBeatsNewerHigherPriority() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1", EnableSlaAging = true, SlaThresholdSeconds = 60 }; + var agedLow = new QueueItem { ItemId = "aged", Priority = InteractionPriority.Lowest, EnqueuedUtc = _now.AddSeconds(-200) }; + var freshHigher = new QueueItem { ItemId = "fresh", Priority = InteractionPriority.Low, EnqueuedUtc = _now }; + + // Act + var withAging = QueueItemPrioritizer.SelectNext([agedLow, freshHigher], queue, _now); + var withoutAging = QueueItemPrioritizer.SelectNext([agedLow, freshHigher], new ActivityQueue { ItemId = "q1" }, _now); + + // Assert + Assert.Same(agedLow, withAging); + Assert.Same(freshHigher, withoutAging); + } + + [Fact] + public void GetEffectivePriority_WithAging_AddsOneStepPerSlaInterval() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1", EnableSlaAging = true, SlaThresholdSeconds = 60 }; + var item = new QueueItem { Priority = InteractionPriority.Normal, EnqueuedUtc = _now.AddSeconds(-200) }; + + // Act + var effective = QueueItemPrioritizer.GetEffectivePriority(item, queue, _now); + + // Assert + // Normal (2) + floor((200 - 60) / 60) = 2 + 2 = 4. + Assert.Equal(4, effective); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/RoutingStrategyTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/RoutingStrategyTests.cs new file mode 100644 index 000000000..739fd58c7 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/RoutingStrategyTests.cs @@ -0,0 +1,91 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class RoutingStrategyTests +{ + private static readonly DateTime _now = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task RoundRobin_SelectsLeastRecentlyAssignedAgent() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1", RoutingStrategy = QueueRoutingStrategy.RoundRobin }; + var item = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var recentlyAssigned = new AgentProfile { ItemId = "a1", LastAssignedUtc = _now }; + var leastRecentlyAssigned = new AgentProfile { ItemId = "a2", LastAssignedUtc = _now.AddMinutes(-30) }; + + var service = new ActivityRoutingService([new RoundRobinRoutingStrategy()]); + + // Act + var decision = await service.SelectAgentAsync(queue, item, [recentlyAssigned, leastRecentlyAssigned], TestContext.Current.CancellationToken); + + // Assert + Assert.True(decision.Succeeded); + Assert.Same(leastRecentlyAssigned, decision.Agent); + } + + [Fact] + public async Task LeastBusy_SelectsAgentWithFewestActiveInteractions() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1", RoutingStrategy = QueueRoutingStrategy.LeastBusy }; + var item = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var busyAgent = new AgentProfile { ItemId = "a1" }; + var freeAgent = new AgentProfile { ItemId = "a2" }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.CountActiveByAgentAsync("a1", It.IsAny())).ReturnsAsync(2); + interactionManager.Setup(m => m.CountActiveByAgentAsync("a2", It.IsAny())).ReturnsAsync(0); + + var service = new ActivityRoutingService([new LeastBusyRoutingStrategy(interactionManager.Object)]); + + // Act + var decision = await service.SelectAgentAsync(queue, item, [busyAgent, freeAgent], TestContext.Current.CancellationToken); + + // Assert + Assert.True(decision.Succeeded); + Assert.Same(freeAgent, decision.Agent); + } + + [Fact] + public async Task StickyAgent_PreferredWhenQueueEnablesStickyRouting() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1", RoutingStrategy = QueueRoutingStrategy.LongestIdle, PreferStickyAgent = true }; + var item = new QueueItem { ItemId = "i1", QueueId = "q1", StickyAgentUserId = "u2" }; + var longestIdleAgent = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceChangedUtc = _now.AddMinutes(-10) }; + var stickyAgent = new AgentProfile { ItemId = "a2", UserId = "u2", PresenceChangedUtc = _now }; + + var service = new ActivityRoutingService([new StickyAgentRoutingStrategy(), new LongestIdleRoutingStrategy()]); + + // Act + var decision = await service.SelectAgentAsync(queue, item, [longestIdleAgent, stickyAgent], TestContext.Current.CancellationToken); + + // Assert + Assert.True(decision.Succeeded); + Assert.Same(stickyAgent, decision.Agent); + } + + [Fact] + public async Task PrimaryStrategy_OnlyTheSelectedQueueStrategyScores() + { + // Arrange + var queue = new ActivityQueue { ItemId = "q1", RoutingStrategy = QueueRoutingStrategy.RoundRobin }; + var item = new QueueItem { ItemId = "i1", QueueId = "q1" }; + var longestIdleButRecentlyAssigned = new AgentProfile { ItemId = "a1", PresenceChangedUtc = _now.AddMinutes(-30), LastAssignedUtc = _now }; + var newerButLeastRecentlyAssigned = new AgentProfile { ItemId = "a2", PresenceChangedUtc = _now, LastAssignedUtc = _now.AddMinutes(-30) }; + + var service = new ActivityRoutingService([new LongestIdleRoutingStrategy(), new RoundRobinRoutingStrategy()]); + + // Act + var decision = await service.SelectAgentAsync(queue, item, [longestIdleButRecentlyAssigned, newerButLeastRecentlyAssigned], TestContext.Current.CancellationToken); + + // Assert + Assert.True(decision.Succeeded); + Assert.Same(newerButLeastRecentlyAssigned, decision.Agent); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Recipes/NamedRecipeStepHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Recipes/NamedRecipeStepHandlerTests.cs index a778e61f4..0e258ebe2 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/Recipes/NamedRecipeStepHandlerTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Recipes/NamedRecipeStepHandlerTests.cs @@ -8,6 +8,8 @@ using CrestApps.Core.AI.Profiles; using CrestApps.Core.Models; using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; using Microsoft.Extensions.Options; using Moq; using OrchardCore.Recipes.Models; @@ -321,6 +323,87 @@ public async Task AIDataSourceStep_WhenDataSourceExists_ShouldUpdateInsteadOfCre manager.Verify(x => x.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task AgentStateReasonCodeStep_WhenReasonCodeExists_ShouldUpdateInsteadOfCreate() + { + // Arrange + var reasonCode = new AgentStateReasonCode + { + ItemId = "reason-1", + Name = "Lunch", + }; + + var manager = new Mock(); + manager.Setup(x => x.FindByIdAsync("reason-1", It.IsAny())) + .Returns(() => ValueTask.FromResult(reasonCode)); + manager.Setup(x => x.ValidateAsync(reasonCode, It.IsAny())) + .Returns(() => ValueTask.FromResult(new ValidationResultDetails())); + + var handler = CreateHandler( + "CrestApps.OrchardCore.ContactCenter.Recipes.AgentStateReasonCodeStep, CrestApps.OrchardCore.ContactCenter", + manager.Object, + null); + + var context = CreateContext("AgentStateReasonCode", new JsonObject + { + ["ReasonCodes"] = new JsonArray + { + new JsonObject + { + [nameof(AgentStateReasonCode.ItemId)] = "reason-1", + [nameof(AgentStateReasonCode.Name)] = "Lunch", + [nameof(AgentStateReasonCode.AppliesTo)] = "Break", + }, + }, + }); + + // Act + await ExecuteAsync(handler, context); + + // Assert + Assert.Empty(context.Errors); + manager.Verify(x => x.UpdateAsync(reasonCode, It.IsAny(), It.IsAny()), Times.Once); + manager.Verify(x => x.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task AgentStateReasonCodeStep_WhenReasonCodeIsNew_ShouldCreate() + { + // Arrange + var manager = new Mock(); + manager.Setup(x => x.FindByNameAsync("Lunch", It.IsAny())) + .Returns(() => Task.FromResult(null)); + manager.Setup(x => x.NewAsync(It.IsAny(), It.IsAny())) + .Returns(() => ValueTask.FromResult(new AgentStateReasonCode { Name = "Lunch" })); + manager.Setup(x => x.ValidateAsync(It.IsAny(), It.IsAny())) + .Returns(() => ValueTask.FromResult(new ValidationResultDetails())); + + var handler = CreateHandler( + "CrestApps.OrchardCore.ContactCenter.Recipes.AgentStateReasonCodeStep, CrestApps.OrchardCore.ContactCenter", + manager.Object, + null); + + var context = CreateContext("AgentStateReasonCode", new JsonObject + { + ["ReasonCodes"] = new JsonArray + { + new JsonObject + { + [nameof(AgentStateReasonCode.Name)] = "Lunch", + [nameof(AgentStateReasonCode.AppliesTo)] = "Break", + }, + }, + }); + + // Act + await ExecuteAsync(handler, context); + + // Assert + Assert.Empty(context.Errors); + manager.Verify(x => x.CreateAsync(It.IsAny(), It.IsAny()), Times.Once); + manager.Verify(x => x.UpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task McpConnectionStep_WhenConnectionExists_ShouldUpdateInsteadOfCreate() { From 061c8afeb14fcf867dc8831da2d6c0aac15789e6 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:04:43 -0700 Subject: [PATCH 16/56] Contact Center: signed provider voice webhook adapters + idempotency enforcement (G6/P1 #18) - IProviderVoiceWebhookAdapter (Abstractions): provider signature validation + normalization to ProviderVoiceEvent from an HTTP-agnostic ProviderVoiceWebhookRequest. - HmacProviderVoiceWebhookAdapterBase: constant-time HMAC-SHA256 signature verification. - IProviderVoiceWebhookProcessor: resolves adapter by technical name, verifies signature, requires an idempotency key on every parsed event, forwards accepted events to IProviderVoiceEventService. - Anonymous ProviderVoiceWebhookController POST /api/contact-center/voice/webhook/{provider}, authenticated by provider signature; maps outcomes to 200/401/404/400. - +6 unit tests (112 ContactCenter tests pass); clean -warnaserror build; changelog + PLAN updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../IProviderVoiceWebhookAdapter.cs | 30 ++++ .../Models/ProviderVoiceWebhookRequest.cs | 28 ++++ .../Models/ProviderVoiceWebhookOutcome.cs | 17 ++ .../Models/ProviderVoiceWebhookStatus.cs | 27 +++ .../HmacProviderVoiceWebhookAdapterBase.cs | 98 +++++++++++ .../IProviderVoiceWebhookProcessor.cs | 20 +++ .../Services/ProviderVoiceWebhookProcessor.cs | 75 +++++++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 1 + .../ProviderVoiceWebhookController.cs | 71 ++++++++ .../Startup.cs | 1 + .../ProviderVoiceWebhookProcessorTests.cs | 155 ++++++++++++++++++ 12 files changed, 525 insertions(+), 1 deletion(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceWebhookAdapter.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceWebhookRequest.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookOutcome.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookStatus.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/HmacProviderVoiceWebhookAdapterBase.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceWebhookProcessor.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceWebhookProcessor.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/ProviderVoiceWebhookController.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceWebhookProcessorTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 5e584abdb..5c1f43867 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1501,7 +1501,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - [x] **G3 — Completion unification via the Subject Flow (completes Phase 6):** make the Subject Flow the single decision controller and route every completion through `IActivityDispositionService`. *(P0 #8; P1 #10)* — **Shipped (2026-06-30):** completion already flows through the source-neutral `IActivityDispositionService` for CRM, inbound, and outbound, which marks the activity `Completed` regardless of its prior contact-center state (P0 #8) and runs the disposition-driven Subject Actions. Added `SubjectFlowSettings.RequireDisposition` (edited on the **Configure** screen) enforced centrally in `IActivityDispositionService` so completion is blocked until a disposition is chosen on every path; completion now also skips Subject Actions when no disposition is selected. **Reversal of the earlier wrap-up implementation:** the `WrapUp` feature / `WrapUpSession` aggregate added on 2026-06-30 was removed as redundant with disposition + subject flow (per the maintainer's "single concept" direction); after-call agent timing/capacity release is deferred to the Phase 7 agent desktop + agent presence. +3 disposition tests (47 ContactCenter + 4 disposition tests pass, clean `-warnaserror` build). - [~] **G4 — Dialer safety (completes Phase 5, pulls forward Phase 10 essentials):** strategy-per-mode (`IDialerStrategy`), an `IDialerEligibilityService`/compliance gate (DNC, communication preferences, calling windows, retry cool-down, suppression audit), cap Power, and block Predictive until metrics + abandonment controls exist. *(P0 #4, #5)* — **Shipped (2026-06-30):** `IDialerStrategy` + `IDialerStrategyResolver` with `PowerDialerStrategy` (hard-capped `MaxCallsPerAgent`) and `ProgressiveDialerStrategy`; `DialerService` now validates the profile and delegates pacing to the resolved strategy, so Manual/Preview stay agent-driven and **Predictive is blocked** (hidden in the editor, rejected on save, and refused at runtime). The single-attempt path moved to `IDialerAttemptService`, which calls the new `IDialerEligibilityService` (`DefaultDialerEligibilityService`) before every attempt: destination present, attempt limit, retry cool-down (last interaction end + `RetryDelayMinutes`), contact `DoNotCall` communication preference, configurable calling window evaluated in the contact's time zone, and any registered `INationalDoNotCallRegistry`. Suppressed attempts release the reservation and publish an auditable `DialSuppressed` event (DNC/registry cancel the activity; window/cool-down leave it available). Added calling-window settings to `DialerProfile`/editor. +16 dialer unit tests (66 ContactCenter tests pass; clean `-warnaserror` build). **Remaining (P2/Phase 10):** abandonment caps, AMD outcomes, and predictive metrics before Predictive can be re-enabled. - [~] **G5 — Agent desktop + supervisor real-time UX (Phase 7):** CRM-integrated agent cockpit, supervisor dashboards, queue monitor/wallboard, and scoped/audited live call-control intents. *(P1 #15, #16)* — **Started (2026-06-30):** shipped the canonical **agent state reason codes** prerequisite — a catalog-backed `AgentStateReasonCode` admin surface (Agents feature, **Interaction Center → Agent states**) following the Skills/TimeZones catalog pattern, an `AgentStateReasonCode` recipe step, a seed recipe executed at setup via `IRecipeMigrator`, and soft-phone presence-dropdown integration. **Real-time SignalR layer shipped (2026-06-30):** the new `RealTime` feature (`CrestApps.OrchardCore.ContactCenter.RealTime`, depends on `Queues` + the `SignalR` module) adds the `ContactCenterHub` (`Hub`) with per-user, per-queue, and `cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`; the live `AgentSession` aggregate (split from `AgentProfile`) with `IAgentSessionService` connect/disconnect/heartbeat; the `Heartbeat`-driven `AgentSessionCleanupBackgroundTask`; the `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`); `IContactCenterRealTimeNotifier` + the `ContactCenterRealTimeEventHandler` event projection that broadcasts presence (`PresenceChanged`), offers (`OfferReceived`/`OfferRevoked`), and queue depth (`QueueStatsChanged`); the `contact-center-realtime` client script resource; and the `MonitorContactCenter` permission + default **Supervisor** role. +13 unit tests (79 ContactCenter tests pass; clean `-warnaserror` build). **Remaining:** the CRM-integrated agent desktop (offer → accept+connect → customer 360 → script/subject → call controls), supervisor dashboards, queue monitor/wallboard, scoped/audited live call-control intents, and a reason-code deployment-plan step. This real-time layer also unblocks the remaining G2 item (real-time per-reservation offer timeout, P1 #14). -- [~] **G6 — Eventing/outbox + provider webhooks (hardens Phase 1, extends Phase 4):** outbox dispatch + retry/backoff, projection checkpoints, mandatory idempotency on provider events, rebuildable projections, and signed per-provider webhook adapters. *(P1 #17, #18)* — **Outbox shipped (2026-06-30):** Contact Center event dispatch is now at-least-once. `DefaultContactCenterEventPublisher` records the immutable `InteractionEvent` then delegates handler dispatch to the new `IContactCenterOutbox`/`ContactCenterOutbox`, which runs handlers inline and, on any handler failure, persists a durable `ContactCenterOutboxMessage` (`Pending`/`DeadLettered`, attempt count, next-attempt time, last error) via `IContactCenterOutboxStore`. The per-minute `OutboxDispatchBackgroundTask` calls `DispatchDueAsync`, re-running all handlers with exponential back-off (30s→30m cap) and dead-lettering after `MaxAttempts` (10); a missing referenced event is dead-lettered. Handlers must be idempotent (the shipped handlers are). +6 outbox tests + reworked 5 publisher tests (85 ContactCenter tests pass; clean `-warnaserror` build). **Remaining:** mandatory idempotency-key enforcement on provider-sourced events (currently deduped but not required); projection checkpoints + rebuildable read-model projections; and signed per-provider webhook adapters that validate signatures, dedupe, and normalize to `ProviderVoiceEvent` before entering the pipeline (P1 #18 — `VoiceIngressController` is still `[Authorize]`+`ManageInteractions`, accepting pre-normalized internal posts only). +- [~] **G6 — Eventing/outbox + provider webhooks (hardens Phase 1, extends Phase 4):** outbox dispatch + retry/backoff, projection checkpoints, mandatory idempotency on provider events, rebuildable projections, and signed per-provider webhook adapters. *(P1 #17, #18)* — **Outbox shipped (2026-06-30):** Contact Center event dispatch is now at-least-once. `DefaultContactCenterEventPublisher` records the immutable `InteractionEvent` then delegates handler dispatch to the new `IContactCenterOutbox`/`ContactCenterOutbox`, which runs handlers inline and, on any handler failure, persists a durable `ContactCenterOutboxMessage` (`Pending`/`DeadLettered`, attempt count, next-attempt time, last error) via `IContactCenterOutboxStore`. The per-minute `OutboxDispatchBackgroundTask` calls `DispatchDueAsync`, re-running all handlers with exponential back-off (30s→30m cap) and dead-lettering after `MaxAttempts` (10); a missing referenced event is dead-lettered. Handlers must be idempotent (the shipped handlers are). +6 outbox tests + reworked 5 publisher tests (85 ContactCenter tests pass; clean `-warnaserror` build). **Remaining:** projection checkpoints + rebuildable read-model projections. **(Idempotency-key enforcement + signed per-provider webhook adapters shipped 2026-06-30 — see change log.)** - [~] **G7 — Routing depth (completes Phase 3):** `RoutingPolicy`, `QueueMembership`, skill proficiency, business-hours/holiday calendars, overflow, sticky agent, priority/SLA-aging strategies. *(P1 #11)* — **Shipped (2026-06-30):** per-queue routing policy on `ActivityQueue` (`RoutingStrategy` = LongestIdle/RoundRobin/LeastBusy, `PreferStickyAgent`, `EnableSlaAging`, `BusinessHoursCalendarId` + `AfterHoursAction`, `OverflowQueueId` + `OverflowAfterSeconds`); `StickyAgentRoutingStrategy` (boosts the activity's last assigned user, captured on `QueueItem.StickyAgentUserId` at enqueue), `RoundRobinRoutingStrategy` (orders by new `AgentProfile.LastAssignedUtc`, stamped on reserve), and `LeastBusyRoutingStrategy` (orders by active interaction count) — each gated so only the queue's selected primary strategy scores; `QueueItemPrioritizer` SLA-aging item selection in the assignment path; a reusable `BusinessHoursCalendar` catalog (model/index/store/manager + `IBusinessHoursService` weekly-schedule + holiday + time-zone evaluation; module index provider/migration/handler/driver/controller/admin menu/views under **Interaction Center → Business hours**) that pauses assignment while closed; and `IActivityQueueService.OverflowDueAsync` (wait-time + after-hours overflow re-homing, publishing `QueueItemOverflowed`) run by the reservation/assignment background task. Queue editor extended with all new fields. +21 unit tests (106 ContactCenter tests pass; clean full-solution `-warnaserror` build). **Remaining:** skill **proficiency** levels (requires migrating agent skills from a name list to a proficiency map), a standalone `QueueMembership` aggregate (membership is still modeled via `AgentProfile.QueueIds`), and bullseye/skill-relaxation overflow expansion. - [ ] **G8 — Inbound entry points/IVR (Phase 8), recording/monitoring (Phase 9), compliance hardening (Phase 10), analytics (Phase 12)** per the existing phase plan once G1–G7 are stable. @@ -1527,3 +1527,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-06-30: **G5 advanced + G2 #13 closed — real-time SignalR layer (Phase 7).** Implemented the real-time foundation that the previous entry flagged as the next concrete step. Added the `CrestApps.OrchardCore.ContactCenter.RealTime` feature (depends on `Queues` + the `CrestApps.OrchardCore.SignalR` module; the module now references the SignalR project with `PrivateAssets="none"` and the `CrestApps.Core.SignalR` package). **Core:** the live `AgentSession` aggregate (model/index/`IAgentSessionStore`+store/`IAgentSessionManager`+manager, in the isolated ContactCenter collection) is split from the administrator-owned `AgentProfile` and tracks open SignalR connection ids, online flag, heartbeat, and queue/campaign snapshot; `IAgentSessionService`/`AgentSessionService` orchestrates `ConnectAsync`/`DisconnectAsync`/`HeartbeatAsync` (serialized per user with a distributed lock), builds the `AgentDesktopSnapshot` reconnect payload, and `ExpireStaleAsync` signs out + deletes sessions whose heartbeat is older than 90s (closing P0 #6/#13's "closed browser stays Available" bug, with a grace window for refresh/reconnect). **Module:** the `ContactCenterHub` (`Hub`, `[Authorize]`) registers connections, joins per-user/per-queue/`cc:supervisors` groups, exposes `Heartbeat`/`GetSnapshot`/`WatchQueue`/`UnwatchQueue`, and runs each call in a child shell scope authorized by `ContactCenterSignIntoQueues` (agents) or the new `MonitorContactCenter` (supervisors); the `AgentSession` index provider + migration; the `AgentSessionCleanupBackgroundTask` (per-minute); `IContactCenterRealTimeNotifier`/`ContactCenterRealTimeNotifier` over `IHubContext`; and the `ContactCenterRealTimeEventHandler` (`IContactCenterEventHandler`) that projects `AgentSignedIn/Out`/`AgentPresenceChanged` → `PresenceChanged`, `AgentReserved` → `OfferReceived`, `AgentReleased`/`QueueItemAssigned` → `OfferRevoked`, and `QueueItemAdded`/`QueueItemDequeued` → `QueueStatsChanged` to the affected agent, queue watchers, and supervisors. Added a hand-written static `wwwroot/scripts/contact-center-realtime.js` client helper registered as the `contact-center-realtime` resource (depends on `signalr`) that connects, heartbeats every 30s, fetches the snapshot, and dispatches the strongly-typed callbacks. Added the `MonitorContactCenter` permission + a default **Supervisor** role stereotype. Added 13 unit tests (`AgentSessionServiceTests`, `ContactCenterRealTimeEventHandlerTests`) for **79 ContactCenter tests passing**; ContactCenter projects build clean with `-warnaserror`. Docs (`contact-center/index.md` real-time section, `agents-queues-dialer.md` feature table/recipe, `v2.0.0` changelog) updated. **Remaining for G5/Phase 7:** the CRM-integrated agent desktop UI, supervisor dashboards, queue monitor/wallboard, scoped/audited live call-control intents, and a reason-code deployment-plan step; and the remaining G2 item — the real-time per-reservation offer timeout (P1 #14) driven from the desktop (the SignalR foundation + `ServerTimeUtc`/`ExpiresUtc` on the offer notification now exist; the background reservation-expiry task remains the safety net). Next: the agent desktop UI + supervisor dashboards on top of this hub, or G6 (eventing/outbox + signed provider webhooks). - 2026-06-30: **G6 started — reliable event dispatch (outbox) (P1 #17).** Made Contact Center domain-event dispatch at-least-once so a transient handler failure no longer silently drops an event. **Core:** added the `ContactCenterOutboxMessage` aggregate (`OutboxMessageStatus` Pending/DeadLettered, `EventId`, `EventType`, `AttemptCount`, `NextAttemptUtc`, `LastError`), its index, and `IContactCenterOutboxStore`/`ContactCenterOutboxStore` (`ListDueAsync`); and `IContactCenterOutbox`/`ContactCenterOutbox`, which owns handler execution: `DispatchAsync` runs every `IContactCenterEventHandler` inline (per-handler try/catch isolation) and, on any failure, persists a Pending retry message; `DispatchDueAsync` reloads the event, re-runs handlers, deletes the message on success, applies exponential back-off (base 30s, ×2 per attempt, capped at 30m) on failure, and dead-letters after `MaxAttempts` (10) or when the referenced event is gone. `DefaultContactCenterEventPublisher` was refactored to stamp + idempotency-dedupe + persist the immutable `InteractionEvent`, then delegate to the outbox (it no longer loops handlers itself). **Module:** registered the outbox store/service, the outbox index provider + migration, and the per-minute `OutboxDispatchBackgroundTask` in the base feature `Startup`. Reworked the 5 publisher tests (now assert persist + delegate-to-outbox + idempotency skip + stamping) and added 6 `ContactCenterOutboxTests` (success no-retry, failure schedules Pending + still runs other handlers, retry-succeeds-deletes, retry-fails-backoff, max-attempts dead-letters, missing-event dead-letters) for **85 ContactCenter tests passing**; ContactCenter projects build clean with `-warnaserror`; docs (`contact-center/index.md` "Domain events and reliable dispatch", `v2.0.0` changelog) updated. **Remaining for G6:** mandatory idempotency-key enforcement on provider-sourced events; projection checkpoints + rebuildable read-model projections; and signed per-provider webhook adapters (P1 #18) that validate provider signatures, dedupe, and normalize to `ProviderVoiceEvent` before the pipeline (`VoiceIngressController` still only accepts authenticated, pre-normalized internal posts). Next: signed provider webhook adapters (P1 #18) to finish G6, or G7 (routing depth). - 2026-06-30: **G7 advanced — routing depth completes Phase 3 (P1 #11).** Turned single-strategy routing into a per-queue routing policy. **Core:** extended `ActivityQueue` with `RoutingStrategy` (`QueueRoutingStrategy` = LongestIdle/RoundRobin/LeastBusy), `PreferStickyAgent`, `EnableSlaAging`, `BusinessHoursCalendarId` + `AfterHoursAction` (`QueueAfterHoursAction`), and `OverflowQueueId` + `OverflowAfterSeconds`; added `QueueItem.StickyAgentUserId`/`OverflowedFromQueueId` and `AgentProfile.LastAssignedUtc`. Added three routing strategies — `StickyAgentRoutingStrategy` (Order 30, additive boost for the activity's last assigned user, captured on the queue item when enqueued), `RoundRobinRoutingStrategy` and `LeastBusyRoutingStrategy` (Order 100) — and gated `LongestIdleRoutingStrategy`/RoundRobin/LeastBusy so only the queue's selected primary strategy scores. Added `QueueItemPrioritizer` (SLA aging: effective priority rises one step per SLA interval past threshold) used by `ActivityAssignmentService` to pick the next item; the assignment service now also pauses while the queue's business-hours calendar reports closed (new `IClock` + `IBusinessHoursService` deps). `ActivityReservationService` stamps `LastAssignedUtc` on reserve. Added a reusable `BusinessHoursCalendar` catalog (`BusinessHoursCalendar`/`BusinessHoursDay` models, index, store/manager mirroring Skills) and `IBusinessHoursService`/`DefaultBusinessHoursService` (time-zone-aware weekly-schedule + holiday evaluation). `IActivityQueueService.OverflowDueAsync` re-homes waiting items to the overflow queue (wait-time threshold or after-hours), publishing the new `QueueItemOverflowed` event; the reservation-expiry background task calls it per queue before assigning. **Module:** registered the strategies + business-hours store/manager/service/index/migration/handler/driver; added the `BusinessHoursCalendarsController` + **Interaction Center → Business hours** admin menu + Index/Edit/Create/summary views (`ManageQueues`); extended the queue editor (view model + driver + options provider + `ActivityQueueFields.Edit.cshtml`) with the routing-strategy select, sticky/SLA-aging toggles, business-hours + after-hours selects, and overflow queue + seconds. Added 21 unit tests (`BusinessHoursServiceTests`, `QueueItemPrioritizerTests`, `RoutingStrategyTests`, `ActivityQueueServiceTests`) and updated `ActivityAssignmentServiceTests` for the new ctor — **106 ContactCenter tests passing**; full solution builds clean with `-warnaserror`. Docs (`contact-center/agents-queues-dialer.md` routing-policy/business-hours/overflow sections + feature table, `v2.0.0` changelog) updated. **Remaining for G7:** skill **proficiency** levels (needs an agent-skill model migration), a standalone `QueueMembership` aggregate (membership still via `AgentProfile.QueueIds`), and bullseye/skill-relaxation overflow expansion. Next: finish G6 (signed provider webhook adapters, P1 #18), or build the Phase 7 agent desktop UI on the real-time hub. +- 2026-07-01: **G6 advanced — signed provider webhook adapters + idempotency enforcement (P1 #18).** Added a provider-grade voice webhook boundary. New `IProviderVoiceWebhookAdapter` (Abstractions) validates a provider signature and normalizes a delivery into `ProviderVoiceEvent`s from an HTTP-agnostic `ProviderVoiceWebhookRequest` (headers/body/query); `HmacProviderVoiceWebhookAdapterBase` provides constant-time HMAC-SHA256 verification with configurable secret + header. `IProviderVoiceWebhookProcessor`/`ProviderVoiceWebhookProcessor` (Core) resolves the adapter by technical name, verifies the signature, **requires an idempotency key on every parsed event** (rejects the batch otherwise), and forwards accepted events to `IProviderVoiceEventService`. The anonymous `ProviderVoiceWebhookController` (`POST /api/contact-center/voice/webhook/{provider}`) reads the raw body, maps the outcome to 200/401/404/400, and is authenticated purely by the provider signature. Registered in `VoiceStartup`. +6 tests (`ProviderVoiceWebhookProcessorTests`: valid/keyed ingest, unknown provider, invalid signature, missing key, HMAC match/tamper/no-secret) for **112 ContactCenter tests passing**; clean `-warnaserror` build; docs (`v2.0.0` changelog) updated. **Remaining for G6:** projection checkpoints + rebuildable read-model projections. Next: G5 agent desktop UI, or G2 optimistic concurrency on reservation. diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceWebhookAdapter.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceWebhookAdapter.cs new file mode 100644 index 000000000..abe3352a8 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IProviderVoiceWebhookAdapter.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter; + +/// +/// Validates and normalizes provider-specific voice webhooks into provider-neutral +/// instances. Each telephony/PBX provider that delivers signed +/// webhooks contributes one adapter so the Contact Center accepts provider callbacks safely. +/// +public interface IProviderVoiceWebhookAdapter +{ + /// + /// Gets the technical name of the provider this adapter handles. Matched against the webhook route. + /// + string TechnicalName { get; } + + /// + /// Validates the provider signature carried by the webhook request. + /// + /// The webhook request to validate. + /// when the signature is valid; otherwise, . + bool ValidateSignature(ProviderVoiceWebhookRequest request); + + /// + /// Parses the webhook request into one or more normalized provider voice events. + /// + /// The validated webhook request. + /// The normalized provider voice events; may be empty when the payload carries no call state. + IReadOnlyList Parse(ProviderVoiceWebhookRequest request); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceWebhookRequest.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceWebhookRequest.cs new file mode 100644 index 000000000..b81ff3dca --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ProviderVoiceWebhookRequest.cs @@ -0,0 +1,28 @@ +namespace CrestApps.OrchardCore.ContactCenter.Models; + +/// +/// Represents a provider webhook delivery in an HTTP-agnostic form so provider adapters can validate +/// signatures and normalize the payload without depending on the web stack. +/// +public sealed class ProviderVoiceWebhookRequest +{ + /// + /// Gets or sets the technical name of the provider the webhook was delivered for. + /// + public string Provider { get; set; } + + /// + /// Gets or sets the raw request body exactly as received, used for signature verification. + /// + public string Body { get; set; } + + /// + /// Gets the request headers, keyed by header name. + /// + public IDictionary Headers { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); + + /// + /// Gets the query string values, keyed by parameter name. + /// + public IDictionary Query { get; init; } = new Dictionary(StringComparer.OrdinalIgnoreCase); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookOutcome.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookOutcome.cs new file mode 100644 index 000000000..5b24343e0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookOutcome.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Represents the outcome of processing a provider voice webhook. +/// +public sealed class ProviderVoiceWebhookOutcome +{ + /// + /// Gets or sets the status of the webhook processing. + /// + public ProviderVoiceWebhookStatus Status { get; set; } + + /// + /// Gets or sets the number of normalized events that were ingested. + /// + public int ProcessedCount { get; set; } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookStatus.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookStatus.cs new file mode 100644 index 000000000..9bad25cc1 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ProviderVoiceWebhookStatus.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Identifies the result of processing a provider voice webhook. +/// +public enum ProviderVoiceWebhookStatus +{ + /// + /// The webhook was accepted and its events were ingested. + /// + Accepted, + + /// + /// No adapter is registered for the requested provider. + /// + UnknownProvider, + + /// + /// The provider signature failed validation. + /// + InvalidSignature, + + /// + /// A parsed event was missing the required idempotency key. + /// + MissingIdempotencyKey, +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/HmacProviderVoiceWebhookAdapterBase.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/HmacProviderVoiceWebhookAdapterBase.cs new file mode 100644 index 000000000..5ab9e6144 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/HmacProviderVoiceWebhookAdapterBase.cs @@ -0,0 +1,98 @@ +using System.Security.Cryptography; +using System.Text; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides a reusable base for provider webhook adapters that authenticate deliveries with an +/// HMAC-SHA256 signature computed over the raw request body. Subclasses supply the shared secret, the +/// signature header name, and the payload parsing. +/// +public abstract class HmacProviderVoiceWebhookAdapterBase : IProviderVoiceWebhookAdapter +{ + /// + public abstract string TechnicalName { get; } + + /// + /// Gets the name of the header that carries the provider signature. + /// + protected abstract string SignatureHeaderName { get; } + + /// + /// Gets the shared secret used to compute the expected signature, or when the + /// provider is not configured. + /// + protected abstract string SigningSecret { get; } + + /// + public virtual bool ValidateSignature(ProviderVoiceWebhookRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var secret = SigningSecret; + + if (string.IsNullOrEmpty(secret)) + { + return false; + } + + if (!request.Headers.TryGetValue(SignatureHeaderName, out var providedSignature) || string.IsNullOrEmpty(providedSignature)) + { + return false; + } + + var expected = ComputeSignature(secret, request.Body ?? string.Empty); + + var providedBytes = DecodeSignature(providedSignature); + var expectedBytes = DecodeSignature(expected); + + if (providedBytes is null || expectedBytes is null || providedBytes.Length != expectedBytes.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals(providedBytes, expectedBytes); + } + + /// + public abstract IReadOnlyList Parse(ProviderVoiceWebhookRequest request); + + /// + /// Computes the lowercase hexadecimal HMAC-SHA256 signature of the payload using the supplied secret. + /// + /// The shared signing secret. + /// The raw payload to sign. + /// The lowercase hexadecimal signature. + protected static string ComputeSignature(string secret, string payload) + { + ArgumentNullException.ThrowIfNull(secret); + ArgumentNullException.ThrowIfNull(payload); + + var key = Encoding.UTF8.GetBytes(secret); + var hash = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(payload)); + + return Convert.ToHexStringLower(hash); + } + + private static byte[] DecodeSignature(string signature) + { + var value = signature.Trim(); + + var separatorIndex = value.IndexOf('='); + + if (separatorIndex >= 0 && separatorIndex < value.Length - 1) + { + value = value[(separatorIndex + 1)..]; + } + + try + { + return Convert.FromHexString(value); + } + catch (FormatException) + { + return null; + } + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceWebhookProcessor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceWebhookProcessor.cs new file mode 100644 index 000000000..718ab0c22 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IProviderVoiceWebhookProcessor.cs @@ -0,0 +1,20 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Validates and ingests provider voice webhooks: it resolves the provider adapter, verifies the +/// provider signature, enforces mandatory idempotency keys, and forwards normalized events to the +/// provider voice event pipeline. +/// +public interface IProviderVoiceWebhookProcessor +{ + /// + /// Processes a provider voice webhook request end to end. + /// + /// The webhook request. + /// The token to monitor for cancellation requests. + /// The processing outcome. + Task ProcessAsync(ProviderVoiceWebhookRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceWebhookProcessor.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceWebhookProcessor.cs new file mode 100644 index 000000000..86a161541 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceWebhookProcessor.cs @@ -0,0 +1,75 @@ +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 ProviderVoiceWebhookProcessor : IProviderVoiceWebhookProcessor +{ + private readonly IEnumerable _adapters; + private readonly IProviderVoiceEventService _providerVoiceEventService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The registered provider webhook adapters. + /// The provider voice event ingestion service. + /// The logger instance. + public ProviderVoiceWebhookProcessor( + IEnumerable adapters, + IProviderVoiceEventService providerVoiceEventService, + ILogger logger) + { + _adapters = adapters; + _providerVoiceEventService = providerVoiceEventService; + _logger = logger; + } + + /// + public async Task ProcessAsync(ProviderVoiceWebhookRequest request, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var adapter = _adapters.FirstOrDefault(a => string.Equals(a.TechnicalName, request.Provider, StringComparison.OrdinalIgnoreCase)); + + if (adapter is null) + { + return new ProviderVoiceWebhookOutcome { Status = ProviderVoiceWebhookStatus.UnknownProvider }; + } + + if (!adapter.ValidateSignature(request)) + { + _logger.LogWarning("Rejected a voice webhook for provider '{Provider}' because the signature failed validation.", request.Provider); + + return new ProviderVoiceWebhookOutcome { Status = ProviderVoiceWebhookStatus.InvalidSignature }; + } + + var events = adapter.Parse(request) ?? []; + + if (events.Any(providerEvent => string.IsNullOrEmpty(providerEvent.IdempotencyKey))) + { + _logger.LogWarning("Rejected a voice webhook for provider '{Provider}' because a parsed event was missing an idempotency key.", request.Provider); + + return new ProviderVoiceWebhookOutcome { Status = ProviderVoiceWebhookStatus.MissingIdempotencyKey }; + } + + var processed = 0; + + foreach (var providerEvent in events) + { + providerEvent.ProviderName ??= adapter.TechnicalName; + await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); + processed++; + } + + return new ProviderVoiceWebhookOutcome + { + Status = ProviderVoiceWebhookStatus.Accepted, + ProcessedCount = processed, + }; + } +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index b23a710d1..0d2e474cd 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -253,6 +253,7 @@ At a high level, the platform changes are: - 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. A retry re-runs every handler, so handlers must be idempotent (the shipped handlers are, and provider-sourced events carry an idempotency key). - 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. +- Adds signed **provider voice webhook** ingestion. A provider posts to `POST /api/contact-center/voice/webhook/{provider}`; the request is authenticated by the provider adapter's signature (not a user identity), so the endpoint is anonymous but every delivery must carry a valid signature. `IProviderVoiceWebhookAdapter` validates the signature and normalizes the payload into `ProviderVoiceEvent`s, `HmacProviderVoiceWebhookAdapterBase` provides constant-time HMAC-SHA256 verification, and `IProviderVoiceWebhookProcessor` resolves the adapter, verifies the signature, **requires an idempotency key on every parsed event**, and forwards accepted events to the provider voice event pipeline. - See [Domain events and reliable dispatch](../contact-center/index.md#domain-events-and-reliable-dispatch). ### Content Access Control diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/ProviderVoiceWebhookController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/ProviderVoiceWebhookController.cs new file mode 100644 index 000000000..49e5ec175 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/ProviderVoiceWebhookController.cs @@ -0,0 +1,71 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Accepts signed provider voice webhooks. Each provider posts to its own route segment; the request is +/// authenticated by the provider adapter's signature check rather than by a user identity, so the +/// endpoint is anonymous but every delivery must carry a valid provider signature. +/// +[ApiController] +[AllowAnonymous] +[Route("api/contact-center/voice/webhook")] +[Feature(ContactCenterConstants.Feature.Voice)] +public sealed class ProviderVoiceWebhookController : ControllerBase +{ + private readonly IProviderVoiceWebhookProcessor _processor; + + /// + /// Initializes a new instance of the class. + /// + /// The provider voice webhook processor. + public ProviderVoiceWebhookController(IProviderVoiceWebhookProcessor processor) + { + _processor = processor; + } + + /// + /// Receives a signed webhook for the specified provider and ingests its normalized voice events. + /// + /// The technical name of the provider that owns the webhook. + /// An HTTP result describing whether the webhook was accepted. + [HttpPost("{provider}")] + public async Task Receive(string provider) + { + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(HttpContext.RequestAborted); + + var request = new ProviderVoiceWebhookRequest + { + Provider = provider, + Body = body, + }; + + foreach (var header in Request.Headers) + { + request.Headers[header.Key] = header.Value.ToString(); + } + + foreach (var query in Request.Query) + { + request.Query[query.Key] = query.Value.ToString(); + } + + var outcome = await _processor.ProcessAsync(request, HttpContext.RequestAborted); + + return outcome.Status switch + { + ProviderVoiceWebhookStatus.Accepted => Ok(new { processed = outcome.ProcessedCount }), + ProviderVoiceWebhookStatus.UnknownProvider => NotFound(), + ProviderVoiceWebhookStatus.InvalidSignature => Unauthorized(), + ProviderVoiceWebhookStatus.MissingIdempotencyKey => BadRequest(), + _ => BadRequest(), + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 6db1435ed..faa2dfad0 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -238,6 +238,7 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped(sp => sp.GetRequiredService()) .AddScoped(sp => sp.GetRequiredService()) diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceWebhookProcessorTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceWebhookProcessorTests.cs new file mode 100644 index 000000000..c70658114 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceWebhookProcessorTests.cs @@ -0,0 +1,155 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ProviderVoiceWebhookProcessorTests +{ + [Fact] + public async Task ProcessAsync_WithValidSignatureAndKeyedEvents_IngestsEachEvent() + { + // Arrange + var eventService = new Mock(); + var adapter = new FakeAdapter + { + Parser = _ => + [ + new ProviderVoiceEvent { ProviderCallId = "c1", IdempotencyKey = "k1" }, + new ProviderVoiceEvent { ProviderCallId = "c1", IdempotencyKey = "k2" }, + ], + }; + + var processor = CreateProcessor(eventService, adapter); + + // Act + var outcome = await processor.ProcessAsync(new ProviderVoiceWebhookRequest { Provider = "fake" }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ProviderVoiceWebhookStatus.Accepted, outcome.Status); + Assert.Equal(2, outcome.ProcessedCount); + eventService.Verify(s => s.IngestAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task ProcessAsync_WithUnknownProvider_ReturnsUnknownProvider() + { + // Arrange + var eventService = new Mock(); + var processor = CreateProcessor(eventService, new FakeAdapter { TechnicalName = "other" }); + + // Act + var outcome = await processor.ProcessAsync(new ProviderVoiceWebhookRequest { Provider = "fake" }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ProviderVoiceWebhookStatus.UnknownProvider, outcome.Status); + eventService.Verify(s => s.IngestAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessAsync_WithInvalidSignature_RejectsWithoutIngesting() + { + // Arrange + var eventService = new Mock(); + var adapter = new FakeAdapter { Validate = _ => false }; + var processor = CreateProcessor(eventService, adapter); + + // Act + var outcome = await processor.ProcessAsync(new ProviderVoiceWebhookRequest { Provider = "fake" }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ProviderVoiceWebhookStatus.InvalidSignature, outcome.Status); + eventService.Verify(s => s.IngestAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task ProcessAsync_WithEventMissingIdempotencyKey_RejectsWithoutIngesting() + { + // Arrange + var eventService = new Mock(); + var adapter = new FakeAdapter + { + Parser = _ => [new ProviderVoiceEvent { ProviderCallId = "c1" }], + }; + + var processor = CreateProcessor(eventService, adapter); + + // Act + var outcome = await processor.ProcessAsync(new ProviderVoiceWebhookRequest { Provider = "fake" }, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(ProviderVoiceWebhookStatus.MissingIdempotencyKey, outcome.Status); + eventService.Verify(s => s.IngestAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public void HmacAdapter_ValidatesMatchingSignatureAndRejectsTampering() + { + // Arrange + var adapter = new TestHmacAdapter("shhh"); + const string body = "{\"call\":\"c1\"}"; + var validRequest = new ProviderVoiceWebhookRequest { Body = body }; + validRequest.Headers["X-Signature"] = adapter.Sign(body); + + var tamperedRequest = new ProviderVoiceWebhookRequest { Body = body + "x" }; + tamperedRequest.Headers["X-Signature"] = adapter.Sign(body); + + // Act & Assert + Assert.True(adapter.ValidateSignature(validRequest)); + Assert.False(adapter.ValidateSignature(tamperedRequest)); + } + + [Fact] + public void HmacAdapter_WithoutConfiguredSecret_RejectsSignature() + { + // Arrange + var adapter = new TestHmacAdapter(secret: null); + var request = new ProviderVoiceWebhookRequest { Body = "{}" }; + request.Headers["X-Signature"] = "deadbeef"; + + // Act + var valid = adapter.ValidateSignature(request); + + // Assert + Assert.False(valid); + } + + private static ProviderVoiceWebhookProcessor CreateProcessor(Mock eventService, params IProviderVoiceWebhookAdapter[] adapters) + { + return new ProviderVoiceWebhookProcessor(adapters, eventService.Object, NullLogger.Instance); + } + + private sealed class FakeAdapter : IProviderVoiceWebhookAdapter + { + public string TechnicalName { get; init; } = "fake"; + + public Func Validate { get; init; } = _ => true; + + public Func> Parser { get; init; } = _ => []; + + public bool ValidateSignature(ProviderVoiceWebhookRequest request) => Validate(request); + + public IReadOnlyList Parse(ProviderVoiceWebhookRequest request) => Parser(request); + } + + private sealed class TestHmacAdapter : HmacProviderVoiceWebhookAdapterBase + { + public TestHmacAdapter(string secret) + { + SigningSecret = secret; + } + + public override string TechnicalName => "test"; + + protected override string SignatureHeaderName => "X-Signature"; + + protected override string SigningSecret { get; } + + public override IReadOnlyList Parse(ProviderVoiceWebhookRequest request) => []; + + public string Sign(string body) => ComputeSignature(SigningSecret, body); + } +} From 5cb467540a6e944012d8936329b9678685ea8c19 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:09:15 -0700 Subject: [PATCH 17/56] Contact Center: compare-and-set on reservation creation (G2/P0 #7) ActivityReservationService.ReserveAsync re-reads the queue item and aborts unless it is still Waiting, preventing double-reservation across nodes. +1 test; updated existing reserve tests to mock the re-fetch. 113 ContactCenter tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 ++- .../Services/ActivityReservationService.cs | 9 +++++++ .../ActivityReservationServiceTests.cs | 26 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 5c1f43867..7324dfbdb 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1497,7 +1497,7 @@ Keep this section current. Use the checklist below to track phase-level progress Ordered by the "Design review" execution order. Each item is a hard requirement to reach a state-of-the-art dialer; numbers reference the P0/P1 findings. - [~] **G1 — Voice foundation hardening (completes Phase 4):** redesign the voice-provider boundary with delivery models (`AgentDeviceNative` vs `ServerSideAcd`), capability-gated lifecycle ops, and a normalized `ProviderVoiceEvent`/`IProviderVoiceEventHandler`; add a `CallSession` aggregate; add a unified Contact Center call-command service that delivers media to the agent on inbound accept and outbound answer as one atomic, audited transition. *(P0 #1, #2, #3; P1 #9)* — **Backend shipped (2026-06-30):** `VoiceProviderDeliveryModel` + `ContactCenterConnectRequest` + `ConnectToAgentAsync` + `AgentConnect` capability on `IContactCenterVoiceProvider` (DialPad declares `AgentDeviceNative`); `CallSession` model/index/store/manager/migration registered in the base feature; `ProviderVoiceEvent` + `IProviderVoiceEventService` idempotent ingestion that advances interaction+call-session and bridges answered outbound calls on server-side ACD; `IContactCenterCallCommandService` accept/decline wired into `VoiceController`; +5 unit tests (41 ContactCenter tests pass, clean `-warnaserror` build). **Remaining:** soft-phone JS coordination (await accept, answer device only when `RequiresDeviceAnswer`) + asset rebuild; per-provider signed webhook adapters that emit `ProviderVoiceEvent`; transfer/conference taxonomy. -- [~] **G2 — Assignment safety (hardens Phases 2/3):** distributed-lock/single-writer assignment + optimistic concurrency on reservation; enforce `MaxConcurrentInteractions`; split a live `AgentSession` from `AgentProfile` with SignalR heartbeat + stale cleanup; drive offer timeout from the real-time layer. *(P0 #6, #7; P1 #13, #14)* — **P0 core shipped (2026-06-30):** per-queue distributed-lock single-writer assignment in `ActivityAssignmentService` (both `AssignNextAsync` and `AssignQueueAsync` acquire the lock; inbound `OfferNextAsync` routes through the same path), and `MaxConcurrentInteractions` enforcement via the new `CapacityRoutingStrategy` (Order 20, between required-skills and longest-idle) backed by `IInteractionManager.CountActiveByAgentAsync`. +6 unit tests (47 ContactCenter tests pass, clean `-warnaserror` build). **P1 #13 shipped (2026-06-30):** the live `AgentSession` aggregate (model/index/store/manager + `IAgentSessionService`) is now split from `AgentProfile`, the `ContactCenterHub` registers each SignalR connection on the session with a per-user distributed lock, the client sends a `Heartbeat` every 30s, and the `AgentSessionCleanupBackgroundTask` signs out + deletes sessions whose heartbeat is older than 90s so routing stops targeting a dead client (a brief reconnect is tolerated by the grace window). **Remaining:** optimistic concurrency / compare-and-set on reservation creation (P0 #7 hardening); and the real-time per-reservation offer timeout (P1 #14) driven from the desktop (the SignalR foundation + `ServerTimeUtc`/`ExpiresUtc` on the offer notification now exist; the background reservation-expiry task remains the safety net). +- [~] **G2 — Assignment safety (hardens Phases 2/3):** distributed-lock/single-writer assignment + optimistic concurrency on reservation; enforce `MaxConcurrentInteractions`; split a live `AgentSession` from `AgentProfile` with SignalR heartbeat + stale cleanup; drive offer timeout from the real-time layer. *(P0 #6, #7; P1 #13, #14)* — **P0 core shipped (2026-06-30):** per-queue distributed-lock single-writer assignment in `ActivityAssignmentService` (both `AssignNextAsync` and `AssignQueueAsync` acquire the lock; inbound `OfferNextAsync` routes through the same path), and `MaxConcurrentInteractions` enforcement via the new `CapacityRoutingStrategy` (Order 20, between required-skills and longest-idle) backed by `IInteractionManager.CountActiveByAgentAsync`. +6 unit tests (47 ContactCenter tests pass, clean `-warnaserror` build). **P1 #13 shipped (2026-06-30):** the live `AgentSession` aggregate (model/index/store/manager + `IAgentSessionService`) is now split from `AgentProfile`, the `ContactCenterHub` registers each SignalR connection on the session with a per-user distributed lock, the client sends a `Heartbeat` every 30s, and the `AgentSessionCleanupBackgroundTask` signs out + deletes sessions whose heartbeat is older than 90s so routing stops targeting a dead client (a brief reconnect is tolerated by the grace window). **Remaining:** the real-time per-reservation offer timeout (P1 #14) driven from the desktop (the SignalR foundation + `ServerTimeUtc`/`ExpiresUtc` on the offer notification now exist; the background reservation-expiry task remains the safety net). **(Compare-and-set on reservation creation shipped 2026-07-01: `ReserveAsync` re-reads the queue item and aborts unless it is still `Waiting`.)** - [x] **G3 — Completion unification via the Subject Flow (completes Phase 6):** make the Subject Flow the single decision controller and route every completion through `IActivityDispositionService`. *(P0 #8; P1 #10)* — **Shipped (2026-06-30):** completion already flows through the source-neutral `IActivityDispositionService` for CRM, inbound, and outbound, which marks the activity `Completed` regardless of its prior contact-center state (P0 #8) and runs the disposition-driven Subject Actions. Added `SubjectFlowSettings.RequireDisposition` (edited on the **Configure** screen) enforced centrally in `IActivityDispositionService` so completion is blocked until a disposition is chosen on every path; completion now also skips Subject Actions when no disposition is selected. **Reversal of the earlier wrap-up implementation:** the `WrapUp` feature / `WrapUpSession` aggregate added on 2026-06-30 was removed as redundant with disposition + subject flow (per the maintainer's "single concept" direction); after-call agent timing/capacity release is deferred to the Phase 7 agent desktop + agent presence. +3 disposition tests (47 ContactCenter + 4 disposition tests pass, clean `-warnaserror` build). - [~] **G4 — Dialer safety (completes Phase 5, pulls forward Phase 10 essentials):** strategy-per-mode (`IDialerStrategy`), an `IDialerEligibilityService`/compliance gate (DNC, communication preferences, calling windows, retry cool-down, suppression audit), cap Power, and block Predictive until metrics + abandonment controls exist. *(P0 #4, #5)* — **Shipped (2026-06-30):** `IDialerStrategy` + `IDialerStrategyResolver` with `PowerDialerStrategy` (hard-capped `MaxCallsPerAgent`) and `ProgressiveDialerStrategy`; `DialerService` now validates the profile and delegates pacing to the resolved strategy, so Manual/Preview stay agent-driven and **Predictive is blocked** (hidden in the editor, rejected on save, and refused at runtime). The single-attempt path moved to `IDialerAttemptService`, which calls the new `IDialerEligibilityService` (`DefaultDialerEligibilityService`) before every attempt: destination present, attempt limit, retry cool-down (last interaction end + `RetryDelayMinutes`), contact `DoNotCall` communication preference, configurable calling window evaluated in the contact's time zone, and any registered `INationalDoNotCallRegistry`. Suppressed attempts release the reservation and publish an auditable `DialSuppressed` event (DNC/registry cancel the activity; window/cool-down leave it available). Added calling-window settings to `DialerProfile`/editor. +16 dialer unit tests (66 ContactCenter tests pass; clean `-warnaserror` build). **Remaining (P2/Phase 10):** abandonment caps, AMD outcomes, and predictive metrics before Predictive can be re-enabled. - [~] **G5 — Agent desktop + supervisor real-time UX (Phase 7):** CRM-integrated agent cockpit, supervisor dashboards, queue monitor/wallboard, and scoped/audited live call-control intents. *(P1 #15, #16)* — **Started (2026-06-30):** shipped the canonical **agent state reason codes** prerequisite — a catalog-backed `AgentStateReasonCode` admin surface (Agents feature, **Interaction Center → Agent states**) following the Skills/TimeZones catalog pattern, an `AgentStateReasonCode` recipe step, a seed recipe executed at setup via `IRecipeMigrator`, and soft-phone presence-dropdown integration. **Real-time SignalR layer shipped (2026-06-30):** the new `RealTime` feature (`CrestApps.OrchardCore.ContactCenter.RealTime`, depends on `Queues` + the `SignalR` module) adds the `ContactCenterHub` (`Hub`) with per-user, per-queue, and `cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`; the live `AgentSession` aggregate (split from `AgentProfile`) with `IAgentSessionService` connect/disconnect/heartbeat; the `Heartbeat`-driven `AgentSessionCleanupBackgroundTask`; the `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`); `IContactCenterRealTimeNotifier` + the `ContactCenterRealTimeEventHandler` event projection that broadcasts presence (`PresenceChanged`), offers (`OfferReceived`/`OfferRevoked`), and queue depth (`QueueStatsChanged`); the `contact-center-realtime` client script resource; and the `MonitorContactCenter` permission + default **Supervisor** role. +13 unit tests (79 ContactCenter tests pass; clean `-warnaserror` build). **Remaining:** the CRM-integrated agent desktop (offer → accept+connect → customer 360 → script/subject → call controls), supervisor dashboards, queue monitor/wallboard, scoped/audited live call-control intents, and a reason-code deployment-plan step. This real-time layer also unblocks the remaining G2 item (real-time per-reservation offer timeout, P1 #14). @@ -1528,3 +1528,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-06-30: **G6 started — reliable event dispatch (outbox) (P1 #17).** Made Contact Center domain-event dispatch at-least-once so a transient handler failure no longer silently drops an event. **Core:** added the `ContactCenterOutboxMessage` aggregate (`OutboxMessageStatus` Pending/DeadLettered, `EventId`, `EventType`, `AttemptCount`, `NextAttemptUtc`, `LastError`), its index, and `IContactCenterOutboxStore`/`ContactCenterOutboxStore` (`ListDueAsync`); and `IContactCenterOutbox`/`ContactCenterOutbox`, which owns handler execution: `DispatchAsync` runs every `IContactCenterEventHandler` inline (per-handler try/catch isolation) and, on any failure, persists a Pending retry message; `DispatchDueAsync` reloads the event, re-runs handlers, deletes the message on success, applies exponential back-off (base 30s, ×2 per attempt, capped at 30m) on failure, and dead-letters after `MaxAttempts` (10) or when the referenced event is gone. `DefaultContactCenterEventPublisher` was refactored to stamp + idempotency-dedupe + persist the immutable `InteractionEvent`, then delegate to the outbox (it no longer loops handlers itself). **Module:** registered the outbox store/service, the outbox index provider + migration, and the per-minute `OutboxDispatchBackgroundTask` in the base feature `Startup`. Reworked the 5 publisher tests (now assert persist + delegate-to-outbox + idempotency skip + stamping) and added 6 `ContactCenterOutboxTests` (success no-retry, failure schedules Pending + still runs other handlers, retry-succeeds-deletes, retry-fails-backoff, max-attempts dead-letters, missing-event dead-letters) for **85 ContactCenter tests passing**; ContactCenter projects build clean with `-warnaserror`; docs (`contact-center/index.md` "Domain events and reliable dispatch", `v2.0.0` changelog) updated. **Remaining for G6:** mandatory idempotency-key enforcement on provider-sourced events; projection checkpoints + rebuildable read-model projections; and signed per-provider webhook adapters (P1 #18) that validate provider signatures, dedupe, and normalize to `ProviderVoiceEvent` before the pipeline (`VoiceIngressController` still only accepts authenticated, pre-normalized internal posts). Next: signed provider webhook adapters (P1 #18) to finish G6, or G7 (routing depth). - 2026-06-30: **G7 advanced — routing depth completes Phase 3 (P1 #11).** Turned single-strategy routing into a per-queue routing policy. **Core:** extended `ActivityQueue` with `RoutingStrategy` (`QueueRoutingStrategy` = LongestIdle/RoundRobin/LeastBusy), `PreferStickyAgent`, `EnableSlaAging`, `BusinessHoursCalendarId` + `AfterHoursAction` (`QueueAfterHoursAction`), and `OverflowQueueId` + `OverflowAfterSeconds`; added `QueueItem.StickyAgentUserId`/`OverflowedFromQueueId` and `AgentProfile.LastAssignedUtc`. Added three routing strategies — `StickyAgentRoutingStrategy` (Order 30, additive boost for the activity's last assigned user, captured on the queue item when enqueued), `RoundRobinRoutingStrategy` and `LeastBusyRoutingStrategy` (Order 100) — and gated `LongestIdleRoutingStrategy`/RoundRobin/LeastBusy so only the queue's selected primary strategy scores. Added `QueueItemPrioritizer` (SLA aging: effective priority rises one step per SLA interval past threshold) used by `ActivityAssignmentService` to pick the next item; the assignment service now also pauses while the queue's business-hours calendar reports closed (new `IClock` + `IBusinessHoursService` deps). `ActivityReservationService` stamps `LastAssignedUtc` on reserve. Added a reusable `BusinessHoursCalendar` catalog (`BusinessHoursCalendar`/`BusinessHoursDay` models, index, store/manager mirroring Skills) and `IBusinessHoursService`/`DefaultBusinessHoursService` (time-zone-aware weekly-schedule + holiday evaluation). `IActivityQueueService.OverflowDueAsync` re-homes waiting items to the overflow queue (wait-time threshold or after-hours), publishing the new `QueueItemOverflowed` event; the reservation-expiry background task calls it per queue before assigning. **Module:** registered the strategies + business-hours store/manager/service/index/migration/handler/driver; added the `BusinessHoursCalendarsController` + **Interaction Center → Business hours** admin menu + Index/Edit/Create/summary views (`ManageQueues`); extended the queue editor (view model + driver + options provider + `ActivityQueueFields.Edit.cshtml`) with the routing-strategy select, sticky/SLA-aging toggles, business-hours + after-hours selects, and overflow queue + seconds. Added 21 unit tests (`BusinessHoursServiceTests`, `QueueItemPrioritizerTests`, `RoutingStrategyTests`, `ActivityQueueServiceTests`) and updated `ActivityAssignmentServiceTests` for the new ctor — **106 ContactCenter tests passing**; full solution builds clean with `-warnaserror`. Docs (`contact-center/agents-queues-dialer.md` routing-policy/business-hours/overflow sections + feature table, `v2.0.0` changelog) updated. **Remaining for G7:** skill **proficiency** levels (needs an agent-skill model migration), a standalone `QueueMembership` aggregate (membership still via `AgentProfile.QueueIds`), and bullseye/skill-relaxation overflow expansion. Next: finish G6 (signed provider webhook adapters, P1 #18), or build the Phase 7 agent desktop UI on the real-time hub. - 2026-07-01: **G6 advanced — signed provider webhook adapters + idempotency enforcement (P1 #18).** Added a provider-grade voice webhook boundary. New `IProviderVoiceWebhookAdapter` (Abstractions) validates a provider signature and normalizes a delivery into `ProviderVoiceEvent`s from an HTTP-agnostic `ProviderVoiceWebhookRequest` (headers/body/query); `HmacProviderVoiceWebhookAdapterBase` provides constant-time HMAC-SHA256 verification with configurable secret + header. `IProviderVoiceWebhookProcessor`/`ProviderVoiceWebhookProcessor` (Core) resolves the adapter by technical name, verifies the signature, **requires an idempotency key on every parsed event** (rejects the batch otherwise), and forwards accepted events to `IProviderVoiceEventService`. The anonymous `ProviderVoiceWebhookController` (`POST /api/contact-center/voice/webhook/{provider}`) reads the raw body, maps the outcome to 200/401/404/400, and is authenticated purely by the provider signature. Registered in `VoiceStartup`. +6 tests (`ProviderVoiceWebhookProcessorTests`: valid/keyed ingest, unknown provider, invalid signature, missing key, HMAC match/tamper/no-secret) for **112 ContactCenter tests passing**; clean `-warnaserror` build; docs (`v2.0.0` changelog) updated. **Remaining for G6:** projection checkpoints + rebuildable read-model projections. Next: G5 agent desktop UI, or G2 optimistic concurrency on reservation. +- 2026-07-01: **G2 hardened — compare-and-set on reservation (P0 #7).** `ActivityReservationService.ReserveAsync` now re-reads the queue item by id and aborts (returns null, creates no reservation) unless it is still `Waiting`, so even if two writers slip past the per-queue lock the second cannot reserve an already-reserved item. +1 test (`ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving`); updated the two existing reserve tests to mock the re-fetch. **113 ContactCenter tests pass**; clean `-warnaserror` build. Remaining G2: real-time per-reservation offer timeout (needs the G5 desktop). diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs index 0b2bcfdb7..d6b2a9fa6 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityReservationService.cs @@ -50,6 +50,15 @@ public async Task ReserveAsync(QueueItem queueItem, AgentPr ArgumentNullException.ThrowIfNull(queueItem); ArgumentNullException.ThrowIfNull(agent); + var current = await _queueItemManager.FindByIdAsync(queueItem.ItemId, cancellationToken); + + if (current is null || current.Status != QueueItemStatus.Waiting) + { + return null; + } + + queueItem = current; + var now = _clock.UtcNow; var reservation = await _reservationManager.NewAsync(cancellationToken: cancellationToken); reservation.ActivityItemId = queueItem.ActivityItemId; diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs index 14a6e8a58..86e638dff 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityReservationServiceTests.cs @@ -27,6 +27,7 @@ public async Task ReserveAsync_SetsReservedStateAndPublishesEvents() var item = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(item); // Act var reservation = await service.ReserveAsync(item, agent, 30, TestContext.Current.CancellationToken); @@ -38,6 +39,30 @@ public async Task ReserveAsync_SetsReservedStateAndPublishesEvents() publisher.Verify(p => p.PublishAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); } + [Fact] + public async Task ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving() + { + // Arrange + var reservationManager = new Mock(); + var queueItemManager = new Mock(); + queueItemManager + .Setup(m => m.FindByIdAsync("qi-1", It.IsAny())) + .ReturnsAsync(new QueueItem { ItemId = "qi-1", Status = QueueItemStatus.Reserved }); + var agentManager = new Mock(); + var activityManager = new Mock(); + var service = CreateService(reservationManager, queueItemManager, agentManager, activityManager, new Mock()); + + var staleItem = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; + var agent = new AgentProfile { ItemId = "a1", UserId = "u1" }; + + // Act + var reservation = await service.ReserveAsync(staleItem, agent, 30, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(reservation); + reservationManager.Verify(m => m.CreateAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Fact] public async Task ReserveAsync_WhenBreakWasGrantedAfterRoutingDecision_PreservesPendingBreak() { @@ -54,6 +79,7 @@ public async Task ReserveAsync_WhenBreakWasGrantedAfterRoutingDecision_Preserves var item = new QueueItem { ItemId = "qi-1", QueueId = "q1", ActivityItemId = "act-1" }; var selectedAgent = new AgentProfile { ItemId = "a1", UserId = "u1", PresenceStatus = AgentPresenceStatus.Available }; + queueItemManager.Setup(m => m.FindByIdAsync("qi-1", It.IsAny())).ReturnsAsync(item); // Act await service.ReserveAsync(item, selectedAgent, 30, TestContext.Current.CancellationToken); From 7d351ed5ad4cb67d60e54492c03c088d8ed5d77f Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:13:46 -0700 Subject: [PATCH 18/56] Contact Center: transfer/conference taxonomy + orchestration (G1, completes Phase 4) - InteractionTransferType/TargetType enums; TransferRequest/TransferResult; IContactCenterTransferService records transfer history, sets Transferring, publishes InteractionTransferred, and re-enqueues the activity for queue transfers. - ContactCenterVoiceProviderCapabilities gains CallTransfer + Conference flags. - +3 tests (116 ContactCenter tests pass); clean -warnaserror build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 1 + .../ContactCenterVoiceProviderCapabilities.cs | 10 ++ .../Models/InteractionTransferTargetType.cs | 27 +++++ .../Models/InteractionTransferType.cs | 17 +++ .../Models/TransferRequest.cs | 34 ++++++ .../Models/TransferResult.cs | 37 ++++++ .../Services/ContactCenterTransferService.cs | 114 ++++++++++++++++++ .../Services/IContactCenterTransferService.cs | 19 +++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 1 + .../Startup.cs | 1 + .../ContactCenterTransferServiceTests.cs | 101 ++++++++++++++++ 11 files changed, 362 insertions(+) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferTargetType.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/InteractionTransferType.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferResult.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTransferService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterTransferService.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterTransferServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 7324dfbdb..d0917d6b0 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1529,3 +1529,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-06-30: **G7 advanced — routing depth completes Phase 3 (P1 #11).** Turned single-strategy routing into a per-queue routing policy. **Core:** extended `ActivityQueue` with `RoutingStrategy` (`QueueRoutingStrategy` = LongestIdle/RoundRobin/LeastBusy), `PreferStickyAgent`, `EnableSlaAging`, `BusinessHoursCalendarId` + `AfterHoursAction` (`QueueAfterHoursAction`), and `OverflowQueueId` + `OverflowAfterSeconds`; added `QueueItem.StickyAgentUserId`/`OverflowedFromQueueId` and `AgentProfile.LastAssignedUtc`. Added three routing strategies — `StickyAgentRoutingStrategy` (Order 30, additive boost for the activity's last assigned user, captured on the queue item when enqueued), `RoundRobinRoutingStrategy` and `LeastBusyRoutingStrategy` (Order 100) — and gated `LongestIdleRoutingStrategy`/RoundRobin/LeastBusy so only the queue's selected primary strategy scores. Added `QueueItemPrioritizer` (SLA aging: effective priority rises one step per SLA interval past threshold) used by `ActivityAssignmentService` to pick the next item; the assignment service now also pauses while the queue's business-hours calendar reports closed (new `IClock` + `IBusinessHoursService` deps). `ActivityReservationService` stamps `LastAssignedUtc` on reserve. Added a reusable `BusinessHoursCalendar` catalog (`BusinessHoursCalendar`/`BusinessHoursDay` models, index, store/manager mirroring Skills) and `IBusinessHoursService`/`DefaultBusinessHoursService` (time-zone-aware weekly-schedule + holiday evaluation). `IActivityQueueService.OverflowDueAsync` re-homes waiting items to the overflow queue (wait-time threshold or after-hours), publishing the new `QueueItemOverflowed` event; the reservation-expiry background task calls it per queue before assigning. **Module:** registered the strategies + business-hours store/manager/service/index/migration/handler/driver; added the `BusinessHoursCalendarsController` + **Interaction Center → Business hours** admin menu + Index/Edit/Create/summary views (`ManageQueues`); extended the queue editor (view model + driver + options provider + `ActivityQueueFields.Edit.cshtml`) with the routing-strategy select, sticky/SLA-aging toggles, business-hours + after-hours selects, and overflow queue + seconds. Added 21 unit tests (`BusinessHoursServiceTests`, `QueueItemPrioritizerTests`, `RoutingStrategyTests`, `ActivityQueueServiceTests`) and updated `ActivityAssignmentServiceTests` for the new ctor — **106 ContactCenter tests passing**; full solution builds clean with `-warnaserror`. Docs (`contact-center/agents-queues-dialer.md` routing-policy/business-hours/overflow sections + feature table, `v2.0.0` changelog) updated. **Remaining for G7:** skill **proficiency** levels (needs an agent-skill model migration), a standalone `QueueMembership` aggregate (membership still via `AgentProfile.QueueIds`), and bullseye/skill-relaxation overflow expansion. Next: finish G6 (signed provider webhook adapters, P1 #18), or build the Phase 7 agent desktop UI on the real-time hub. - 2026-07-01: **G6 advanced — signed provider webhook adapters + idempotency enforcement (P1 #18).** Added a provider-grade voice webhook boundary. New `IProviderVoiceWebhookAdapter` (Abstractions) validates a provider signature and normalizes a delivery into `ProviderVoiceEvent`s from an HTTP-agnostic `ProviderVoiceWebhookRequest` (headers/body/query); `HmacProviderVoiceWebhookAdapterBase` provides constant-time HMAC-SHA256 verification with configurable secret + header. `IProviderVoiceWebhookProcessor`/`ProviderVoiceWebhookProcessor` (Core) resolves the adapter by technical name, verifies the signature, **requires an idempotency key on every parsed event** (rejects the batch otherwise), and forwards accepted events to `IProviderVoiceEventService`. The anonymous `ProviderVoiceWebhookController` (`POST /api/contact-center/voice/webhook/{provider}`) reads the raw body, maps the outcome to 200/401/404/400, and is authenticated purely by the provider signature. Registered in `VoiceStartup`. +6 tests (`ProviderVoiceWebhookProcessorTests`: valid/keyed ingest, unknown provider, invalid signature, missing key, HMAC match/tamper/no-secret) for **112 ContactCenter tests passing**; clean `-warnaserror` build; docs (`v2.0.0` changelog) updated. **Remaining for G6:** projection checkpoints + rebuildable read-model projections. Next: G5 agent desktop UI, or G2 optimistic concurrency on reservation. - 2026-07-01: **G2 hardened — compare-and-set on reservation (P0 #7).** `ActivityReservationService.ReserveAsync` now re-reads the queue item by id and aborts (returns null, creates no reservation) unless it is still `Waiting`, so even if two writers slip past the per-queue lock the second cannot reserve an already-reserved item. +1 test (`ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving`); updated the two existing reserve tests to mock the re-fetch. **113 ContactCenter tests pass**; clean `-warnaserror` build. Remaining G2: real-time per-reservation offer timeout (needs the G5 desktop). +- 2026-07-01: **G1 advanced — transfer/conference taxonomy (completes Phase 4 orchestration).** Added `InteractionTransferType` (Blind/Consultative) and `InteractionTransferTargetType` (Agent/Queue/External/EntryPoint) enums, `TransferRequest`/`TransferResult` models, and `IContactCenterTransferService`/`ContactCenterTransferService` which records the transfer on `Interaction.TransferHistory`, sets the interaction `Transferring`, publishes `InteractionTransferred`, and re-enqueues the activity into the target queue for queue transfers (agent/external/entry-point record intent; media handoff stays a provider concern). Added `CallTransfer` + `Conference` capability flags to `ContactCenterVoiceProviderCapabilities`. Registered in `VoiceStartup`. +3 tests (`ContactCenterTransferServiceTests`) for **116 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining G1: soft-phone JS accept-then-answer coordination + asset rebuild (client-side, not unit-testable here). diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs index 3dd884693..7d7018751 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs @@ -41,4 +41,14 @@ public enum ContactCenterVoiceProviderCapabilities /// 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, } 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/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs new file mode 100644 index 000000000..454e90010 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferRequest.cs @@ -0,0 +1,34 @@ +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; } +} 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..19c64764e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/TransferResult.cs @@ -0,0 +1,37 @@ +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 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 }; + } +} 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..65cc541e7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterTransferService.cs @@ -0,0 +1,114 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; +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 IActivityQueueService _queueService; + private readonly IContactCenterEventPublisher _publisher; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The queue service used to re-enqueue queue transfers. + /// The Contact Center event publisher. + /// The clock used to stamp transfer times. + public ContactCenterTransferService( + IInteractionManager interactionManager, + IActivityQueueService queueService, + IContactCenterEventPublisher publisher, + IClock clock) + { + _interactionManager = interactionManager; + _queueService = queueService; + _publisher = publisher; + _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."); + } + + var interaction = await _interactionManager.FindByIdAsync(request.InteractionId, cancellationToken); + + if (interaction is null) + { + return TransferResult.Failure("The interaction could not be found."); + } + + var now = _clock.UtcNow; + var entry = new InteractionTransferHistoryEntry + { + FromParticipantId = request.InitiatedByAgentId ?? interaction.AgentId, + ToParticipantId = request.TargetId, + TargetType = request.TargetType.ToString(), + RequestedUtc = now, + }; + + var reason = await ApplyTargetAsync(request, interaction, cancellationToken); + + entry.CompletedUtc = now; + entry.Result = reason; + interaction.TransferHistory.Add(entry); + interaction.Status = InteractionStatus.Transferring; + + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + 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); + + return TransferResult.Success(reason); + } + + private async Task ApplyTargetAsync(TransferRequest request, Interaction interaction, CancellationToken cancellationToken) + { + switch (request.TargetType) + { + case InteractionTransferTargetType.Queue: + if (!string.IsNullOrEmpty(interaction.ActivityItemId)) + { + await _queueService.EnqueueAsync(interaction.ActivityItemId, request.TargetId, 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."; + } + } +} 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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 0d2e474cd..20273d404 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -205,6 +205,7 @@ At a high level, the platform changes are: - 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 diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index faa2dfad0..bf7207f76 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -239,6 +239,7 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped(sp => sp.GetRequiredService()) .AddScoped(sp => sp.GetRequiredService()) diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterTransferServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterTransferServiceTests.cs new file mode 100644 index 000000000..13c139da4 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterTransferServiceTests.cs @@ -0,0 +1,101 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterTransferServiceTests +{ + private static readonly DateTime _now = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task TransferAsync_ToQueue_ReEnqueuesActivityAndRecordsHistory() + { + // Arrange + var interaction = new Interaction { ItemId = "int-1", ActivityItemId = "act-1", AgentId = "a1" }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int-1", It.IsAny())).ReturnsAsync(interaction); + + var queueService = new Mock(); + var publisher = new Mock(); + var service = CreateService(interactionManager, queueService, publisher); + + var request = new TransferRequest + { + InteractionId = "int-1", + Type = InteractionTransferType.Blind, + TargetType = InteractionTransferTargetType.Queue, + TargetId = "q2", + InitiatedByAgentId = "a1", + }; + + // Act + var result = await service.TransferAsync(request, TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + queueService.Verify(s => s.EnqueueAsync("act-1", "q2", null, It.IsAny()), Times.Once); + Assert.Single(interaction.TransferHistory); + Assert.Equal(InteractionStatus.Transferring, interaction.Status); + publisher.Verify(p => p.PublishAsync(It.Is(e => e.EventType == ContactCenterConstants.Events.InteractionTransferred), It.IsAny()), Times.Once); + } + + [Fact] + public async Task TransferAsync_ToExternal_RecordsHistoryWithoutReEnqueue() + { + // Arrange + var interaction = new Interaction { ItemId = "int-1", ActivityItemId = "act-1", AgentId = "a1" }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int-1", It.IsAny())).ReturnsAsync(interaction); + + var queueService = new Mock(); + var service = CreateService(interactionManager, queueService, new Mock()); + + var request = new TransferRequest + { + InteractionId = "int-1", + TargetType = InteractionTransferTargetType.External, + TargetId = "+15551234567", + }; + + // Act + var result = await service.TransferAsync(request, TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + queueService.Verify(s => s.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + Assert.Single(interaction.TransferHistory); + } + + [Fact] + public async Task TransferAsync_WhenInteractionMissing_Fails() + { + // Arrange + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int-1", It.IsAny())).ReturnsAsync((Interaction)null); + + var service = CreateService(interactionManager, new Mock(), new Mock()); + + var request = new TransferRequest { InteractionId = "int-1", TargetType = InteractionTransferTargetType.Queue, TargetId = "q2" }; + + // Act + var result = await service.TransferAsync(request, TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + } + + private static ContactCenterTransferService CreateService( + Mock interactionManager, + Mock queueService, + Mock publisher) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new ContactCenterTransferService(interactionManager.Object, queueService.Object, publisher.Object, clock.Object); + } +} From 2bfe4b4403d979436a223f696e3a5c1274c8491d Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:24:42 -0700 Subject: [PATCH 19/56] Contact Center: inbound entry points (Phase 8) - ContactCenterEntryPoint catalog: DID->queue mapping, priority, business-hours gating, closed action (hold/voicemail/overflow/reject), welcome/closed messages. - IEntryPointResolver + EntryPointRoutingPlanner build an EntryPointRoutingPlan from the matched DID and business-hours state. - VoiceContactCenterCallRouter consults the resolver first: open -> entry point queue at its priority; closed -> hold/overflow or voicemail/reject; falls back to endpoint->queue mapping. - Interaction Center -> Entry points admin CRUD (Voice feature, ManageQueues). - +6 tests (122 ContactCenter tests pass); clean -warnaserror build; changelog + PLAN updated. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../Models/EntryPointClosedAction.cs | 28 ++ .../Indexes/ContactCenterEntryPointIndex.cs | 24 ++ .../Models/ContactCenterEntryPoint.cs | 79 +++++ .../Models/EntryPointRoutingPlan.cs | 39 +++ .../ContactCenterEntryPointManager.cs | 54 ++++ .../Services/ContactCenterEntryPointStore.cs | 44 +++ .../Services/EntryPointResolver.cs | 54 ++++ .../Services/EntryPointRoutingPlanner.cs | 58 ++++ .../IContactCenterEntryPointManager.cs | 25 ++ .../Services/IContactCenterEntryPointStore.cs | 25 ++ .../Services/IEntryPointResolver.cs | 25 ++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 6 + .../Controllers/EntryPointsController.cs | 289 ++++++++++++++++++ .../ContactCenterEntryPointDisplayDriver.cs | 135 ++++++++ .../ContactCenterEntryPointHandler.cs | 55 ++++ .../ContactCenterEntryPointIndexProvider.cs | 32 ++ .../ContactCenterEntryPointIndexMigrations.cs | 32 ++ .../ContactCenterAdminFormOptionsProvider.cs | 7 + .../ContactCenterEntryPointsAdminMenu.cs | 40 +++ .../Services/VoiceContactCenterCallRouter.cs | 23 +- .../Startup.cs | 11 + .../ViewModels/EntryPointViewModel.cs | 87 ++++++ .../Views/ContactCenterEntryPoint.Edit.cshtml | 4 + ...ontactCenterEntryPoint.SummaryAdmin.cshtml | 29 ++ .../ContactCenterEntryPointFields.Edit.cshtml | 115 +++++++ .../Views/EntryPoints/Create.cshtml | 19 ++ .../Views/EntryPoints/Edit.cshtml | 19 ++ .../Views/EntryPoints/Index.cshtml | 57 ++++ ...nterEntryPoint.Buttons.SummaryAdmin.cshtml | 14 + ...EntryPoint.DefaultMeta.SummaryAdmin.cshtml | 17 ++ ...enterEntryPoint.Fields.SummaryAdmin.cshtml | 23 ++ .../ContactCenter/EntryPointResolverTests.cs | 105 +++++++ .../ContactCenter/InboundVoiceServiceTests.cs | 3 + 34 files changed, 1577 insertions(+), 3 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/EntryPointClosedAction.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEntryPointIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEntryPoint.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/EntryPointRoutingPlan.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterEntryPointStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointResolver.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/EntryPointRoutingPlanner.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterEntryPointStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IEntryPointResolver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/EntryPointsController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterEntryPointDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterEntryPointHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEntryPointIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEntryPointIndexMigrations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterEntryPointsAdminMenu.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/EntryPointViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Create.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Index.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Buttons.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.DefaultMeta.SummaryAdmin.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Fields.SummaryAdmin.cshtml create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/EntryPointResolverTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index d0917d6b0..4a38bc34e 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1484,7 +1484,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [~] Phase 5 — Outbound dialer MVP (`Dialer` feature: profiles, modes, power/progressive pacing, dialer batch sources, outbound calls routed through the Voice Contact Center Call Router, DialPad Contact Center Voice provider. **G4 dialer safety shipped (2026-06-30):** each mode is now a dedicated `IDialerStrategy` (Predictive disabled in editor + rejected server-side + refused at runtime; Power hard-capped via `PowerDialerStrategy.MaxCallsPerAgent`); the new `IDialerEligibilityService` compliance gate runs before every attempt and audits `DialSuppressed` (destination, max-attempts, retry cool-down, contact do-not-call, calling window in the contact's time zone, and national DNC registries); single-attempt logic moved to `IDialerAttemptService`. Remaining: callback scheduling (`CallbackRequest`) + callback queues, and dialer run/attempt projections.) - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call agent timing/capacity release is deferred to the Phase 7 agent desktop and agent presence, not a domain aggregate) - [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. Remaining: the CRM-integrated agent desktop UI, supervisor dashboards, the queue monitor/wallboard, and scoped/audited live call-control intents — see G5) -- [ ] Phase 8 — Inbound entry points, IVR and self-service +- [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [ ] Phase 9 — Recording and live monitoring - [ ] Phase 10 — Outbound compliance hardening - [ ] Phase 11 — Optional Workflow bridge @@ -1530,3 +1530,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **G6 advanced — signed provider webhook adapters + idempotency enforcement (P1 #18).** Added a provider-grade voice webhook boundary. New `IProviderVoiceWebhookAdapter` (Abstractions) validates a provider signature and normalizes a delivery into `ProviderVoiceEvent`s from an HTTP-agnostic `ProviderVoiceWebhookRequest` (headers/body/query); `HmacProviderVoiceWebhookAdapterBase` provides constant-time HMAC-SHA256 verification with configurable secret + header. `IProviderVoiceWebhookProcessor`/`ProviderVoiceWebhookProcessor` (Core) resolves the adapter by technical name, verifies the signature, **requires an idempotency key on every parsed event** (rejects the batch otherwise), and forwards accepted events to `IProviderVoiceEventService`. The anonymous `ProviderVoiceWebhookController` (`POST /api/contact-center/voice/webhook/{provider}`) reads the raw body, maps the outcome to 200/401/404/400, and is authenticated purely by the provider signature. Registered in `VoiceStartup`. +6 tests (`ProviderVoiceWebhookProcessorTests`: valid/keyed ingest, unknown provider, invalid signature, missing key, HMAC match/tamper/no-secret) for **112 ContactCenter tests passing**; clean `-warnaserror` build; docs (`v2.0.0` changelog) updated. **Remaining for G6:** projection checkpoints + rebuildable read-model projections. Next: G5 agent desktop UI, or G2 optimistic concurrency on reservation. - 2026-07-01: **G2 hardened — compare-and-set on reservation (P0 #7).** `ActivityReservationService.ReserveAsync` now re-reads the queue item by id and aborts (returns null, creates no reservation) unless it is still `Waiting`, so even if two writers slip past the per-queue lock the second cannot reserve an already-reserved item. +1 test (`ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving`); updated the two existing reserve tests to mock the re-fetch. **113 ContactCenter tests pass**; clean `-warnaserror` build. Remaining G2: real-time per-reservation offer timeout (needs the G5 desktop). - 2026-07-01: **G1 advanced — transfer/conference taxonomy (completes Phase 4 orchestration).** Added `InteractionTransferType` (Blind/Consultative) and `InteractionTransferTargetType` (Agent/Queue/External/EntryPoint) enums, `TransferRequest`/`TransferResult` models, and `IContactCenterTransferService`/`ContactCenterTransferService` which records the transfer on `Interaction.TransferHistory`, sets the interaction `Transferring`, publishes `InteractionTransferred`, and re-enqueues the activity into the target queue for queue transfers (agent/external/entry-point record intent; media handoff stays a provider concern). Added `CallTransfer` + `Conference` capability flags to `ContactCenterVoiceProviderCapabilities`. Registered in `VoiceStartup`. +3 tests (`ContactCenterTransferServiceTests`) for **116 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining G1: soft-phone JS accept-then-answer coordination + asset rebuild (client-side, not unit-testable here). +- 2026-07-01: **Phase 8 started — inbound entry points.** Added the `ContactCenterEntryPoint` catalog (Core: model/index/store/manager) mapping dialed numbers (DIDs) to a target queue + priority, gated by a business-hours calendar, with a `ClosedAction` (HoldInQueue/Voicemail/Overflow/Reject) and welcome/closed messages. `IEntryPointResolver`/`EntryPointResolver` matches the DID and, via `IBusinessHoursService`, builds an `EntryPointRoutingPlan` (testable `EntryPointRoutingPlanner`). Wired into `VoiceContactCenterCallRouter`: open calls route to the entry point queue at its priority; closed calls hold/overflow or return not-routed for voicemail/reject; falls back to endpoint→queue mapping when no entry point matches. Module: index provider/migration/handler/display driver/`EntryPointsController`/**Interaction Center → Entry points** admin menu + views (Voice feature, `ManageQueues`). +6 tests (`EntryPointResolverTests`) for **122 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 8: multi-step IVR decision trees, DTMF/menu capture, estimated-wait announcements, ambiguous-contact disambiguation. Next: Phase 10 (compliance: callbacks + calling-window calendars + abandonment) or Phase 9 (recording). 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/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/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/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/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/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/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/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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 20273d404..82c82a958 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -193,6 +193,12 @@ At a high level, the platform changes are: - 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 → 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. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/EntryPointsController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/EntryPointsController.cs new file mode 100644 index 000000000..e5c3fd8d2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/EntryPointsController.cs @@ -0,0 +1,289 @@ +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Options; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.Modules; +using OrchardCore.Navigation; +using OrchardCore.Routing; +using QueryContext = CrestApps.Core.Models.QueryContext; + +namespace CrestApps.OrchardCore.ContactCenter.Controllers; + +/// +/// Provides administration of Contact Center inbound entry points. +/// +[Admin] +[Feature(ContactCenterConstants.Feature.Voice)] +public sealed class EntryPointsController : Controller +{ + private const string _optionsSearch = "Options.Search"; + + private readonly IContactCenterEntryPointManager _manager; + private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IDisplayManager _displayManager; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The entry point manager. + /// The authorization service. + /// The update model accessor. + /// The display manager. + /// The notifier. + /// The HTML localizer. + /// The string localizer. + public EntryPointsController( + IContactCenterEntryPointManager manager, + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IDisplayManager displayManager, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _manager = manager; + _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _displayManager = displayManager; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + /// + /// Lists the entry points. + /// + /// The catalog entry options. + /// The pager parameters. + /// The pager options. + /// The shape factory. + /// The entry points list view. + [Admin("contact-center/entry-points", "ContactCenterEntryPointsIndex")] + public async Task Index( + CatalogEntryOptions options, + PagerParameters pagerParameters, + [FromServices] IOptions pagerOptions, + [FromServices] IShapeFactory shapeFactory) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var pager = new Pager(pagerParameters, pagerOptions.Value.GetPageSize()); + var result = await _manager.PageAsync(pager.Page, pager.PageSize, new QueryContext + { + Name = options.Search, + }); + + var routeData = new RouteData(); + + if (!string.IsNullOrEmpty(options.Search)) + { + routeData.Values.TryAdd(_optionsSearch, options.Search); + } + + var viewModel = new ListCatalogEntryViewModel> + { + Models = [], + Options = options, + Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), + }; + + foreach (var model in result.Entries) + { + viewModel.Models.Add(new CatalogEntryViewModel + { + Model = model, + Shape = await _displayManager.BuildDisplayAsync(model, _updateModelAccessor.ModelUpdater, "SummaryAdmin"), + }); + } + + return View(viewModel); + } + + /// + /// Applies the entry points list filter. + /// + /// The submitted list model. + /// A redirect to the filtered list. + [HttpPost] + [ActionName(nameof(Index))] + [FormValueRequired("submit.Filter")] + [Admin("contact-center/entry-points", "ContactCenterEntryPointsIndex")] + public async Task IndexFilterPost(ListCatalogEntryViewModel model) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + return RedirectToAction(nameof(Index), new RouteValueDictionary + { + { _optionsSearch, model.Options?.Search }, + }); + } + + /// + /// Displays the entry point create form. + /// + /// The create view. + [Admin("contact-center/entry-points/create", "ContactCenterEntryPointsCreate")] + public async Task Create() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["Entry point"], + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + return View(viewModel); + } + + /// + /// Persists a new entry point. + /// + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Create))] + [Admin("contact-center/entry-points/create", "ContactCenterEntryPointsCreate")] + public async Task CreatePost() + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.NewAsync(); + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = S["New entry point"], + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: true), + }; + + if (ModelState.IsValid) + { + await _manager.CreateAsync(model); + await _notifier.SuccessAsync(H["A new entry point has been created successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Displays the entry point edit form. + /// + /// The entry point identifier. + /// The edit view. + [Admin("contact-center/entry-points/edit/{id}", "ContactCenterEntryPointsEdit")] + public async Task Edit(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.BuildEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + return View(viewModel); + } + + /// + /// Persists changes to an entry point. + /// + /// The entry point identifier. + /// A redirect to the list or the form when invalid. + [HttpPost] + [ActionName(nameof(Edit))] + [Admin("contact-center/entry-points/edit/{id}", "ContactCenterEntryPointsEdit")] + public async Task EditPost(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var model = await _manager.FindByIdAsync(id); + + if (model is null) + { + return NotFound(); + } + + var viewModel = new EditCatalogEntryViewModel + { + DisplayName = model.Name, + Editor = await _displayManager.UpdateEditorAsync(model, _updateModelAccessor.ModelUpdater, isNew: false), + }; + + if (ModelState.IsValid) + { + await _manager.UpdateAsync(model); + await _notifier.SuccessAsync(H["The entry point has been updated successfully."]); + + return RedirectToAction(nameof(Index)); + } + + return View(viewModel); + } + + /// + /// Deletes an entry point. + /// + /// The entry point identifier. + /// A redirect to the list. + [HttpPost] + [Admin("contact-center/entry-points/delete/{id}", "ContactCenterEntryPointsDelete")] + public async Task Delete(string id) + { + if (!await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.ManageQueues)) + { + return Forbid(); + } + + var entryPoint = await _manager.FindByIdAsync(id); + + if (entryPoint is not null) + { + await _manager.DeleteAsync(entryPoint); + await _notifier.SuccessAsync(H["The entry point has been deleted successfully."]); + } + + return RedirectToAction(nameof(Index)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterEntryPointDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterEntryPointDisplayDriver.cs new file mode 100644 index 000000000..cd3805798 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Drivers/ContactCenterEntryPointDisplayDriver.cs @@ -0,0 +1,135 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.ContactCenter.ViewModels; +using Microsoft.Extensions.Localization; +using OrchardCore; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.ContactCenter.Drivers; + +internal sealed class ContactCenterEntryPointDisplayDriver : DisplayDriver +{ + private readonly ContactCenterAdminFormOptionsProvider _optionsProvider; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The admin form options provider. + /// The string localizer. + public ContactCenterEntryPointDisplayDriver( + ContactCenterAdminFormOptionsProvider optionsProvider, + IStringLocalizer stringLocalizer) + { + _optionsProvider = optionsProvider; + S = stringLocalizer; + } + + /// + public override Task DisplayAsync(ContactCenterEntryPoint entryPoint, BuildDisplayContext context) + { + return CombineAsync( + View("ContactCenterEntryPoint_Fields_SummaryAdmin", entryPoint) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Content:1"), + View("ContactCenterEntryPoint_Buttons_SummaryAdmin", entryPoint) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Actions:5"), + View("ContactCenterEntryPoint_DefaultMeta_SummaryAdmin", entryPoint) + .Location(OrchardCoreConstants.DisplayType.SummaryAdmin, "Meta:5") + ); + } + + /// + public override async Task EditAsync(ContactCenterEntryPoint entryPoint, BuildEditorContext context) + { + var viewModel = new EntryPointViewModel + { + Id = entryPoint.ItemId, + Name = entryPoint.Name, + Description = entryPoint.Description, + DialedNumbersText = entryPoint.DialedNumbers is { Count: > 0 } + ? string.Join(Environment.NewLine, entryPoint.DialedNumbers) + : null, + TargetQueueId = entryPoint.TargetQueueId, + Priority = entryPoint.Priority, + BusinessHoursCalendarId = entryPoint.BusinessHoursCalendarId, + ClosedAction = entryPoint.ClosedAction, + OverflowQueueId = entryPoint.OverflowQueueId, + WelcomeMessage = entryPoint.WelcomeMessage, + ClosedMessage = entryPoint.ClosedMessage, + Enabled = entryPoint.Enabled, + }; + + await _optionsProvider.PopulateEntryPointEditorAsync(viewModel); + + return Initialize("ContactCenterEntryPointFields_Edit", model => + { + model.Id = viewModel.Id; + model.Name = viewModel.Name; + model.Description = viewModel.Description; + model.DialedNumbersText = viewModel.DialedNumbersText; + model.TargetQueueId = viewModel.TargetQueueId; + model.TargetQueueOptions = viewModel.TargetQueueOptions; + model.Priority = viewModel.Priority; + model.BusinessHoursCalendarId = viewModel.BusinessHoursCalendarId; + model.BusinessHoursCalendarOptions = viewModel.BusinessHoursCalendarOptions; + model.ClosedAction = viewModel.ClosedAction; + model.OverflowQueueId = viewModel.OverflowQueueId; + model.OverflowQueueOptions = viewModel.OverflowQueueOptions; + model.WelcomeMessage = viewModel.WelcomeMessage; + model.ClosedMessage = viewModel.ClosedMessage; + model.Enabled = viewModel.Enabled; + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(ContactCenterEntryPoint entryPoint, UpdateEditorContext context) + { + var model = new EntryPointViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + if (string.IsNullOrWhiteSpace(model.Name)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Name), S["Name is a required field."]); + } + + entryPoint.Name = model.Name?.Trim(); + entryPoint.Description = model.Description?.Trim(); + entryPoint.DialedNumbers = ParseLines(model.DialedNumbersText); + entryPoint.TargetQueueId = string.IsNullOrWhiteSpace(model.TargetQueueId) ? null : model.TargetQueueId.Trim(); + entryPoint.Priority = model.Priority; + entryPoint.BusinessHoursCalendarId = string.IsNullOrWhiteSpace(model.BusinessHoursCalendarId) ? null : model.BusinessHoursCalendarId.Trim(); + entryPoint.ClosedAction = model.ClosedAction; + entryPoint.OverflowQueueId = string.IsNullOrWhiteSpace(model.OverflowQueueId) ? null : model.OverflowQueueId.Trim(); + entryPoint.WelcomeMessage = model.WelcomeMessage?.Trim(); + entryPoint.ClosedMessage = model.ClosedMessage?.Trim(); + entryPoint.Enabled = model.Enabled; + + return await EditAsync(entryPoint, context); + } + + private static List ParseLines(string text) + { + var values = new List(); + + if (string.IsNullOrWhiteSpace(text)) + { + return values; + } + + foreach (var line in text.Split('\n')) + { + var trimmed = line.Trim(); + + if (trimmed.Length > 0 && !values.Contains(trimmed, StringComparer.OrdinalIgnoreCase)) + { + values.Add(trimmed); + } + } + + return values; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterEntryPointHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterEntryPointHandler.cs new file mode 100644 index 000000000..b4c1c1bf8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterEntryPointHandler.cs @@ -0,0 +1,55 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.Core.Handlers; +using CrestApps.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +internal sealed class ContactCenterEntryPointHandler : CatalogEntryHandlerBase +{ + private readonly IClock _clock; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The clock used to stamp audit times. + /// The string localizer. + public ContactCenterEntryPointHandler( + IClock clock, + IStringLocalizer stringLocalizer) + { + _clock = clock; + S = stringLocalizer; + } + + /// + public override Task InitializedAsync(InitializedContext context, CancellationToken cancellationToken = default) + { + context.Model.CreatedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task UpdatingAsync(UpdatingContext context, CancellationToken cancellationToken = default) + { + context.Model.ModifiedUtc = _clock.UtcNow; + + return Task.CompletedTask; + } + + /// + public override Task ValidatingAsync(ValidatingContext context, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(context.Model.Name)) + { + context.Result.Fail(new ValidationResult(S["Name is required."], [nameof(ContactCenterEntryPoint.Name)])); + } + + return Task.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEntryPointIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEntryPointIndexProvider.cs new file mode 100644 index 000000000..a35489e60 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEntryPointIndexProvider.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class ContactCenterEntryPointIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public ContactCenterEntryPointIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(entryPoint => new ContactCenterEntryPointIndex + { + ItemId = entryPoint.ItemId, + Name = entryPoint.Name, + Enabled = entryPoint.Enabled, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEntryPointIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEntryPointIndexMigrations.cs new file mode 100644 index 000000000..54d419faa --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEntryPointIndexMigrations.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class ContactCenterEntryPointIndexMigrations : DataMigration +{ + /// + /// Creates the entry point index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Name", column => column.WithLength(255)) + .Column("Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_ContactCenterEntryPointIndex_DocumentId", "DocumentId", "ItemId", "Enabled"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs index bf7e59e36..b0b81185c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminFormOptionsProvider.cs @@ -166,6 +166,13 @@ internal async Task PopulateDialerProfileEditorAsync(DialerProfileViewModel mode model.ProviderOptions = GetVoiceProviderOptions(model.ProviderName); } + internal async Task PopulateEntryPointEditorAsync(EntryPointViewModel model) + { + model.TargetQueueOptions = await GetQueueOptionsAsync(model.TargetQueueId); + model.OverflowQueueOptions = await GetQueueOptionsAsync(model.OverflowQueueId); + model.BusinessHoursCalendarOptions = await GetBusinessHoursCalendarOptionsAsync(model.BusinessHoursCalendarId); + } + private static HashSet CreateSelectedSet(IEnumerable values, StringComparer comparer) { return values is null diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterEntryPointsAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterEntryPointsAdminMenu.cs new file mode 100644 index 000000000..333a23f5a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterEntryPointsAdminMenu.cs @@ -0,0 +1,40 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace CrestApps.OrchardCore.ContactCenter.Services; + +/// +/// Adds the inbound entry points entry to the Interaction Center admin navigation. +/// +public sealed class ContactCenterEntryPointsAdminMenu : AdminNavigationProvider +{ + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ContactCenterEntryPointsAdminMenu(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + builder + .Add(S["Interaction Center"], "80", interactionCenter => interactionCenter + .AddClass("interaction-center") + .Id("interactionCenter") + .Add(S["Entry points"], S["Entry points"].PrefixPosition(), entryPoints => entryPoints + .AddClass("contact-center-entry-points") + .Id("contactCenterEntryPoints") + .Action("Index", "EntryPoints", "CrestApps.OrchardCore.ContactCenter") + .Permission(ContactCenterPermissions.ManageQueues) + .LocalNav()), + priority: 1); + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs index f4e1746e0..857749982 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/VoiceContactCenterCallRouter.cs @@ -34,6 +34,7 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter private readonly IInboundContactLookup _contactLookup; private readonly IIncomingCallDispatcher _incomingCallDispatcher; private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly IEntryPointResolver _entryPointResolver; private readonly IClock _clock; /// @@ -52,6 +53,7 @@ public sealed class VoiceContactCenterCallRouter : IVoiceContactCenterCallRouter /// The contact lookup used to resolve the caller. /// The dispatcher used to offer the ringing call to the agent. /// The voice provider resolver used for outbound voice calls. + /// The entry point resolver used to route inbound calls by dialed number. /// The clock used to stamp times. public VoiceContactCenterCallRouter( IOmnichannelChannelEndpointManager channelEndpointManager, @@ -67,6 +69,7 @@ public VoiceContactCenterCallRouter( IInboundContactLookup contactLookup, IIncomingCallDispatcher incomingCallDispatcher, IContactCenterVoiceProviderResolver voiceProviderResolver, + IEntryPointResolver entryPointResolver, IClock clock) { _channelEndpointManager = channelEndpointManager; @@ -82,6 +85,7 @@ public VoiceContactCenterCallRouter( _contactLookup = contactLookup; _incomingCallDispatcher = incomingCallDispatcher; _voiceProviderResolver = voiceProviderResolver; + _entryPointResolver = entryPointResolver; _clock = clock; } @@ -156,11 +160,24 @@ public async Task RouteInboundAsync(InboundVoiceEvent var activity = await CreateActivityAsync(endpoint, flow, fromAddress, contactItemId, now); result.ActivityItemId = activity.ItemId; - var queue = await ResolveQueueAsync(endpoint, cancellationToken); + var plan = await _entryPointResolver.ResolveAsync(serviceAddress, cancellationToken); + + var queue = plan is not null && !string.IsNullOrEmpty(plan.TargetQueueId) + ? await _queueManager.FindByIdAsync(plan.TargetQueueId, cancellationToken) + : await ResolveQueueAsync(endpoint, cancellationToken); var interaction = await CreateInteractionAsync(inboundEvent, activity, queue, fromAddress, serviceAddress); result.InteractionId = interaction.ItemId; + if (plan is not null && !plan.ShouldQueue) + { + result.Reason = plan.ClosedAction == EntryPointClosedAction.Voicemail + ? "The entry point is closed; the caller was routed to voicemail." + : "The entry point is closed; the call was rejected."; + + return result; + } + if (queue is null) { result.Reason = "No inbound queue is configured to receive this call."; @@ -170,7 +187,9 @@ public async Task RouteInboundAsync(InboundVoiceEvent result.QueueId = queue.ItemId; - await _queueService.EnqueueAsync(activity.ItemId, queue.ItemId, priority: null, cancellationToken); + var priority = plan is not null ? plan.Priority : (InteractionPriority?)null; + + await _queueService.EnqueueAsync(activity.ItemId, queue.ItemId, priority, cancellationToken); var agentUserId = await OfferNextAsync(queue.ItemId, cancellationToken); diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index bf7207f76..9474cd633 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -240,10 +240,21 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped(sp => sp.GetRequiredService()) .AddScoped(sp => sp.GetRequiredService()) .AddScoped(); + + services + .AddDisplayDriver() + .AddScoped, ContactCenterEntryPointHandler>() + .AddIndexProvider() + .AddDataMigration(); + + services.AddNavigationProvider(); } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/EntryPointViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/EntryPointViewModel.cs new file mode 100644 index 000000000..0a1cebbd6 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/EntryPointViewModel.cs @@ -0,0 +1,87 @@ +using System.ComponentModel.DataAnnotations; +using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.AspNetCore.Mvc.Rendering; + +namespace CrestApps.OrchardCore.ContactCenter.ViewModels; + +/// +/// Represents the edit view model for an inbound entry point. +/// +public class EntryPointViewModel +{ + /// + /// Gets or sets the entry point identifier. + /// + public string Id { get; set; } + + /// + /// Gets or sets the unique entry point name. + /// + [Required] + public string Name { get; set; } + + /// + /// Gets or sets the entry point description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the dialed numbers, one per line. + /// + public string DialedNumbersText { get; set; } + + /// + /// Gets or sets the target queue identifier. + /// + public string TargetQueueId { get; set; } + + /// + /// Gets or sets the available target queues. + /// + public IList TargetQueueOptions { get; set; } = []; + + /// + /// Gets or sets the call priority. + /// + public InteractionPriority Priority { get; set; } = InteractionPriority.Normal; + + /// + /// Gets or sets the business-hours calendar identifier. + /// + public string BusinessHoursCalendarId { get; set; } + + /// + /// Gets or sets the available business-hours calendars. + /// + public IList BusinessHoursCalendarOptions { get; set; } = []; + + /// + /// Gets or sets the closed action. + /// + public EntryPointClosedAction ClosedAction { get; set; } = EntryPointClosedAction.HoldInQueue; + + /// + /// Gets or sets the overflow queue identifier. + /// + public string OverflowQueueId { get; set; } + + /// + /// Gets or sets the available overflow queues. + /// + public IList OverflowQueueOptions { get; set; } = []; + + /// + /// Gets or sets the welcome message. + /// + public string WelcomeMessage { get; set; } + + /// + /// Gets or sets the closed message. + /// + public string ClosedMessage { get; set; } + + /// + /// Gets or sets a value indicating whether the entry point is enabled. + /// + public bool Enabled { get; set; } = true; +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.Edit.cshtml new file mode 100644 index 000000000..be403296b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.Edit.cshtml @@ -0,0 +1,4 @@ +@if (Model.Content != null) +{ + @await DisplayAsync(Model.Content) +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.SummaryAdmin.cshtml new file mode 100644 index 000000000..d07548c93 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPoint.SummaryAdmin.cshtml @@ -0,0 +1,29 @@ +
    +
    +
    +
    +
    + @if (Model.Content != null) + { + @await DisplayAsync(Model.Content) + } +
    + + @if (Model.Meta != null) + { + + } +
    +
    +
    +
    +
    + @if (Model.Actions != null) + { + @await DisplayAsync(Model.Actions) + } +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml new file mode 100644 index 000000000..1498a96d6 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml @@ -0,0 +1,115 @@ +@model EntryPointViewModel + + + + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + @T["One dialed number (DID) per line. Calls to these numbers route through this entry point."] +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + + @T["Gates when the entry point is open. Leave empty to accept calls around the clock."] +
    +
    + +
    + +
    + + + @T["What happens to a call that arrives while the entry point is closed."] +
    +
    + +
    + +
    + + + @T["Used when the closed action is Overflow."] +
    +
    + +
    + +
    + + +
    +
    + +
    + +
    + + +
    +
    + +
    +
    +
    + + +
    + @T["Disabled entry points do not receive inbound calls."] +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Create.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Create.cshtml new file mode 100644 index 000000000..839ab2fa9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Create.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["New '{0}'", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Edit.cshtml new file mode 100644 index 000000000..c238c55b7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Edit.cshtml @@ -0,0 +1,19 @@ +@using CrestApps.OrchardCore.Core.Models + +@model EditCatalogEntryViewModel + + +

    @RenderTitleSegments(T["Edit '{0}' Entry Point", Model.DisplayName])

    +
    + +
    + @Html.ValidationSummary() + @await DisplayAsync(Model.Editor) + +
    +
    + + @T["Cancel"] +
    +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Index.cshtml new file mode 100644 index 000000000..d136c26f5 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/EntryPoints/Index.cshtml @@ -0,0 +1,57 @@ +@using CrestApps.Core.Models +@using CrestApps.OrchardCore.Core.Models + +@model ListCatalogEntryViewModel> + +

    @RenderTitleSegments(T["Entry points"])

    + +@* The form is necessary to generate an antiforgery token for delete actions. *@ +
    + + +
    +
    +
    +
    + +
    + +
    +
    +
    + +
      + @if (Model.Models.Count > 0) + { +
    • + @(Model.Models.Count == 1 ? T["1 item"] : T["{0} items", Model.Models.Count]) +
    • + + @foreach (var entry in Model.Models) + { +
    • +
      + @await DisplayAsync(entry.Shape) +
      +
    • + } + } + else + { +
    • + @T["Nothing here! There are no inbound entry points at the moment."] +
    • + } +
    + + +
    + +@await DisplayAsync(Model.Pager) diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Buttons.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Buttons.SummaryAdmin.cshtml new file mode 100644 index 000000000..25f654d8b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Buttons.SummaryAdmin.cshtml @@ -0,0 +1,14 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@T["Edit"] +@T["Delete"] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.DefaultMeta.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.DefaultMeta.SummaryAdmin.cshtml new file mode 100644 index 000000000..26ab0b712 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.DefaultMeta.SummaryAdmin.cshtml @@ -0,0 +1,17 @@ +@using System.Globalization +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +@{ + var modifiedAt = Model.Value.ModifiedUtc ?? Model.Value.CreatedUtc; +} + +@if (modifiedAt != default) +{ + + + + +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Fields.SummaryAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Fields.SummaryAdmin.cshtml new file mode 100644 index 000000000..e0dec0f53 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEntryPoint.Fields.SummaryAdmin.cshtml @@ -0,0 +1,23 @@ +@using CrestApps.OrchardCore.ContactCenter.Core.Models +@using OrchardCore.DisplayManagement.Views + +@model ShapeViewModel + +
    @Model.Value.Name
    + +@if (!string.IsNullOrWhiteSpace(Model.Value.Description)) +{ +
    @Model.Value.Description
    +} + +@if (Model.Value.DialedNumbers is { Count: > 0 }) +{ +
    @T["Numbers: {0}", string.Join(", ", Model.Value.DialedNumbers)]
    +} + +@if (!Model.Value.Enabled) +{ +
    + @T["Disabled"] +
    +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/EntryPointResolverTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/EntryPointResolverTests.cs new file mode 100644 index 000000000..1ce765d06 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/EntryPointResolverTests.cs @@ -0,0 +1,105 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class EntryPointResolverTests +{ + [Fact] + public void CreatePlan_WhenOpen_QueuesToTarget() + { + // Arrange + var entryPoint = new ContactCenterEntryPoint { ItemId = "e1", TargetQueueId = "q1", Priority = InteractionPriority.High }; + + // Act + var plan = EntryPointRoutingPlanner.CreatePlan(entryPoint, isOpen: true); + + // Assert + Assert.True(plan.ShouldQueue); + Assert.Equal("q1", plan.TargetQueueId); + Assert.Equal(InteractionPriority.High, plan.Priority); + } + + [Fact] + public void CreatePlan_WhenClosedWithOverflow_QueuesToOverflow() + { + // Arrange + var entryPoint = new ContactCenterEntryPoint + { + ItemId = "e1", + TargetQueueId = "q1", + OverflowQueueId = "q2", + ClosedAction = EntryPointClosedAction.Overflow, + }; + + // Act + var plan = EntryPointRoutingPlanner.CreatePlan(entryPoint, isOpen: false); + + // Assert + Assert.True(plan.ShouldQueue); + Assert.Equal("q2", plan.TargetQueueId); + } + + [Theory] + [InlineData(EntryPointClosedAction.Voicemail)] + [InlineData(EntryPointClosedAction.Reject)] + public void CreatePlan_WhenClosedWithVoicemailOrReject_DoesNotQueue(EntryPointClosedAction action) + { + // Arrange + var entryPoint = new ContactCenterEntryPoint { ItemId = "e1", TargetQueueId = "q1", ClosedAction = action }; + + // Act + var plan = EntryPointRoutingPlanner.CreatePlan(entryPoint, isOpen: false); + + // Assert + Assert.False(plan.ShouldQueue); + Assert.Null(plan.TargetQueueId); + } + + [Fact] + public async Task ResolveAsync_MatchesDialedNumberAndEvaluatesBusinessHours() + { + // Arrange + var entryPoint = new ContactCenterEntryPoint + { + ItemId = "e1", + TargetQueueId = "q1", + BusinessHoursCalendarId = "cal1", + DialedNumbers = ["+15551234567"], + }; + + var manager = new Mock(); + manager.Setup(m => m.ListEnabledAsync(It.IsAny())).ReturnsAsync([entryPoint]); + + var businessHours = new Mock(); + businessHours.Setup(b => b.IsOpenAsync("cal1", It.IsAny())).ReturnsAsync(true); + + var resolver = new EntryPointResolver(manager.Object, businessHours.Object); + + // Act + var plan = await resolver.ResolveAsync("+15551234567", TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(plan); + Assert.True(plan.IsOpen); + Assert.Equal("q1", plan.TargetQueueId); + } + + [Fact] + public async Task ResolveAsync_WhenNoEntryPointMatches_ReturnsNull() + { + // Arrange + var manager = new Mock(); + manager.Setup(m => m.ListEnabledAsync(It.IsAny())).ReturnsAsync([]); + + var resolver = new EntryPointResolver(manager.Object, new Mock().Object); + + // Act + var plan = await resolver.ResolveAsync("+15550000000", TestContext.Current.CancellationToken); + + // Assert + Assert.Null(plan); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index 3a6b031a6..cbf170ea5 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -215,6 +215,8 @@ private sealed class Harness public Mock VoiceProviderResolver { get; } = new(); + public Mock EntryPointResolver { get; } = new(); + public void SetupNoContext() { ChannelEndpointManager @@ -257,6 +259,7 @@ public VoiceContactCenterCallRouter CreateService() ContactLookup.Object, IncomingCallDispatcher.Object, VoiceProviderResolver.Object, + EntryPointResolver.Object, clock.Object); } } From 663060b002880d816ec052d5368c25c4accbfdf1 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:30:35 -0700 Subject: [PATCH 20/56] Contact Center: callback scheduling (Phases 5/10) - CallbackRequest catalog + CallbackRequestStatus; ICallbackService schedules pending callbacks and promotes due ones into outbound Callback-source activities (enqueued when a queue is set). - Per-minute CallbackDispatchBackgroundTask; registered in the Dialer feature. - +3 tests (125 ContactCenter tests pass); clean full-solution -warnaserror build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../ContactCenterConstants.cs | 10 ++ .../Models/CallbackRequestStatus.cs | 37 +++++ .../Indexes/CallbackRequestIndex.cs | 25 ++++ .../Models/CallbackRequest.cs | 82 +++++++++++ .../Services/CallbackRequestManager.cs | 41 ++++++ .../Services/CallbackRequestStore.cs | 35 +++++ .../Services/CallbackService.cs | 127 ++++++++++++++++++ .../Services/ICallbackRequestManager.cs | 18 +++ .../Services/ICallbackRequestStore.cs | 18 +++ .../Services/ICallbackService.cs | 25 ++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 5 + .../CallbackDispatchBackgroundTask.cs | 34 +++++ .../Indexes/CallbackRequestIndexProvider.cs | 32 +++++ .../CallbackRequestIndexMigrations.cs | 32 +++++ .../Startup.cs | 8 +- .../ContactCenter/CallbackServiceTests.cs | 104 ++++++++++++++ 17 files changed, 634 insertions(+), 2 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/CallbackRequestStatus.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallbackRequest.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestManager.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/CallbackDispatchBackgroundTask.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/CallbackRequestIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/CallbackRequestIndexMigrations.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/CallbackServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 4a38bc34e..85cd41c25 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1486,7 +1486,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. Remaining: the CRM-integrated agent desktop UI, supervisor dashboards, the queue monitor/wallboard, and scoped/audited live call-control intents — see G5) - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [ ] Phase 9 — Recording and live monitoring -- [ ] Phase 10 — Outbound compliance hardening +- [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [ ] Phase 11 — Optional Workflow bridge - [ ] Phase 12 — Analytics and operations - [ ] Phase 13 — Scale-out, resilience and data governance @@ -1531,3 +1531,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **G2 hardened — compare-and-set on reservation (P0 #7).** `ActivityReservationService.ReserveAsync` now re-reads the queue item by id and aborts (returns null, creates no reservation) unless it is still `Waiting`, so even if two writers slip past the per-queue lock the second cannot reserve an already-reserved item. +1 test (`ReserveAsync_WhenItemNoLongerWaiting_AbortsWithoutReserving`); updated the two existing reserve tests to mock the re-fetch. **113 ContactCenter tests pass**; clean `-warnaserror` build. Remaining G2: real-time per-reservation offer timeout (needs the G5 desktop). - 2026-07-01: **G1 advanced — transfer/conference taxonomy (completes Phase 4 orchestration).** Added `InteractionTransferType` (Blind/Consultative) and `InteractionTransferTargetType` (Agent/Queue/External/EntryPoint) enums, `TransferRequest`/`TransferResult` models, and `IContactCenterTransferService`/`ContactCenterTransferService` which records the transfer on `Interaction.TransferHistory`, sets the interaction `Transferring`, publishes `InteractionTransferred`, and re-enqueues the activity into the target queue for queue transfers (agent/external/entry-point record intent; media handoff stays a provider concern). Added `CallTransfer` + `Conference` capability flags to `ContactCenterVoiceProviderCapabilities`. Registered in `VoiceStartup`. +3 tests (`ContactCenterTransferServiceTests`) for **116 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining G1: soft-phone JS accept-then-answer coordination + asset rebuild (client-side, not unit-testable here). - 2026-07-01: **Phase 8 started — inbound entry points.** Added the `ContactCenterEntryPoint` catalog (Core: model/index/store/manager) mapping dialed numbers (DIDs) to a target queue + priority, gated by a business-hours calendar, with a `ClosedAction` (HoldInQueue/Voicemail/Overflow/Reject) and welcome/closed messages. `IEntryPointResolver`/`EntryPointResolver` matches the DID and, via `IBusinessHoursService`, builds an `EntryPointRoutingPlan` (testable `EntryPointRoutingPlanner`). Wired into `VoiceContactCenterCallRouter`: open calls route to the entry point queue at its priority; closed calls hold/overflow or return not-routed for voicemail/reject; falls back to endpoint→queue mapping when no entry point matches. Module: index provider/migration/handler/display driver/`EntryPointsController`/**Interaction Center → Entry points** admin menu + views (Voice feature, `ManageQueues`). +6 tests (`EntryPointResolverTests`) for **122 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 8: multi-step IVR decision trees, DTMF/menu capture, estimated-wait announcements, ambiguous-contact disambiguation. Next: Phase 10 (compliance: callbacks + calling-window calendars + abandonment) or Phase 9 (recording). +- 2026-07-01: **Phase 10 started — callbacks (Phase 5/10 remnant).** Added the `CallbackRequest` catalog (Core: model/index/store/manager) with `CallbackRequestStatus` (Pending/Scheduled/InProgress/Completed/Canceled/Failed). `ICallbackService`/`CallbackService` schedules pending callbacks (`CallbackScheduled` event) and `PromoteDueAsync` turns each due pending callback into an outbound `ActivitySources.Callback` `OmnichannelActivity`, enqueues it when a queue is set, marks it `Scheduled`, and publishes `CallbackPromoted`. Per-minute `CallbackDispatchBackgroundTask` runs promotion; registered in the Dialer feature with index provider + migration. +3 tests (`CallbackServiceTests`) for **125 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 10: abandonment caps, AMD outcomes, calling-window calendars, predictive metrics. Next: Phase 12 (analytics projections) or Phase 9 (recording). diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index c7f7e5fba..8a1b9a7ea 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -248,6 +248,16 @@ public static class Events ///
    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. /// 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/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs new file mode 100644 index 000000000..37ebd2664 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/CallbackRequestIndex.cs @@ -0,0 +1,25 @@ +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; } +} 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..9f04ed87a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/CallbackRequest.cs @@ -0,0 +1,82 @@ +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 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/Services/CallbackRequestManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestManager.cs new file mode 100644 index 000000000..2693d7ed6 --- /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, CancellationToken cancellationToken = default) + { + var callbacks = await _store.ListDueAsync(utcNow, 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..50480bc5e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackRequestStore.cs @@ -0,0 +1,35 @@ +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 +{ + /// + /// 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, CancellationToken cancellationToken = default) + { + var callbacks = await Session.Query( + index => index.Status == CallbackRequestStatus.Pending && index.ScheduledUtc <= utcNow, + collection: ContactCenterConstants.CollectionName) + .OrderBy(index => index.ScheduledUtc) + .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..749b49d68 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/CallbackService.cs @@ -0,0 +1,127 @@ +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; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class CallbackService : ICallbackService +{ + private readonly ICallbackRequestManager _callbackManager; + private readonly IOmnichannelActivityManager _activityManager; + 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 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, + IActivityQueueService queueService, + IContactCenterEventPublisher publisher, + IClock clock) + { + _callbackManager = callbackManager; + _activityManager = activityManager; + _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 due = await _callbackManager.ListDueAsync(_clock.UtcNow, cancellationToken); + var count = 0; + + foreach (var callback in due) + { + var activity = await CreateActivityAsync(callback, cancellationToken); + callback.ActivityItemId = activity.ItemId; + callback.Status = CallbackRequestStatus.Scheduled; + callback.Attempts++; + + 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 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; + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + activity.Status = ActivityStatus.NotStated; + activity.ScheduledUtc = now; + activity.CreatedUtc = now; + + await _activityManager.CreateAsync(activity, cancellationToken: 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/ICallbackRequestManager.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestManager.cs new file mode 100644 index 000000000..579eb2bed --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestManager.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 callback requests. +/// +public interface ICallbackRequestManager : ICatalogManager +{ + /// + /// Lists pending callbacks that are due at or before the supplied UTC instant. + /// + /// The current UTC instant. + /// The token to monitor for cancellation requests. + /// The due callbacks ordered by their scheduled time. + Task> ListDueAsync(DateTime utcNow, 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..8cc689fd0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ICallbackRequestStore.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 callback requests. +/// +public interface ICallbackRequestStore : ICatalog +{ + /// + /// Lists pending callbacks that are due at or before the supplied UTC instant. + /// + /// The current UTC instant. + /// The token to monitor for cancellation requests. + /// The due callbacks ordered by their scheduled time. + Task> ListDueAsync(DateTime utcNow, 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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 82c82a958..34ea88727 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -238,6 +238,11 @@ At a high level, the platform changes are: - The single-attempt logic moved into `IDialerAttemptService`, and `DialerService` now validates the profile and delegates pacing to the resolved strategy. - 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 → 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`. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/CallbackDispatchBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/CallbackDispatchBackgroundTask.cs new file mode 100644 index 000000000..5ebe1ebd2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/CallbackDispatchBackgroundTask.cs @@ -0,0 +1,34 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using OrchardCore.BackgroundTasks; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Promotes due callbacks into outbound activities so the dialer or an agent can handle them. +/// +[BackgroundTask( + Title = "Contact Center Callback Dispatch", + Schedule = "* * * * *", + Description = "Promotes scheduled callbacks that have become due into outbound work.", + LockTimeout = 5_000, + LockExpiration = 60_000)] +public sealed class CallbackDispatchBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var callbackService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + try + { + await callbackService.PromoteDueAsync(cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while promoting due callbacks."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/CallbackRequestIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/CallbackRequestIndexProvider.cs new file mode 100644 index 000000000..a11e3eb54 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/CallbackRequestIndexProvider.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class CallbackRequestIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public CallbackRequestIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(callback => new CallbackRequestIndex + { + ItemId = callback.ItemId, + Status = callback.Status, + ScheduledUtc = callback.ScheduledUtc, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/CallbackRequestIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/CallbackRequestIndexMigrations.cs new file mode 100644 index 000000000..6993a0d8a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/CallbackRequestIndexMigrations.cs @@ -0,0 +1,32 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class CallbackRequestIndexMigrations : DataMigration +{ + /// + /// Creates the callback request index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("Status") + .Column("ScheduledUtc"), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_CallbackRequestIndex_DocumentId", "DocumentId", "ItemId", "Status", "ScheduledUtc"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 9474cd633..cf2abb578 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -188,6 +188,9 @@ public override void ConfigureServices(IServiceCollection services) services .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() @@ -200,9 +203,12 @@ public override void ConfigureServices(IServiceCollection services) .AddDisplayDriver() .AddScoped, DialerProfileHandler>() .AddIndexProvider() - .AddDataMigration(); + .AddDataMigration() + .AddIndexProvider() + .AddDataMigration(); services.AddSingleton(); + services.AddSingleton(); services.AddNavigationProvider(); services.Configure(options => diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/CallbackServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/CallbackServiceTests.cs new file mode 100644 index 000000000..efe5e94fa --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/CallbackServiceTests.cs @@ -0,0 +1,104 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class CallbackServiceTests +{ + private static readonly DateTime _now = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task ScheduleAsync_SetsPendingStatusAndPublishesEvent() + { + // Arrange + var callbackManager = new Mock(); + var publisher = new Mock(); + var service = CreateService(callbackManager, new Mock(), new Mock(), publisher); + + var callback = new CallbackRequest { ItemId = "cb1", Destination = "+15551234567" }; + + // Act + var scheduled = await service.ScheduleAsync(callback, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(CallbackRequestStatus.Pending, scheduled.Status); + Assert.Equal(_now, scheduled.RequestedUtc); + callbackManager.Verify(m => m.CreateAsync(callback, It.IsAny()), Times.Once); + publisher.Verify(p => p.PublishAsync(It.Is(e => e.EventType == ContactCenterConstants.Events.CallbackScheduled), It.IsAny()), Times.Once); + } + + [Fact] + public async Task PromoteDueAsync_CreatesActivityAndEnqueuesWhenQueueSet() + { + // Arrange + var callback = new CallbackRequest + { + ItemId = "cb1", + Destination = "+15551234567", + QueueId = "q1", + Status = CallbackRequestStatus.Pending, + ScheduledUtc = _now.AddMinutes(-1), + }; + + var callbackManager = new Mock(); + callbackManager.Setup(m => m.ListDueAsync(_now, It.IsAny())).ReturnsAsync([callback]); + + var activityManager = new Mock(); + activityManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + + var queueService = new Mock(); + var service = CreateService(callbackManager, activityManager, queueService, new Mock()); + + // Act + var count = await service.PromoteDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, count); + Assert.Equal(CallbackRequestStatus.Scheduled, callback.Status); + Assert.Equal("act-1", callback.ActivityItemId); + queueService.Verify(s => s.EnqueueAsync("act-1", "q1", null, It.IsAny()), Times.Once); + } + + [Fact] + public async Task PromoteDueAsync_WithoutQueue_DoesNotEnqueue() + { + // Arrange + var callback = new CallbackRequest { ItemId = "cb1", Destination = "+15551234567", Status = CallbackRequestStatus.Pending }; + + var callbackManager = new Mock(); + callbackManager.Setup(m => m.ListDueAsync(_now, It.IsAny())).ReturnsAsync([callback]); + + var activityManager = new Mock(); + activityManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + + var queueService = new Mock(); + var service = CreateService(callbackManager, activityManager, queueService, new Mock()); + + // Act + var count = await service.PromoteDueAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(1, count); + queueService.Verify(s => s.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + private static CallbackService CreateService( + Mock callbackManager, + Mock activityManager, + Mock queueService, + Mock publisher) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new CallbackService(callbackManager.Object, activityManager.Object, queueService.Object, publisher.Object, clock.Object); + } +} From d613f33d599a22d46d7862d7a666f34bfe6e9d7a Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:38:03 -0700 Subject: [PATCH 21/56] Contact Center: daily event-metrics projection (Phase 12) - ContactCenterEventMetric projection + IContactCenterMetricsService (record + range summary) + ContactCenterMetricsProjectionHandler projecting every domain event into rebuildable per-day, per-event-type counts. - Registered in the always-on base feature. - +4 tests (129 ContactCenter tests pass); clean -warnaserror build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../Indexes/ContactCenterEventMetricIndex.cs | 29 ++++++ .../Models/ContactCenterEventMetric.cs | 41 ++++++++ .../Services/ContactCenterMetricStore.cs | 45 +++++++++ .../Services/ContactCenterMetricsService.cs | 79 +++++++++++++++ .../Services/IContactCenterMetricStore.cs | 28 ++++++ .../Services/IContactCenterMetricsService.cs | 25 +++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 + .../ContactCenterMetricsProjectionHandler.cs | 30 ++++++ .../ContactCenterEventMetricIndexProvider.cs | 33 +++++++ ...ContactCenterEventMetricIndexMigrations.cs | 33 +++++++ .../Startup.cs | 7 ++ .../ContactCenterMetricsServiceTests.cs | 98 +++++++++++++++++++ 13 files changed, 454 insertions(+), 1 deletion(-) create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Indexes/ContactCenterEventMetricIndex.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterEventMetric.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricStore.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricsService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterMetricsProjectionHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEventMetricIndexProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEventMetricIndexMigrations.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterMetricsServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 85cd41c25..c95208ad7 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1488,7 +1488,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [ ] Phase 9 — Recording and live monitoring - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [ ] Phase 11 — Optional Workflow bridge -- [ ] Phase 12 — Analytics and operations +- [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. Remaining: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, and CSV/export surfaces.) - [ ] Phase 13 — Scale-out, resilience and data governance - [ ] Phase 14 — Advanced capabilities @@ -1532,3 +1532,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **G1 advanced — transfer/conference taxonomy (completes Phase 4 orchestration).** Added `InteractionTransferType` (Blind/Consultative) and `InteractionTransferTargetType` (Agent/Queue/External/EntryPoint) enums, `TransferRequest`/`TransferResult` models, and `IContactCenterTransferService`/`ContactCenterTransferService` which records the transfer on `Interaction.TransferHistory`, sets the interaction `Transferring`, publishes `InteractionTransferred`, and re-enqueues the activity into the target queue for queue transfers (agent/external/entry-point record intent; media handoff stays a provider concern). Added `CallTransfer` + `Conference` capability flags to `ContactCenterVoiceProviderCapabilities`. Registered in `VoiceStartup`. +3 tests (`ContactCenterTransferServiceTests`) for **116 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining G1: soft-phone JS accept-then-answer coordination + asset rebuild (client-side, not unit-testable here). - 2026-07-01: **Phase 8 started — inbound entry points.** Added the `ContactCenterEntryPoint` catalog (Core: model/index/store/manager) mapping dialed numbers (DIDs) to a target queue + priority, gated by a business-hours calendar, with a `ClosedAction` (HoldInQueue/Voicemail/Overflow/Reject) and welcome/closed messages. `IEntryPointResolver`/`EntryPointResolver` matches the DID and, via `IBusinessHoursService`, builds an `EntryPointRoutingPlan` (testable `EntryPointRoutingPlanner`). Wired into `VoiceContactCenterCallRouter`: open calls route to the entry point queue at its priority; closed calls hold/overflow or return not-routed for voicemail/reject; falls back to endpoint→queue mapping when no entry point matches. Module: index provider/migration/handler/display driver/`EntryPointsController`/**Interaction Center → Entry points** admin menu + views (Voice feature, `ManageQueues`). +6 tests (`EntryPointResolverTests`) for **122 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 8: multi-step IVR decision trees, DTMF/menu capture, estimated-wait announcements, ambiguous-contact disambiguation. Next: Phase 10 (compliance: callbacks + calling-window calendars + abandonment) or Phase 9 (recording). - 2026-07-01: **Phase 10 started — callbacks (Phase 5/10 remnant).** Added the `CallbackRequest` catalog (Core: model/index/store/manager) with `CallbackRequestStatus` (Pending/Scheduled/InProgress/Completed/Canceled/Failed). `ICallbackService`/`CallbackService` schedules pending callbacks (`CallbackScheduled` event) and `PromoteDueAsync` turns each due pending callback into an outbound `ActivitySources.Callback` `OmnichannelActivity`, enqueues it when a queue is set, marks it `Scheduled`, and publishes `CallbackPromoted`. Per-minute `CallbackDispatchBackgroundTask` runs promotion; registered in the Dialer feature with index provider + migration. +3 tests (`CallbackServiceTests`) for **125 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 10: abandonment caps, AMD outcomes, calling-window calendars, predictive metrics. Next: Phase 12 (analytics projections) or Phase 9 (recording). +- 2026-07-01: **Phase 12 started — daily event-metrics projection.** Added `ContactCenterEventMetric` (Core: model/index/store) keyed by (DateKey `yyyy-MM-dd` + Date + EventType), `IContactCenterMetricsService`/`ContactCenterMetricsService` (`RecordAsync` find-or-create+increment, `GetSummaryAsync` range totals by event type), and `ContactCenterMetricsProjectionHandler` (`IContactCenterEventHandler`) that records every published domain event. Registered in the always-on base feature (store/service/handler + index provider + migration). Because it derives from the durable event log it is rebuildable. +4 tests (`ContactCenterMetricsServiceTests`) for **129 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 12: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, exports. Next: Phase 9 (recording) or Phase 11 (workflow bridge). 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/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/Services/ContactCenterMetricStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricStore.cs new file mode 100644 index 000000000..aef7f6da7 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricStore.cs @@ -0,0 +1,45 @@ +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 +{ + /// + /// 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(); + } +} 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..6b38ab2ce --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMetricsService.cs @@ -0,0 +1,79 @@ +using System.Globalization; +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 const string DateKeyFormat = "yyyy-MM-dd"; + + private readonly IContactCenterMetricStore _store; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The metric store. + /// The clock used to stamp metric times. + public ContactCenterMetricsService( + IContactCenterMetricStore store, + IClock clock) + { + _store = store; + _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; + var dateKey = date.ToString(DateKeyFormat, CultureInfo.InvariantCulture); + + var metric = await _store.FindAsync(dateKey, eventType, cancellationToken); + + if (metric is null) + { + metric = new ContactCenterEventMetric + { + ItemId = IdGenerator.GenerateId(), + DateKey = dateKey, + Date = date, + EventType = eventType, + Count = 1, + CreatedUtc = _clock.UtcNow, + }; + + await _store.CreateAsync(metric, cancellationToken); + + return; + } + + metric.Count++; + metric.ModifiedUtc = _clock.UtcNow; + await _store.UpdateAsync(metric, cancellationToken); + } + + /// + public async Task> GetSummaryAsync(DateOnly fromDate, DateOnly toDate, CancellationToken cancellationToken = default) + { + var metrics = await _store.ListByDateRangeAsync( + fromDate.ToDateTime(TimeOnly.MinValue, DateTimeKind.Utc), + toDate.ToDateTime(TimeOnly.MaxValue, DateTimeKind.Utc), + cancellationToken); + + return metrics + .GroupBy(metric => metric.EventType, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Sum(metric => metric.Count), StringComparer.Ordinal); + } +} 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..3784a6181 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMetricStore.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 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); +} 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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 34ea88727..69b8b533a 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -268,6 +268,10 @@ At a high level, the platform changes are: - Adds signed **provider voice webhook** ingestion. A provider posts to `POST /api/contact-center/voice/webhook/{provider}`; the request is authenticated by the provider adapter's signature (not a user identity), so the endpoint is anonymous but every delivery must carry a valid signature. `IProviderVoiceWebhookAdapter` validates the signature and normalizes the payload into `ProviderVoiceEvent`s, `HmacProviderVoiceWebhookAdapterBase` provides constant-time HMAC-SHA256 verification, and `IProviderVoiceWebhookProcessor` resolves the adapter, verifies the signature, **requires an idempotency key on every parsed event**, and forwards accepted events to the provider voice event pipeline. - See [Domain events and reliable dispatch](../contact-center/index.md#domain-events-and-reliable-dispatch). +#### Analytics: daily event metrics (Phase 12) + +- Adds a rebuildable 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. Because the projection is derived purely from the durable event log, it can be rebuilt. The projection lives in the always-on base feature. + ### Content Access Control #### Role-based content restrictions diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterMetricsProjectionHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterMetricsProjectionHandler.cs new file mode 100644 index 000000000..919e01168 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterMetricsProjectionHandler.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +/// +/// Projects every published Contact Center domain event into the daily event-count metrics used for +/// operational and historical reporting. +/// +public sealed class ContactCenterMetricsProjectionHandler : IContactCenterEventHandler +{ + private readonly IContactCenterMetricsService _metricsService; + + /// + /// Initializes a new instance of the class. + /// + /// The metrics service. + public ContactCenterMetricsProjectionHandler(IContactCenterMetricsService metricsService) + { + _metricsService = metricsService; + } + + /// + public Task HandleAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + return _metricsService.RecordAsync(interactionEvent.EventType, interactionEvent.OccurredUtc, cancellationToken); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEventMetricIndexProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEventMetricIndexProvider.cs new file mode 100644 index 000000000..949ab2cb2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Indexes/ContactCenterEventMetricIndexProvider.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using YesSql.Indexes; + +namespace CrestApps.OrchardCore.ContactCenter.Indexes; + +/// +/// Maps documents to the . +/// +public sealed class ContactCenterEventMetricIndexProvider : IndexProvider +{ + /// + /// Initializes a new instance of the class. + /// + public ContactCenterEventMetricIndexProvider() + { + CollectionName = ContactCenterConstants.CollectionName; + } + + /// + public override void Describe(DescribeContext context) + { + context + .For() + .Map(metric => new ContactCenterEventMetricIndex + { + ItemId = metric.ItemId, + DateKey = metric.DateKey, + Date = metric.Date, + EventType = metric.EventType, + }); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEventMetricIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEventMetricIndexMigrations.cs new file mode 100644 index 000000000..6f3f892dd --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Migrations/ContactCenterEventMetricIndexMigrations.cs @@ -0,0 +1,33 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Indexes; +using OrchardCore.Data.Migration; +using YesSql.Sql; + +namespace CrestApps.OrchardCore.ContactCenter.Migrations; + +/// +/// Creates the schema for the . +/// +internal sealed class ContactCenterEventMetricIndexMigrations : DataMigration +{ + /// + /// Creates the event metric index table. + /// + /// The migration version number. + public async Task CreateAsync() + { + await SchemaBuilder.CreateMapIndexTableAsync(table => table + .Column("ItemId", column => column.WithLength(26)) + .Column("DateKey", column => column.WithLength(10)) + .Column("Date") + .Column("EventType", column => column.WithLength(128)), + collection: ContactCenterConstants.CollectionName + ); + + await SchemaBuilder.AlterIndexTableAsync(table => table + .CreateIndex("IDX_ContactCenterEventMetricIndex_DocumentId", "DocumentId", "DateKey", "Date", "EventType"), + collection: ContactCenterConstants.CollectionName + ); + + return 1; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index cf2abb578..1d2a040e0 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -45,8 +45,15 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() .AddScoped, InteractionHandler>(); + services + .AddIndexProvider() + .AddDataMigration(); + services .AddScoped() .AddScoped(); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterMetricsServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterMetricsServiceTests.cs new file mode 100644 index 000000000..f38efbe9c --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterMetricsServiceTests.cs @@ -0,0 +1,98 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Handlers; +using Moq; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterMetricsServiceTests +{ + private static readonly DateTime _now = new(2026, 1, 5, 12, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task RecordAsync_WhenMetricMissing_CreatesWithCountOne() + { + // Arrange + var store = new Mock(); + store.Setup(s => s.FindAsync("2026-01-05", "CallEnded", It.IsAny())).ReturnsAsync((ContactCenterEventMetric)null); + + ContactCenterEventMetric created = null; + store.Setup(s => s.CreateAsync(It.IsAny(), It.IsAny())) + .Callback((m, _) => created = m) + .Returns(ValueTask.CompletedTask); + + var service = CreateService(store); + + // Act + await service.RecordAsync("CallEnded", _now, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(created); + Assert.Equal("2026-01-05", created.DateKey); + Assert.Equal(1, created.Count); + } + + [Fact] + public async Task RecordAsync_WhenMetricExists_IncrementsCount() + { + // Arrange + var existing = new ContactCenterEventMetric { ItemId = "m1", DateKey = "2026-01-05", EventType = "CallEnded", Count = 4 }; + var store = new Mock(); + store.Setup(s => s.FindAsync("2026-01-05", "CallEnded", It.IsAny())).ReturnsAsync(existing); + + var service = CreateService(store); + + // Act + await service.RecordAsync("CallEnded", _now, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(5, existing.Count); + store.Verify(s => s.UpdateAsync(existing, It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetSummaryAsync_AggregatesCountsByEventType() + { + // Arrange + var store = new Mock(); + store.Setup(s => s.ListByDateRangeAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync( + [ + new ContactCenterEventMetric { EventType = "CallEnded", Count = 3 }, + new ContactCenterEventMetric { EventType = "CallEnded", Count = 2 }, + new ContactCenterEventMetric { EventType = "QueueItemAdded", Count = 7 }, + ]); + + var service = CreateService(store); + + // Act + var summary = await service.GetSummaryAsync(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31), TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(5, summary["CallEnded"]); + Assert.Equal(7, summary["QueueItemAdded"]); + } + + [Fact] + public async Task ProjectionHandler_RecordsEventType() + { + // Arrange + var metricsService = new Mock(); + var handler = new ContactCenterMetricsProjectionHandler(metricsService.Object); + + // Act + await handler.HandleAsync(new InteractionEvent { EventType = "OfferAccepted", OccurredUtc = _now }, TestContext.Current.CancellationToken); + + // Assert + metricsService.Verify(s => s.RecordAsync("OfferAccepted", _now, It.IsAny()), Times.Once); + } + + private static ContactCenterMetricsService CreateService(Mock store) + { + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + + return new ContactCenterMetricsService(store.Object, clock.Object); + } +} From ba7ea9476746294ebc76509b35ff3981a198d9b3 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:42:40 -0700 Subject: [PATCH 22/56] Contact Center: recording + live-monitoring orchestration (Phase 9) - RecordingState/MonitorMode enums + Interaction.RecordingState; provider capability flags Recording/Monitor/Whisper/Barge/TakeOver. - IContactCenterRecordingService recording state machine with Recording* events. - IContactCenterMonitoringService capability-gated, audited supervisor monitor/whisper/barge/take-over (SupervisorMonitorStarted). - +4 tests (133 ContactCenter tests pass); clean full-solution -warnaserror build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../ContactCenterConstants.cs | 25 +++++ .../ContactCenterVoiceProviderCapabilities.cs | 25 +++++ .../Models/MonitorMode.cs | 27 ++++++ .../Models/RecordingState.cs | 27 ++++++ .../Models/Interaction.cs | 5 + .../Models/SupervisorEngagementResult.cs | 36 +++++++ .../ContactCenterMonitoringService.cs | 87 +++++++++++++++++ .../Services/ContactCenterRecordingService.cs | 80 +++++++++++++++ .../IContactCenterMonitoringService.cs | 21 ++++ .../IContactCenterRecordingService.cs | 40 ++++++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 5 + .../Startup.cs | 2 + ...ontactCenterRecordingAndMonitoringTests.cs | 97 +++++++++++++++++++ 14 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/MonitorMode.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/RecordingState.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SupervisorEngagementResult.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMonitoringService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRecordingService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMonitoringService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRecordingService.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRecordingAndMonitoringTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index c95208ad7..c27435542 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1485,7 +1485,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 6 — Disposition lifecycle (the **Subject Flow is the single decision controller** for CRM, inbound, and outbound: every activity carries a Subject + Subject Flow, and completion routes through the source-neutral `IActivityDispositionService`, which applies the disposition, marks the activity `Completed` regardless of its prior contact-center state — resolving P0 #8 — and runs the disposition-driven Subject Actions. A subject flow can require a disposition (`SubjectFlowSettings.RequireDisposition`), enforced centrally so completion is blocked until a disposition is chosen. **Design decision (2026-06-30):** the separate `WrapUp`/`WrapUpSession` concept added earlier was removed as redundant with disposition + subject flow; after-call agent timing/capacity release is deferred to the Phase 7 agent desktop and agent presence, not a domain aggregate) - [~] Phase 7 — Agent desktop and supervisor real-time UX (**Agent state reason codes shipped (2026-06-30)** + **real-time SignalR foundation shipped (2026-06-30):** the new `RealTime` feature adds the `ContactCenterHub` (user/queue/`cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`), a live `AgentSession` aggregate split from `AgentProfile`, a `Heartbeat`-driven stale-session cleanup background task that signs out dead clients, a `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`), the `ContactCenterRealTimeEventHandler` that broadcasts presence/offer/queue events, a `contact-center-realtime` client script, and the `MonitorContactCenter` permission + Supervisor role. Remaining: the CRM-integrated agent desktop UI, supervisor dashboards, the queue monitor/wallboard, and scoped/audited live call-control intents — see G5) - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) -- [ ] Phase 9 — Recording and live monitoring +- [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [ ] Phase 11 — Optional Workflow bridge - [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. Remaining: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, and CSV/export surfaces.) @@ -1533,3 +1533,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **Phase 8 started — inbound entry points.** Added the `ContactCenterEntryPoint` catalog (Core: model/index/store/manager) mapping dialed numbers (DIDs) to a target queue + priority, gated by a business-hours calendar, with a `ClosedAction` (HoldInQueue/Voicemail/Overflow/Reject) and welcome/closed messages. `IEntryPointResolver`/`EntryPointResolver` matches the DID and, via `IBusinessHoursService`, builds an `EntryPointRoutingPlan` (testable `EntryPointRoutingPlanner`). Wired into `VoiceContactCenterCallRouter`: open calls route to the entry point queue at its priority; closed calls hold/overflow or return not-routed for voicemail/reject; falls back to endpoint→queue mapping when no entry point matches. Module: index provider/migration/handler/display driver/`EntryPointsController`/**Interaction Center → Entry points** admin menu + views (Voice feature, `ManageQueues`). +6 tests (`EntryPointResolverTests`) for **122 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 8: multi-step IVR decision trees, DTMF/menu capture, estimated-wait announcements, ambiguous-contact disambiguation. Next: Phase 10 (compliance: callbacks + calling-window calendars + abandonment) or Phase 9 (recording). - 2026-07-01: **Phase 10 started — callbacks (Phase 5/10 remnant).** Added the `CallbackRequest` catalog (Core: model/index/store/manager) with `CallbackRequestStatus` (Pending/Scheduled/InProgress/Completed/Canceled/Failed). `ICallbackService`/`CallbackService` schedules pending callbacks (`CallbackScheduled` event) and `PromoteDueAsync` turns each due pending callback into an outbound `ActivitySources.Callback` `OmnichannelActivity`, enqueues it when a queue is set, marks it `Scheduled`, and publishes `CallbackPromoted`. Per-minute `CallbackDispatchBackgroundTask` runs promotion; registered in the Dialer feature with index provider + migration. +3 tests (`CallbackServiceTests`) for **125 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 10: abandonment caps, AMD outcomes, calling-window calendars, predictive metrics. Next: Phase 12 (analytics projections) or Phase 9 (recording). - 2026-07-01: **Phase 12 started — daily event-metrics projection.** Added `ContactCenterEventMetric` (Core: model/index/store) keyed by (DateKey `yyyy-MM-dd` + Date + EventType), `IContactCenterMetricsService`/`ContactCenterMetricsService` (`RecordAsync` find-or-create+increment, `GetSummaryAsync` range totals by event type), and `ContactCenterMetricsProjectionHandler` (`IContactCenterEventHandler`) that records every published domain event. Registered in the always-on base feature (store/service/handler + index provider + migration). Because it derives from the durable event log it is rebuildable. +4 tests (`ContactCenterMetricsServiceTests`) for **129 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 12: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, exports. Next: Phase 9 (recording) or Phase 11 (workflow bridge). +- 2026-07-01: **Phase 9 started — recording + live-monitoring orchestration.** Added `RecordingState` + `MonitorMode` enums, `Interaction.RecordingState`, and provider capability flags `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver`. `IContactCenterRecordingService` drives the recording state machine (start/pause/resume/stop) with `RecordingStarted/Paused/Resumed/Stopped` events; `IContactCenterMonitoringService.EngageAsync` performs a capability-gated, audited supervisor engagement (`SupervisorMonitorStarted` with mode+supervisor) and refuses unsupported modes. Registered in the Voice feature. +4 tests for **133 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 9: provider media execution, consent/retention, recording storage/access audit, QM scorecards. Next: Phase 11 (workflow bridge) or G5 (agent desktop UI). diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index 8a1b9a7ea..8e1eea108 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -287,5 +287,30 @@ public static class Events /// Raised when an agent declines an offered interaction. /// public const string OfferDeclined = "OfferDeclined"; + + /// + /// 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 a supervisor starts monitoring, whispering, barging, or taking over a live call. + /// + public const string SupervisorMonitorStarted = "SupervisorMonitorStarted"; } } diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs index 7d7018751..0c527d3ea 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/ContactCenterVoiceProviderCapabilities.cs @@ -51,4 +51,29 @@ public enum ContactCenterVoiceProviderCapabilities /// 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, + + /// + /// The provider can take over a live call from the agent. + /// + TakeOver = 1 << 12, } 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..8587633db --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/MonitorMode.cs @@ -0,0 +1,27 @@ +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, + + /// + /// The supervisor takes the call over from the agent. + /// + TakeOver, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/RecordingState.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/RecordingState.cs new file mode 100644 index 000000000..6c146a61c --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/RecordingState.cs @@ -0,0 +1,27 @@ +namespace CrestApps.OrchardCore.ContactCenter.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/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs index 2469ce9d3..1e25e1852 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Interaction.cs @@ -82,6 +82,11 @@ JsonObject IEntity.Properties /// public string RecordingReference { get; set; } + /// + /// Gets or sets the recording state of the interaction. + /// + public RecordingState RecordingState { get; set; } + /// /// Gets or sets the transcript reference when a transcript is available for the interaction. /// 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..426700347 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/SupervisorEngagementResult.cs @@ -0,0 +1,36 @@ +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 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 }; + } +} 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..a71eeadac --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterMonitoringService.cs @@ -0,0 +1,87 @@ +using System.Collections.Generic; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterMonitoringService : IContactCenterMonitoringService +{ + private readonly IInteractionManager _interactionManager; + private readonly IContactCenterVoiceProviderResolver _voiceProviderResolver; + private readonly IContactCenterEventPublisher _publisher; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The voice provider resolver used to check monitoring capabilities. + /// The Contact Center event publisher. + public ContactCenterMonitoringService( + IInteractionManager interactionManager, + IContactCenterVoiceProviderResolver voiceProviderResolver, + IContactCenterEventPublisher publisher) + { + _interactionManager = interactionManager; + _voiceProviderResolver = voiceProviderResolver; + _publisher = publisher; + } + + /// + public async Task EngageAsync(string interactionId, string supervisorId, 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 provider = _voiceProviderResolver.Get(interaction.ProviderName); + var capability = ResolveCapability(mode); + + if (provider is null || !provider.Capabilities.HasFlag(capability)) + { + return SupervisorEngagementResult.Failure($"The voice provider does not support the '{mode}' engagement."); + } + + 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); + + return SupervisorEngagementResult.Success(); + } + + private static ContactCenterVoiceProviderCapabilities ResolveCapability(MonitorMode mode) + { + return mode switch + { + MonitorMode.Monitor => ContactCenterVoiceProviderCapabilities.Monitor, + MonitorMode.Whisper => ContactCenterVoiceProviderCapabilities.Whisper, + MonitorMode.Barge => ContactCenterVoiceProviderCapabilities.Barge, + MonitorMode.TakeOver => ContactCenterVoiceProviderCapabilities.TakeOver, + _ => ContactCenterVoiceProviderCapabilities.Monitor, + }; + } +} 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..45e08a1cd --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRecordingService.cs @@ -0,0 +1,80 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterRecordingService : IContactCenterRecordingService +{ + private readonly IInteractionManager _interactionManager; + private readonly IContactCenterEventPublisher _publisher; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction manager. + /// The Contact Center event publisher. + public ContactCenterRecordingService( + IInteractionManager interactionManager, + IContactCenterEventPublisher publisher) + { + _interactionManager = interactionManager; + _publisher = publisher; + } + + /// + 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 false; + } + + var interaction = await _interactionManager.FindByIdAsync(interactionId, cancellationToken); + + if (interaction is null || interaction.RecordingState == state) + { + return false; + } + + interaction.RecordingState = state; + await _interactionManager.UpdateAsync(interaction, cancellationToken: cancellationToken); + + await _publisher.PublishAsync(new InteractionEvent + { + EventType = eventType, + InteractionId = interaction.ItemId, + AggregateType = nameof(Interaction), + AggregateId = interaction.ItemId, + ActorId = interaction.AgentId, + SourceComponent = ContactCenterConstants.Components.Interactions, + }, cancellationToken); + + return true; + } +} 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..fdc919983 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterMonitoringService.cs @@ -0,0 +1,21 @@ +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 +{ + /// + /// 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. + Task EngageAsync(string interactionId, string supervisorId, MonitorMode mode, 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..75786c84b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRecordingService.cs @@ -0,0 +1,40 @@ +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. + /// when the recording state changed; otherwise, . + 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. + /// when the recording state changed; otherwise, . + Task PauseAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Resumes a paused recording. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// when the recording state changed; otherwise, . + Task ResumeAsync(string interactionId, CancellationToken cancellationToken = default); + + /// + /// Stops recording the interaction. + /// + /// The interaction identifier. + /// The token to monitor for cancellation requests. + /// when the recording state changed; otherwise, . + Task StopAsync(string interactionId, CancellationToken cancellationToken = default); +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 69b8b533a..b586294d4 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -272,6 +272,11 @@ At a high level, the platform changes are: - Adds a rebuildable 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. Because the projection is derived purely from the durable event log, it can be rebuilt. The projection lives in the always-on base feature. +#### 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, barge, or take-over engagement, gated by the voice provider's capabilities, and publishes a `SupervisorMonitorStarted` event carrying the mode and supervisor. `ContactCenterVoiceProviderCapabilities` gains `Recording`, `Monitor`, `Whisper`, `Barge`, and `TakeOver` flags so provider modules advertise support and unsupported engagements are refused. + ### Content Access Control #### Role-based content restrictions diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 1d2a040e0..86d9958f6 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -253,6 +253,8 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRecordingAndMonitoringTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRecordingAndMonitoringTests.cs new file mode 100644 index 000000000..580c3b22c --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRecordingAndMonitoringTests.cs @@ -0,0 +1,97 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterRecordingAndMonitoringTests +{ + [Fact] + public async Task StartAsync_SetsRecordingStateAndPublishes() + { + // Arrange + var interaction = new Interaction { ItemId = "int1", RecordingState = RecordingState.None }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + var publisher = new Mock(); + + var service = new ContactCenterRecordingService(interactionManager.Object, publisher.Object); + + // Act + var changed = await service.StartAsync("int1", TestContext.Current.CancellationToken); + + // Assert + Assert.True(changed); + Assert.Equal(RecordingState.Recording, interaction.RecordingState); + publisher.Verify(p => p.PublishAsync(It.Is(e => e.EventType == ContactCenterConstants.Events.RecordingStarted), It.IsAny()), Times.Once); + } + + [Fact] + public async Task StartAsync_WhenAlreadyRecording_DoesNothing() + { + // Arrange + var interaction = new Interaction { ItemId = "int1", RecordingState = RecordingState.Recording }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + var publisher = new Mock(); + + var service = new ContactCenterRecordingService(interactionManager.Object, publisher.Object); + + // Act + var changed = await service.StartAsync("int1", TestContext.Current.CancellationToken); + + // Assert + Assert.False(changed); + publisher.Verify(p => p.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task EngageAsync_WhenProviderSupportsMode_Succeeds() + { + // Arrange + var interaction = new Interaction { ItemId = "int1", ProviderName = "p1" }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var provider = new Mock(); + provider.SetupGet(p => p.Capabilities).Returns(ContactCenterVoiceProviderCapabilities.Monitor | ContactCenterVoiceProviderCapabilities.Whisper); + var resolver = new Mock(); + resolver.Setup(r => r.Get("p1")).Returns(provider.Object); + + var publisher = new Mock(); + var service = new ContactCenterMonitoringService(interactionManager.Object, resolver.Object, publisher.Object); + + // Act + var result = await service.EngageAsync("int1", "sup1", MonitorMode.Whisper, TestContext.Current.CancellationToken); + + // Assert + Assert.True(result.Succeeded); + publisher.Verify(p => p.PublishAsync(It.Is(e => e.EventType == ContactCenterConstants.Events.SupervisorMonitorStarted), It.IsAny()), Times.Once); + } + + [Fact] + public async Task EngageAsync_WhenProviderLacksCapability_Fails() + { + // Arrange + var interaction = new Interaction { ItemId = "int1", ProviderName = "p1" }; + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + + var provider = new Mock(); + provider.SetupGet(p => p.Capabilities).Returns(ContactCenterVoiceProviderCapabilities.Monitor); + var resolver = new Mock(); + resolver.Setup(r => r.Get("p1")).Returns(provider.Object); + + var publisher = new Mock(); + var service = new ContactCenterMonitoringService(interactionManager.Object, resolver.Object, publisher.Object); + + // Act + var result = await service.EngageAsync("int1", "sup1", MonitorMode.Barge, TestContext.Current.CancellationToken); + + // Assert + Assert.False(result.Succeeded); + publisher.Verify(p => p.PublishAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} From 01bfc6ac8dc0fd1dd89be6dcf14afe68af1c6d6a Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:49:38 -0700 Subject: [PATCH 23/56] Contact Center: optional OrchardCore Workflows bridge (Phase 11) - ContactCenterEvent workflow event activity (optional EventType filter, Matched/Ignored outcomes) + display driver + views. - ContactCenterWorkflowEventHandler triggers the activity for every published domain event via IWorkflowManager. - Feature-gated on OrchardCore.Workflows; module builds clean; 133 ContactCenter tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 + ...CrestApps.OrchardCore.ContactCenter.csproj | 1 + .../ContactCenterWorkflowEventHandler.cs | 45 ++++++++++ .../Startup.cs | 17 ++++ .../ContactCenterEvent.Fields.Design.cshtml | 12 +++ .../ContactCenterEvent.Fields.Edit.cshtml | 11 +++ ...ContactCenterEvent.Fields.Thumbnail.cshtml | 9 ++ .../ContactCenterEventDisplayDriver.cs | 30 +++++++ .../Workflows/Models/ContactCenterEvent.cs | 86 +++++++++++++++++++ .../ViewModels/ContactCenterEventViewModel.cs | 12 +++ 11 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterWorkflowEventHandler.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Design.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Thumbnail.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Drivers/ContactCenterEventDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Models/ContactCenterEvent.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/ViewModels/ContactCenterEventViewModel.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index c27435542..831235acd 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1487,7 +1487,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [~] Phase 8 — Inbound entry points, IVR and self-service (**entry point layer shipped 2026-07-01:** `ContactCenterEntryPoint` catalog — DID→queue mapping, priority, business-hours gating, closed action hold/voicemail/overflow/reject, welcome/closed messages; `IEntryPointResolver`/`EntryPointRoutingPlanner` + router integration; **Interaction Center → Entry points** admin CRUD. Remaining: multi-step IVR/self-service decision trees, menu/DTMF capture, estimated-wait/position announcements, and ambiguous-contact disambiguation.) - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) -- [ ] Phase 11 — Optional Workflow bridge +- [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) - [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. Remaining: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, and CSV/export surfaces.) - [ ] Phase 13 — Scale-out, resilience and data governance - [ ] Phase 14 — Advanced capabilities @@ -1534,3 +1534,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **Phase 10 started — callbacks (Phase 5/10 remnant).** Added the `CallbackRequest` catalog (Core: model/index/store/manager) with `CallbackRequestStatus` (Pending/Scheduled/InProgress/Completed/Canceled/Failed). `ICallbackService`/`CallbackService` schedules pending callbacks (`CallbackScheduled` event) and `PromoteDueAsync` turns each due pending callback into an outbound `ActivitySources.Callback` `OmnichannelActivity`, enqueues it when a queue is set, marks it `Scheduled`, and publishes `CallbackPromoted`. Per-minute `CallbackDispatchBackgroundTask` runs promotion; registered in the Dialer feature with index provider + migration. +3 tests (`CallbackServiceTests`) for **125 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 10: abandonment caps, AMD outcomes, calling-window calendars, predictive metrics. Next: Phase 12 (analytics projections) or Phase 9 (recording). - 2026-07-01: **Phase 12 started — daily event-metrics projection.** Added `ContactCenterEventMetric` (Core: model/index/store) keyed by (DateKey `yyyy-MM-dd` + Date + EventType), `IContactCenterMetricsService`/`ContactCenterMetricsService` (`RecordAsync` find-or-create+increment, `GetSummaryAsync` range totals by event type), and `ContactCenterMetricsProjectionHandler` (`IContactCenterEventHandler`) that records every published domain event. Registered in the always-on base feature (store/service/handler + index provider + migration). Because it derives from the durable event log it is rebuildable. +4 tests (`ContactCenterMetricsServiceTests`) for **129 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 12: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, exports. Next: Phase 9 (recording) or Phase 11 (workflow bridge). - 2026-07-01: **Phase 9 started — recording + live-monitoring orchestration.** Added `RecordingState` + `MonitorMode` enums, `Interaction.RecordingState`, and provider capability flags `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver`. `IContactCenterRecordingService` drives the recording state machine (start/pause/resume/stop) with `RecordingStarted/Paused/Resumed/Stopped` events; `IContactCenterMonitoringService.EngageAsync` performs a capability-gated, audited supervisor engagement (`SupervisorMonitorStarted` with mode+supervisor) and refuses unsupported modes. Registered in the Voice feature. +4 tests for **133 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 9: provider media execution, consent/retention, recording storage/access audit, QM scorecards. Next: Phase 11 (workflow bridge) or G5 (agent desktop UI). +- 2026-07-01: **Phase 11 complete — optional Workflows bridge.** Added an `OrchardCore.Workflows.Abstractions` package reference and a feature-gated (`[RequireFeatures(\"OrchardCore.Workflows\")]`) bridge: the `ContactCenterEvent` workflow event activity (`EventActivity` with an optional `EventType` filter, Matched/Ignored outcomes, +display driver + view model + Fields.Edit/Design/Thumbnail views) and `ContactCenterWorkflowEventHandler` (`IContactCenterEventHandler`) that triggers `TriggerEventAsync(nameof(ContactCenterEvent), input, correlationId)` for every published domain event, exposing event type + interaction/aggregate/actor/source as workflow input. Registered in `ContactCenterWorkflowsStartup`. Module builds clean; **133 ContactCenter tests still pass** (workflow activities are integration-tested, not unit-tested); changelog updated. Next: Phase 13 (scale-out/resilience/data governance) or Phase 14 (AI assist), or G5 agent desktop UI. diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index b586294d4..b5ac54d41 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -277,6 +277,10 @@ At a high level, the platform changes are: - 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, barge, or take-over engagement, gated by the voice provider's capabilities, and publishes a `SupervisorMonitorStarted` event carrying the mode and supervisor. `ContactCenterVoiceProviderCapabilities` gains `Recording`, `Monitor`, `Whisper`, `Barge`, and `TakeOver` 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, so tenants can automate pre-call routing enrichment, post-call automation, and callback scheduling without custom code. + ### Content Access Control #### Role-based content restrictions diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index 7774ff730..6b7fe05ac 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -25,6 +25,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterWorkflowEventHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterWorkflowEventHandler.cs new file mode 100644 index 000000000..779441d43 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterWorkflowEventHandler.cs @@ -0,0 +1,45 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Workflows.Models; +using OrchardCore.Workflows.Services; + +namespace CrestApps.OrchardCore.ContactCenter.Handlers; + +/// +/// Bridges Contact Center domain events to OrchardCore Workflows by triggering the +/// workflow event for every published domain event. +/// +public sealed class ContactCenterWorkflowEventHandler : IContactCenterEventHandler +{ + private readonly IWorkflowManager _workflowManager; + + /// + /// Initializes a new instance of the class. + /// + /// The workflow manager used to trigger the workflow event. + public ContactCenterWorkflowEventHandler(IWorkflowManager workflowManager) + { + _workflowManager = workflowManager; + } + + /// + public Task HandleAsync(InteractionEvent interactionEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(interactionEvent); + + var input = new Dictionary + { + ["EventType"] = interactionEvent.EventType, + ["InteractionId"] = interactionEvent.InteractionId, + ["AggregateType"] = interactionEvent.AggregateType, + ["AggregateId"] = interactionEvent.AggregateId, + ["ActorId"] = interactionEvent.ActorId, + ["SourceComponent"] = interactionEvent.SourceComponent, + }; + + return _workflowManager.TriggerEventAsync( + nameof(ContactCenterEvent), + input, + correlationId: interactionEvent.InteractionId ?? interactionEvent.AggregateId); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 86d9958f6..99f8cb64f 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -10,6 +10,8 @@ using CrestApps.OrchardCore.ContactCenter.Migrations; using CrestApps.OrchardCore.ContactCenter.Recipes; using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.ContactCenter.Workflows.Drivers; +using CrestApps.OrchardCore.ContactCenter.Workflows.Models; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.Models; using CrestApps.OrchardCore.Telephony; @@ -26,6 +28,7 @@ using OrchardCore.Navigation; using OrchardCore.Recipes; using OrchardCore.Security.Permissions; +using OrchardCore.Workflows.Helpers; namespace CrestApps.OrchardCore.ContactCenter; @@ -303,3 +306,17 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro HubRouteManager.MapHub(routes); } } + +/// +/// Registers the optional OrchardCore Workflows bridge: a Contact Center workflow event activity and the +/// handler that triggers it for every published domain event. +/// +[RequireFeatures("OrchardCore.Workflows")] +public sealed class ContactCenterWorkflowsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddActivity(); + services.AddScoped(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Design.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Design.cshtml new file mode 100644 index 000000000..8e2a466d8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Design.cshtml @@ -0,0 +1,12 @@ +@using CrestApps.OrchardCore.ContactCenter.Workflows.Models +@using OrchardCore.Workflows.Helpers +@using OrchardCore.Workflows.ViewModels + +@model ActivityViewModel + +
    +

    + + @Model.Activity.GetTitleOrDefault(() => T["Contact Center Event"]) +

    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Edit.cshtml new file mode 100644 index 000000000..b784934bf --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Edit.cshtml @@ -0,0 +1,11 @@ +@using CrestApps.OrchardCore.ContactCenter.Workflows.ViewModels + +@model ContactCenterEventViewModel + +
    + +
    + + @T["Optionally react to a single Contact Center event type, such as CallEnded, InteractionCreated, or RoutingDecisionMade. Leave empty to react to every event."] +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Thumbnail.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Thumbnail.cshtml new file mode 100644 index 000000000..d3e6f6b8a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterEvent.Fields.Thumbnail.cshtml @@ -0,0 +1,9 @@ +@using CrestApps.OrchardCore.ContactCenter.Workflows.Models +@using OrchardCore.Workflows.ViewModels + +@model ActivityViewModel + +

    + + @T["Contact Center Event"] +

    diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Drivers/ContactCenterEventDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Drivers/ContactCenterEventDisplayDriver.cs new file mode 100644 index 000000000..d632867ef --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Drivers/ContactCenterEventDisplayDriver.cs @@ -0,0 +1,30 @@ +using CrestApps.OrchardCore.ContactCenter.Workflows.Models; +using CrestApps.OrchardCore.ContactCenter.Workflows.ViewModels; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Workflows.Display; + +namespace CrestApps.OrchardCore.ContactCenter.Workflows.Drivers; + +/// +/// Display driver for the workflow activity. +/// +public sealed class ContactCenterEventDisplayDriver : ActivityDisplayDriver +{ + /// + protected override void EditActivity(ContactCenterEvent activity, ContactCenterEventViewModel model) + { + model.EventType = activity.EventType; + } + + /// + public override async Task UpdateAsync(ContactCenterEvent activity, UpdateEditorContext context) + { + var model = new ContactCenterEventViewModel(); + await context.Updater.TryUpdateModelAsync(model, Prefix); + + activity.EventType = model.EventType?.Trim(); + + return Edit(activity, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Models/ContactCenterEvent.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Models/ContactCenterEvent.cs new file mode 100644 index 000000000..9302abb53 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/Models/ContactCenterEvent.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Localization; +using OrchardCore.Workflows.Abstractions.Models; +using OrchardCore.Workflows.Activities; +using OrchardCore.Workflows.Models; + +namespace CrestApps.OrchardCore.ContactCenter.Workflows.Models; + +/// +/// A workflow event that starts or resumes a workflow when a Contact Center domain event is published. +/// The optional filters the activity to a single event type. +/// +public sealed class ContactCenterEvent : EventActivity +{ + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ContactCenterEvent(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + public override string Name => nameof(ContactCenterEvent); + + /// + public override LocalizedString DisplayText => S["Contact Center Event"]; + + /// + public override LocalizedString Category => S["Contact Center"]; + + /// + /// Gets or sets the domain event type this activity reacts to. When empty, it reacts to every event. + /// + public string EventType + { + get => GetProperty(); + set => SetProperty(value); + } + + /// + public override ValueTask> GetPossibleOutcomesAsync( + WorkflowExecutionContext workflowContext, + ActivityContext activityContext) + { + return new ValueTask>( + [ + Outcome(S["Matched"]), + Outcome(S["Ignored"]), + ]); + } + + /// + public override ActivityExecutionResult Resume( + WorkflowExecutionContext workflowContext, + ActivityContext activityContext) + { + return Evaluate(workflowContext); + } + + /// + public override ActivityExecutionResult Execute( + WorkflowExecutionContext workflowContext, + ActivityContext activityContext) + { + return Evaluate(workflowContext); + } + + private ActivityExecutionResult Evaluate(WorkflowExecutionContext workflowContext) + { + if (string.IsNullOrEmpty(EventType)) + { + return Outcomes("Matched"); + } + + if (workflowContext.Input.TryGetValue("EventType", out var value) && + string.Equals(value?.ToString(), EventType, StringComparison.OrdinalIgnoreCase)) + { + return Outcomes("Matched"); + } + + return Outcomes("Ignored"); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/ViewModels/ContactCenterEventViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/ViewModels/ContactCenterEventViewModel.cs new file mode 100644 index 000000000..66b7231c9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Workflows/ViewModels/ContactCenterEventViewModel.cs @@ -0,0 +1,12 @@ +namespace CrestApps.OrchardCore.ContactCenter.Workflows.ViewModels; + +/// +/// Represents the edit view model for the Contact Center event workflow activity. +/// +public class ContactCenterEventViewModel +{ + /// + /// Gets or sets the domain event type to react to. When empty, the activity reacts to every event. + /// + public string EventType { get; set; } +} From ee57213bfa82c73deebc9975811cb366daa89532 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:55:22 -0700 Subject: [PATCH 24/56] Contact Center: interaction-event data retention (Phase 13) - ContactCenterRetentionOptions (config-bound) + IContactCenterRetentionService batch-purge of interaction events older than a cutoff + daily ContactCenterRetentionBackgroundTask. - IInteractionEventStore.ListOlderThanAsync added (indexed OccurredUtc); base Startup binds options from IShellConfiguration. - +2 tests (135 ContactCenter tests pass); clean full-solution -warnaserror build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../Models/ContactCenterRetentionOptions.cs | 13 +++++ .../Services/ContactCenterRetentionService.cs | 50 +++++++++++++++++++ .../IContactCenterRetentionService.cs | 15 ++++++ .../Services/IInteractionEventStore.cs | 9 ++++ .../Services/InteractionEventStore.cs | 15 ++++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 ++ .../ContactCenterRetentionBackgroundTask.cs | 47 +++++++++++++++++ .../Startup.cs | 16 ++++++ .../ContactCenterRetentionServiceTests.cs | 50 +++++++++++++++++++ 10 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionOptions.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRetentionService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRetentionService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ContactCenterRetentionBackgroundTask.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRetentionServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 831235acd..c57a89e7c 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1489,7 +1489,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) - [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. Remaining: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, and CSV/export surfaces.) -- [ ] Phase 13 — Scale-out, resilience and data governance +- [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, and retention for call sessions/metrics/recordings.) - [ ] Phase 14 — Advanced capabilities ### Gap-closure backlog (from the 2026-06-30 design review) @@ -1535,3 +1535,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **Phase 12 started — daily event-metrics projection.** Added `ContactCenterEventMetric` (Core: model/index/store) keyed by (DateKey `yyyy-MM-dd` + Date + EventType), `IContactCenterMetricsService`/`ContactCenterMetricsService` (`RecordAsync` find-or-create+increment, `GetSummaryAsync` range totals by event type), and `ContactCenterMetricsProjectionHandler` (`IContactCenterEventHandler`) that records every published domain event. Registered in the always-on base feature (store/service/handler + index provider + migration). Because it derives from the durable event log it is rebuildable. +4 tests (`ContactCenterMetricsServiceTests`) for **129 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 12: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, exports. Next: Phase 9 (recording) or Phase 11 (workflow bridge). - 2026-07-01: **Phase 9 started — recording + live-monitoring orchestration.** Added `RecordingState` + `MonitorMode` enums, `Interaction.RecordingState`, and provider capability flags `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver`. `IContactCenterRecordingService` drives the recording state machine (start/pause/resume/stop) with `RecordingStarted/Paused/Resumed/Stopped` events; `IContactCenterMonitoringService.EngageAsync` performs a capability-gated, audited supervisor engagement (`SupervisorMonitorStarted` with mode+supervisor) and refuses unsupported modes. Registered in the Voice feature. +4 tests for **133 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 9: provider media execution, consent/retention, recording storage/access audit, QM scorecards. Next: Phase 11 (workflow bridge) or G5 (agent desktop UI). - 2026-07-01: **Phase 11 complete — optional Workflows bridge.** Added an `OrchardCore.Workflows.Abstractions` package reference and a feature-gated (`[RequireFeatures(\"OrchardCore.Workflows\")]`) bridge: the `ContactCenterEvent` workflow event activity (`EventActivity` with an optional `EventType` filter, Matched/Ignored outcomes, +display driver + view model + Fields.Edit/Design/Thumbnail views) and `ContactCenterWorkflowEventHandler` (`IContactCenterEventHandler`) that triggers `TriggerEventAsync(nameof(ContactCenterEvent), input, correlationId)` for every published domain event, exposing event type + interaction/aggregate/actor/source as workflow input. Registered in `ContactCenterWorkflowsStartup`. Module builds clean; **133 ContactCenter tests still pass** (workflow activities are integration-tested, not unit-tested); changelog updated. Next: Phase 13 (scale-out/resilience/data governance) or Phase 14 (AI assist), or G5 agent desktop UI. +- 2026-07-01: **Phase 13 started — data-governance retention.** Added `ContactCenterRetentionOptions` (`InteractionEventRetentionDays`, bound from the `CrestApps_ContactCenter:Retention` shell config section), `IContactCenterRetentionService`/`ContactCenterRetentionService` that batch-purges interaction events older than a cutoff (`IInteractionEventStore.ListOlderThanAsync` added, querying the indexed `OccurredUtc`), and a daily `ContactCenterRetentionBackgroundTask` that computes the cutoff and purges when retention is enabled. Registered in the base feature (the base `Startup` now takes `IShellConfiguration`). +2 tests for **135 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Multi-node safety (distributed locks) already shipped in G2. Remaining Phase 13: SignalR backplane guidance, projection rebuild tooling, PII redaction/erasure, retention for sessions/metrics/recordings. Next: Phase 14 (AI assist) or G5 (agent desktop UI). 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..74117600b --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/ContactCenterRetentionOptions.cs @@ -0,0 +1,13 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Models; + +/// +/// Configures Contact Center data-governance retention windows. A value of zero disables purging so +/// data is kept indefinitely. +/// +public sealed class ContactCenterRetentionOptions +{ + /// + /// Gets or sets the number of days to retain durable interaction events before they are purged. + /// + public int InteractionEventRetentionDays { 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..f33b09b30 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterRetentionService.cs @@ -0,0 +1,50 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Provides the default implementation of . +/// +public sealed class ContactCenterRetentionService : IContactCenterRetentionService +{ + private const int BatchSize = 100; + private const int MaxBatches = 100; + + private readonly IInteractionEventStore _eventStore; + + /// + /// Initializes a new instance of the class. + /// + /// The interaction event store. + public ContactCenterRetentionService(IInteractionEventStore eventStore) + { + _eventStore = eventStore; + } + + /// + public async Task PurgeInteractionEventsAsync(DateTime cutoffUtc, CancellationToken cancellationToken = default) + { + var purged = 0; + + for (var batch = 0; batch < MaxBatches; batch++) + { + var expired = await _eventStore.ListOlderThanAsync(cutoffUtc, BatchSize, cancellationToken); + + if (expired.Count == 0) + { + break; + } + + foreach (var interactionEvent in expired) + { + await _eventStore.DeleteAsync(interactionEvent, cancellationToken); + purged++; + } + + if (expired.Count < BatchSize) + { + break; + } + } + + return purged; + } +} 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..94e838c13 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterRetentionService.cs @@ -0,0 +1,15 @@ +namespace CrestApps.OrchardCore.ContactCenter.Core.Services; + +/// +/// Enforces Contact Center data-governance retention by purging durable data older than a cutoff. +/// +public interface IContactCenterRetentionService +{ + /// + /// Purges durable interaction events that occurred strictly before the supplied cutoff. + /// + /// The exclusive UTC cutoff; events older than this are deleted. + /// The token to monitor for cancellation requests. + /// The number of events purged. + Task PurgeInteractionEventsAsync(DateTime cutoffUtc, 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 index 3fb3e00f8..e1f5804f0 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IInteractionEventStore.cs @@ -23,4 +23,13 @@ public interface IInteractionEventStore : ICatalog /// 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); } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs index 0a09177dd..a36c350d0 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/InteractionEventStore.cs @@ -46,4 +46,19 @@ public async Task ExistsByIdempotencyKeyAsync(string idempotencyKey, Cance 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 events.ToArray(); + } } diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index b5ac54d41..7467b85ed 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -281,6 +281,10 @@ At a high level, the platform changes are: - 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, so 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. + ### Content Access Control #### Role-based content restrictions diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ContactCenterRetentionBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ContactCenterRetentionBackgroundTask.cs new file mode 100644 index 000000000..2b3e2e843 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/BackgroundTasks/ContactCenterRetentionBackgroundTask.cs @@ -0,0 +1,47 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.BackgroundTasks; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.ContactCenter.BackgroundTasks; + +/// +/// Purges durable interaction events beyond the configured data-governance retention window once a day. +/// +[BackgroundTask( + Title = "Contact Center Data Retention", + Schedule = "0 3 * * *", + Description = "Purges durable interaction events older than the configured retention window.", + LockTimeout = 10_000, + LockExpiration = 300_000)] +public sealed class ContactCenterRetentionBackgroundTask : IBackgroundTask +{ + /// + public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken) + { + var options = serviceProvider.GetRequiredService>().Value; + + if (options.InteractionEventRetentionDays <= 0) + { + return; + } + + var clock = serviceProvider.GetRequiredService(); + var retentionService = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + var cutoff = clock.UtcNow.AddDays(-options.InteractionEventRetentionDays); + + try + { + await retentionService.PurgeInteractionEventsAsync(cutoff, cancellationToken); + } + catch (Exception ex) + { + logger.LogError(ex, "An error occurred while purging expired Contact Center interaction events."); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 99f8cb64f..b520aa5f6 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -24,6 +24,7 @@ using OrchardCore.Data; using OrchardCore.Data.Migration; using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Environment.Shell.Configuration; using OrchardCore.Modules; using OrchardCore.Navigation; using OrchardCore.Recipes; @@ -37,10 +38,23 @@ namespace CrestApps.OrchardCore.ContactCenter; /// public sealed class Startup : StartupBase { + private readonly IShellConfiguration _shellConfiguration; + + /// + /// Initializes a new instance of the class. + /// + /// The shell configuration used to bind Contact Center options. + public Startup(IShellConfiguration shellConfiguration) + { + _shellConfiguration = shellConfiguration; + } + public override void ConfigureServices(IServiceCollection services) { services.Configure(options => options.Collections.Add(ContactCenterConstants.CollectionName)); + services.Configure(_shellConfiguration.GetSection("CrestApps_ContactCenter:Retention")); + services .AddScoped() .AddScoped() @@ -51,6 +65,7 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped, InteractionHandler>(); services @@ -78,6 +93,7 @@ public override void ConfigureServices(IServiceCollection services) .AddDataMigration(); services.AddSingleton(); + services.AddSingleton(); services.AddPermissionProvider(); } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRetentionServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRetentionServiceTests.cs new file mode 100644 index 000000000..187243231 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRetentionServiceTests.cs @@ -0,0 +1,50 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterRetentionServiceTests +{ + private static readonly DateTime _cutoff = new(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc); + + [Fact] + public async Task PurgeInteractionEventsAsync_DeletesEveryExpiredEvent() + { + // Arrange + var expired = new[] + { + new InteractionEvent { ItemId = "e1" }, + new InteractionEvent { ItemId = "e2" }, + }; + + var store = new Mock(); + store.Setup(s => s.ListOlderThanAsync(_cutoff, It.IsAny(), It.IsAny())).ReturnsAsync(expired); + + var service = new ContactCenterRetentionService(store.Object); + + // Act + var purged = await service.PurgeInteractionEventsAsync(_cutoff, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(2, purged); + store.Verify(s => s.DeleteAsync(It.IsAny(), It.IsAny()), Times.Exactly(2)); + } + + [Fact] + public async Task PurgeInteractionEventsAsync_WhenNoExpiredEvents_DeletesNothing() + { + // Arrange + var store = new Mock(); + store.Setup(s => s.ListOlderThanAsync(_cutoff, It.IsAny(), It.IsAny())).ReturnsAsync([]); + + var service = new ContactCenterRetentionService(store.Object); + + // Act + var purged = await service.PurgeInteractionEventsAsync(_cutoff, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(0, purged); + store.Verify(s => s.DeleteAsync(It.IsAny(), It.IsAny()), Times.Never); + } +} From fe87f6a561123b52b177bb1f2da67b3c2756507b Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 30 Jun 2026 22:58:12 -0700 Subject: [PATCH 25/56] Contact Center: AI assist extensibility seam (Phase 14) - IContactCenterAssistProvider + IContactCenterAssistService orchestrate optional summarization + disposition-suggestion providers by order, decoupled from any specific AI provider. - Registered in the base feature; +4 tests (139 ContactCenter tests pass); clean -warnaserror build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 3 +- .../Models/AssistContext.cs | 32 +++++++ .../Models/DispositionSuggestion.cs | 27 ++++++ .../Services/ContactCenterAssistService.cs | 59 +++++++++++++ .../Services/IContactCenterAssistProvider.cs | 32 +++++++ .../Services/IContactCenterAssistService.cs | 31 +++++++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 + .../Startup.cs | 1 + .../ContactCenterAssistServiceTests.cs | 85 +++++++++++++++++++ 9 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/AssistContext.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/DispositionSuggestion.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterAssistService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistProvider.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterAssistService.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterAssistServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index c57a89e7c..533beba13 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1490,7 +1490,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) - [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. Remaining: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, and CSV/export surfaces.) - [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, and retention for call sessions/metrics/recordings.) -- [ ] Phase 14 — Advanced capabilities +- [~] Phase 14 — Advanced capabilities (**AI assist seam shipped 2026-07-01:** `IContactCenterAssistProvider` + `IContactCenterAssistService` orchestrate optional summarization + disposition-suggestion providers by order, decoupled from any specific AI provider. Remaining: concrete AI provider (summaries/disposition/sentiment) wired to the AI module, virtual-agent handoff, and AI routing recommendations.) ### Gap-closure backlog (from the 2026-06-30 design review) @@ -1536,3 +1536,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **Phase 9 started — recording + live-monitoring orchestration.** Added `RecordingState` + `MonitorMode` enums, `Interaction.RecordingState`, and provider capability flags `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver`. `IContactCenterRecordingService` drives the recording state machine (start/pause/resume/stop) with `RecordingStarted/Paused/Resumed/Stopped` events; `IContactCenterMonitoringService.EngageAsync` performs a capability-gated, audited supervisor engagement (`SupervisorMonitorStarted` with mode+supervisor) and refuses unsupported modes. Registered in the Voice feature. +4 tests for **133 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Remaining Phase 9: provider media execution, consent/retention, recording storage/access audit, QM scorecards. Next: Phase 11 (workflow bridge) or G5 (agent desktop UI). - 2026-07-01: **Phase 11 complete — optional Workflows bridge.** Added an `OrchardCore.Workflows.Abstractions` package reference and a feature-gated (`[RequireFeatures(\"OrchardCore.Workflows\")]`) bridge: the `ContactCenterEvent` workflow event activity (`EventActivity` with an optional `EventType` filter, Matched/Ignored outcomes, +display driver + view model + Fields.Edit/Design/Thumbnail views) and `ContactCenterWorkflowEventHandler` (`IContactCenterEventHandler`) that triggers `TriggerEventAsync(nameof(ContactCenterEvent), input, correlationId)` for every published domain event, exposing event type + interaction/aggregate/actor/source as workflow input. Registered in `ContactCenterWorkflowsStartup`. Module builds clean; **133 ContactCenter tests still pass** (workflow activities are integration-tested, not unit-tested); changelog updated. Next: Phase 13 (scale-out/resilience/data governance) or Phase 14 (AI assist), or G5 agent desktop UI. - 2026-07-01: **Phase 13 started — data-governance retention.** Added `ContactCenterRetentionOptions` (`InteractionEventRetentionDays`, bound from the `CrestApps_ContactCenter:Retention` shell config section), `IContactCenterRetentionService`/`ContactCenterRetentionService` that batch-purges interaction events older than a cutoff (`IInteractionEventStore.ListOlderThanAsync` added, querying the indexed `OccurredUtc`), and a daily `ContactCenterRetentionBackgroundTask` that computes the cutoff and purges when retention is enabled. Registered in the base feature (the base `Startup` now takes `IShellConfiguration`). +2 tests for **135 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Multi-node safety (distributed locks) already shipped in G2. Remaining Phase 13: SignalR backplane guidance, projection rebuild tooling, PII redaction/erasure, retention for sessions/metrics/recordings. Next: Phase 14 (AI assist) or G5 (agent desktop UI). +- 2026-07-01: **Phase 14 started — AI assist seam.** Added a pluggable, provider-agnostic assist seam: `IContactCenterAssistProvider` (`SuggestDispositionAsync`/`SummarizeAsync`, ordered) + `AssistContext`/`DispositionSuggestion` models, and `IContactCenterAssistService`/`ContactCenterAssistService` that orchestrates registered providers by order and returns the first result (`IsAvailable` reflects whether any provider is installed). Registered in the base feature; the Contact Center stays decoupled from any specific AI provider. +4 tests for **139 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 14: a concrete AI provider wired to the AI module (summaries/disposition/sentiment), virtual-agent handoff, AI routing recommendations. Remaining overall: G5 agent-desktop/supervisor UI, G7 skill proficiency, G4 abandonment/AMD. 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/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/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/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/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 7467b85ed..7199f1764 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -285,6 +285,10 @@ At a high level, the platform changes are: - 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. + ### Content Access Control #### Role-based content restrictions diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index b520aa5f6..b5533231e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -66,6 +66,7 @@ public override void ConfigureServices(IServiceCollection services) .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped, InteractionHandler>(); services diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterAssistServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterAssistServiceTests.cs new file mode 100644 index 000000000..b19223d18 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterAssistServiceTests.cs @@ -0,0 +1,85 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterAssistServiceTests +{ + [Fact] + public void IsAvailable_ReflectsRegisteredProviders() + { + // Arrange & Act + var withProvider = new ContactCenterAssistService([new Mock().Object]); + var without = new ContactCenterAssistService([]); + + // Assert + Assert.True(withProvider.IsAvailable); + Assert.False(without.IsAvailable); + } + + [Fact] + public async Task SuggestDispositionAsync_ReturnsFirstProviderSuggestionByOrder() + { + // Arrange + var context = new AssistContext { InteractionId = "int1" }; + + var lowPriority = new Mock(); + lowPriority.SetupGet(p => p.Order).Returns(100); + lowPriority.Setup(p => p.SuggestDispositionAsync(context, It.IsAny())) + .ReturnsAsync(new DispositionSuggestion { DispositionId = "low" }); + + var highPriority = new Mock(); + highPriority.SetupGet(p => p.Order).Returns(10); + highPriority.Setup(p => p.SuggestDispositionAsync(context, It.IsAny())) + .ReturnsAsync(new DispositionSuggestion { DispositionId = "high" }); + + var service = new ContactCenterAssistService([lowPriority.Object, highPriority.Object]); + + // Act + var suggestion = await service.SuggestDispositionAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("high", suggestion.DispositionId); + } + + [Fact] + public async Task SuggestDispositionAsync_WhenNoProviderHasSuggestion_ReturnsNull() + { + // Arrange + var context = new AssistContext { InteractionId = "int1" }; + var provider = new Mock(); + provider.Setup(p => p.SuggestDispositionAsync(context, It.IsAny())).ReturnsAsync((DispositionSuggestion)null); + + var service = new ContactCenterAssistService([provider.Object]); + + // Act + var suggestion = await service.SuggestDispositionAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Null(suggestion); + } + + [Fact] + public async Task SummarizeAsync_ReturnsFirstNonEmptySummary() + { + // Arrange + var context = new AssistContext { InteractionId = "int1" }; + + var empty = new Mock(); + empty.SetupGet(p => p.Order).Returns(10); + empty.Setup(p => p.SummarizeAsync(context, It.IsAny())).ReturnsAsync((string)null); + + var withSummary = new Mock(); + withSummary.SetupGet(p => p.Order).Returns(20); + withSummary.Setup(p => p.SummarizeAsync(context, It.IsAny())).ReturnsAsync("A concise summary."); + + var service = new ContactCenterAssistService([empty.Object, withSummary.Object]); + + // Act + var summary = await service.SummarizeAsync(context, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal("A concise summary.", summary); + } +} From 915422fea36435c5c8160243f6af51bf578c3ca5 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 1 Jul 2026 00:16:13 -0700 Subject: [PATCH 26/56] DialPad: complete inbound + outbound as the Contact Center phone provider Outbound already routed through DialAsync; this adds the inbound path so agents can make AND take calls via DialPad: - DialPadWebhookController POST /api/dialpad/webhook/call (AllowAnonymous, DialPad Contact Center Voice feature) validates DialPad's HS256-signed webhook JWT (DialPadJwtValidator, no external dependency) using a new protected Webhook signing secret setting. - DialPadWebhookService normalizes DialPadCallEvent: state changes -> IProviderVoiceEventService.IngestAsync (update interaction + call session); new inbound live calls -> IVoiceContactCenterCallRouter.RouteInboundAsync (create activity+interaction, resolve entry point/queue, offer to an agent). - DialPad voice provider advertises the CallTransfer capability; webhook secret added to DialPad settings (view model + protected driver + view). - +9 DialPad tests; clean full-solution -warnaserror build; docs (telephony/dialpad.md, v2.0.0 changelog) + PLAN updated; docs site builds. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/contact-center/PLAN.md | 1 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 1 + src/CrestApps.Docs/docs/telephony/dialpad.md | 18 +++ .../Controllers/DialPadWebhookController.cs | 118 ++++++++++++++++ .../DialPadConstants.cs | 5 + .../DialerStartup.cs | 3 +- .../Drivers/DialPadSettingsDisplayDriver.cs | 11 ++ .../Models/DialPadSettings.cs | 6 + .../Services/DialPadCallEvent.cs | 57 ++++++++ .../DialPadContactCenterVoiceProvider.cs | 2 +- .../Services/DialPadJwtValidator.cs | 126 ++++++++++++++++++ .../Services/DialPadWebhookResult.cs | 22 +++ .../Services/DialPadWebhookService.cs | 114 ++++++++++++++++ .../Services/IDialPadWebhookService.cs | 16 +++ .../ViewModels/DialPadSettingsViewModel.cs | 11 ++ .../Views/DialPadSettings.Edit.cshtml | 9 ++ .../DialPad/DialPadJwtValidatorTests.cs | 88 ++++++++++++ .../DialPad/DialPadWebhookServiceTests.cs | 108 +++++++++++++++ 18 files changed, 714 insertions(+), 2 deletions(-) create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJwtValidator.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookResult.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs create mode 100644 src/Modules/CrestApps.OrchardCore.DialPad/Services/IDialPadWebhookService.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadJwtValidatorTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/DialPad/DialPadWebhookServiceTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 533beba13..4d79e86b8 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1537,3 +1537,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **Phase 11 complete — optional Workflows bridge.** Added an `OrchardCore.Workflows.Abstractions` package reference and a feature-gated (`[RequireFeatures(\"OrchardCore.Workflows\")]`) bridge: the `ContactCenterEvent` workflow event activity (`EventActivity` with an optional `EventType` filter, Matched/Ignored outcomes, +display driver + view model + Fields.Edit/Design/Thumbnail views) and `ContactCenterWorkflowEventHandler` (`IContactCenterEventHandler`) that triggers `TriggerEventAsync(nameof(ContactCenterEvent), input, correlationId)` for every published domain event, exposing event type + interaction/aggregate/actor/source as workflow input. Registered in `ContactCenterWorkflowsStartup`. Module builds clean; **133 ContactCenter tests still pass** (workflow activities are integration-tested, not unit-tested); changelog updated. Next: Phase 13 (scale-out/resilience/data governance) or Phase 14 (AI assist), or G5 agent desktop UI. - 2026-07-01: **Phase 13 started — data-governance retention.** Added `ContactCenterRetentionOptions` (`InteractionEventRetentionDays`, bound from the `CrestApps_ContactCenter:Retention` shell config section), `IContactCenterRetentionService`/`ContactCenterRetentionService` that batch-purges interaction events older than a cutoff (`IInteractionEventStore.ListOlderThanAsync` added, querying the indexed `OccurredUtc`), and a daily `ContactCenterRetentionBackgroundTask` that computes the cutoff and purges when retention is enabled. Registered in the base feature (the base `Startup` now takes `IShellConfiguration`). +2 tests for **135 ContactCenter tests passing**; clean full-solution `-warnaserror` build; changelog updated. Multi-node safety (distributed locks) already shipped in G2. Remaining Phase 13: SignalR backplane guidance, projection rebuild tooling, PII redaction/erasure, retention for sessions/metrics/recordings. Next: Phase 14 (AI assist) or G5 (agent desktop UI). - 2026-07-01: **Phase 14 started — AI assist seam.** Added a pluggable, provider-agnostic assist seam: `IContactCenterAssistProvider` (`SuggestDispositionAsync`/`SummarizeAsync`, ordered) + `AssistContext`/`DispositionSuggestion` models, and `IContactCenterAssistService`/`ContactCenterAssistService` that orchestrates registered providers by order and returns the first result (`IsAvailable` reflects whether any provider is installed). Registered in the base feature; the Contact Center stays decoupled from any specific AI provider. +4 tests for **139 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 14: a concrete AI provider wired to the AI module (summaries/disposition/sentiment), virtual-agent handoff, AI routing recommendations. Remaining overall: G5 agent-desktop/supervisor UI, G7 skill proficiency, G4 abandonment/AMD. +- 2026-07-01: **DialPad completed as the Contact Center phone provider (inbound + outbound).** Outbound already routed through `DialAsync`; added the missing inbound path so agents can both make and take calls through DialPad. New `DialPadWebhookController` (`POST /api/dialpad/webhook/call`, `[AllowAnonymous]`, DialPad Contact Center Voice feature) validates the DialPad HS256-signed webhook JWT with a new protected **Webhook signing secret** setting (`DialPadJwtValidator` — no external JWT dependency; unsigned JSON accepted only when no secret is configured), parses the `DialPadCallEvent` payload, and `DialPadWebhookService` normalizes it: state changes go to `IProviderVoiceEventService.IngestAsync` (updates interaction + call session), and a new inbound live call with no matching interaction is routed via `IVoiceContactCenterCallRouter.RouteInboundAsync` (creates activity+interaction, resolves entry point/queue, offers to an agent). Advertised the `CallTransfer` capability. Added the webhook secret to DialPad settings (view model + protected driver + view). +9 DialPad tests (JWT validate/tamper/secret + webhook route/update/ignore) — clean full-solution `-warnaserror` build. Docs (`telephony/dialpad.md`, `v2.0.0` changelog) updated. This realizes the per-provider signed webhook adapter goal (P1 #18 / G1 inbound) for DialPad and gives Phase 8 entry points a real inbound provider. diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 7199f1764..9d3d0031c 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -515,6 +515,7 @@ At a high level, the platform changes are: - A new DialPad **Environment** setting selects between the **Production** (`dialpad.com`) and **Sandbox** (`sandbox.dialpad.com`) DialPad environments for both the REST API and the OAuth 2.0 authorize, token, and deauthorize endpoints, so an integration can be validated against the sandbox before going live. - The telephony abstractions gained PKCE support (`TelephonyAuthorizationContext.CodeChallenge`/`CodeChallengeMethod`, `TelephonyCodeExchangeContext.CodeVerifier`, and `TelephonyAuthorizationRequest`) and token revocation (`ITelephonyAuthenticationProvider.SupportsProofKeyForCodeExchange` and `RevokeTokensAsync`), so any OAuth-based telephony provider can opt in to PKCE and provider-side token revocation on disconnect. OAuth completion now returns a `TelephonyResult`, so callers can inspect the failure reason instead of receiving only a boolean. - The DialPad provider now resolves a named HTTP client configured by startup with standard ASP.NET Core HTTP resiliency policies for DialPad REST API and OAuth token requests. +- The **DialPad Contact Center Voice** feature now handles inbound calls end to end. A new `DialPadWebhookController` at `POST /api/dialpad/webhook/call` receives DialPad call-event webhooks, validated by a configurable **Webhook signing secret** (DialPad signs the payload as an HS256 JWT; validated without any external JWT dependency, with unsigned JSON accepted only when no secret is set). New inbound DialPad calls are normalized into an `InboundVoiceEvent` and routed through the Voice Contact Center Call Router — creating a CRM activity and voice interaction, resolving the entry point/queue, and offering the call to an available agent — while subsequent state events (ringing, connected, held, ended) are normalized into `ProviderVoiceEvent`s that update the interaction and call session. Together with the existing outbound dialing, DialPad can now serve as the complete phone provider for the Contact Center. The DialPad voice provider also advertises the `CallTransfer` capability. ### Time Zones diff --git a/src/CrestApps.Docs/docs/telephony/dialpad.md b/src/CrestApps.Docs/docs/telephony/dialpad.md index f2bad99bb..6dda3f92d 100644 --- a/src/CrestApps.Docs/docs/telephony/dialpad.md +++ b/src/CrestApps.Docs/docs/telephony/dialpad.md @@ -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** | 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. @@ -107,6 +108,23 @@ 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, ended) update the interaction and call session. When no signing secret is + configured, unsigned JSON webhooks are accepted, which is only recommended for local testing. + ## Registering the provider in code The provider is registered by the module's startup with a named HTTP client that uses the standard diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs new file mode 100644 index 000000000..82e30e3b2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Controllers/DialPadWebhookController.cs @@ -0,0 +1,118 @@ +using System.Security.Cryptography; +using System.Text.Json; +using CrestApps.OrchardCore.DialPad.Models; +using CrestApps.OrchardCore.DialPad.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.DialPad.Controllers; + +/// +/// Receives DialPad call-event webhooks and drives the Contact Center: it validates the DialPad +/// signature, then updates existing interactions or routes new inbound calls to an available agent. +/// +[ApiController] +[AllowAnonymous] +[Route("api/dialpad/webhook")] +[Feature(DialPadConstants.Feature.Dialer)] +public sealed class DialPadWebhookController : ControllerBase +{ + private static readonly JsonSerializerOptions _jsonOptions = new() + { + PropertyNameCaseInsensitive = true, + }; + + private readonly IDialPadWebhookService _webhookService; + private readonly ISiteService _siteService; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The DialPad webhook service. + /// The site service used to read DialPad settings. + /// The data protection provider used to unprotect the webhook secret. + /// The logger instance. + public DialPadWebhookController( + IDialPadWebhookService webhookService, + ISiteService siteService, + IDataProtectionProvider dataProtectionProvider, + ILogger logger) + { + _webhookService = webhookService; + _siteService = siteService; + _dataProtectionProvider = dataProtectionProvider; + _logger = logger; + } + + /// + /// Handles a DialPad call-event webhook. + /// + /// An HTTP result describing whether the event was accepted. + [HttpPost("call")] + public async Task Call() + { + var settings = await _siteService.GetSettingsAsync(); + + if (!settings.IsEnabled) + { + return NotFound(); + } + + using var reader = new StreamReader(Request.Body); + var body = await reader.ReadToEndAsync(HttpContext.RequestAborted); + + var secret = UnprotectSecret(settings.WebhookSigningSecret); + + if (!DialPadJwtValidator.TryValidateAndExtract(body, secret, out var payloadJson)) + { + _logger.LogWarning("Rejected a DialPad webhook because the signature could not be validated."); + + return Unauthorized(); + } + + DialPadCallEvent callEvent; + + try + { + callEvent = JsonSerializer.Deserialize(payloadJson, _jsonOptions); + } + catch (JsonException) + { + return BadRequest(); + } + + if (callEvent is null) + { + return BadRequest(); + } + + var result = await _webhookService.ProcessAsync(callEvent, HttpContext.RequestAborted); + + return Ok(new { result = result.ToString() }); + } + + private string UnprotectSecret(string protectedSecret) + { + if (string.IsNullOrEmpty(protectedSecret)) + { + return null; + } + + try + { + var protector = _dataProtectionProvider.CreateProtector(DialPadConstants.WebhookProtectorName); + + return protector.Unprotect(protectedSecret); + } + catch (CryptographicException) + { + return null; + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs index e377f6d4b..01103ac3a 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs @@ -22,6 +22,11 @@ public static class DialPadConstants /// public const string OAuthProtectorName = "DialPad.OAuth"; + /// + /// The name of the data protector used to protect the DialPad webhook signing secret. + /// + public const string WebhookProtectorName = "DialPad.Webhook"; + /// /// The DialPad OAuth scope that allows access to a refresh token so access tokens can be renewed /// without prompting the user to reconnect. diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs index 5fdf1aa09..e79114eb8 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialerStartup.cs @@ -16,6 +16,7 @@ public override void ConfigureServices(IServiceCollection services) services .AddScoped() .AddScoped(sp => sp.GetRequiredService()) - .AddScoped(sp => sp.GetRequiredService()); + .AddScoped(sp => sp.GetRequiredService()) + .AddScoped(); } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs index 412b01b5a..f72f5f41b 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs @@ -75,6 +75,7 @@ public override IDisplayResult Edit(ISite site, DialPadSettings settings, BuildE model.OutboundCallerId = settings.OutboundCallerId; model.HasApiToken = !string.IsNullOrEmpty(settings.ApiToken); model.HasClientSecret = !string.IsNullOrEmpty(settings.ClientSecret); + model.HasWebhookSigningSecret = !string.IsNullOrEmpty(settings.WebhookSigningSecret); }).Location("Content:10#DialPad;15") .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, TelephonyPermissions.ManageTelephonySettings)) .OnGroup(SettingsGroupId); @@ -174,6 +175,16 @@ public override async Task UpdateAsync(ISite site, DialPadSettin settings.ClientSecret = protectedSecret; } + + if (!string.IsNullOrWhiteSpace(model.WebhookSigningSecret)) + { + var protector = _dataProtectionProvider.CreateProtector(DialPadConstants.WebhookProtectorName); + var protectedWebhookSecret = protector.Protect(model.WebhookSigningSecret); + + hasChanges |= settings.WebhookSigningSecret != protectedWebhookSecret; + + settings.WebhookSigningSecret = protectedWebhookSecret; + } } if (context.Updater.ModelState.IsValid && settings.IsEnabled && string.IsNullOrEmpty(telephonySettings.DefaultProviderName)) diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs index eef60cf72..b0d8d0e75 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs @@ -74,4 +74,10 @@ public bool UseOAuth /// Gets or sets the space-separated OAuth scopes requested during authorization. /// public string Scopes { get; set; } + + /// + /// Gets or sets the protected secret DialPad uses to sign call-event webhooks (JWT HS256). The value + /// is stored encrypted using the data protection provider. When empty, unsigned webhooks are accepted. + /// + public string WebhookSigningSecret { get; set; } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs new file mode 100644 index 000000000..1518e6aad --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadCallEvent.cs @@ -0,0 +1,57 @@ +using System.Text.Json.Serialization; + +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Represents the normalized fields of a DialPad call-event webhook payload. +/// +public sealed class DialPadCallEvent +{ + /// + /// Gets or sets the DialPad call identifier. + /// + [JsonPropertyName("call_id")] + public string CallId { get; set; } + + /// + /// Gets or sets the DialPad call state (for example ringing, connected, hangup). + /// + [JsonPropertyName("state")] + public string State { get; set; } + + /// + /// Gets or sets the call direction (inbound or outbound). + /// + [JsonPropertyName("direction")] + public string Direction { get; set; } + + /// + /// Gets or sets the external party number (the customer number). + /// + [JsonPropertyName("external_number")] + public string ExternalNumber { get; set; } + + /// + /// Gets or sets the internal number the call was placed to (the dialed DID for inbound calls). + /// + [JsonPropertyName("internal_number")] + public string InternalNumber { get; set; } + + /// + /// Gets or sets the target number, used as a fallback for the dialed DID. + /// + [JsonPropertyName("target")] + public string Target { get; set; } + + /// + /// Gets or sets the contact display name supplied by DialPad, when available. + /// + [JsonPropertyName("contact_name")] + public string ContactName { get; set; } + + /// + /// Gets or sets the epoch milliseconds the event occurred. + /// + [JsonPropertyName("event_timestamp")] + public long? EventTimestamp { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs index 60f483b31..03470c6bb 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadContactCenterVoiceProvider.cs @@ -34,7 +34,7 @@ public DialPadContactCenterVoiceProvider( public LocalizedString Name { get; } /// - public ContactCenterVoiceProviderCapabilities Capabilities => ContactCenterVoiceProviderCapabilities.DialerDial; + public ContactCenterVoiceProviderCapabilities Capabilities => ContactCenterVoiceProviderCapabilities.DialerDial | ContactCenterVoiceProviderCapabilities.CallTransfer; /// public VoiceProviderDeliveryModel DeliveryModel => VoiceProviderDeliveryModel.AgentDeviceNative; diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJwtValidator.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJwtValidator.cs new file mode 100644 index 000000000..c49c342de --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadJwtValidator.cs @@ -0,0 +1,126 @@ +using System.Security.Cryptography; +using System.Text; + +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Validates and decodes DialPad webhook payloads. DialPad delivers call-event webhooks as an HS256 +/// signed JSON Web Token (JWT) whose payload is the event body. When no signing secret is configured, +/// an unsigned JSON body is accepted. +/// +public static class DialPadJwtValidator +{ + /// + /// Validates the webhook body and extracts the event JSON payload. + /// + /// The raw webhook body (a signed JWT, or JSON when no secret is configured). + /// The configured DialPad webhook signing secret, or . + /// When this method returns, contains the extracted event JSON payload. + /// when the body is valid; otherwise, . + public static bool TryValidateAndExtract(string body, string signingSecret, out string payloadJson) + { + payloadJson = null; + + if (string.IsNullOrWhiteSpace(body)) + { + return false; + } + + var trimmed = body.Trim(); + var segments = trimmed.Split('.'); + + // A body that is not a three-segment JWT is treated as a raw JSON payload, which is only + // acceptable when no signing secret has been configured. + if (segments.Length != 3) + { + if (!string.IsNullOrEmpty(signingSecret)) + { + return false; + } + + payloadJson = trimmed; + + return LooksLikeJson(payloadJson); + } + + var payload = DecodeSegment(segments[1]); + + if (payload is null) + { + return false; + } + + if (string.IsNullOrEmpty(signingSecret)) + { + payloadJson = payload; + + return true; + } + + if (!VerifySignature(segments[0], segments[1], segments[2], signingSecret)) + { + return false; + } + + payloadJson = payload; + + return true; + } + + private static bool VerifySignature(string headerSegment, string payloadSegment, string signatureSegment, string secret) + { + var signingInput = $"{headerSegment}.{payloadSegment}"; + var key = Encoding.UTF8.GetBytes(secret); + var expected = HMACSHA256.HashData(key, Encoding.UTF8.GetBytes(signingInput)); + + var provided = DecodeSegmentBytes(signatureSegment); + + if (provided is null || provided.Length != expected.Length) + { + return false; + } + + return CryptographicOperations.FixedTimeEquals(provided, expected); + } + + private static string DecodeSegment(string segment) + { + var bytes = DecodeSegmentBytes(segment); + + return bytes is null ? null : Encoding.UTF8.GetString(bytes); + } + + private static byte[] DecodeSegmentBytes(string segment) + { + if (string.IsNullOrEmpty(segment)) + { + return null; + } + + var value = segment.Replace('-', '+').Replace('_', '/'); + + switch (value.Length % 4) + { + case 2: + value += "=="; + break; + case 3: + value += "="; + break; + } + + try + { + return Convert.FromBase64String(value); + } + catch (FormatException) + { + return null; + } + } + + private static bool LooksLikeJson(string value) + { + return value.StartsWith('{') && value.EndsWith('}'); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookResult.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookResult.cs new file mode 100644 index 000000000..887f5d20c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookResult.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Identifies how a DialPad call-event webhook was handled by the Contact Center. +/// +public enum DialPadWebhookResult +{ + /// + /// The event updated an existing interaction and call session. + /// + Updated, + + /// + /// The event started a new inbound interaction and routed it to an agent. + /// + Routed, + + /// + /// The event was ignored (unknown state, or no matching interaction for a non-inbound event). + /// + Ignored, +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs new file mode 100644 index 000000000..b1cf5431b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadWebhookService.cs @@ -0,0 +1,114 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Provides the default implementation of . It normalizes DialPad +/// call events into Contact Center provider voice events, updating existing interactions, and routes new +/// inbound calls through the Voice Contact Center Call Router. +/// +public sealed class DialPadWebhookService : IDialPadWebhookService +{ + private readonly IProviderVoiceEventService _providerVoiceEventService; + private readonly IVoiceContactCenterCallRouter _voiceCallRouter; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The provider voice event service used to update existing interactions. + /// The voice call router used to route new inbound calls. + /// The clock used to stamp event times. + public DialPadWebhookService( + IProviderVoiceEventService providerVoiceEventService, + IVoiceContactCenterCallRouter voiceCallRouter, + IClock clock) + { + _providerVoiceEventService = providerVoiceEventService; + _voiceCallRouter = voiceCallRouter; + _clock = clock; + } + + /// + public async Task ProcessAsync(DialPadCallEvent callEvent, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(callEvent); + + if (string.IsNullOrEmpty(callEvent.CallId) || !TryMapState(callEvent.State, out var state)) + { + return DialPadWebhookResult.Ignored; + } + + var occurredUtc = callEvent.EventTimestamp.HasValue + ? DateTimeOffset.FromUnixTimeMilliseconds(callEvent.EventTimestamp.Value).UtcDateTime + : _clock.UtcNow; + + var toAddress = string.IsNullOrEmpty(callEvent.InternalNumber) ? callEvent.Target : callEvent.InternalNumber; + + var providerEvent = new ProviderVoiceEvent + { + ProviderName = DialPadConstants.ProviderTechnicalName, + ProviderCallId = callEvent.CallId, + State = state, + FromAddress = callEvent.ExternalNumber, + ToAddress = toAddress, + OccurredUtc = occurredUtc, + IdempotencyKey = $"{callEvent.CallId}:{callEvent.State}:{callEvent.EventTimestamp}", + }; + + var session = await _providerVoiceEventService.IngestAsync(providerEvent, cancellationToken); + + if (session is not null) + { + return DialPadWebhookResult.Updated; + } + + if (IsInbound(callEvent.Direction) && IsLive(state)) + { + await _voiceCallRouter.RouteInboundAsync(new InboundVoiceEvent + { + ProviderName = DialPadConstants.ProviderTechnicalName, + ProviderCallId = callEvent.CallId, + FromAddress = callEvent.ExternalNumber, + ToAddress = toAddress, + CallerName = callEvent.ContactName, + ReceivedUtc = occurredUtc, + }, cancellationToken); + + return DialPadWebhookResult.Routed; + } + + return DialPadWebhookResult.Ignored; + } + + private static bool IsInbound(string direction) + { + return string.Equals(direction?.Trim(), "inbound", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsLive(ContactCenterCallState state) + { + return state is ContactCenterCallState.Dialing or ContactCenterCallState.Ringing or ContactCenterCallState.Connected; + } + + private static bool TryMapState(string state, out ContactCenterCallState mapped) + { + mapped = state?.Trim().ToLowerInvariant() switch + { + "calling" or "dialing" or "connecting" or "preanswer" => ContactCenterCallState.Dialing, + "ringing" => ContactCenterCallState.Ringing, + "connected" or "active" => ContactCenterCallState.Connected, + "hold" or "on_hold" or "parked" => ContactCenterCallState.OnHold, + "hangup" or "ended" or "disconnected" or "completed" or "voicemail" => ContactCenterCallState.Ended, + "missed" or "no_answer" or "noanswer" => ContactCenterCallState.NoAnswer, + "rejected" or "declined" or "busy" => ContactCenterCallState.Rejected, + "canceled" or "cancelled" or "abandoned" => ContactCenterCallState.Canceled, + "transferred" => ContactCenterCallState.Transferred, + _ => (ContactCenterCallState)(-1), + }; + + return Enum.IsDefined(mapped); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/IDialPadWebhookService.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/IDialPadWebhookService.cs new file mode 100644 index 000000000..fdf9f0496 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/IDialPadWebhookService.cs @@ -0,0 +1,16 @@ +namespace CrestApps.OrchardCore.DialPad.Services; + +/// +/// Handles a parsed DialPad call event: it updates existing Contact Center interactions and routes new +/// inbound calls into the Contact Center. +/// +public interface IDialPadWebhookService +{ + /// + /// Processes a DialPad call event. + /// + /// The parsed DialPad call event. + /// The token to monitor for cancellation requests. + /// The result describing how the event was handled. + Task ProcessAsync(DialPadCallEvent callEvent, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs b/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs index 3f84afb94..e2fa779b1 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs @@ -54,6 +54,11 @@ public class DialPadSettingsViewModel /// public string UserId { get; set; } + /// + /// Gets or sets the DialPad webhook signing secret used to validate inbound call-event webhooks. + /// + public string WebhookSigningSecret { get; set; } + /// /// Gets or sets a value indicating whether an API key has already been saved. /// @@ -65,4 +70,10 @@ public class DialPadSettingsViewModel /// [BindNever] public bool HasClientSecret { get; set; } + + /// + /// Gets or sets a value indicating whether a webhook signing secret has already been saved. + /// + [BindNever] + public bool HasWebhookSigningSecret { get; set; } } diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml index d09622967..98c4dd775 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml @@ -83,6 +83,15 @@ @T["The phone number presented to the recipient on outbound calls. Include a country code, for example +1 for the United States."]
    + +
    + +
    + + + @T["Used to validate inbound DialPad call-event webhooks posted to /api/dialpad/webhook/call. Configure a DialPad webhook with this secret so inbound calls create Contact Center activities and route to agents."] +
    +
    @@ -18,7 +18,7 @@ - @T["The chat profile that controls the AI behavior for this subject."] + @T["Only chat profiles with Add initial prompt enabled are shown because the initial prompt is sent as the first outbound SMS message."]
    @@ -31,15 +31,6 @@ -
    - -
    - - @T["This is the first message sent to the contact to initiate the conversation. Supports Liquid syntax."] - -
    -
    -
    @T["AI permissions"]
    @@ -51,6 +42,31 @@
    + @T["Opt-out SMS requests always update the contact's Do not SMS preference, even when contact updates are disabled."] +
    + + +
    +
    @T["SMS automation"]
    +
    +
    + + + + @T["When set, an automated SMS activity is failed if the contact does not respond before this timeout."] +
    +
    + + + + @T["Optional delay before each AI SMS response is sent. Leave empty or use 0 to reply immediately."] +
    +
    + + + + @T["Enter one keyword per line or separate keywords with commas. Defaults include STOP, STOPALL, UNSUBSCRIBE, CANCEL, END, and QUIT."] +
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs index 7b1ce06b2..5404c9ea2 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs @@ -6,6 +6,7 @@ using CrestApps.Core.AI.Completions; using CrestApps.Core.AI.Deployments; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; using CrestApps.Core.Services; using CrestApps.Core.Support; using CrestApps.Core.Templates.Services; @@ -18,6 +19,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OrchardCore.ContentManagement; +using OrchardCore.Entities; using OrchardCore.Environment.Shell.Scope; using OrchardCore.Json; using OrchardCore.Modules; @@ -34,9 +36,13 @@ internal sealed class SmsOmnichannelEventHandler : IOmnichannelEventHandler private readonly IAIChatSessionPromptStore _promptStore; private readonly IAICompletionService _aICompletionService; private readonly IAIDeploymentManager _deploymentManager; + private readonly IAICompletionContextBuilder _completionContextBuilder; + private readonly IAIProfileManager _profileManager; private readonly ITemplateService _aiTemplateService; private readonly IOmnichannelChannelEndpointManager _channelEndpointsManager; - private readonly ICatalogManager _campaignManager; + private readonly ICatalog _flowSettingsCatalog; + private readonly IContentManager _contentManager; + private readonly IClock _clock; private readonly ISession _session; private readonly ISmsService _smsService; @@ -54,9 +60,13 @@ internal sealed class SmsOmnichannelEventHandler : IOmnichannelEventHandler /// The prompt store. /// The AI completion service. /// The deployment manager. + /// The AI completion context builder. + /// The AI profile manager. /// The ai template service. /// The channel endpoints manager. - /// The campaign manager. + /// The subject flow settings catalog. + /// The content manager. + /// The clock. /// The session. /// The sms service. /// The omnichannel activity store. @@ -68,9 +78,13 @@ public SmsOmnichannelEventHandler( IAIChatSessionPromptStore promptStore, IAICompletionService aICompletionService, IAIDeploymentManager deploymentManager, + IAICompletionContextBuilder completionContextBuilder, + IAIProfileManager profileManager, ITemplateService aiTemplateService, IOmnichannelChannelEndpointManager channelEndpointsManager, - ICatalogManager campaignManager, + ICatalog flowSettingsCatalog, + IContentManager contentManager, + IClock clock, ISession session, ISmsService smsService, IOmnichannelActivityStore omnichannelActivityStore, @@ -82,9 +96,13 @@ public SmsOmnichannelEventHandler( _promptStore = promptStore; _aICompletionService = aICompletionService; _deploymentManager = deploymentManager; + _completionContextBuilder = completionContextBuilder; + _profileManager = profileManager; _aiTemplateService = aiTemplateService; _channelEndpointsManager = channelEndpointsManager; - _campaignManager = campaignManager; + _flowSettingsCatalog = flowSettingsCatalog; + _contentManager = contentManager; + _clock = clock; _session = session; _smsService = smsService; _omnichannelActivityStore = omnichannelActivityStore; @@ -136,18 +154,34 @@ public async Task HandleAsync(OmnichannelEvent omnichannelEvent, CancellationTok await _omnichannelActivityStore.UpdateAsync(activity, cancellationToken); - if (string.IsNullOrWhiteSpace(activity.CampaignId)) + var flowSettings = await FindFlowSettingsAsync(activity.SubjectContentType, cancellationToken); + + if (OmnichannelSmsComplianceHelper.IsOptOutRequest(omnichannelEvent.Message.Content, flowSettings?.SmsOptOutKeywords)) { - _logger.LogWarning("The linked Activity {ActivityId} does not have an CampaignId associated with it. Cannot process incoming SMS message.", activity.ItemId); + await ApplySmsOptOutAsync(activity, cancellationToken); return; } - var campaign = await _campaignManager.FindByIdAsync(activity.CampaignId, cancellationToken); + if (flowSettings is null) + { + _logger.LogWarning("The subject flow settings for subject '{SubjectContentType}' associated with Activity {ActivityId} were not found. Cannot process incoming SMS message.", activity.SubjectContentType, activity.ItemId); + + return; + } - if (campaign == null) + if (string.IsNullOrWhiteSpace(flowSettings.ProfileId)) { - _logger.LogWarning("The Campaign {CampaignId} associated with Activity {ActivityId} was not found. Cannot process incoming SMS message.", activity.CampaignId, activity.ItemId); + _logger.LogWarning("The subject flow settings for subject '{SubjectContentType}' associated with Activity {ActivityId} do not have an AI profile. Cannot process incoming SMS message.", activity.SubjectContentType, activity.ItemId); + + return; + } + + var profile = await _profileManager.FindByIdAsync(flowSettings.ProfileId, cancellationToken); + + if (profile is null || profile.Type != AIProfileType.Chat) + { + _logger.LogWarning("The AI profile '{ProfileId}' associated with Activity {ActivityId} was not found or is not a chat profile. Cannot process incoming SMS message.", flowSettings.ProfileId, activity.ItemId); return; } @@ -168,6 +202,19 @@ public async Task HandleAsync(OmnichannelEvent omnichannelEvent, CancellationTok return; } + if (!string.IsNullOrWhiteSpace(chatSession.ProfileId) && + !string.Equals(chatSession.ProfileId, profile.ItemId, StringComparison.OrdinalIgnoreCase)) + { + profile = await _profileManager.FindByIdAsync(chatSession.ProfileId, cancellationToken); + + if (profile is null || profile.Type != AIProfileType.Chat) + { + _logger.LogWarning("The AI profile '{ProfileId}' associated with AI Chat Session {AISessionId} was not found or is not a chat profile. Cannot process incoming SMS message.", chatSession.ProfileId, chatSession.SessionId); + + return; + } + } + await _promptStore.CreateAsync(new AIChatSessionPrompt { ItemId = UniqueId.GenerateId(), @@ -176,9 +223,6 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt Content = omnichannelEvent.Message.Content }, cancellationToken); - // TODO: add a way to extract data from the message when needed to update Subject or the Contact objects. - // Maybe in the campaign we can see if updating the subject or contact should be allowed and use AI to extract the data. - string bestChoice = null; try @@ -188,21 +232,12 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt var transcript = prompts.Where(x => !x.IsGeneratedPrompt) .Select(prompt => new ChatMessage(prompt.Role, prompt.Content)); - var context = new AICompletionContext - { - ChatDeploymentName = campaign.DeploymentName, - Temperature = campaign.Temperature, - TopP = campaign.TopP, - FrequencyPenalty = campaign.FrequencyPenalty, - PresencePenalty = campaign.PresencePenalty, - MaxTokens = campaign.MaxTokens, - ToolNames = campaign.ToolNames, - }; + var context = await _completionContextBuilder.BuildAsync(profile, cancellationToken: cancellationToken); context.AdditionalProperties["Session"] = chatSession; var deployment = await _deploymentManager.ResolveOrDefaultAsync(AIDeploymentPurpose.Chat, deploymentName: context.ChatDeploymentName, cancellationToken: cancellationToken) - ?? throw new InvalidOperationException($"Unable to resolve a chat deployment for campaign '{campaign.ItemId}'."); + ?? throw new InvalidOperationException($"Unable to resolve a chat deployment for AI profile '{profile.ItemId}'."); var completion = await _aICompletionService.CompleteAsync(deployment, transcript, context, cancellationToken); @@ -210,7 +245,7 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt if (string.IsNullOrWhiteSpace(bestChoice)) { - _logger.LogWarning("AI Completion did not return any content for Activity {ActivityId} using AI Campaign {CampaignId}.", activity.ItemId, campaign.ItemId); + _logger.LogWarning("AI Completion did not return any content for Activity {ActivityId} using AI profile {ProfileId}.", activity.ItemId, profile.ItemId); return; } @@ -218,14 +253,22 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt catch (Exception ex) { - _logger.LogError(ex, "AI Completion failed for Activity {ActivityId} using AI Campaign {CampaignId}.", activity.ItemId, campaign.ItemId); + _logger.LogError(ex, "AI Completion failed for Activity {ActivityId} using AI profile {ProfileId}.", activity.ItemId, profile.ItemId); + + return; } try { + if (flowSettings.SmsResponseDelayInSeconds is > 0) + { + await Task.Delay(TimeSpan.FromSeconds(flowSettings.SmsResponseDelayInSeconds.Value), cancellationToken); + } + var result = await _smsService.SendAsync(new SmsMessage { To = activity.PreferredDestination, + From = endpoint.Value, Body = bestChoice, }, cancellationToken); @@ -239,8 +282,17 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt Content = bestChoice, }, cancellationToken); + chatSession.LastActivityUtc = _clock.UtcNow; + activity.Status = ActivityStatus.AwaitingCustomerAnswer; + if (OmnichannelAutomationHelper.HasNoResponseTimeout(flowSettings)) + { + activity.ScheduledUtc = OmnichannelAutomationHelper.ResolveNoResponseDeadline( + flowSettings, + _clock.UtcNow); + } + await _omnichannelActivityStore.UpdateAsync(activity, cancellationToken); ShellScope.AddDeferredTask(async scope => @@ -254,6 +306,7 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt var clientFactory = scope.ServiceProvider.GetRequiredService(); var deploymentManager = scope.ServiceProvider.GetRequiredService(); + var completionContextBuilder = scope.ServiceProvider.GetRequiredService(); var deferredPromptStore = scope.ServiceProvider.GetRequiredService(); var allActions = await actionCatalog.GetAllAsync(); @@ -265,7 +318,16 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt .ToList(); var dispositions = await dispositionCatalog.GetAsync(subjectDispositionIds); - var deployment = await deploymentManager.ResolveOrDefaultAsync(AIDeploymentPurpose.Chat, campaign.DeploymentName, campaign.ProviderName); + var conclusionPrompt = await _aiTemplateService.RenderAsync(SmsConclusionAnalysisPromptId); + var conclusionContext = await completionContextBuilder.BuildAsync(profile, context => + { + context.SystemMessage = conclusionPrompt; + context.DisableTools = true; + }); + + var deployment = await deploymentManager.ResolveOrDefaultAsync( + AIDeploymentPurpose.Chat, + deploymentName: conclusionContext.ChatDeploymentName); if (deployment == null) { @@ -284,12 +346,12 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt var userPrompt = $""" Chat Summary: {JsonSerializer.Serialize(sessionPrompts)} - Campaign Goal: {campaign.CampaignGoal} + Subject Goal: {flowSettings.SubjectGoal} List of Dispositions: {JsonSerializer.Serialize(dispositions.Select(x => new { Id = x.ItemId, x.Name, x.Description }))} """; - if (campaign.AllowAIToUpdateSubject) + if (flowSettings.AllowAIToUpdateSubject) { subject ??= activity.Subject ?? await contentManager.NewAsync(activity.SubjectContentType); @@ -299,7 +361,7 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt """; } - if (campaign.AllowAIToUpdateContact) + if (flowSettings.AllowAIToUpdateContact) { contact ??= await contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); @@ -311,8 +373,6 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt """; } - var conclusionPrompt = await _aiTemplateService.RenderAsync(SmsConclusionAnalysisPromptId); - var transcript = new List { new (ChatRole.System, conclusionPrompt), @@ -325,7 +385,7 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt { OmnichannelActivity omnichannelActivity = null; - if (campaign.AllowAIToUpdateSubject && result.Result.Subject is not null) + if (flowSettings.AllowAIToUpdateSubject && result.Result.Subject is not null) { subject ??= activity.Subject ?? await contentManager.NewAsync(activity.SubjectContentType); subject.Merge(result.Result.Subject); @@ -335,10 +395,10 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt omnichannelActivity.Subject = subject; // Update the activity with the new subject since the converation may not be concluded. - await _omnichannelActivityStore.UpdateAsync(omnichannelActivity); + await store.UpdateAsync(omnichannelActivity); } - if (campaign.AllowAIToUpdateContact && result.Result.Contact is not null) + if (flowSettings.AllowAIToUpdateContact && result.Result.Contact is not null) { contact ??= await contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); @@ -366,7 +426,7 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt omnichannelActivity.CompletedById = omnichannelActivity.AssignedToId; omnichannelActivity.CompletedByUsername = omnichannelActivity.AssignedToUsername; - await _omnichannelActivityStore.UpdateAsync(omnichannelActivity); + await store.UpdateAsync(omnichannelActivity); subject ??= activity.Subject ?? await contentManager.NewAsync(activity.SubjectContentType); contact ??= await contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); @@ -396,6 +456,51 @@ await executor.ExecuteAsync(new SubjectActionExecutionContext await _session.SaveAsync(chatSession); } + private async Task FindFlowSettingsAsync( + string subjectContentType, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(subjectContentType)) + { + return null; + } + + var flowSettings = await _flowSettingsCatalog.GetAllAsync(cancellationToken); + + return flowSettings.FirstOrDefault(settings => + string.Equals(settings.SubjectContentType, subjectContentType, StringComparison.OrdinalIgnoreCase)); + } + + private async Task ApplySmsOptOutAsync( + OmnichannelActivity activity, + CancellationToken cancellationToken) + { + var contact = await _contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); + + if (contact is null) + { + _logger.LogWarning("Unable to update Do not SMS for Activity {ActivityId} because contact {ContactContentItemId} was not found.", activity.ItemId, activity.ContactContentItemId); + } + else + { + contact.Alter(part => + { + part.SetDoNotSms(true, _clock.UtcNow); + }); + + await _contentManager.UpdateAsync(contact); + } + + activity.Status = ActivityStatus.Cancelled; + + if (string.IsNullOrWhiteSpace(activity.Notes)) + { + activity.Notes = "The automated SMS activity was cancelled because the contact requested SMS opt-out."; + } + + await _omnichannelActivityStore.UpdateAsync(activity, cancellationToken); + } + private sealed class ConverationConclusionResult { /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs index 46b5ca8b8..1ad75ed25 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs @@ -2,9 +2,11 @@ using CrestApps.Core.AI; using CrestApps.Core.AI.Chat; using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; using CrestApps.Core.Services; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; using Fluid; using Fluid.Values; using Microsoft.Extensions.AI; @@ -23,7 +25,9 @@ public sealed class SmsOmnichannelProcessor : IOmnichannelProcessor { private readonly IAIChatSessionManager _aIChatSessionManager; private readonly IAIChatSessionPromptStore _promptStore; + private readonly IAIProfileManager _profileManager; private readonly ICatalog _campaignCatalog; + private readonly ICatalog _flowSettingsCatalog; private readonly ICatalog _channelEndpointCatalog; private readonly ISmsService _smsService; private readonly ILiquidTemplateManager _liquidTemplateManager; @@ -37,7 +41,9 @@ public sealed class SmsOmnichannelProcessor : IOmnichannelProcessor /// /// The AI chat session manager. /// The prompt store. + /// The AI profile manager. /// The campaign catalog. + /// The subject flow settings catalog. /// The channel endpoint catalog. /// The sms service. /// The liquid template manager. @@ -47,7 +53,9 @@ public sealed class SmsOmnichannelProcessor : IOmnichannelProcessor public SmsOmnichannelProcessor( IAIChatSessionManager aIChatSessionManager, IAIChatSessionPromptStore promptStore, + IAIProfileManager profileManager, ICatalog campaignCatalog, + ICatalog flowSettingsCatalog, ICatalog channelEndpointCatalog, ISmsService smsService, ILiquidTemplateManager liquidTemplateManager, @@ -57,7 +65,9 @@ public SmsOmnichannelProcessor( { _aIChatSessionManager = aIChatSessionManager; _promptStore = promptStore; + _profileManager = profileManager; _campaignCatalog = campaignCatalog; + _flowSettingsCatalog = flowSettingsCatalog; _channelEndpointCatalog = channelEndpointCatalog; _smsService = smsService; _liquidTemplateManager = liquidTemplateManager; @@ -85,36 +95,61 @@ public async Task StartAsync(OmnichannelActivity activity, CancellationToken can chatSession = await _aIChatSessionManager.FindByIdAsync(activity.AISessionId, cancellationToken); } - var campaign = await _campaignCatalog.FindByIdAsync(activity.CampaignId, cancellationToken) - ?? throw new InvalidOperationException($"Unable to find the campaign '{activity.CampaignId}' that is associated with the activity '{activity.ItemId}'."); + var flowSettings = await FindFlowSettingsAsync(activity.SubjectContentType, cancellationToken) + ?? throw new InvalidOperationException($"Unable to find subject flow settings for the activity '{activity.ItemId}' and subject '{activity.SubjectContentType}'."); + + var profile = await _profileManager.FindByIdAsync(flowSettings.ProfileId, cancellationToken) + ?? throw new InvalidOperationException($"Unable to find the AI profile '{flowSettings.ProfileId}' for the activity '{activity.ItemId}'."); + + if (profile.Type != AIProfileType.Chat) + { + throw new InvalidOperationException($"The AI profile '{profile.ItemId}' must be a chat profile."); + } + + var profileMetadata = profile.GetOrCreate(); + var initialPromptPattern = profileMetadata.InitialPrompt?.Trim(); + + if (string.IsNullOrWhiteSpace(initialPromptPattern)) + { + throw new InvalidOperationException($"The AI profile '{profile.ItemId}' must have Add initial prompt enabled."); + } + + var campaign = string.IsNullOrWhiteSpace(activity.CampaignId) + ? null + : await _campaignCatalog.FindByIdAsync(activity.CampaignId, cancellationToken); if (chatSession is null) { chatSession = new AIChatSession { SessionId = UniqueId.GenerateId(), + ProfileId = profile.ItemId, CreatedUtc = _clock.UtcNow, + LastActivityUtc = _clock.UtcNow, Title = S["Automated SMS Activity"], }; - - await _promptStore.CreateAsync(new AIChatSessionPrompt - { - ItemId = UniqueId.GenerateId(), - SessionId = chatSession.SessionId, - Role = ChatRole.System, - Content = campaign.SystemMessage, - }, cancellationToken); } var contact = await _contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); - var initialPrompt = await _liquidTemplateManager.RenderStringAsync(campaign.InitialOutboundPromptPattern, NullEncoder.Default, - new Dictionary() + var templateContext = new Dictionary { + ["Activity"] = new ObjectValue(activity), ["Contact"] = new ObjectValue(contact), - ["Campaign"] = new ObjectValue(campaign), + ["FlowSettings"] = new ObjectValue(flowSettings), + ["Profile"] = new ObjectValue(profile), ["Session"] = new ObjectValue(chatSession), - }); + }; + + if (campaign is not null) + { + templateContext["Campaign"] = new ObjectValue(campaign); + } + + var initialPrompt = await _liquidTemplateManager.RenderStringAsync( + initialPromptPattern, + NullEncoder.Default, + templateContext); initialPrompt = initialPrompt?.Trim(); @@ -151,15 +186,39 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt Content = initialPrompt, }, cancellationToken); + chatSession.LastActivityUtc = _clock.UtcNow; + await _aIChatSessionManager.SaveAsync(chatSession, cancellationToken); // Update the activity with the AI session details. activity.AISessionId = chatSession.SessionId; activity.Status = ActivityStatus.AwaitingCustomerAnswer; + + if (OmnichannelAutomationHelper.HasNoResponseTimeout(flowSettings)) + { + activity.ScheduledUtc = OmnichannelAutomationHelper.ResolveNoResponseDeadline( + flowSettings, + _clock.UtcNow); + } } else { throw new InvalidOperationException("Failed to send SMS for an automated activity."); } } + + private async Task FindFlowSettingsAsync( + string subjectContentType, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(subjectContentType)) + { + return null; + } + + var flowSettings = await _flowSettingsCatalog.GetAllAsync(cancellationToken); + + return flowSettings.FirstOrDefault(settings => + string.Equals(settings.SubjectContentType, subjectContentType, StringComparison.OrdinalIgnoreCase)); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Core/Omnichannel/Services/OmnichannelAutomationHelperTests.cs b/tests/CrestApps.OrchardCore.Tests/Core/Omnichannel/Services/OmnichannelAutomationHelperTests.cs new file mode 100644 index 000000000..bb64eedc1 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Core/Omnichannel/Services/OmnichannelAutomationHelperTests.cs @@ -0,0 +1,54 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; + +namespace CrestApps.OrchardCore.Tests.Core.Omnichannel.Services; + +public sealed class OmnichannelAutomationHelperTests +{ + [Theory] + [InlineData(ActivityInteractionType.Automated, false, ActivityStatus.NotStated)] + [InlineData(ActivityInteractionType.Automated, true, ActivityStatus.NotStated)] + [InlineData(ActivityInteractionType.Manual, false, ActivityStatus.Scheduled)] + [InlineData(ActivityInteractionType.Manual, true, ActivityStatus.NotStated)] + public void GetInitialActivityStatus_WhenActivityIsLoaded_ShouldReturnExpectedStatus( + ActivityInteractionType interactionType, + bool hasAssignedUser, + ActivityStatus expectedStatus) + { + // Act + var status = OmnichannelAutomationHelper.GetInitialActivityStatus(interactionType, hasAssignedUser); + + // Assert + Assert.Equal(expectedStatus, status); + } + + [Fact] + public void ResolveNoResponseDeadline_WhenTimeoutIsConfigured_ShouldAddTimeout() + { + // Arrange + var utcNow = new DateTime(2026, 1, 2, 3, 4, 5, DateTimeKind.Utc); + var flowSettings = new SubjectFlowSettings + { + NoResponseTimeoutInMinutes = 15, + }; + + // Act + var deadline = OmnichannelAutomationHelper.ResolveNoResponseDeadline(flowSettings, utcNow); + + // Assert + Assert.Equal(utcNow.AddMinutes(15), deadline); + } + + [Fact] + public void HasNoResponseTimeout_WhenTimeoutIsMissing_ShouldReturnFalse() + { + // Arrange + var flowSettings = new SubjectFlowSettings(); + + // Act + var result = OmnichannelAutomationHelper.HasNoResponseTimeout(flowSettings); + + // Assert + Assert.False(result); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Core/Omnichannel/Services/OmnichannelSmsComplianceHelperTests.cs b/tests/CrestApps.OrchardCore.Tests/Core/Omnichannel/Services/OmnichannelSmsComplianceHelperTests.cs new file mode 100644 index 000000000..2638d18e0 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Core/Omnichannel/Services/OmnichannelSmsComplianceHelperTests.cs @@ -0,0 +1,43 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Services; + +namespace CrestApps.OrchardCore.Tests.Core.Omnichannel.Services; + +public sealed class OmnichannelSmsComplianceHelperTests +{ + [Theory] + [InlineData("STOP")] + [InlineData("stop")] + [InlineData("Stop texting me")] + [InlineData("UNSUBSCRIBE")] + public void IsOptOutRequest_WhenMessageMatchesDefaultKeyword_ShouldReturnTrue(string message) + { + // Act + var result = OmnichannelSmsComplianceHelper.IsOptOutRequest(message); + + // Assert + Assert.True(result); + } + + [Theory] + [InlineData("")] + [InlineData("stopwatch")] + [InlineData("endoscopy")] + public void IsOptOutRequest_WhenMessageDoesNotMatchKeywordBoundary_ShouldReturnFalse(string message) + { + // Act + var result = OmnichannelSmsComplianceHelper.IsOptOutRequest(message); + + // Assert + Assert.False(result); + } + + [Fact] + public void ParseOptOutKeywords_WhenKeywordsAreDelimited_ShouldNormalizeDistinctValues() + { + // Act + var keywords = OmnichannelSmsComplianceHelper.ParseOptOutKeywords("pause, STOP; pause"); + + // Assert + Assert.Equal(["pause", "STOP"], keywords); + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/SubjectFlowSettingsServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/SubjectFlowSettingsServiceTests.cs new file mode 100644 index 000000000..cf469e8d8 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Services/SubjectFlowSettingsServiceTests.cs @@ -0,0 +1,70 @@ +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Services; + +public sealed class SubjectFlowSettingsServiceTests +{ + [Fact] + public void IsConfigured_WhenAutomatedFlowHasProfileAndEndpoint_ShouldReturnTrue() + { + // Arrange + var service = new SubjectFlowSettingsService(null, null); + var flowSettings = CreateAutomatedFlowSettings(); + + // Act + var result = service.IsConfigured(flowSettings); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsConfigured_WhenAutomatedFlowMissingProfile_ShouldReturnFalse() + { + // Arrange + var service = new SubjectFlowSettingsService(null, null); + var flowSettings = CreateAutomatedFlowSettings(); + flowSettings.ProfileId = null; + + // Act + var result = service.IsConfigured(flowSettings); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsConfigured_WhenManualFlowHasNoProfile_ShouldReturnTrue() + { + // Arrange + var service = new SubjectFlowSettingsService(null, null); + var flowSettings = new SubjectFlowSettings + { + SubjectContentType = "Renewal", + CampaignId = "campaign-1", + Channel = OmnichannelConstants.Channels.Phone, + InteractionType = ActivityInteractionType.Manual, + }; + + // Act + var result = service.IsConfigured(flowSettings); + + // Assert + Assert.True(result); + } + + private static SubjectFlowSettings CreateAutomatedFlowSettings() + { + return new SubjectFlowSettings + { + SubjectContentType = "Renewal", + CampaignId = "campaign-1", + Channel = OmnichannelConstants.Channels.Sms, + ChannelEndpointId = "endpoint-1", + InteractionType = ActivityInteractionType.Automated, + ProfileId = "profile-1", + }; + } +} From 526002f423e78fb53e2a0884e96ca822d8c3a21b Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 1 Jul 2026 12:19:29 -0700 Subject: [PATCH 31/56] [Feature] Align agent workspace and update resources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> AI-Model: gpt-5.5 --- .github/contact-center/PLAN.md | 2 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 5 + .../docs/contact-center/agent-desktop.md | 22 +-- .../docs/contact-center/index.md | 8 +- .../Controllers/AgentWorkspaceController.cs | 99 +++++++++++--- .../SupervisorDashboardController.cs | 34 ++++- ...CrestApps.OrchardCore.ContactCenter.csproj | 1 + .../ContactCenterRealTimeEventHandler.cs | 33 ++++- .../Hubs/ContactCenterHub.cs | 25 +++- .../AgentWorkspaceIndexViewModel.cs | 6 - .../AgentWorkspaceStateViewModel.cs | 6 - .../WorkspaceActiveInteractionViewModel.cs | 6 + .../Views/AgentWorkspace/Index.cshtml | 7 +- .../wwwroot/scripts/agent-workspace.js | 102 +------------- .../Controllers/ActivitiesController.cs | 3 +- ...OmnichannelActivityAuthorizationHandler.cs | 76 +++++++++++ .../Services/PermissionProvider.cs | 2 + .../Startup.cs | 2 + .../ResourceManagementOptionsConfiguration.cs | 18 ++- .../package-lock.json | 8 +- .../package.json | 2 +- .../ContactCenterRealTimeEventHandlerTests.cs | 31 +++++ ...hannelActivityAuthorizationHandlerTests.cs | 128 ++++++++++++++++++ 23 files changed, 459 insertions(+), 167 deletions(-) create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/OmnichannelActivityAuthorizationHandler.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/OmnichannelActivityAuthorizationHandlerTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index b9c6057ba..6e4340253 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1500,7 +1500,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - [~] **G2 — Assignment safety (hardens Phases 2/3):** distributed-lock/single-writer assignment + optimistic concurrency on reservation; enforce `MaxConcurrentInteractions`; split a live `AgentSession` from `AgentProfile` with SignalR heartbeat + stale cleanup; drive offer timeout from the real-time layer. *(P0 #6, #7; P1 #13, #14)* — **P0 core shipped (2026-06-30):** per-queue distributed-lock single-writer assignment in `ActivityAssignmentService` (both `AssignNextAsync` and `AssignQueueAsync` acquire the lock; inbound `OfferNextAsync` routes through the same path), and `MaxConcurrentInteractions` enforcement via the new `CapacityRoutingStrategy` (Order 20, between required-skills and longest-idle) backed by `IInteractionManager.CountActiveByAgentAsync`. +6 unit tests (47 ContactCenter tests pass, clean `-warnaserror` build). **P1 #13 shipped (2026-06-30):** the live `AgentSession` aggregate (model/index/store/manager + `IAgentSessionService`) is now split from `AgentProfile`, the `ContactCenterHub` registers each SignalR connection on the session with a per-user distributed lock, the client sends a `Heartbeat` every 30s, and the `AgentSessionCleanupBackgroundTask` signs out + deletes sessions whose heartbeat is older than 90s so routing stops targeting a dead client (a brief reconnect is tolerated by the grace window). **Remaining:** the real-time per-reservation offer timeout (P1 #14) driven from the desktop (the SignalR foundation + `ServerTimeUtc`/`ExpiresUtc` on the offer notification now exist; the background reservation-expiry task remains the safety net). **(Compare-and-set on reservation creation shipped 2026-07-01: `ReserveAsync` re-reads the queue item and aborts unless it is still `Waiting`.)** - [x] **G3 — Completion unification via the Subject Flow (completes Phase 6):** make the Subject Flow the single decision controller and route every completion through `IActivityDispositionService`. *(P0 #8; P1 #10)* — **Shipped (2026-06-30):** completion already flows through the source-neutral `IActivityDispositionService` for CRM, inbound, and outbound, which marks the activity `Completed` regardless of its prior contact-center state (P0 #8) and runs the disposition-driven Subject Actions. Added `SubjectFlowSettings.RequireDisposition` (edited on the **Configure** screen) enforced centrally in `IActivityDispositionService` so completion is blocked until a disposition is chosen on every path; completion now also skips Subject Actions when no disposition is selected. **Reversal of the earlier wrap-up implementation:** the `WrapUp` feature / `WrapUpSession` aggregate added on 2026-06-30 was removed as redundant with disposition + subject flow (per the maintainer's "single concept" direction); after-call agent timing/capacity release is deferred to the Phase 7 agent desktop + agent presence. +3 disposition tests (47 ContactCenter + 4 disposition tests pass, clean `-warnaserror` build). - [~] **G4 — Dialer safety (completes Phase 5, pulls forward Phase 10 essentials):** strategy-per-mode (`IDialerStrategy`), an `IDialerEligibilityService`/compliance gate (DNC, communication preferences, calling windows, retry cool-down, suppression audit), cap Power, and block Predictive until metrics + abandonment controls exist. *(P0 #4, #5)* — **Shipped (2026-06-30):** `IDialerStrategy` + `IDialerStrategyResolver` with `PowerDialerStrategy` (hard-capped `MaxCallsPerAgent`) and `ProgressiveDialerStrategy`; `DialerService` now validates the profile and delegates pacing to the resolved strategy, so Manual/Preview stay agent-driven and **Predictive is blocked** (hidden in the editor, rejected on save, and refused at runtime). The single-attempt path moved to `IDialerAttemptService`, which calls the new `IDialerEligibilityService` (`DefaultDialerEligibilityService`) before every attempt: destination present, attempt limit, retry cool-down (last interaction end + `RetryDelayMinutes`), contact `DoNotCall` communication preference, configurable calling window evaluated in the contact's time zone, and any registered `INationalDoNotCallRegistry`. Suppressed attempts release the reservation and publish an auditable `DialSuppressed` event (DNC/registry cancel the activity; window/cool-down leave it available). Added calling-window settings to `DialerProfile`/editor. +16 dialer unit tests (66 ContactCenter tests pass; clean `-warnaserror` build). **Remaining (P2/Phase 10):** abandonment caps, AMD outcomes, and predictive metrics before Predictive can be re-enabled. -- [~] **G5 — Agent desktop + supervisor real-time UX (Phase 7):** CRM-integrated agent cockpit, supervisor dashboards, queue monitor/wallboard, and scoped/audited live call-control intents. *(P1 #15, #16)* — **Started (2026-06-30):** shipped the canonical **agent state reason codes** prerequisite — a catalog-backed `AgentStateReasonCode` admin surface (Agents feature, **Interaction Center → Agent states**) following the Skills/TimeZones catalog pattern, an `AgentStateReasonCode` recipe step, a seed recipe executed at setup via `IRecipeMigrator`, and soft-phone presence-dropdown integration. **Real-time SignalR layer shipped (2026-06-30):** the new `RealTime` feature (`CrestApps.OrchardCore.ContactCenter.RealTime`, depends on `Queues` + the `SignalR` module) adds the `ContactCenterHub` (`Hub`) with per-user, per-queue, and `cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`; the live `AgentSession` aggregate (split from `AgentProfile`) with `IAgentSessionService` connect/disconnect/heartbeat; the `Heartbeat`-driven `AgentSessionCleanupBackgroundTask`; the `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`); `IContactCenterRealTimeNotifier` + the `ContactCenterRealTimeEventHandler` event projection that broadcasts presence (`PresenceChanged`), offers (`OfferReceived`/`OfferRevoked`), and queue depth (`QueueStatsChanged`); the `contact-center-realtime` client script resource; and the `MonitorContactCenter` permission + default **Supervisor** role. +13 unit tests (79 ContactCenter tests pass; clean `-warnaserror` build). **Agent desktop + supervisor dashboard shipped (2026-07-01):** a CRM-integrated **Agent Workspace** (`AgentWorkspaceController` + `Views/AgentWorkspace` + `agent-workspace.js`) at **Interaction Center → My workspace** binds to the hub and a live `State` endpoint to show presence + reason-code control, live queue depth, the ringing offer card with a countdown and one-click Accept (server accept+connect) / Decline, the active-interaction panel with a live talk timer and customer 360 link, an inline disposition + notes wrap-up that completes through the source-neutral `IActivityDispositionService`, and recent history; and a **Supervisor Dashboard** (`SupervisorDashboardController` + `supervisor-dashboard.js`) at **Interaction Center → Live dashboard** (gated by `MonitorContactCenter`) with live summary metrics, per-queue SLA-health tiles, and an agent presence board. Change-detection rendering keeps the agent's in-progress disposition/notes from being clobbered by live refreshes. **Hardened (2026-07-01):** offer accept/decline commands are bound to the current agent, failed provider connect compensates by canceling the accepted reservation, provider call-end events move the agent into `WrapUp`, completion releases the agent to the requested or default presence state, and supervisor cards now expose provider-gated **Monitor/Whisper/Barge/Take over** actions backed by `IContactCenterMonitoringService`. **Remaining:** reason-code deployment-plan step and browser coverage for the core agent/supervisor flows. This real-time layer also unblocks the remaining G2 item (real-time per-reservation offer timeout, P1 #14). +- [~] **G5 — Agent desktop + supervisor real-time UX (Phase 7):** CRM-integrated agent cockpit, supervisor dashboards, queue monitor/wallboard, and scoped/audited live call-control intents. *(P1 #15, #16)* — **Started (2026-06-30):** shipped the canonical **agent state reason codes** prerequisite — a catalog-backed `AgentStateReasonCode` admin surface (Agents feature, **Interaction Center → Agent states**) following the Skills/TimeZones catalog pattern, an `AgentStateReasonCode` recipe step, a seed recipe executed at setup via `IRecipeMigrator`, and soft-phone presence-dropdown integration. **Real-time SignalR layer shipped (2026-06-30):** the new `RealTime` feature (`CrestApps.OrchardCore.ContactCenter.RealTime`, depends on `Queues` + the `SignalR` module) adds the `ContactCenterHub` (`Hub`) with per-user, per-queue, and `cc:supervisors` groups + `WatchQueue`/`UnwatchQueue`; the live `AgentSession` aggregate (split from `AgentProfile`) with `IAgentSessionService` connect/disconnect/heartbeat; the `Heartbeat`-driven `AgentSessionCleanupBackgroundTask`; the `GetSnapshot` reconnect snapshot (`AgentDesktopSnapshot`); `IContactCenterRealTimeNotifier` + the `ContactCenterRealTimeEventHandler` event projection that broadcasts presence (`PresenceChanged`), offers (`OfferReceived`/`OfferRevoked`), and queue depth (`QueueStatsChanged`); the `contact-center-realtime` client script resource; and the `MonitorContactCenter` permission + default **Supervisor** role. +13 unit tests (79 ContactCenter tests pass; clean `-warnaserror` build). **Agent desktop + supervisor dashboard shipped (2026-07-01):** a CRM-integrated **Agent Workspace** (`AgentWorkspaceController` + `Views/AgentWorkspace` + `agent-workspace.js`) at **Interaction Center → My workspace** binds to the hub and a live `State` endpoint to show presence + reason-code control, live queue depth, the ringing offer card with a countdown and one-click Accept (server accept+connect) / Decline, the active-interaction panel with a live talk timer, customer 360 link, and a **Complete activity** link into the shared Omnichannel activity-completion page, and recent history; and a **Supervisor Dashboard** (`SupervisorDashboardController` + `supervisor-dashboard.js`) at **Interaction Center → Live dashboard** (gated by `MonitorContactCenter`) with live summary metrics, per-queue SLA-health tiles, and an agent presence board. **Hardened (2026-07-01):** offer accept/decline commands are bound to the current agent, failed provider connect compensates by canceling the accepted reservation, provider call-end events move the agent into `WrapUp`, the CRM completion page accepts assigned contact-center states and runs the same source-neutral disposition flow, agent/supervisor names resolve through `IDisplayNameProvider`, and supervisor cards now expose provider-gated **Monitor/Whisper/Barge/Take over** actions backed by `IContactCenterMonitoringService`. **Remaining:** reason-code deployment-plan step and browser coverage for the core agent/supervisor flows. This real-time layer also unblocks the remaining G2 item (real-time per-reservation offer timeout, P1 #14). - [~] **G6 — Eventing/outbox + provider webhooks (hardens Phase 1, extends Phase 4):** outbox dispatch + retry/backoff, projection checkpoints, mandatory idempotency on provider events, rebuildable projections, and signed per-provider webhook adapters. *(P1 #17, #18)* — **Outbox shipped (2026-06-30):** Contact Center event dispatch is now at-least-once. `DefaultContactCenterEventPublisher` records the immutable `InteractionEvent` then delegates handler dispatch to the new `IContactCenterOutbox`/`ContactCenterOutbox`, which runs handlers inline and, on any handler failure, persists a durable `ContactCenterOutboxMessage` (`Pending`/`DeadLettered`, attempt count, next-attempt time, last error) via `IContactCenterOutboxStore`. The per-minute `OutboxDispatchBackgroundTask` calls `DispatchDueAsync`, re-running all handlers with exponential back-off (30s→30m cap) and dead-lettering after `MaxAttempts` (10); a missing referenced event is dead-lettered. Handlers must be idempotent (the shipped handlers are). +6 outbox tests + reworked 5 publisher tests (85 ContactCenter tests pass; clean `-warnaserror` build). **Remaining:** projection checkpoints + rebuildable read-model projections. **(Idempotency-key enforcement + signed per-provider webhook adapters shipped 2026-06-30 — see change log.)** - [~] **G7 — Routing depth (completes Phase 3):** `RoutingPolicy`, `QueueMembership`, skill proficiency, business-hours/holiday calendars, overflow, sticky agent, priority/SLA-aging strategies. *(P1 #11)* — **Shipped (2026-06-30):** per-queue routing policy on `ActivityQueue` (`RoutingStrategy` = LongestIdle/RoundRobin/LeastBusy, `PreferStickyAgent`, `EnableSlaAging`, `BusinessHoursCalendarId` + `AfterHoursAction`, `OverflowQueueId` + `OverflowAfterSeconds`); `StickyAgentRoutingStrategy` (boosts the activity's last assigned user, captured on `QueueItem.StickyAgentUserId` at enqueue), `RoundRobinRoutingStrategy` (orders by new `AgentProfile.LastAssignedUtc`, stamped on reserve), and `LeastBusyRoutingStrategy` (orders by active interaction count) — each gated so only the queue's selected primary strategy scores; `QueueItemPrioritizer` SLA-aging item selection in the assignment path; a reusable `BusinessHoursCalendar` catalog (model/index/store/manager + `IBusinessHoursService` weekly-schedule + holiday + time-zone evaluation; module index provider/migration/handler/driver/controller/admin menu/views under **Interaction Center → Business hours**) that pauses assignment while closed; and `IActivityQueueService.OverflowDueAsync` (wait-time + after-hours overflow re-homing, publishing `QueueItemOverflowed`) run by the reservation/assignment background task. Queue editor extended with all new fields. +21 unit tests (106 ContactCenter tests pass; clean full-solution `-warnaserror` build). **Remaining:** skill **proficiency** levels (requires migrating agent skills from a name list to a proficiency map), a standalone `QueueMembership` aggregate (membership is still modeled via `AgentProfile.QueueIds`), and bullseye/skill-relaxation overflow expansion. - [ ] **G8 — Inbound entry points/IVR (Phase 8), recording/monitoring (Phase 9), compliance hardening (Phase 10), analytics (Phase 12)** per the existing phase plan once G1–G7 are stable. diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index e1f604870..19ff3f988 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -184,6 +184,7 @@ At a high level, the platform changes are: - 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. - 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). @@ -503,6 +504,10 @@ At a high level, the platform changes are: - The Recipes documentation now explains when to implement `PartSchemaDefinitionBase`, `FieldSchemaDefinitionBase`, and `IContentSchemaDefinition`, shows both part and field examples, and clarifies how multiple schema contributors for the same part or field name are merged. - The Recipe Schema Exporter utility now lives under `utilities\` instead of `tests\`, and the documented build and run commands were updated accordingly. +### Resources + +- `crestapps-bootstrap-select` now references `@crestapps/bootstrap-select` `1.2.1` for both local package assets and CDN resources, with CDN integrity hashes configured for the minified and debug CSS/JavaScript files. + ### Telephony #### Provider-agnostic soft phone diff --git a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md index 9db630638..6c9df5586 100644 --- a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md +++ b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md @@ -151,7 +151,7 @@ device controls live. 2. Sign in to the queues and campaigns you are staffed for. 3. Set presence to **Available** when ready, or choose a reason code when not ready. 4. Accept or decline offers from the ringing card; use the soft phone for media controls. -5. End the conversation, choose the disposition, add notes when needed, and click **Complete & wrap up**. +5. End the conversation, open **Complete activity**, review/update the CRM context, choose the disposition, add notes when needed, and submit. 6. Use **Recent activity** to verify your last outcomes before taking the next offer. ### 1. Sign in and set your presence @@ -187,24 +187,28 @@ Once you accept, the **active interaction** panel shows: - The **customer**, with a link to open the full CRM contact record (customer 360). - The **direction** (inbound or outbound), the current **call status**, and a **live talk timer**. - The **queue** the work came from. +- A **Complete activity** link that opens the same Omnichannel CRM completion page used by manual + activities. Use the soft phone for hold, mute, transfer, and hang-up. The workspace reflects the call state in real time. -### 4. Wrap up with a disposition +### 4. Complete the activity in the CRM -When the conversation ends, capture the outcome in the **wrap-up** section of the active panel: +When the conversation ends, click **Complete activity** in the active panel. This opens the shared +Omnichannel completion page for the assigned activity, so contact-center work follows the same CRM +experience as manual activities: -1. Choose a **disposition** from the list defined by the activity's subject flow. -2. Optionally add **notes**. -3. Click **Complete & wrap up**. +1. Review the customer/contact context and open the customer record when details need correction. +2. Review activity details such as campaign, channel, urgency, schedule, instructions, and assignee. +3. Update the subject details captured by the activity's subject content type. +4. Choose a **disposition** from the list defined by the activity's subject flow. +5. Add notes when needed and submit the completion form. Completing routes through the shared disposition path, which applies the disposition, marks the activity completed, and runs the subject flow's follow-up actions - the same path used everywhere in the CRM, so inbound, outbound, and manual work all behave consistently. If the subject flow **requires** a -disposition, completion is blocked until you pick one and the workspace shows why. - -Your in-progress disposition and notes are never lost when the screen refreshes for a live update. +disposition, completion is blocked until you pick one and the completion page shows why. ### 5. Review recent activity diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 8cf07e55f..687afc7fa 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -177,10 +177,10 @@ 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 -wrap-up disposition. 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. +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 diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs index 4c2e81ff3..0d9e71b64 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/AgentWorkspaceController.cs @@ -1,5 +1,4 @@ using System.Security.Claims; -using CrestApps.Core.Models; using CrestApps.Core.Services; using CrestApps.Core.SignalR.Services; using CrestApps.OrchardCore.ContactCenter.Core; @@ -8,13 +7,17 @@ using CrestApps.OrchardCore.ContactCenter.Hubs; using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.ContactCenter.ViewModels; +using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; +using CrestApps.OrchardCore.Users; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OrchardCore.Admin; using OrchardCore.ContentManagement; using OrchardCore.Modules; +using OrchardCore.Users; namespace CrestApps.OrchardCore.ContactCenter.Controllers; @@ -37,10 +40,11 @@ public sealed class AgentWorkspaceController : Controller private readonly IQueueItemManager _queueItemManager; private readonly IInteractionManager _interactionManager; private readonly IOmnichannelActivityManager _activityManager; - private readonly INamedCatalogManager _dispositionManager; private readonly IContentManager _contentManager; private readonly IActivityDispositionService _dispositionService; private readonly IAgentStateReasonCodeManager _reasonCodeManager; + private readonly UserManager _userManager; + private readonly IDisplayNameProvider _displayNameProvider; private readonly HubRouteManager _hubRouteManager; private readonly IClock _clock; @@ -55,10 +59,11 @@ public sealed class AgentWorkspaceController : Controller /// The queue item manager used to compute live queue depth. /// The interaction manager used to resolve active and recent work. /// The CRM activity manager used to resolve activity context. - /// The disposition catalog used to populate the wrap-up choices. /// The content manager used to resolve contact display names. /// The source-neutral activity disposition service used to complete work. /// The agent state reason code manager used to build presence options. + /// The user manager used to resolve the current Orchard user. + /// The display name provider used to render the agent's full name. /// The hub route manager used to resolve the real-time hub URL. /// The clock used to stamp times. public AgentWorkspaceController( @@ -70,10 +75,11 @@ public AgentWorkspaceController( IQueueItemManager queueItemManager, IInteractionManager interactionManager, IOmnichannelActivityManager activityManager, - INamedCatalogManager dispositionManager, IContentManager contentManager, IActivityDispositionService dispositionService, IAgentStateReasonCodeManager reasonCodeManager, + UserManager userManager, + IDisplayNameProvider displayNameProvider, HubRouteManager hubRouteManager, IClock clock) { @@ -85,10 +91,11 @@ public AgentWorkspaceController( _queueItemManager = queueItemManager; _interactionManager = interactionManager; _activityManager = activityManager; - _dispositionManager = dispositionManager; _contentManager = contentManager; _dispositionService = dispositionService; _reasonCodeManager = reasonCodeManager; + _userManager = userManager; + _displayNameProvider = displayNameProvider; _hubRouteManager = hubRouteManager; _clock = clock; } @@ -106,15 +113,15 @@ public async Task Index() } var reasonCodes = await _reasonCodeManager.ListEnabledAsync(); + var displayName = await GetCurrentUserDisplayNameAsync(HttpContext.RequestAborted); var viewModel = new AgentWorkspaceIndexViewModel { - DisplayName = User.Identity?.Name, + DisplayName = displayName, CanMonitor = await _authorizationService.AuthorizeAsync(User, ContactCenterPermissions.MonitorContactCenter), HubUrl = _hubRouteManager.GetPathByHub(), StateUrl = Url.Action(nameof(State)), SetPresenceUrl = Url.Action(nameof(SetPresence)), - CompleteUrl = Url.Action(nameof(Complete)), AcceptOfferUrl = Url.RouteUrl("ContactCenterVoiceAcceptOffer"), DeclineOfferUrl = Url.RouteUrl("ContactCenterVoiceDeclineOffer"), SupervisorDashboardUrl = Url.Action(nameof(SupervisorDashboardController.Index), "SupervisorDashboard"), @@ -143,11 +150,12 @@ public async Task State() var userId = User.FindFirstValue(ClaimTypes.NameIdentifier); var now = _clock.UtcNow; + var displayName = await GetCurrentUserDisplayNameAsync(HttpContext.RequestAborted); var model = new AgentWorkspaceStateViewModel { UserId = userId, - DisplayName = User.Identity?.Name, + DisplayName = displayName, ServerTimeUtc = now, }; @@ -160,7 +168,7 @@ public async Task State() model.AgentId = profile.ItemId; model.HasProfile = true; - model.DisplayName = string.IsNullOrEmpty(profile.DisplayName) ? model.DisplayName : profile.DisplayName; + model.DisplayName = await GetUserDisplayNameAsync(profile.UserId, profile.DisplayName ?? model.DisplayName, HttpContext.RequestAborted); model.IsSignedIn = profile.QueueIds.Count > 0 || profile.CampaignIds.Count > 0; model.Presence = new WorkspacePresenceViewModel { @@ -190,7 +198,6 @@ public async Task State() model.Offer = await BuildOfferAsync(profile.ItemId, now, HttpContext.RequestAborted); model.ActiveInteraction = await BuildActiveInteractionAsync(profile, HttpContext.RequestAborted); - model.Dispositions = await BuildDispositionsAsync(HttpContext.RequestAborted); model.RecentHistory = await BuildRecentHistoryAsync(profile.ItemId, HttpContext.RequestAborted); return Json(model); @@ -271,7 +278,7 @@ public async Task Complete(string activityId, string dispositionI Notes = notes, Source = ActivityDispositionSource.Agent, ActorId = User.FindFirstValue(ClaimTypes.NameIdentifier), - ActorDisplayName = User.Identity?.Name, + ActorDisplayName = await GetCurrentUserDisplayNameAsync(HttpContext.RequestAborted), }, HttpContext.RequestAborted); if (result.Succeeded) @@ -345,6 +352,7 @@ private async Task BuildActiveInteractionAs CustomerAddress = interaction.CustomerAddress, QueueName = queue?.Name, ContactUrl = BuildContactUrl(activity), + CompleteUrl = await BuildCompleteActivityUrlAsync(activity), StartedUtc = interaction.StartedUtc, AnsweredUtc = interaction.AnsweredUtc, }; @@ -398,17 +406,6 @@ private async Task FindPendingWrapUpInteractionAsync(AgentProfile p return null; } - private async Task> BuildDispositionsAsync(CancellationToken cancellationToken) - { - var page = await _dispositionManager.PageAsync(1, 200, new QueryContext(), cancellationToken); - - return [.. page.Entries.Select(disposition => new WorkspaceLookupViewModel - { - Id = disposition.ItemId, - Name = disposition.Name, - })]; - } - private async Task> BuildRecentHistoryAsync(string agentId, CancellationToken cancellationToken) { var interactions = await _interactionManager.ListRecentByAgentAsync(agentId, _recentHistoryCount, cancellationToken); @@ -448,4 +445,62 @@ private string BuildContactUrl(OmnichannelActivity activity) return Url.Action("Edit", "Admin", new { area = "OrchardCore.Contents", contentItemId = activity.ContactContentItemId }); } + + private async Task BuildCompleteActivityUrlAsync(OmnichannelActivity activity) + { + if (activity is null || + string.IsNullOrEmpty(activity.ItemId) || + activity.Status is ActivityStatus.Completed or ActivityStatus.Cancelled or ActivityStatus.Purged || + !await _authorizationService.AuthorizeAsync(User, OmnichannelConstants.Permissions.CompleteActivity, activity)) + { + return null; + } + + return Url.Action("Complete", "Activities", new { area = OmnichannelConstants.Features.Managements, id = activity.ItemId }); + } + + private async Task GetCurrentUserDisplayNameAsync(CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(User); + + if (user is not null) + { + return await GetUserDisplayNameAsync(user, User.Identity?.Name, cancellationToken); + } + + return User.Identity?.Name; + } + + private async Task GetUserDisplayNameAsync( + string userId, + string fallback, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(userId)) + { + return fallback; + } + + var user = await _userManager.FindByIdAsync(userId); + + return await GetUserDisplayNameAsync(user, fallback, cancellationToken); + } + + private async Task GetUserDisplayNameAsync( + IUser user, + string fallback, + CancellationToken cancellationToken) + { + if (user is not null) + { + var displayName = await _displayNameProvider.GetAsync(user, cancellationToken); + + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + } + + return fallback; + } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SupervisorDashboardController.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SupervisorDashboardController.cs index 903d680a7..3fdab3b49 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SupervisorDashboardController.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Controllers/SupervisorDashboardController.cs @@ -2,14 +2,18 @@ using CrestApps.Core.Models; using CrestApps.Core.SignalR.Services; using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Hubs; using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.ContactCenter.ViewModels; +using CrestApps.OrchardCore.Users; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using OrchardCore.Admin; using OrchardCore.Modules; +using OrchardCore.Users; namespace CrestApps.OrchardCore.ContactCenter.Controllers; @@ -29,6 +33,8 @@ public sealed class SupervisorDashboardController : Controller private readonly IAgentProfileManager _agentManager; private readonly IInteractionManager _interactionManager; private readonly IContactCenterMonitoringService _monitoringService; + private readonly UserManager _userManager; + private readonly IDisplayNameProvider _displayNameProvider; private readonly HubRouteManager _hubRouteManager; private readonly IClock _clock; @@ -41,6 +47,8 @@ public sealed class SupervisorDashboardController : Controller /// The agent profile manager used to build the agent board. /// The interaction manager used to count active work per agent. /// The optional services used to start audited supervisor live-monitoring engagements. + /// The user manager used to resolve Orchard users. + /// The display name provider used to render agent full names. /// The hub route manager used to resolve the real-time hub URL. /// The clock used to compute wait times. public SupervisorDashboardController( @@ -50,6 +58,8 @@ public SupervisorDashboardController( IAgentProfileManager agentManager, IInteractionManager interactionManager, IEnumerable monitoringServices, + UserManager userManager, + IDisplayNameProvider displayNameProvider, HubRouteManager hubRouteManager, IClock clock) { @@ -59,6 +69,8 @@ public SupervisorDashboardController( _agentManager = agentManager; _interactionManager = interactionManager; _monitoringService = monitoringServices.FirstOrDefault(); + _userManager = userManager; + _displayNameProvider = displayNameProvider; _hubRouteManager = hubRouteManager; _clock = clock; } @@ -147,7 +159,7 @@ public async Task State() { AgentId = agent.ItemId, UserId = agent.UserId, - DisplayName = string.IsNullOrEmpty(agent.DisplayName) ? agent.UserName : agent.DisplayName, + DisplayName = await GetAgentDisplayNameAsync(agent), PresenceStatus = agent.PresenceStatus.ToString(), PresenceReason = agent.PresenceReason, QueueCount = agent.QueueIds.Count, @@ -205,4 +217,24 @@ public async Task Engage(string interactionId, MonitorMode mode) ErrorMessage = result.Reason, }); } + + private async Task GetAgentDisplayNameAsync(AgentProfile agent) + { + if (!string.IsNullOrEmpty(agent.UserId)) + { + var user = await _userManager.FindByIdAsync(agent.UserId); + + if (user is not null) + { + var displayName = await _displayNameProvider.GetAsync(user, HttpContext.RequestAborted); + + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + } + } + + return string.IsNullOrEmpty(agent.DisplayName) ? agent.UserName : agent.DisplayName; + } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index 6b7fe05ac..babfc55b2 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs index 91b5dad6f..1bd4333dd 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs @@ -3,7 +3,10 @@ using CrestApps.OrchardCore.ContactCenter.Hubs; using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.Users; +using Microsoft.AspNetCore.Identity; using OrchardCore.Modules; +using OrchardCore.Users; namespace CrestApps.OrchardCore.ContactCenter.Handlers; @@ -18,6 +21,8 @@ public sealed class ContactCenterRealTimeEventHandler : IContactCenterEventHandl private readonly IAgentProfileManager _agentManager; private readonly IActivityReservationManager _reservationManager; private readonly IQueueItemStore _queueItemStore; + private readonly UserManager _userManager; + private readonly IDisplayNameProvider _displayNameProvider; private readonly IClock _clock; /// @@ -27,18 +32,24 @@ public sealed class ContactCenterRealTimeEventHandler : IContactCenterEventHandl /// The agent profile manager used to resolve agents. /// The reservation manager used to resolve offers. /// The queue item store used to compute queue depth. + /// The user manager used to resolve Orchard users. + /// The display name provider used to render agent full names. /// The clock used to stamp notifications. public ContactCenterRealTimeEventHandler( IContactCenterRealTimeNotifier notifier, IAgentProfileManager agentManager, IActivityReservationManager reservationManager, IQueueItemStore queueItemStore, + UserManager userManager, + IDisplayNameProvider displayNameProvider, IClock clock) { _notifier = notifier; _agentManager = agentManager; _reservationManager = reservationManager; _queueItemStore = queueItemStore; + _userManager = userManager; + _displayNameProvider = displayNameProvider; _clock = clock; } @@ -92,7 +103,7 @@ await _notifier.NotifyPresenceChangedAsync(new AgentPresenceNotification { UserId = profile.UserId, AgentId = profile.ItemId, - DisplayName = profile.DisplayName ?? profile.UserName, + DisplayName = await GetAgentDisplayNameAsync(profile, cancellationToken), Status = profile.PresenceStatus.ToString(), Reason = profile.PresenceReason, QueueIds = [.. profile.QueueIds], @@ -126,6 +137,26 @@ await _notifier.NotifyOfferReceivedAsync(new AgentOfferNotification await BroadcastQueueStatsAsync(reservation.QueueId, cancellationToken); } + private async Task GetAgentDisplayNameAsync(AgentProfile agent, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(agent.UserId)) + { + var user = await _userManager.FindByIdAsync(agent.UserId); + + if (user is not null) + { + var displayName = await _displayNameProvider.GetAsync(user, cancellationToken); + + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + } + } + + return string.IsNullOrEmpty(agent.DisplayName) ? agent.UserName : agent.DisplayName; + } + private async Task BroadcastOfferRevokedAsync(InteractionEvent interactionEvent, AgentOfferRevokedReason reason, CancellationToken cancellationToken) { var reservation = await ResolveReservationAsync(interactionEvent.AggregateId, cancellationToken); diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs index d981db03e..938b828ab 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/ContactCenterHub.cs @@ -1,12 +1,15 @@ using CrestApps.OrchardCore.ContactCenter.Core; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Users; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using OrchardCore.Environment.Shell.Scope; using OrchardCore.Security.Permissions; +using OrchardCore.Users; namespace CrestApps.OrchardCore.ContactCenter.Hubs; @@ -70,8 +73,9 @@ await ShellScope.UsingChildScopeAsync(async scope => { var sessionService = services.GetRequiredService(); var userName = Context.User?.Identity?.Name; + var displayName = await GetDisplayNameAsync(services, userName); - var session = await sessionService.ConnectAsync(userId, Context.ConnectionId, userName, userName, Context.ConnectionAborted); + var session = await sessionService.ConnectAsync(userId, Context.ConnectionId, userName, displayName, Context.ConnectionAborted); foreach (var queueId in session.QueueIds) { @@ -217,4 +221,23 @@ private async Task AuthorizeAsync(IServiceProvider services, Permission pe return await authorizationService.AuthorizeAsync(httpContext.User, permission); } + + private async Task GetDisplayNameAsync(IServiceProvider services, string fallback) + { + var userManager = services.GetRequiredService>(); + var displayNameProvider = services.GetRequiredService(); + var user = await userManager.GetUserAsync(Context.User); + + if (user is not null) + { + var displayName = await displayNameProvider.GetAsync(user, Context.ConnectionAborted); + + if (!string.IsNullOrWhiteSpace(displayName)) + { + return displayName; + } + } + + return fallback; + } } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceIndexViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceIndexViewModel.cs index ac104ee1f..22f240334 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceIndexViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceIndexViewModel.cs @@ -42,12 +42,6 @@ public sealed class AgentWorkspaceIndexViewModel /// public string DeclineOfferUrl { get; set; } - /// - /// Gets or sets the URL that completes the active activity with a disposition. - /// - public string CompleteUrl { get; set; } - - /// /// Gets or sets the URL of the supervisor dashboard, when the current user may open it. /// public string SupervisorDashboardUrl { get; set; } diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceStateViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceStateViewModel.cs index c2e41f1aa..8881e438f 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceStateViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/AgentWorkspaceStateViewModel.cs @@ -52,12 +52,6 @@ public sealed class AgentWorkspaceStateViewModel /// public WorkspaceActiveInteractionViewModel ActiveInteraction { get; set; } - /// - /// Gets or sets the dispositions the agent can apply to complete the active work. - /// - public IList Dispositions { get; set; } = []; - - /// /// Gets or sets the agent's most recent interactions. /// public IList RecentHistory { get; set; } = []; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/WorkspaceActiveInteractionViewModel.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/WorkspaceActiveInteractionViewModel.cs index 3cf411f35..13c8adf01 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/WorkspaceActiveInteractionViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/ViewModels/WorkspaceActiveInteractionViewModel.cs @@ -46,6 +46,12 @@ public sealed class WorkspaceActiveInteractionViewModel /// public string ContactUrl { get; set; } + /// + /// Gets or sets the admin URL used to complete the linked CRM activity through the shared + /// Omnichannel completion experience. + /// + public string CompleteUrl { get; set; } + /// /// Gets or sets the UTC time work on the interaction started. /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml index 8e0ad51e4..a21d46c4d 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/AgentWorkspace/Index.cshtml @@ -13,7 +13,6 @@ ["setPresenceUrl"] = Model.SetPresenceUrl, ["acceptOfferUrl"] = Model.AcceptOfferUrl, ["declineOfferUrl"] = Model.DeclineOfferUrl, - ["completeUrl"] = Model.CompleteUrl, ["antiForgeryToken"] = antiForgeryToken, ["strings"] = new Dictionary { @@ -29,11 +28,7 @@ ["status"] = T["Status"].Value, ["talkTime"] = T["Talk time"].Value, ["openContact"] = T["Open customer record"].Value, - ["disposition"] = T["Disposition"].Value, - ["selectDisposition"] = T["Select a disposition..."].Value, - ["notes"] = T["Wrap-up notes (optional)"].Value, - ["completeWork"] = T["Complete & wrap up"].Value, - ["completeFailed"] = T["The work could not be completed."].Value, + ["completeWork"] = T["Complete activity"].Value, ["acceptFailed"] = T["The offer could not be accepted. It may have been re-offered."].Value, ["declineFailed"] = T["The offer could not be declined. Refresh the workspace and try again."].Value, ["noHistory"] = T["No recent interactions."].Value, diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/agent-workspace.js b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/agent-workspace.js index 3ab068b0d..200c25511 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/agent-workspace.js +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/agent-workspace.js @@ -87,7 +87,6 @@ var serverOffsetMs = 0; var activeSignature = null; var offerSignature = null; - var activeDrafts = {}; var refs = { presence: root.querySelector('[data-cc-presence]'), @@ -124,12 +123,7 @@ render(data); } }) - .catch(function () { - if (error) { - error.textContent = label('completeFailed', 'The work could not be completed.'); - error.hidden = false; - } - }); + .catch(function () { }); } function render(data) { @@ -236,8 +230,6 @@ return; } - saveActiveDraft(); - activeSignature = signature; if (!active) { @@ -251,10 +243,6 @@ } var inbound = active.direction === 'Inbound'; - var dispositions = (state.dispositions || []).map(function (item) { - return ''; - }).join(''); - refs.active.innerHTML = '
    ' + '
    ' + @@ -272,63 +260,11 @@ '
    ' + escapeHtml(label('status', 'Status')) + '
    ' + escapeHtml(active.status) + '
    ' + '
    ' + escapeHtml(label('talkTime', 'Talk time')) + '
    0:00
    ' + '
    ' + - (active.contactUrl ? ' ' + escapeHtml(label('openContact', 'Open customer record')) + '' : '') + - '
    ' + - '' + - '' + - '' + - '' + - '' + + '' + '
    '; - - var completeButton = refs.active.querySelector('[data-cc-complete]'); - - if (completeButton) { - completeButton.addEventListener('click', function () { complete(active.activityItemId); }); - } - - restoreActiveDraft(active.interactionId); - } - - function saveActiveDraft() { - if (!state || !state.activeInteraction || !refs.active) { - return; - } - - var select = refs.active.querySelector('[data-cc-disposition]'); - var notes = refs.active.querySelector('[data-cc-notes]'); - - if (!select && !notes) { - return; - } - - activeDrafts[state.activeInteraction.interactionId] = { - dispositionId: select ? select.value : '', - notes: notes ? notes.value : '' - }; - } - - function restoreActiveDraft(interactionId) { - var draft = activeDrafts[interactionId]; - - if (!draft || !refs.active) { - return; - } - - var select = refs.active.querySelector('[data-cc-disposition]'); - var notes = refs.active.querySelector('[data-cc-notes]'); - - if (select) { - select.value = draft.dispositionId || ''; - } - - if (notes) { - notes.value = draft.notes || ''; - } } function renderHistory() { @@ -442,36 +378,6 @@ }); } - function complete(activityId) { - var select = refs.active.querySelector('[data-cc-disposition]'); - var notes = refs.active.querySelector('[data-cc-notes]'); - var error = refs.active.querySelector('[data-cc-error]'); - - post(config.completeUrl, config.antiForgeryToken, { - activityId: activityId, - dispositionId: select ? select.value : '', - notes: notes ? notes.value : '' - }) - .then(function (response) { return response.ok ? response.json() : { succeeded: false }; }) - .then(function (result) { - if (result && result.succeeded) { - if (state && state.activeInteraction) { - delete activeDrafts[state.activeInteraction.interactionId]; - } - - activeSignature = null; - - return refresh(); - } - - if (error) { - error.textContent = (result && result.errorMessage) || label('completeFailed', 'The work could not be completed.'); - error.hidden = false; - } - }) - .catch(function () { }); - } - function setPresence(status, reason) { post(config.setPresenceUrl, config.antiForgeryToken, { status: status, reason: reason || '' }) .then(function () { return refresh(); }) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index 638ec4a6f..59b5d758c 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -510,7 +510,8 @@ public async Task CompleteAsync(string id) { var activity = await _omnichannelActivityManager.FindByIdAsync(id); - if (activity is null || activity.Status != ActivityStatus.NotStated) + if (activity is null || + activity.Status is ActivityStatus.Completed or ActivityStatus.Cancelled or ActivityStatus.Purged) { return NotFound(); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/OmnichannelActivityAuthorizationHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/OmnichannelActivityAuthorizationHandler.cs new file mode 100644 index 000000000..0b991b4dc --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/OmnichannelActivityAuthorizationHandler.cs @@ -0,0 +1,76 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Security; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Handlers; + +internal sealed class OmnichannelActivityAuthorizationHandler : AuthorizationHandler +{ + private readonly IServiceProvider _serviceProvider; + + private IAuthorizationService _authorizationService; + + public OmnichannelActivityAuthorizationHandler(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + protected override async Task HandleRequirementAsync(AuthorizationHandlerContext context, PermissionRequirement requirement) + { + if (context.HasSucceeded) + { + return; + } + + if (requirement.Permission != OmnichannelConstants.Permissions.CompleteActivity) + { + return; + } + + var activity = GetActivity(context.Resource); + + if (activity is null || !OwnsActivity(context.User, activity)) + { + return; + } + + _authorizationService ??= _serviceProvider.GetService(); + + if (_authorizationService is not null && + await _authorizationService.AuthorizeAsync(context.User, OmnichannelConstants.Permissions.CompleteOwnActivity)) + { + context.Succeed(requirement); + } + } + + private static OmnichannelActivity GetActivity(object resource) + { + if (resource is OmnichannelActivity activity) + { + return activity; + } + + if (resource is OmnichannelActivityContainer container) + { + return container.Activity; + } + + return null; + } + + private static bool OwnsActivity(ClaimsPrincipal user, OmnichannelActivity activity) + { + var userId = user.FindFirstValue(ClaimTypes.NameIdentifier); + + if (string.IsNullOrEmpty(userId)) + { + return false; + } + + return string.Equals(activity.AssignedToId, userId, StringComparison.Ordinal) || + string.Equals(activity.ReservedById, userId, StringComparison.Ordinal); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs index 5c010a9fe..f9d2ab982 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs @@ -10,6 +10,7 @@ internal sealed class PermissionProvider : IPermissionProvider [ OmnichannelConstants.Permissions.ListActivities, OmnichannelConstants.Permissions.ListContactActivities, + OmnichannelConstants.Permissions.CompleteOwnActivity, ]; private readonly IEnumerable _allPermissions = @@ -17,6 +18,7 @@ internal sealed class PermissionProvider : IPermissionProvider OmnichannelConstants.Permissions.ListActivities, OmnichannelConstants.Permissions.ListContactActivities, OmnichannelConstants.Permissions.CompleteActivity, + OmnichannelConstants.Permissions.CompleteOwnActivity, OmnichannelConstants.Permissions.ManageActivities, OmnichannelConstants.Permissions.ManageDispositions, OmnichannelConstants.Permissions.ManageCampaigns, diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 953c6c1a4..28c94d2c1 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -17,6 +17,7 @@ using CrestApps.OrchardCore.Omnichannel.Managements.Services; using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels; using CrestApps.OrchardCore.PhoneNumbers.Core; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; @@ -163,6 +164,7 @@ public override void ConfigureServices(IServiceCollection services) }); services.AddPermissionProvider(); + services.AddScoped(); services.AddNavigationProvider(); services diff --git a/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs b/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs index f881ea2b3..0a14a8562 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs +++ b/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs @@ -188,9 +188,12 @@ static ResourceManagementOptionsConfiguration() "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/css/bootstrap-select.min.css", "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/css/bootstrap-select.css") .SetCdn( - "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/css/bootstrap-select.min.css", - "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/css/bootstrap-select.css") - .SetVersion("1.2.0"); + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.1/dist/css/bootstrap-select.min.css", + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.1/dist/css/bootstrap-select.css") + .SetCdnIntegrity( + "sha384-7yj05Iamc4rfesLo160W11ywhuIME+BcB43waDx0Y1rI1LEyCK368ml0MI9Lwzr7", + "sha384-jN5x01dkUiVJwxGy3x+e/53AB0jN9D9bBomsrbCJehtMMCIK5vOzd7aTNXV3/bze") + .SetVersion("1.2.1"); _manifest .DefineScript("crestapps-bootstrap-select") @@ -198,10 +201,13 @@ static ResourceManagementOptionsConfiguration() "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/js/bootstrap-select.min.js", "~/CrestApps.OrchardCore.Resources/vendors/crestapps/bootstrap-select/js/bootstrap-select.js") .SetCdn( - "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/js/bootstrap-select.min.js", - "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.0/dist/js/bootstrap-select.js") + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.1/dist/js/bootstrap-select.min.js", + "https://cdn.jsdelivr.net/npm/@crestapps/bootstrap-select@1.2.1/dist/js/bootstrap-select.js") + .SetCdnIntegrity( + "sha384-R7aQ+h/YwfYWxwVbXnRupt5dDCEB+GFE5kGRXdMRqqTb8a44gKz4FhiTVCIl1utj", + "sha384-OoRqHGsY2f8cxSKpRli5U/ioHYo5CO4Nqz9+jMhs/Ah3slKOn36MzgaO3Dixk2D0") .SetDependencies("bootstrap") - .SetVersion("1.2.0"); + .SetVersion("1.2.1"); _manifest .DefineStyle("intl-tel-input") diff --git a/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json b/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json index 4eaaca719..73832c964 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json @@ -7,7 +7,7 @@ "name": "crestapps.orchardcore.resources", "dependencies": { "@crestapps/ai-chat-ui": "1.0.0-rc.3", - "@crestapps/bootstrap-select": "1.2.0", + "@crestapps/bootstrap-select": "1.2.1", "chart.js": "^4.5.1", "intl-tel-input": "29.1.0" }, @@ -33,9 +33,9 @@ } }, "node_modules/@crestapps/bootstrap-select": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@crestapps/bootstrap-select/-/bootstrap-select-1.2.0.tgz", - "integrity": "sha512-Wbizy8hRrg/y3/bkVlcarJ3jHK8RrzhRNFljmgrVjEboeOV02g1pCV7eTheZ9+tDQArZZE+4M3F14luq9hgoFQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@crestapps/bootstrap-select/-/bootstrap-select-1.2.1.tgz", + "integrity": "sha512-FooJlmDj070lsg38Nc93223k+VrUOXA/INjtiqO45Ho3nmtkl5vn1BTlDuZqD6GZSzdv+Tq2YrlV+d/CTy0X6A==", "engines": { "node": ">=20.19.0" }, diff --git a/src/Modules/CrestApps.OrchardCore.Resources/package.json b/src/Modules/CrestApps.OrchardCore.Resources/package.json index ab779b467..d1be7840b 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/package.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@crestapps/ai-chat-ui": "1.0.0-rc.3", - "@crestapps/bootstrap-select": "1.2.0", + "@crestapps/bootstrap-select": "1.2.1", "chart.js": "^4.5.1", "intl-tel-input": "29.1.0" }, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs index 5fc15bc00..01203263b 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterRealTimeEventHandlerTests.cs @@ -5,8 +5,11 @@ using CrestApps.OrchardCore.ContactCenter.Hubs; using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.ContactCenter.Services; +using CrestApps.OrchardCore.Users; +using Microsoft.AspNetCore.Identity; using Moq; using OrchardCore.Modules; +using OrchardCore.Users; namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; @@ -188,6 +191,34 @@ private static ContactCenterRealTimeEventHandler CreateHandler( (agentManager ?? new Mock()).Object, (reservationManager ?? new Mock()).Object, (queueItemStore ?? new Mock()).Object, + MockUserManager().Object, + MockDisplayNameProvider().Object, clock.Object); } + + private static Mock MockDisplayNameProvider() + { + var displayNameProvider = new Mock(); + displayNameProvider + .Setup(provider => provider.GetAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync((IUser user, CancellationToken _) => user?.UserName); + + return displayNameProvider; + } + + private static Mock> MockUserManager() + { + var store = new Mock>(); + + return new Mock>( + store.Object, + null, + null, + null, + null, + null, + null, + null, + null); + } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/OmnichannelActivityAuthorizationHandlerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/OmnichannelActivityAuthorizationHandlerTests.cs new file mode 100644 index 000000000..2e1e45a67 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Handlers/OmnichannelActivityAuthorizationHandlerTests.cs @@ -0,0 +1,128 @@ +using System.Security.Claims; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Handlers; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OrchardCore.Security; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Handlers; + +public sealed class OmnichannelActivityAuthorizationHandlerTests +{ + [Fact] + public async Task HandleAsync_WhenUserCanCompleteOwnAssignedActivity_ShouldSucceed() + { + // Arrange + var handler = CreateHandler(authorizeOwnActivity: true); + var user = CreatePrincipal("user-1"); + var activity = new OmnichannelActivity + { + AssignedToId = "user-1", + }; + var context = CreateContext(user, activity); + + // Act + await handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_WhenUserCanCompleteOwnReservedActivity_ShouldSucceed() + { + // Arrange + var handler = CreateHandler(authorizeOwnActivity: true); + var user = CreatePrincipal("user-1"); + var activity = new OmnichannelActivity + { + ReservedById = "user-1", + }; + var context = CreateContext(user, activity); + + // Act + await handler.HandleAsync(context); + + // Assert + Assert.True(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_WhenUserDoesNotOwnActivity_ShouldNotSucceed() + { + // Arrange + var handler = CreateHandler(authorizeOwnActivity: true); + var user = CreatePrincipal("user-1"); + var activity = new OmnichannelActivity + { + AssignedToId = "user-2", + ReservedById = "user-3", + }; + var context = CreateContext(user, activity); + + // Act + await handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + [Fact] + public async Task HandleAsync_WhenUserLacksCompleteOwnPermission_ShouldNotSucceed() + { + // Arrange + var handler = CreateHandler(authorizeOwnActivity: false); + var user = CreatePrincipal("user-1"); + var activity = new OmnichannelActivity + { + AssignedToId = "user-1", + }; + var context = CreateContext(user, activity); + + // Act + await handler.HandleAsync(context); + + // Assert + Assert.False(context.HasSucceeded); + } + + private static OmnichannelActivityAuthorizationHandler CreateHandler(bool authorizeOwnActivity) + { + var authorizationService = new Mock(); + authorizationService + .Setup(service => service.AuthorizeAsync( + It.IsAny(), + It.IsAny(), + It.IsAny>())) + .ReturnsAsync(authorizeOwnActivity + ? AuthorizationResult.Success() + : AuthorizationResult.Failed()); + + var services = new ServiceCollection() + .AddSingleton(authorizationService.Object) + .BuildServiceProvider(); + + return new OmnichannelActivityAuthorizationHandler(services); + } + + private static AuthorizationHandlerContext CreateContext(ClaimsPrincipal user, OmnichannelActivity activity) + { + var requirement = new PermissionRequirement(OmnichannelConstants.Permissions.CompleteActivity); + + return new AuthorizationHandlerContext( + [requirement], + user, + activity); + } + + private static ClaimsPrincipal CreatePrincipal(string userId) + { + return new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, userId), + ], + authenticationType: "Test")); + } +} From 5b75a4523d2a2c02ad5957771b1bd0824860fd67 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 1 Jul 2026 12:37:09 -0700 Subject: [PATCH 32/56] [Fix] Improve live dashboard agent status Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 2 +- .../wwwroot/scripts/supervisor-dashboard.js | 2 +- .../wwwroot/styles/contact-center-workspace.css | 9 ++++++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 19ff3f988..2e63c2d16 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -184,7 +184,7 @@ At a high level, the platform changes are: - 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. +- 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). diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js index 0aa594e3f..6de2a4698 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/scripts/supervisor-dashboard.js @@ -160,7 +160,7 @@ '' + '' + '' + escapeHtml(agent.displayName || agent.userId) + '' + - '' + escapeHtml(detail) + '' + + '' + escapeHtml(detail) + '' + '' + '' + agent.activeInteractions + '' + actions + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/styles/contact-center-workspace.css b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/styles/contact-center-workspace.css index fea8d325f..4225c5934 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/styles/contact-center-workspace.css +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/wwwroot/styles/contact-center-workspace.css @@ -473,6 +473,10 @@ } .cc-agent__body { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.35rem; min-width: 0; flex: 1 1 auto; } @@ -485,8 +489,11 @@ } .cc-agent__state { + display: inline-flex; + max-width: 100%; font-size: 0.78rem; - color: var(--bs-secondary-color, #6c757d); + line-height: 1.2; + white-space: normal; } .cc-badge-count { From 03d5ff844e6f47cb096b3eaaa61fc50e23d8b958 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Wed, 1 Jul 2026 13:12:37 -0700 Subject: [PATCH 33/56] [Fix] Align select picker styling Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 2 +- .../Views/ActivityQueueFields.Edit.cshtml | 8 ++++---- .../Views/ContactCenterEntryPointFields.Edit.cshtml | 6 +++--- .../Views/DialerProfileFields.Edit.cshtml | 6 +++--- .../Views/Items/ContactCenterSoftPhoneWork.View.cshtml | 4 ++-- .../Views/ContentTransferEntriesAdminListFilters.cshtml | 4 ++-- .../ListContentTransferEntriesAdminListFilters.cshtml | 4 ++-- .../Views/BulkManageActivityFilterFields.Edit.cshtml | 2 +- .../Views/OmnichannelActivityBatchFields.Edit.cshtml | 2 +- 9 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 2e63c2d16..f32171275 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -506,7 +506,7 @@ At a high level, the platform changes are: ### Resources -- `crestapps-bootstrap-select` now references `@crestapps/bootstrap-select` `1.2.1` for both local package assets and CDN resources, with CDN integrity hashes configured for the minified and debug CSS/JavaScript files. +- `crestapps-bootstrap-select` now references `@crestapps/bootstrap-select` `1.2.1` for both local package assets and CDN resources, with CDN integrity hashes configured for the minified and debug CSS/JavaScript files. Select picker views now avoid overriding the package's default `btn-light` style unless they only add the small button size, so local pickers match the package examples. ### Telephony diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml index 86bf5b55c..cacde4e58 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ActivityQueueFields.Edit.cshtml @@ -85,7 +85,7 @@
    - + @T["Agents must have every selected skill to receive work from this queue."]
    @@ -94,7 +94,7 @@
    - @@ -105,7 +105,7 @@
    - @@ -128,7 +128,7 @@
    - diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml index 1498a96d6..e33d6937a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/ContactCenterEntryPointFields.Edit.cshtml @@ -31,7 +31,7 @@
    - @@ -55,7 +55,7 @@
    - @@ -80,7 +80,7 @@
    - diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml index 4104ee1fb..c76b7d4d1 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/DialerProfileFields.Edit.cshtml @@ -22,7 +22,7 @@
    - @@ -33,7 +33,7 @@
    - @@ -58,7 +58,7 @@
    - diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml index 808cba701..47dfaee7b 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml @@ -35,7 +35,7 @@
    - @foreach (var queue in viewModel.AvailableQueues) { @@ -45,7 +45,7 @@
    - @foreach (var option in viewModel.CampaignOptions) { diff --git a/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ContentTransferEntriesAdminListFilters.cshtml b/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ContentTransferEntriesAdminListFilters.cshtml index dd1b718b9..2b20b54de 100644 --- a/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ContentTransferEntriesAdminListFilters.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ContentTransferEntriesAdminListFilters.cshtml @@ -1,8 +1,8 @@ @model ListContentTransferEntryOptions
    - - +
    diff --git a/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ListContentTransferEntriesAdminListFilters.cshtml b/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ListContentTransferEntriesAdminListFilters.cshtml index dd1b718b9..2b20b54de 100644 --- a/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ListContentTransferEntriesAdminListFilters.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContentTransfer/Views/ListContentTransferEntriesAdminListFilters.cshtml @@ -1,8 +1,8 @@ @model ListContentTransferEntryOptions
    - - +
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml index 6fa368ddb..727049da7 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml @@ -36,7 +36,7 @@
    -
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml index 13f1b9129..0fc90bd06 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml @@ -243,7 +243,7 @@
    - @T["Optionally, only include contacts in the selected time zones."]
    From 518d9531f4aed413d9d1a041ea8f33307b612052 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 2 Jul 2026 10:48:11 -0700 Subject: [PATCH 34/56] Add Reporting Module --- .github/contact-center/PLAN.md | 5 +- CrestApps.OrchardCore.slnx | 2 + .../ContactCenterConstants.cs | 5 + ...ps.OrchardCore.Reports.Abstractions.csproj | 20 + .../IReport.cs | 46 ++ .../IReportExportFormat.cs | 38 ++ .../Models/ReportBar.cs | 43 ++ .../Models/ReportColumn.cs | 35 ++ .../Models/ReportColumnAlign.cs | 22 + .../Models/ReportDocument.cs | 39 ++ .../Models/ReportFilter.cs | 27 + .../Models/ReportFormat.cs | 60 ++ .../Models/ReportMetric.cs | 42 ++ .../Models/ReportRow.cs | 36 ++ .../Models/ReportSection.cs | 93 +++ .../Models/ReportSectionKind.cs | 22 + .../ReportContext.cs | 48 ++ .../ReportsConstants.cs | 33 + .../ContactCenterPermissions.cs | 5 + .../Models/Reports/ActivityProgressCounts.cs | 59 ++ .../Models/Reports/AgentProductivityReport.cs | 23 + .../Models/Reports/AgentProductivityRow.cs | 47 ++ .../Models/Reports/CallInsightsDailyPoint.cs | 27 + .../Models/Reports/CallInsightsReport.cs | 110 ++++ .../Models/Reports/CampaignSummaryReport.cs | 28 + .../Models/Reports/CampaignSummaryRow.cs | 23 + .../Reports/ContactCenterReportCount.cs | 18 + .../Models/Reports/QueueUsageReport.cs | 23 + .../Models/Reports/QueueUsageRow.cs | 53 ++ .../Models/Reports/SubjectInventoryReport.cs | 28 + .../Models/Reports/SubjectInventoryRow.cs | 18 + .../Services/ContactCenterReportingService.cs | 570 ++++++++++++++++++ .../IContactCenterReportingService.cs | 57 ++ .../Models/OmnichannelActivity.cs | 5 + .../Models/OmnichannelActivityBatch.cs | 6 + .../OmnichannelConstants.cs | 10 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 10 +- .../contact-center/agents-queues-dialer.md | 2 + .../docs/contact-center/index.md | 48 ++ src/CrestApps.Docs/docs/modules/index.md | 1 + src/CrestApps.Docs/docs/modules/reports.md | 107 ++++ src/CrestApps.Docs/docs/omnichannel/index.md | 16 + .../docs/omnichannel/management.md | 4 +- ...CrestApps.OrchardCore.ContactCenter.csproj | 1 + .../Manifest.cs | 13 + .../AgentProductivityReportProvider.cs | 64 ++ .../Reports/CallInsightsReportProvider.cs | 95 +++ .../Reports/CampaignSummaryReportProvider.cs | 67 ++ .../Reports/ContactCenterReportBase.cs | 56 ++ .../Reports/ContactCenterReportCells.cs | 56 ++ .../Reports/QueueUsageReportProvider.cs | 68 +++ .../Reports/SubjectInventoryReportProvider.cs | 55 ++ .../ContactCenterPermissionProvider.cs | 2 + .../Startup.cs | 23 + .../Controllers/ActivityBatchesController.cs | 3 + ...OrchardCore.Omnichannel.Managements.csproj | 5 + .../OmnichannelActivityBatchDisplayDriver.cs | 69 ++- .../Manifest.cs | 13 + .../Reports/OmnichannelActivityDailyPoint.cs | 17 + .../Reports/OmnichannelActivitySummaryData.cs | 32 + .../OmnichannelCampaignPerformanceData.cs | 17 + .../Models/Reports/OmnichannelCampaignRow.cs | 17 + .../Reports/OmnichannelProgressCounts.cs | 53 ++ .../Reports/ActivitySummaryReportProvider.cs | 95 +++ .../CampaignPerformanceReportProvider.cs | 103 ++++ .../DispositionBreakdownReportProvider.cs | 88 +++ .../Reports/OmnichannelReportBase.cs | 46 ++ .../Services/OmnichannelReportAggregator.cs | 155 +++++ .../Services/OmnichannelReportQuery.cs | 56 ++ .../Services/PermissionProvider.cs | 1 + .../Startup.cs | 17 + .../OmnichannelActivityBatchViewModel.cs | 11 + .../Views/Activities/Complete.cshtml | 19 +- ...BulkManageActivityFilterFields.Edit.cshtml | 3 +- ...ompleteOmnichannelActivityContainer.cshtml | 43 +- ...OmnichannelActivityBatchFields.Edit.cshtml | 17 +- .../Handlers/SmsOmnichannelEventHandler.cs | 10 +- .../Services/SmsOmnichannelProcessor.cs | 8 +- .../Controllers/ReportsController.cs | 174 ++++++ .../CrestApps.OrchardCore.Reports.csproj | 36 ++ .../ReportDateRangeFilterDisplayDriver.cs | 36 ++ .../CrestApps.OrchardCore.Reports/Manifest.cs | 19 + .../Services/CsvReportExportFormat.cs | 122 ++++ .../Services/IReportExportManager.cs | 20 + .../Services/IReportManager.cs | 20 + .../Services/ReportExportManager.cs | 49 ++ .../Services/ReportManager.cs | 49 ++ .../Services/ReportsAdminMenu.cs | 70 +++ .../CrestApps.OrchardCore.Reports/Startup.cs | 28 + .../ReportDateRangeFilterViewModel.cs | 17 + .../ViewModels/ReportDisplayViewModel.cs | 36 ++ .../ViewModels/ReportsIndexViewModel.cs | 12 + .../Views/ReportDateRangeFilter.Edit.cshtml | 10 + .../Views/ReportFilter.Edit.cshtml | 1 + .../Views/Reports/Display.cshtml | 39 ++ .../Views/Reports/Index.cshtml | 29 + .../Views/Reports/_ReportDocument.cshtml | 93 +++ .../Views/_ViewImports.cshtml | 8 + ...stApps.OrchardCore.Cms.Core.Targets.csproj | 1 + .../ContactCenterReportingServiceTests.cs | 267 ++++++++ .../OmnichannelReportAggregatorTests.cs | 118 ++++ 101 files changed, 4531 insertions(+), 35 deletions(-) create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/CrestApps.OrchardCore.Reports.Abstractions.csproj create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReport.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReportExportFormat.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportBar.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumn.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumnAlign.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportDocument.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFilter.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFormat.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportMetric.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportRow.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSection.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSectionKind.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportContext.cs create mode 100644 src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ActivityProgressCounts.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityReport.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityRow.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsDailyPoint.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsReport.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryReport.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryRow.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/ContactCenterReportCount.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageReport.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageRow.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryReport.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/SubjectInventoryRow.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterReportingService.cs create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterReportingService.cs create mode 100644 src/CrestApps.Docs/docs/modules/reports.md create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/AgentProductivityReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CallInsightsReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CampaignSummaryReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportBase.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportCells.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/QueueUsageReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/SubjectInventoryReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivityDailyPoint.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivitySummaryData.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignPerformanceData.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignRow.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelProgressCounts.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/ActivitySummaryReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/CampaignPerformanceReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/DispositionBreakdownReportProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportAggregator.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportQuery.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Drivers/ReportDateRangeFilterDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Manifest.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Services/ReportExportManager.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Services/ReportsAdminMenu.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Startup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDateRangeFilterViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportsIndexViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Views/ReportDateRangeFilter.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Views/ReportFilter.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Index.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/_ReportDocument.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.Reports/Views/_ViewImports.cshtml create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterReportingServiceTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Reports/OmnichannelReportAggregatorTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 6e4340253..7551ea31d 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1488,7 +1488,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [~] Phase 9 — Recording and live monitoring (**orchestration shipped 2026-07-01:** `IContactCenterRecordingService` tracks `Interaction.RecordingState` (none/recording/paused/stopped) with `Recording*` events; `IContactCenterMonitoringService.EngageAsync` performs scoped, audited monitor/whisper/barge/take-over gated by new `Recording`/`Monitor`/`Whisper`/`Barge`/`TakeOver` provider capability flags, publishing `SupervisorMonitorStarted`. Remaining: provider media execution of recording + monitoring, consent/retention policy, recording storage/access audit, and quality-management scorecards.) - [~] Phase 10 — Outbound compliance hardening (**callbacks shipped 2026-07-01:** `CallbackRequest` model + store/manager + `ICallbackService` schedule/promote-due + per-minute `CallbackDispatchBackgroundTask` that turns due callbacks into outbound `Callback`-source activities and enqueues them. Remaining: abandonment-rate caps, answering-machine detection outcomes, calling-window calendars, and predictive metrics before Predictive can be re-enabled.) - [x] Phase 11 — Optional Workflow bridge (**shipped 2026-07-01:** feature-gated `[RequireFeatures("OrchardCore.Workflows")]` bridge — a `ContactCenterEvent` workflow event activity (optional event-type filter; Matched/Ignored outcomes; exposes event type + interaction/aggregate/actor as input) and a `ContactCenterWorkflowEventHandler` that triggers it for every published domain event via `IWorkflowManager`.) -- [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. Remaining: per-queue/agent/campaign breakdowns, SLA/adherence snapshots, wallboard/report UI, and CSV/export surfaces.) +- [~] Phase 12 — Analytics and operations (**daily event-metrics projection shipped 2026-07-01:** `ContactCenterEventMetric` + `IContactCenterMetricsService` (record + range summary) + `ContactCenterMetricsProjectionHandler` projecting every domain event into rebuildable per-day, per-event-type counts in the base feature. **Reusable Reports framework shipped 2026-07-02:** new `CrestApps.OrchardCore.Reports` module (`IReport` registry + display-driver-extensible `ReportFilter` with a from/to range + uniform `ReportDocument` renderer + pluggable `IReportExportFormat`/CSV) surfaced under a single top-level **Reports** admin menu grouped by category. The five Contact Center reports were migrated into this framework (moved off Interaction Center) and a new **Omnichannel Reports** feature adds CRM reports — activity summary, campaign performance, and disposition breakdown. Remaining: SLA/adherence trend snapshots, operational alerts, and per-agent self-service report scoping.) - [~] Phase 13 — Scale-out, resilience and data governance (**event retention shipped 2026-07-01:** `IContactCenterRetentionService` batch-purges interaction events older than a cutoff, driven by a daily `ContactCenterRetentionBackgroundTask` from `ContactCenterRetentionOptions.InteractionEventRetentionDays` (config-bound; 0 = keep forever). Multi-node safety via per-queue/per-user distributed locks already shipped in G2. Remaining: SignalR backplane guidance/config, projection rebuild tooling, PII redaction + right-to-erasure, and retention for call sessions/metrics/recordings.) - [~] Phase 14 — Advanced capabilities (**AI assist seam shipped 2026-07-01:** `IContactCenterAssistProvider` + `IContactCenterAssistService` orchestrate optional summarization + disposition-suggestion providers by order, decoupled from any specific AI provider. Remaining: concrete AI provider (summaries/disposition/sentiment) wired to the AI module, virtual-agent handoff, and AI routing recommendations.) @@ -1539,5 +1539,8 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **Phase 14 started — AI assist seam.** Added a pluggable, provider-agnostic assist seam: `IContactCenterAssistProvider` (`SuggestDispositionAsync`/`SummarizeAsync`, ordered) + `AssistContext`/`DispositionSuggestion` models, and `IContactCenterAssistService`/`ContactCenterAssistService` that orchestrates registered providers by order and returns the first result (`IsAvailable` reflects whether any provider is installed). Registered in the base feature; the Contact Center stays decoupled from any specific AI provider. +4 tests for **139 ContactCenter tests passing**; clean `-warnaserror` build; changelog updated. Remaining Phase 14: a concrete AI provider wired to the AI module (summaries/disposition/sentiment), virtual-agent handoff, AI routing recommendations. Remaining overall: G5 agent-desktop/supervisor UI, G7 skill proficiency, G4 abandonment/AMD. - 2026-07-01: **DialPad completed as the Contact Center phone provider (inbound + outbound).** Outbound already routed through `DialAsync`; added the missing inbound path so agents can both make and take calls through DialPad. New `DialPadWebhookController` (`POST /api/dialpad/webhook/call`, `[AllowAnonymous]`, DialPad Contact Center Voice feature) validates the DialPad HS256-signed webhook JWT with a new protected **Webhook signing secret** setting (`DialPadJwtValidator` — no external JWT dependency; unsigned JSON accepted only when no secret is configured), parses the `DialPadCallEvent` payload, and `DialPadWebhookService` normalizes it: state changes go to `IProviderVoiceEventService.IngestAsync` (updates interaction + call session), and a new inbound live call with no matching interaction is routed via `IVoiceContactCenterCallRouter.RouteInboundAsync` (creates activity+interaction, resolves entry point/queue, offers to an agent). Advertised the `CallTransfer` capability. Added the webhook secret to DialPad settings (view model + protected driver + view). +9 DialPad tests (JWT validate/tamper/secret + webhook route/update/ignore) — clean full-solution `-warnaserror` build. Docs (`telephony/dialpad.md`, `v2.0.0` changelog) updated. This realizes the per-provider signed webhook adapter goal (P1 #18 / G1 inbound) for DialPad and gives Phase 8 entry points a real inbound provider. - 2026-07-01: **G5 agent desktop + supervisor dashboard shipped (P1 #15, #16); G1 completed; obsolete dialer-provider code removed.** Built the CRM-integrated **Agent Workspace** in the `RealTime` feature: `AgentWorkspaceController` (`Index` page + `State` JSON snapshot + `SetPresence` + `Complete`), 8 workspace view models, `Views/AgentWorkspace/Index.cshtml`, the hand-written static `agent-workspace.js` (binds to `ContactCenterHub` via the shared `contact-center-realtime` helper + the `State` endpoint), and `styles/contact-center-workspace.css`. The desktop shows presence with reason-code control, live queue chips, the ringing **offer card** (countdown + one-click **Accept** → the existing `ContactCenterVoiceAcceptOffer` accept+connect command, or **Decline** → re-offer), the **active-interaction** panel (live talk timer + customer 360 link), an inline **disposition + notes wrap-up** that completes through the source-neutral `IActivityDispositionService`, and recent history — with change-detection rendering so a live refresh never clobbers an in-progress disposition/notes entry. Added a **Supervisor Dashboard** (`SupervisorDashboardController` + `supervisor-dashboard.js`) at **Interaction Center → Live dashboard** (gated by `MonitorContactCenter`): live summary metrics, per-queue SLA-health tiles (waiting / longest wait / SLA breaches), and an agent presence board. Added `IInteractionStore`/`IInteractionManager.FindActiveByAgentAsync` + `ListRecentByAgentAsync` (indexed), a `ContactCenterRealTimeAdminMenu` (**My workspace** + **Live dashboard**), and registered the two scripts + the stylesheet as named resources. **G1 completed:** the Telephony soft-phone `answerIncoming` now awaits the Contact Center accept and only answers the agent device when the accepted offer reports `RequiresDeviceAnswer`, closing the P0 #2 "two uncoordinated actions" race; asset rebuilt (`soft-phone.min.js`). **Obsolete code removed (maintainer-approved cleanup of this not-yet-merged module):** deleted the superseded outbound `IDialerProvider`/`IDialerProviderResolver`/`DialerProviderResolver` chain and its `DialerDialRequest`/`DialerDialResult`/`DialerProviderCapabilities` models (outbound already routes through `IContactCenterVoiceProvider`, leaving a single provider boundary), removed the DialPad `IDialerProvider` implementation + registration, and dropped the orphaned `ContactCenterConstants.Components.WrapUp` constant. All **139 ContactCenter tests pass**; clean full-solution `-warnaserror` build. Docs (`contact-center/index.md`, `contact-center/agent-desktop.md`, `agents-queues-dialer.md`, `v2.0.0` changelog) updated. **Remaining for Phase 7/G5:** scoped/audited supervisor live call-control intents (monitor/whisper/barge/take-over UI on top of `IContactCenterMonitoringService`) and a reason-code deployment-plan step. +- 2026-07-02: **Contact Center admin documentation clarified.** Expanded `src\CrestApps.Docs\docs\contact-center\index.md` with an Interaction Center admin-menu concept table explaining agent states, agents, business hours, campaigns, channel endpoints, entry points, queues, skills, dialer profiles, My workspace, and Live dashboard with examples. This was documentation-only for Contact Center; no Contact Center domain code changed. - 2026-07-01: **Contact Center code review hardening pass.** Fixed the highest-risk orchestration gaps found during review without adding new domain aggregates: offer accept/decline now verifies the reservation belongs to the current agent before changing state; failed server-side provider connection after accept compensates by canceling the accepted reservation so the queue/agent/activity are not stranded; successful outbound dial attempts accept their reservation so the expiry task cannot later release a live call; provider terminal call events move agents into `WrapUp`; workspace completion verifies the active/wrap-up interaction belongs to the current agent and then releases presence to the requested state or availability; the Agent Workspace preserves disposition/notes drafts across status-refresh rerenders and surfaces offer failures; and the Supervisor Dashboard now exposes provider-gated **Monitor**, **Whisper**, **Barge**, and **Take over** actions backed by the existing audited monitoring service. Docs were expanded with manager runbooks for entry points, outbound/callback operations, and workflow automation. Remaining: browser coverage, reason-code deployment-plan step, projection rebuild tooling, multi-step IVR, advanced outbound compliance, and quality-management features. - 2026-07-01: **PR review security hardening.** Addressed the CodeQL logging findings on provider voice webhooks by logging the matched adapter's registered provider technical name instead of the webhook-supplied provider route value when signature validation or idempotency-key validation fails. +- 2026-07-02: **Phase 12 Reports UI shipped — the "Reports" tab (Analytics feature).** Added the new `CrestApps.OrchardCore.ContactCenter.Analytics` feature (depends on `Queues`) that surfaces an **Interaction Center → Reports** admin area (`ContactCenterReportsAdminMenu`, nested Reports group) with six pages served by `ReportsController` (`[Feature(Analytics)]`, `[Admin]`): **Overview**, **Call insights**, **Agent productivity**, **Queue usage**, **Campaign summary**, and **Subject inventory**. Each page shares a `yyyy-MM-dd` date-range filter (default last 30 days) and a CSV export (`ReportCsvBuilder`, UTF-8 BOM). **Core:** added `IContactCenterReportingService`/`ContactCenterReportingService` (Core) that aggregates the durable interaction history and the CRM `OmnichannelActivityIndex` inventory into strongly-typed report models under `Models/Reports` (`CallInsightsReport` with per-day trend + channel/status breakdowns + answered/abandoned/failed + AHT/ASA/talk time; `AgentProductivityReport` per-agent handled/talk-time/completed-activities; `QueueUsageReport` per-queue handled/answered/abandoned/AHT/ASA + live waiting depth + SLA threshold; `CampaignSummaryReport` and `SubjectInventoryReport` bucketing each group into Completed/Pending/InProgress/Failed/Cancelled + attempts + completion rate — i.e. "what is completed vs pending" per campaign and per subject). The heavy aggregation lives in `internal static` builders so it is unit-testable without a live session. Added the `ViewContactCenterReports` permission (granted to Administrator + the built-in Supervisor role). **Tests:** +6 reporting unit tests (call insights totals/handle-time, daily grouping, agent productivity aggregation, queue usage + waiting, campaign completed-vs-pending bucketing, subject grouping) — **all ContactCenter tests pass**; clean `-warnaserror` Release build of `ContactCenter.Core` and the module. Docs (`contact-center/index.md` new "Reports and analytics" section, `agents-queues-dialer.md` feature table + recipe, `v2.0.0` changelog "Reports & Analytics UI") updated. Also verified DialPad completeness as the phone provider: outbound dial + inbound signed webhook routing + `CallTransfer` are shipped (`AgentDeviceNative` delivery); provider media execution of recording/monitoring remains a Phase 9 roadmap item, not a dialing-path gap. **Remaining Phase 12:** SLA/adherence trend snapshots and operational alerts. +- 2026-07-02: **Reports generalized into a reusable framework + industry-standard CRM reports (maintainer direction).** Per the maintainer's request for a reusable reports structure, a top-level Reports tab, extensible (display-driver) filters with a from/to range, exports, and CRM reports, the Contact Center-specific reports UI was replaced by a shared framework. **New `CrestApps.OrchardCore.Reports` module** (+ `CrestApps.OrchardCore.Reports.Abstractions`): `IReport` (name/category/permission/`RunAsync`→`ReportDocument`), a display-driver-extensible `ReportFilter` with a built-in from/to date-range driver, a uniform `ReportDocument` (metric-card / table / bar sections, with emphasized totals rows for aggregated reports), a generic `ReportsController` (Index landing + `Display(id)` + `Export(id, format)`), a top-level **Reports** admin menu (`ReportsAdminMenu`) that groups registered reports by category and gates each by its own permission, an `IReportManager` registry, and a pluggable `IReportExportFormat` with a built-in `CsvReportExportFormat`. Registered in `.slnx` and the `Cms.Core.Targets` bundle. **Contact Center migration:** deleted the CC-specific `ReportsController`, `ContactCenterReportsAdminMenu`, `ReportCsvBuilder`, `ReportFormat`, report `ViewModels`, and `Views/Reports`; kept `IContactCenterReportingService` + its DTOs (and the 6 tests) and added five thin `IReport` adapters (`*ReportProvider`) that map the DTOs to `ReportDocument`. The Analytics feature now depends on the Reports feature and registers the adapters. **Omnichannel CRM reports:** added an **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) in the Managements module with `OmnichannelReportAggregator` (pure, tested) + `OmnichannelReportQuery` and three `IReport`s — **Activity summary**, **Campaign performance**, and **Disposition breakdown** — plus a `ViewOmnichannelReports` permission (implied by `ManageActivities`). **Validation:** clean full-solution `-warnaserror` build; 226 ContactCenter/Omnichannel/Report tests pass (+3 new Omnichannel aggregator tests); and a live end-to-end smoke test confirmed the top-level **Reports** menu, all eight report pages (5 CC + 3 CRM) rendering with the from/to filter, and CSV export, on a fresh tenant. Docs: new `modules/reports.md`, updated `modules/index.md`, `contact-center/index.md`, `omnichannel/index.md`, and the `v2.0.0` changelog. **Remaining:** per-agent self-service report scoping, additional export formats (Excel/PDF), and SLA/adherence trend snapshots. diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index 036de7232..c3faf2d0b 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -21,6 +21,7 @@ + @@ -81,6 +82,7 @@ + diff --git a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs index d93ab5ee9..e2b2e307a 100644 --- a/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/ContactCenterConstants.cs @@ -54,6 +54,11 @@ public static class Feature /// The identifier of the real-time agent and supervisor experience 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"; } /// diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/CrestApps.OrchardCore.Reports.Abstractions.csproj b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/CrestApps.OrchardCore.Reports.Abstractions.csproj new file mode 100644 index 000000000..f121509b5 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/CrestApps.OrchardCore.Reports.Abstractions.csproj @@ -0,0 +1,20 @@ + + + + CrestApps.OrchardCore.Reports + CrestApps OrchardCore Reports Abstractions + + $(CrestAppsDescription) + + Reusable reporting abstractions for the CrestApps OrchardCore platform. Defines the report + contract, the extensible report filter, and the uniform report document (metrics, tables, and + aggregated rows) that any module can implement to surface a report under the admin Reports area. + + $(PackageTags) Reports Analytics Abstractions + + + + + + + diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReport.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReport.cs new file mode 100644 index 000000000..0e855a83b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReport.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.Reports; + +/// +/// Defines a report that can be surfaced under the admin Reports area. A module contributes a report by +/// registering an implementation of this interface; the Reports framework handles navigation, filtering, +/// rendering, and export uniformly. +/// +public interface IReport +{ + /// + /// Gets the stable, unique technical name used to resolve and route to the report. + /// + string Name { get; } + + /// + /// Gets the localized, human-readable name of the report. + /// + LocalizedString DisplayName { get; } + + /// + /// Gets the localized description of what the report shows. + /// + LocalizedString Description { get; } + + /// + /// Gets the category the report is grouped under in the admin navigation. + /// + string Category { get; } + + /// + /// Gets the permission required to view and export the report. + /// + Permission Permission { get; } + + /// + /// Runs the report for the supplied filter and returns the resulting document. + /// + /// The report context, including the resolved period and filter. + /// The token to monitor for cancellation requests. + /// The report document to render and export. + Task RunAsync(ReportContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReportExportFormat.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReportExportFormat.cs new file mode 100644 index 000000000..dcdafbbda --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/IReportExportFormat.cs @@ -0,0 +1,38 @@ +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.Reports; + +/// +/// Defines an export format for a report document. A module contributes a new export format (for +/// example CSV, Excel, or PDF) by registering an implementation of this interface. +/// +public interface IReportExportFormat +{ + /// + /// Gets the stable technical name of the format used to resolve it (for example csv). + /// + string Name { get; } + + /// + /// Gets the localized, human-readable name of the format. + /// + LocalizedString DisplayName { get; } + + /// + /// Gets the MIME content type produced by the format. + /// + string ContentType { get; } + + /// + /// Gets the file extension (without a leading dot) produced by the format. + /// + string FileExtension { get; } + + /// + /// Serializes the report document into the format's byte representation. + /// + /// The report document to serialize. + /// The serialized content. + byte[] Serialize(ReportDocument document); +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportBar.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportBar.cs new file mode 100644 index 000000000..d333faa23 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportBar.cs @@ -0,0 +1,43 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents a single labeled bar in a report bars section, used for simple horizontal-bar breakdowns +/// (for example volume by channel or a daily trend). +/// +public sealed class ReportBar +{ + /// + /// Initializes a new instance of the class. + /// + public ReportBar() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The bar label. + /// The pre-formatted value shown next to the bar. + /// The fill ratio of the bar, between 0 and 1. + public ReportBar(string label, string value, double ratio) + { + Label = label; + Value = value; + Ratio = ratio; + } + + /// + /// Gets or sets the bar label. + /// + public string Label { get; set; } + + /// + /// Gets or sets the pre-formatted value shown next to the bar. + /// + public string Value { get; set; } + + /// + /// Gets or sets the fill ratio of the bar, between 0 and 1. + /// + public double Ratio { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumn.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumn.cs new file mode 100644 index 000000000..81c25cc13 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumn.cs @@ -0,0 +1,35 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents a single column of a report table section. +/// +public sealed class ReportColumn +{ + /// + /// Initializes a new instance of the class. + /// + public ReportColumn() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The column header label. + /// The column alignment. + public ReportColumn(string label, ReportColumnAlign align = ReportColumnAlign.Start) + { + Label = label; + Align = align; + } + + /// + /// Gets or sets the column header label. + /// + public string Label { get; set; } + + /// + /// Gets or sets the column alignment. + /// + public ReportColumnAlign Align { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumnAlign.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumnAlign.cs new file mode 100644 index 000000000..ec86215a3 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportColumnAlign.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Identifies how a report table column is aligned. +/// +public enum ReportColumnAlign +{ + /// + /// The column is aligned to the start (left in left-to-right cultures). + /// + Start, + + /// + /// The column is centered. + /// + Center, + + /// + /// The column is aligned to the end (right in left-to-right cultures), typical for numbers. + /// + End, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportDocument.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportDocument.cs new file mode 100644 index 000000000..21fd65e5b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportDocument.cs @@ -0,0 +1,39 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents the uniform result of running a report: an ordered set of sections (metrics, tables, and +/// bars) that the shared renderer displays and the exporter serializes. +/// +public sealed class ReportDocument +{ + /// + /// Gets or sets the sections that make up the report, in display order. + /// + public IList Sections { get; set; } = []; + + /// + /// Gets a value indicating whether the report has any content to display. + /// + public bool HasContent + { + get + { + return Sections.Count > 0; + } + } + + /// + /// Adds a section to the report and returns the same document for chaining. + /// + /// The section to add. Ignored when . + /// The current document. + public ReportDocument Add(ReportSection section) + { + if (section is not null) + { + Sections.Add(section); + } + + return this; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFilter.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFilter.cs new file mode 100644 index 000000000..92fd34673 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFilter.cs @@ -0,0 +1,27 @@ +using OrchardCore.Entities; + +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents the filter applied when running a report. Every report shares the built-in from/to date +/// range; additional, report-specific filters are contributed through display drivers and stored in the +/// extensible entity bag. +/// +public sealed class ReportFilter : Entity +{ + /// + /// Gets or sets the technical name of the report being filtered. Filter display drivers use this to + /// decide whether they apply to the current report. + /// + public string ReportName { get; set; } + + /// + /// 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; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFormat.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFormat.cs new file mode 100644 index 000000000..243c5c795 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportFormat.cs @@ -0,0 +1,60 @@ +using System.Globalization; + +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Provides shared value formatting helpers used when building report documents so numbers, durations, +/// and rates render consistently across every report. +/// +public static class ReportFormat +{ + /// + /// Formats a number of seconds as a compact, human-readable duration (for example 1h 03m). + /// + /// The duration in seconds. + /// The formatted duration. + public static string Duration(double seconds) + { + if (seconds <= 0) + { + return "0s"; + } + + var total = (long)Math.Round(seconds, MidpointRounding.AwayFromZero); + var hours = total / 3600; + var minutes = total % 3600 / 60; + var secs = total % 60; + + if (hours > 0) + { + return string.Create(CultureInfo.InvariantCulture, $"{hours}h {minutes:00}m"); + } + + if (minutes > 0) + { + return string.Create(CultureInfo.InvariantCulture, $"{minutes}m {secs:00}s"); + } + + return string.Create(CultureInfo.InvariantCulture, $"{secs}s"); + } + + /// + /// Formats a rate between 0 and 1 as a percentage string (for example 42.5%). + /// + /// The rate to format. + /// The formatted percentage. + public static string Percent(double rate) + { + return string.Create(CultureInfo.InvariantCulture, $"{Math.Round(rate * 100, 1):0.0}%"); + } + + /// + /// Formats an integer value using the invariant culture. + /// + /// The value to format. + /// The formatted number. + public static string Number(long value) + { + return value.ToString(CultureInfo.InvariantCulture); + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportMetric.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportMetric.cs new file mode 100644 index 000000000..9d2da454a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportMetric.cs @@ -0,0 +1,42 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents a single headline metric shown as a card in a report metrics section. +/// +public sealed class ReportMetric +{ + /// + /// Initializes a new instance of the class. + /// + public ReportMetric() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The metric label. + /// The pre-formatted metric value. + /// An optional secondary hint shown under the value. + public ReportMetric(string label, string value, string hint = null) + { + Label = label; + Value = value; + Hint = hint; + } + + /// + /// Gets or sets the metric label. + /// + public string Label { get; set; } + + /// + /// Gets or sets the pre-formatted metric value. + /// + public string Value { get; set; } + + /// + /// Gets or sets an optional secondary hint shown under the value. + /// + public string Hint { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportRow.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportRow.cs new file mode 100644 index 000000000..15b4ea4db --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportRow.cs @@ -0,0 +1,36 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents a single row of a report table section. Cell values are pre-formatted strings so the +/// renderer and exporter stay format-agnostic. +/// +public sealed class ReportRow +{ + /// + /// Initializes a new instance of the class. + /// + public ReportRow() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The pre-formatted cell values. + /// Whether the row should be visually emphasized (for example, a totals row). + public ReportRow(IList cells, bool emphasize = false) + { + Cells = cells; + Emphasize = emphasize; + } + + /// + /// Gets or sets the pre-formatted cell values, one per column. + /// + public IList Cells { get; set; } = []; + + /// + /// Gets or sets a value indicating whether the row should be visually emphasized (a totals row). + /// + public bool Emphasize { get; set; } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSection.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSection.cs new file mode 100644 index 000000000..12638e4b4 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSection.cs @@ -0,0 +1,93 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Represents a single section of a report document. A section renders either headline metrics, a +/// table, or a set of bars, allowing reports to combine summary and detail views uniformly. +/// +public sealed class ReportSection +{ + /// + /// Gets or sets the optional section title. + /// + public string Title { get; set; } + + /// + /// Gets or sets the optional section description. + /// + public string Description { get; set; } + + /// + /// Gets or sets the kind of content the section renders. + /// + public ReportSectionKind Kind { get; set; } + + /// + /// Gets or sets the metric cards, when is . + /// + public IList Metrics { get; set; } = []; + + /// + /// Gets or sets the table columns, when is . + /// + public IList Columns { get; set; } = []; + + /// + /// Gets or sets the table rows, when is . + /// + public IList Rows { get; set; } = []; + + /// + /// Gets or sets the bars, when is . + /// + public IList Bars { get; set; } = []; + + /// + /// Creates a metrics section. + /// + /// The section title. + /// The metric cards. + /// The metrics section. + public static ReportSection ForMetrics(string title, IEnumerable metrics) + { + return new ReportSection + { + Title = title, + Kind = ReportSectionKind.Metrics, + Metrics = metrics is null ? [] : [.. metrics], + }; + } + + /// + /// Creates a table section. + /// + /// The section title. + /// The table columns. + /// The table rows. + /// The table section. + public static ReportSection ForTable(string title, IEnumerable columns, IEnumerable rows) + { + return new ReportSection + { + Title = title, + Kind = ReportSectionKind.Table, + Columns = columns is null ? [] : [.. columns], + Rows = rows is null ? [] : [.. rows], + }; + } + + /// + /// Creates a bars section. + /// + /// The section title. + /// The bars. + /// The bars section. + public static ReportSection ForBars(string title, IEnumerable bars) + { + return new ReportSection + { + Title = title, + Kind = ReportSectionKind.Bars, + Bars = bars is null ? [] : [.. bars], + }; + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSectionKind.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSectionKind.cs new file mode 100644 index 000000000..538865b7a --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Models/ReportSectionKind.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Reports.Models; + +/// +/// Identifies the kind of content a report section renders. +/// +public enum ReportSectionKind +{ + /// + /// The section renders a set of headline metric cards. + /// + Metrics, + + /// + /// The section renders a tabular grid of columns and rows. + /// + Table, + + /// + /// The section renders a set of horizontal bars. + /// + Bars, +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportContext.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportContext.cs new file mode 100644 index 000000000..ea425e5a0 --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportContext.cs @@ -0,0 +1,48 @@ +using CrestApps.OrchardCore.Reports.Models; + +namespace CrestApps.OrchardCore.Reports; + +/// +/// Provides the context passed to a report when it runs, exposing the resolved reporting period and the +/// full filter (including any report-specific values contributed by filter display drivers). +/// +public sealed class ReportContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The report filter. Its from/to values are guaranteed to be set by the caller. + public ReportContext(ReportFilter filter) + { + ArgumentNullException.ThrowIfNull(filter); + + Filter = filter; + } + + /// + /// Gets the report filter, including the extensible property bag of report-specific filter values. + /// + public ReportFilter Filter { get; } + + /// + /// Gets the inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc + { + get + { + return Filter.FromUtc.GetValueOrDefault(); + } + } + + /// + /// Gets the inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc + { + get + { + return Filter.ToUtc.GetValueOrDefault(); + } + } +} diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs new file mode 100644 index 000000000..1daef864b --- /dev/null +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs @@ -0,0 +1,33 @@ +namespace CrestApps.OrchardCore.Reports; + +/// +/// Contains constant values shared across the Reports module and the modules that contribute reports. +/// +public static class ReportsConstants +{ + /// + /// The identifier of the Reports framework feature. + /// + public const string Feature = "CrestApps.OrchardCore.Reports"; + + /// + /// The technical name of the built-in CSV export format. + /// + public const string CsvExportFormat = "csv"; + + /// + /// Contains the well-known report category names used to group reports in the admin navigation. + /// + public static class Categories + { + /// + /// The customer relationship management report category. + /// + public const string Crm = "CRM"; + + /// + /// The contact center report category. + /// + public const string ContactCenter = "Contact Center"; + } +} diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs index 5ceb0c678..d93be2924 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/ContactCenterPermissions.cs @@ -51,4 +51,9 @@ public static class ContactCenterPermissions /// 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 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/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..af83277e8 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/AgentProductivityRow.cs @@ -0,0 +1,47 @@ +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 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 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..307693bb2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CallInsightsReport.cs @@ -0,0 +1,110 @@ +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 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/CampaignSummaryReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryReport.cs new file mode 100644 index 000000000..9cee547df --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/CampaignSummaryReport.cs @@ -0,0 +1,28 @@ +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 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/QueueUsageReport.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageReport.cs new file mode 100644 index 000000000..4324e397e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageReport.cs @@ -0,0 +1,23 @@ +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; } = []; +} 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..ef5241f3a --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Models/Reports/QueueUsageRow.cs @@ -0,0 +1,53 @@ +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 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/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/Services/ContactCenterReportingService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterReportingService.cs new file mode 100644 index 000000000..763b9f951 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ContactCenterReportingService.cs @@ -0,0 +1,570 @@ +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 IActivityQueueManager _queueManager; + private readonly IQueueItemManager _queueItemManager; + private readonly IAgentProfileManager _agentManager; + private readonly ICatalogManager _campaignManager; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session used to query interactions and activities. + /// 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. + public ContactCenterReportingService( + ISession session, + IActivityQueueManager queueManager, + IQueueItemManager queueItemManager, + IAgentProfileManager agentManager, + ICatalogManager campaignManager) + { + _session = session; + _queueManager = queueManager; + _queueItemManager = queueItemManager; + _agentManager = agentManager; + _campaignManager = campaignManager; + } + + /// + public async Task GetCallInsightsAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + var interactions = await QueryInteractionsAsync(fromUtc, toUtc, cancellationToken); + + return BuildCallInsights(fromUtc, toUtc, interactions); + } + + /// + public async Task GetAgentProductivityAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + var interactions = await QueryInteractionsAsync(fromUtc, toUtc, cancellationToken); + var completedByUser = await QueryCompletedActivitiesByUserAsync(fromUtc, toUtc, cancellationToken); + var agents = await _agentManager.GetAllAsync(cancellationToken); + + return BuildAgentProductivity(fromUtc, toUtc, interactions, completedByUser, agents.ToArray()); + } + + /// + public async Task GetQueueUsageAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + var interactions = await QueryInteractionsAsync(fromUtc, toUtc, cancellationToken); + var queues = (await _queueManager.GetAllAsync(cancellationToken)).ToArray(); + + var waitingByQueue = new Dictionary(StringComparer.Ordinal); + + foreach (var queue in queues) + { + var waiting = await _queueItemManager.ListWaitingAsync(queue.ItemId, cancellationToken); + waitingByQueue[queue.ItemId] = waiting.Count; + } + + return BuildQueueUsage(fromUtc, toUtc, interactions, queues, waitingByQueue); + } + + /// + public async Task GetCampaignSummaryAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + var activities = await QueryActivityIndexesAsync(fromUtc, toUtc, cancellationToken); + var campaigns = await _campaignManager.GetAllAsync(cancellationToken); + + var names = new Dictionary(StringComparer.Ordinal); + + foreach (var campaign in campaigns) + { + names[campaign.ItemId] = string.IsNullOrWhiteSpace(campaign.DisplayText) ? campaign.ItemId : campaign.DisplayText; + } + + return BuildCampaignSummary(fromUtc, toUtc, activities, names); + } + + /// + public async Task GetSubjectInventoryAsync(DateTime fromUtc, DateTime toUtc, CancellationToken cancellationToken = default) + { + var activities = await QueryActivityIndexesAsync(fromUtc, toUtc, cancellationToken); + + return BuildSubjectInventory(fromUtc, toUtc, activities); + } + + 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 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++; + } + } + + 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.AverageHandleTimeSeconds = answeredWithHandleTime > 0 ? talkTimeTotal / 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; + } + } + + 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.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.AverageHandleTimeSeconds = row.InteractionsHandled > 0 ? row.TotalTalkTimeSeconds / 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, + IReadOnlyDictionary waitingByQueue) + { + var byQueue = new Dictionary(StringComparer.Ordinal); + + foreach (var interaction in interactions) + { + var key = interaction.QueueId ?? string.Empty; + + if (!byQueue.TryGetValue(key, out var accumulator)) + { + accumulator = new QueueUsageAccumulator(); + byQueue[key] = accumulator; + } + + 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++; + } + } + + 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); + + if (accumulator is null && waiting == 0) + { + continue; + } + + report.Rows.Add(BuildQueueRow(queue.ItemId, queue.Name ?? queue.ItemId, 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, 0, 0, entry.Value)); + } + + report.Rows = report.Rows + .OrderByDescending(row => row.InteractionsHandled) + .ThenByDescending(row => row.CurrentWaiting) + .ToList(); + + return report; + } + + internal static CampaignSummaryReport BuildCampaignSummary( + DateTime fromUtc, + DateTime toUtc, + IReadOnlyList activities, + IReadOnlyDictionary campaignNames) + { + 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, group.Key), + Counts = counts, + }); + + Accumulate(report.Totals, counts); + } + + report.Rows = report.Rows + .OrderByDescending(row => row.Counts.Total) + .ToList(); + + return report; + } + + 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, CancellationToken cancellationToken) + { + 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); + + foreach (var activity in completed) + { + var userId = activity.AssignedToId; + + if (string.IsNullOrEmpty(userId)) + { + continue; + } + + result[userId] = result.GetValueOrDefault(userId) + 1; + } + + return result; + } + + private static QueueUsageRow BuildQueueRow(string queueId, string queueName, int slaThresholdSeconds, int currentWaiting, QueueUsageAccumulator accumulator) + { + var row = new QueueUsageRow + { + QueueId = queueId, + QueueName = queueName, + 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 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 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; } + } +} 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..49e6857d2 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/IContactCenterReportingService.cs @@ -0,0 +1,57 @@ +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 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 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 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 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); +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs index a7a53e9b2..cd74fe92a 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs @@ -44,6 +44,11 @@ public sealed class OmnichannelActivity : CatalogItem /// public string AISessionId { get; set; } + /// + /// Gets or sets the AI profile identifier used to drive this automated activity. + /// + public string AIProfileId { get; set; } + /// /// When the interaction type is Automatic, we specify the preferred destination (Customer's Phone number or Email) to reach the Contact. /// diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs index 1bd5654a8..f2ef7890e 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs @@ -34,6 +34,11 @@ public sealed class OmnichannelActivityBatch : CatalogItem, IDisplayTextAwareMod /// public string ContactContentType { get; set; } + /// + /// Gets or sets the AI profile identifier assigned to automated activities loaded from this batch. + /// + public string AIProfileId { get; set; } + /// /// Gets or sets the user ids. /// @@ -164,6 +169,7 @@ public OmnichannelActivityBatch Clone() Source = Source, SubjectContentType = SubjectContentType, ContactContentType = ContactContentType, + AIProfileId = AIProfileId, UserIds = UserIds?.ToArray(), IncludeDoNoCalls = IncludeDoNoCalls, IncludeDoNoSms = IncludeDoNoSms, diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs index ef0ddd4ce..51252eb65 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs @@ -98,6 +98,11 @@ public static class Features public const string AzureCommunicationServices = "CrestApps.OrchardCore.Omnichannel.AzureCommunicationServices"; public const string Managements = "CrestApps.OrchardCore.Omnichannel.Managements"; + + /// + /// The identifier of the optional Omnichannel CRM reports feature. + /// + public const string Reports = "CrestApps.OrchardCore.Omnichannel.Reports"; } /// @@ -159,5 +164,10 @@ public static class Permissions /// Gets the permission to manage subject flows. /// public readonly static Permission ManageSubjectFlows = new("ManageSubjectFlows", "Manage subject flows"); + + /// + /// Gets the permission to view the Omnichannel CRM reports. + /// + public readonly static Permission ViewReports = new("ViewOmnichannelReports", "View Omnichannel reports", [ManageActivities]); } } diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index f32171275..ceeca158f 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -276,6 +276,12 @@ At a high level, the platform changes are: - Adds a rebuildable 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. Because the projection is derived purely from the durable event log, it can be rebuilt. 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`). +- Adds an **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) that contributes CRM reports to the same area: **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. @@ -397,10 +403,12 @@ At a high level, the platform changes are: - 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 batches now use a source-selection modal before creation. Manual batches keep the existing selected-user assignment flow, while dialer batches hide the user selector and load activities as unassigned, available dialer work for later reservation. - Automatic activity batches now load unassigned AI-ready activities, and automated subject flows require a chat AI profile with **Add initial prompt** enabled so the first outbound message can start the conversation reliably. +- Automatic activity batches can now 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. - Activity batch sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. - 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. -- Admin editors in Omnichannel Management now use the current `ocat-*` admin layout classes instead of older generic Bootstrap field markup. +- 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 Activity Batch 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. #### SMS diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 95528fae1..de9edc0e0 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -18,6 +18,7 @@ module so tenants enable only what they need. | Contact Center Queues | `CrestApps.OrchardCore.ContactCenter.Queues` | Managed skills, business-hours calendars, work queues, queue items, reservations, policy-based routing, and availability-based assignment. | | Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Outbound profiles, pacing, and dialer activity batches routed through Contact Center Voice. | | Contact Center Real-Time | `CrestApps.OrchardCore.ContactCenter.RealTime` | SignalR hub, live agent sessions with heartbeat and stale-session cleanup, and real-time presence, offer, and queue broadcasts. | +| Contact Center Reports & Analytics | `CrestApps.OrchardCore.ContactCenter.Analytics` | Reports area under Interaction Center with call insights, agent productivity, queue usage, and campaign/subject progress reports plus CSV exports. | | DialPad Contact Center Voice | `CrestApps.OrchardCore.DialPad.Dialer` | DialPad implementation of the Contact Center voice provider boundary. | ## Agents and presence @@ -232,6 +233,7 @@ planned follow-up. "CrestApps.OrchardCore.ContactCenter.Queues", "CrestApps.OrchardCore.ContactCenter.Dialer", "CrestApps.OrchardCore.ContactCenter.RealTime", + "CrestApps.OrchardCore.ContactCenter.Analytics", "CrestApps.OrchardCore.DialPad.Dialer" ] } diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index 687afc7fa..aa5cdc39f 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -89,6 +89,30 @@ bridge, live dashboards, and AI-assist extension points are now present. Multi-s provider-side recording storage, quality scorecards, abandonment caps, and predictive dialing remain advanced roadmap items. +## Interaction Center admin menu concepts + +Contact Center administration is intentionally split into focused menu entries under +**Interaction Center**. Each entry owns one concept so a new tenant can configure routing in a predictable +order instead of mixing CRM, routing, and telephony settings on one screen. + +| Menu entry | What it controls | Example | +| --- | --- | --- | +| **Agent states** | The not-ready reason codes agents can choose when they are unavailable. The selected reason changes presence and leaves an audit trail. | `Lunch` sets the agent to `Break`; `Coaching` sets the agent to `Training`. | +| **Agents** | The Orchard users who can receive contact center work, their capacity, allowed queues/campaigns, and administrator-assigned skills. | Maria can handle two chats but only one voice call, belongs to the Billing queue, and has the `Spanish` skill. | +| **Business hours** | Reusable open/closed calendars with time zone, weekly schedule, and holiday dates. Queues and entry points use them to decide whether work should route, hold, overflow, or close. | The Support calendar is open Monday-Friday 08:00-17:00 America/New_York and closed on holidays. | +| **Campaigns** | Omnichannel CRM grouping and reporting records. Campaigns describe the business initiative; they do not decide routing or media execution. | `July renewal outreach` groups all outbound renewal activities and reports outcomes. | +| **Channel endpoints** | Omnichannel sender/receiver addresses such as SMS numbers, phone numbers, or email addresses. Contact Center references them but Telephony/provider modules still execute media. | `+1 800 555 0100` is the public support number used by an inbound entry point. | +| **Entry points** | Inbound front doors for voice work. They map dialed numbers to queues, apply business-hours behavior, set priority, and define closed-hours handling. | Calls to the support DID route to Tier 1 during business hours and overflow to voicemail after hours. | +| **Queues** | Waiting rooms for activities. A queue owns priority, SLA threshold, reservation timeout, routing strategy, required skills, business-hours behavior, and overflow. | `Billing Voice` requires the Billing skill, uses longest-idle routing, and overflows to General Support after 10 minutes. | +| **Skills** | Routeable capabilities assigned by supervisors/administrators. Queues can require skills, and routing filters out agents who do not have them. | A Spanish-language queue requires both `Spanish` and `Billing`. | +| **Dialer profiles** | Outbound execution policy over existing CRM activities. The profile selects queue, dialing mode, pacing, voice provider, retry, and compliance rules. | A power dialer profile reserves available Billing agents and dials renewal activities within the allowed calling window. | +| **My workspace** | The agent desktop where agents receive offers, accept or decline work, see active customer context, and complete the activity with a disposition. | An agent accepts an inbound support offer, handles the call, selects `Resolved`, and completes the activity. | +| **Live dashboard** | Supervisor wallboard for queue depth, SLA health, and live agent presence. | A supervisor sees Billing Voice breaching SLA and asks another skilled agent to sign in. | + +The practical setup sequence is: create channel endpoints and campaigns in Omnichannel, configure subject +flows and dispositions, define skills and business hours, create queues, map inbound entry points or +dialer profiles, assign agents, then use My workspace and Live dashboard for daily operations. + ## Real-time experience The Contact Center publishes its own real-time event stream over SignalR for agent desktops, @@ -339,6 +363,30 @@ dialer, or automation) for both inbound and outbound work. This replaces the nee 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. + +- **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. + ## UI extensibility All Contact Center UI is built with Orchard Core display management: shapes, display drivers, diff --git a/src/CrestApps.Docs/docs/modules/index.md b/src/CrestApps.Docs/docs/modules/index.md index 0c292bae6..62155e9fc 100644 --- a/src/CrestApps.Docs/docs/modules/index.md +++ b/src/CrestApps.Docs/docs/modules/index.md @@ -22,6 +22,7 @@ CrestApps provides a set of standard modules that enhance core Orchard Core CMS | [Phone Number Verifications - Veriphone](phone-number-verifications-veriphone) | `CrestApps.OrchardCore.PhoneNumbers.Verifications.Veriphone` | Veriphone provider for phone number verification | | [Phone Number Verifications - Twilio](phone-number-verifications-twilio) | `CrestApps.OrchardCore.PhoneNumbers.Verifications.Twilio` | Twilio Lookup provider for phone number verification | | [Recipes](recipes) | `CrestApps.OrchardCore.Recipes` | JSON-Schema support for Orchard Core recipes | +| [Reports](reports) | `CrestApps.OrchardCore.Reports` | Reusable reporting framework with a shared admin Reports area, extensible filters, and exports | | [Resources](resources) | `CrestApps.OrchardCore.Resources` | Shared scripts and stylesheets | | [Roles](roles) | `CrestApps.OrchardCore.Roles` | Enhanced role management with RolePickerPart | | [SignalR](signalr) | `CrestApps.OrchardCore.SignalR` | Real-time communication via SignalR | diff --git a/src/CrestApps.Docs/docs/modules/reports.md b/src/CrestApps.Docs/docs/modules/reports.md new file mode 100644 index 000000000..913da74c4 --- /dev/null +++ b/src/CrestApps.Docs/docs/modules/reports.md @@ -0,0 +1,107 @@ +--- +sidebar_label: Reports +sidebar_position: 7 +title: Reports +description: A reusable reporting framework for OrchardCore with a shared admin Reports area, extensible filters, a uniform report renderer, and pluggable exports. +--- + +| | | +| --- | --- | +| **Feature Name** | Reports | +| **Feature ID** | `CrestApps.OrchardCore.Reports` | + +The **Reports** module is a reusable reporting framework. It provides a single admin **Reports** area +and a small contract that any module can implement to surface an industry-standard report — with a +shared from/to date-range filter, extensible filters, a uniform renderer (metric cards, tables, and +bars), and pluggable exports (CSV built in). Modules such as the +[Contact Center](../contact-center/index.md) and [Omnichannel](../omnichannel/index.md) CRM contribute +their reports through this framework so every report looks and behaves the same. + +## Concepts + +- **`IReport`** — a report definition. It declares a technical `Name`, a `DisplayName`, a `Description`, + a `Category` (used to group reports in the menu), a `Permission`, and a `RunAsync` method that returns + a `ReportDocument` for a given `ReportContext`. +- **`ReportFilter`** — the filter applied when a report runs. Every report shares the built-in + from/to date range; additional, report-specific filters are contributed with display drivers. +- **`ReportDocument`** — the uniform result. It is an ordered list of **sections**, where each section + is a set of metric cards, a table (with optional emphasized totals rows for aggregated reports), or a + set of horizontal bars. The same document is rendered in the browser and serialized by every exporter. +- **`IReportExportFormat`** — an export format. CSV ships in the box; a module can add Excel, PDF, or + any other format by registering another implementation. + +## Reports area + +Enabling the feature adds a top-level **Reports** item to the admin menu. Reports are grouped by their +category, and each entry is gated by the report's own permission, so a user only sees the reports they +are allowed to run. Selecting a report opens a page with the filter form, the rendered document, and an +**Export CSV** button that exports the current filter. + +## Extensible filters + +Every report automatically gets the from/to date range. To add a report-specific filter (for example a +queue, campaign, or channel selector), register a display driver for `ReportFilter` and gate it to your +report by checking `filter.ReportName`: + +```csharp +public sealed class MyQueueFilterDisplayDriver : DisplayDriver +{ + public override IDisplayResult Edit(ReportFilter filter, BuildEditorContext context) + { + if (!string.Equals(filter.ReportName, "my-report", StringComparison.Ordinal)) + { + return null; + } + + return Initialize("MyQueueFilter_Edit", model => { /* ... */ }) + .Location("Content:2"); + } + + public override async Task UpdateAsync(ReportFilter filter, UpdateEditorContext context) + { + if (!string.Equals(filter.ReportName, "my-report", StringComparison.Ordinal)) + { + return null; + } + + var model = new MyQueueFilterViewModel(); + await context.Updater.TryUpdateModelAsync(model, Prefix); + filter.Properties["QueueId"] = model.QueueId; + + return Edit(filter, context); + } +} +``` + +The report reads the bound value from `context.Filter.Properties` when it runs. + +## Contributing a report + +Implement `IReport` and register it as a scoped service: + +```csharp +services.AddScoped(); +``` + +`RunAsync` builds a `ReportDocument` from the resolved period (`context.FromUtc` / `context.ToUtc`) and +any report-specific filter values. Use `ReportSection.ForMetrics`, `ReportSection.ForTable`, and +`ReportSection.ForBars` to compose the document, and `ReportFormat` to format numbers, durations, and +percentages consistently. + +## Enable via recipe + +```json +{ + "steps": [ + { + "name": "Feature", + "enable": [ + "CrestApps.OrchardCore.Reports" + ] + } + ] +} +``` + +The Reports feature is enabled automatically when you enable a feature that contributes reports, such as +**Contact Center Reports & Analytics** or **Omnichannel Reports**. diff --git a/src/CrestApps.Docs/docs/omnichannel/index.md b/src/CrestApps.Docs/docs/omnichannel/index.md index 3e6be7abb..1c5dad9f9 100644 --- a/src/CrestApps.Docs/docs/omnichannel/index.md +++ b/src/CrestApps.Docs/docs/omnichannel/index.md @@ -47,6 +47,22 @@ The base module exposes: Provider-specific integrations can forward inbound events into this endpoint. +## Reports + +When the **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) is enabled, CRM +reports are contributed to the reusable [Reports](../modules/reports.md) framework and appear under the +top-level admin **Reports** menu (grouped under **CRM**). Each report shares the standard from/to +date-range filter and a CSV export. + +- **Activity summary** - activity volume and completion, broken down by source, channel, and status, + with a daily created-activity trend. +- **Campaign performance** - per-campaign *completed vs pending* progress across the CRM activity + inventory. +- **Disposition breakdown** - how completed activities were dispositioned in the period. + +Access is gated by the **View Omnichannel reports** (`ViewOmnichannelReports`) permission, which is +implied by **Manage activities** and granted to administrators by default. + ## Notes - The base module does not provide the full management UI by itself. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 042e4b8ba..c3762c12e 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -207,11 +207,13 @@ Subjects without any actions show a **Missing flow** badge in the Subject Flows 3. Create the new batch: - Select contact type - Select subject type + - For **Automatic** batches, optionally select an AI profile. The list only includes chat profiles + with **Add initial prompt** enabled; leaving it empty uses the subject flow's AI profile. - Assign users when the selected source requires assignment - Optionally set lead created range, phone number, time zone, and last activity filters 4. Click `Load`. -The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the campaign, interaction type, channel, channel endpoint, and AI profile. Manual batches assign each created activity to a selected user. Automatic batches leave activities unassigned but immediately eligible for the automated activity processor when their schedule is due. Dialer batches leave activities unassigned with assignment status `Available` so dialers can reserve and assign them safely later. +The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the campaign, interaction type, channel, and channel endpoint. For Automatic batches, the loader stores the selected batch AI profile on each activity; if no batch profile is selected, the activity uses the subject flow's AI profile. The automated activity processor then uses that profile's initial prompt to send the first outbound SMS and to continue the AI conversation when the contact replies. Manual batches assign each created activity to a selected user. Automatic batches leave activities unassigned but immediately eligible for the automated activity processor when their schedule is due. Dialer batches leave activities unassigned with assignment status `Available` so dialers can reserve and assign them safely later. ### 9) Complete Activities diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj index babfc55b2..71bace125 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/CrestApps.OrchardCore.ContactCenter.csproj @@ -33,6 +33,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs index 68ff5e668..2a9ddb034 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Manifest.cs @@ -1,6 +1,7 @@ using CrestApps.OrchardCore; using CrestApps.OrchardCore.ContactCenter; using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Reports; using OrchardCore.Modules.Manifest; [assembly: Module( @@ -80,3 +81,15 @@ "CrestApps.OrchardCore.SignalR", ] )] + +[assembly: Feature( + Id = ContactCenterConstants.Feature.Analytics, + Name = "Contact Center Reports & Analytics", + Description = "Adds contact center productivity, call insights, queue usage, and campaign/subject progress reports to the admin Reports area, with CSV export.", + Category = "Communication", + Dependencies = + [ + ContactCenterConstants.Feature.Queues, + ReportsConstants.Feature, + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/AgentProductivityReportProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/AgentProductivityReportProvider.cs new file mode 100644 index 000000000..07cf14014 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/AgentProductivityReportProvider.cs @@ -0,0 +1,64 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// The Contact Center agent productivity report: per-agent handled volume, talk time, and completed work. +/// +public sealed class AgentProductivityReportProvider : ContactCenterReportBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center reporting service. + /// The string localizer. + public AgentProductivityReportProvider( + IContactCenterReportingService reportingService, + IStringLocalizer stringLocalizer) + : base(reportingService, stringLocalizer) + { + } + + /// + public override string Name => "contact-center-agent-productivity"; + + /// + public override LocalizedString DisplayName => S["Agent productivity"]; + + /// + public override LocalizedString Description => S["Per-agent handled volume, talk time, average handle time, and completed activities."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var report = await ReportingService.GetAgentProductivityAsync(context.FromUtc, context.ToUtc, cancellationToken); + + var columns = new[] + { + new ReportColumn(S["Agent"].Value), + new ReportColumn(S["Handled"].Value, ReportColumnAlign.End), + new ReportColumn(S["Inbound"].Value, ReportColumnAlign.End), + new ReportColumn(S["Outbound"].Value, ReportColumnAlign.End), + new ReportColumn(S["Avg handle time"].Value, ReportColumnAlign.End), + new ReportColumn(S["Total talk time"].Value, ReportColumnAlign.End), + new ReportColumn(S["Activities completed"].Value, ReportColumnAlign.End), + }; + + var rows = report.Rows.Select(row => new ReportRow( + [ + row.DisplayName, + ReportFormat.Number(row.InteractionsHandled), + ReportFormat.Number(row.InboundHandled), + ReportFormat.Number(row.OutboundHandled), + ReportFormat.Duration(row.AverageHandleTimeSeconds), + ReportFormat.Duration(row.TotalTalkTimeSeconds), + ReportFormat.Number(row.ActivitiesCompleted), + ])); + + return new ReportDocument() + .Add(ReportSection.ForTable(S["Agents"].Value, columns, rows)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CallInsightsReportProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CallInsightsReportProvider.cs new file mode 100644 index 000000000..449bb8e81 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CallInsightsReportProvider.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// The Contact Center call insights report: inbound/outbound volume, outcomes, handle time, channel and +/// status breakdowns, and a daily trend. +/// +public sealed class CallInsightsReportProvider : ContactCenterReportBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center reporting service. + /// The string localizer. + public CallInsightsReportProvider( + IContactCenterReportingService reportingService, + IStringLocalizer stringLocalizer) + : base(reportingService, stringLocalizer) + { + } + + /// + public override string Name => "contact-center-call-insights"; + + /// + public override LocalizedString DisplayName => S["Call insights"]; + + /// + public override LocalizedString Description => S["Inbound and outbound call volume, answered, abandoned, and failed calls, handle time, and a daily trend."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var report = await ReportingService.GetCallInsightsAsync(context.FromUtc, context.ToUtc, cancellationToken); + + var document = new ReportDocument(); + + document.Add(ReportSection.ForMetrics(S["Summary"].Value, + [ + new ReportMetric(S["Total"].Value, ReportFormat.Number(report.Total)), + new ReportMetric(S["Inbound"].Value, ReportFormat.Number(report.Inbound)), + new ReportMetric(S["Outbound"].Value, ReportFormat.Number(report.Outbound)), + new ReportMetric(S["Answered"].Value, ReportFormat.Number(report.Answered), ReportFormat.Percent(report.AnswerRate)), + new ReportMetric(S["Abandoned"].Value, ReportFormat.Number(report.Abandoned), ReportFormat.Percent(report.AbandonmentRate)), + new ReportMetric(S["Failed"].Value, ReportFormat.Number(report.Failed)), + new ReportMetric(S["Avg handle time"].Value, ReportFormat.Duration(report.AverageHandleTimeSeconds)), + new ReportMetric(S["Avg speed of answer"].Value, ReportFormat.Duration(report.AverageSpeedOfAnswerSeconds)), + new ReportMetric(S["Total talk time"].Value, ReportFormat.Duration(report.TotalTalkTimeSeconds)), + ])); + + if (report.ByChannel.Count > 0) + { + var max = report.ByChannel.Max(entry => entry.Count); + + document.Add(ReportSection.ForBars(S["By channel"].Value, + report.ByChannel.Select(entry => new ReportBar(entry.Label, ReportFormat.Number(entry.Count), max > 0 ? (double)entry.Count / max : 0)))); + } + + if (report.ByStatus.Count > 0) + { + var max = report.ByStatus.Max(entry => entry.Count); + + document.Add(ReportSection.ForBars(S["By status"].Value, + report.ByStatus.Select(entry => new ReportBar(entry.Label, ReportFormat.Number(entry.Count), max > 0 ? (double)entry.Count / max : 0)))); + } + + if (report.Daily.Count > 0) + { + var columns = new[] + { + new ReportColumn(S["Date"].Value), + new ReportColumn(S["Total"].Value, ReportColumnAlign.End), + new ReportColumn(S["Answered"].Value, ReportColumnAlign.End), + new ReportColumn(S["Abandoned"].Value, ReportColumnAlign.End), + }; + + var rows = report.Daily.Select(point => new ReportRow( + [ + point.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + ReportFormat.Number(point.Total), + ReportFormat.Number(point.Answered), + ReportFormat.Number(point.Abandoned), + ])); + + document.Add(ReportSection.ForTable(S["Daily volume"].Value, columns, rows)); + } + + return document; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CampaignSummaryReportProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CampaignSummaryReportProvider.cs new file mode 100644 index 000000000..ec4f3df97 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/CampaignSummaryReportProvider.cs @@ -0,0 +1,67 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// The Contact Center campaign summary report: per-campaign completed-versus-pending activity progress. +/// +public sealed class CampaignSummaryReportProvider : ContactCenterReportBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center reporting service. + /// The string localizer. + public CampaignSummaryReportProvider( + IContactCenterReportingService reportingService, + IStringLocalizer stringLocalizer) + : base(reportingService, stringLocalizer) + { + } + + /// + public override string Name => "contact-center-campaign-summary"; + + /// + public override LocalizedString DisplayName => S["Campaign summary"]; + + /// + public override LocalizedString Description => S["Per-campaign completed-versus-pending progress across the activity inventory."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var report = await ReportingService.GetCampaignSummaryAsync(context.FromUtc, context.ToUtc, cancellationToken); + + var noCampaign = S["(No campaign)"].Value; + + var document = new ReportDocument(); + + document.Add(ReportSection.ForMetrics(S["Summary"].Value, + [ + new ReportMetric(S["Total"].Value, ReportFormat.Number(report.Totals.Total)), + new ReportMetric(S["Completed"].Value, ReportFormat.Number(report.Totals.Completed), ReportFormat.Percent(report.Totals.CompletionRate)), + new ReportMetric(S["Pending"].Value, ReportFormat.Number(report.Totals.Pending)), + new ReportMetric(S["In progress"].Value, ReportFormat.Number(report.Totals.InProgress)), + ])); + + var rows = new List(); + + foreach (var row in report.Rows) + { + rows.Add(new ReportRow(ContactCenterReportCells.Progress( + string.IsNullOrEmpty(row.CampaignName) ? noCampaign : row.CampaignName, + row.Counts))); + } + + rows.Add(new ReportRow(ContactCenterReportCells.Progress(S["All campaigns"].Value, report.Totals), emphasize: true)); + + document.Add(ReportSection.ForTable(S["Campaigns"].Value, ContactCenterReportCells.ProgressColumns(S, S["Campaign"].Value), rows)); + + return document; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportBase.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportBase.cs new file mode 100644 index 000000000..1158eae5b --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportBase.cs @@ -0,0 +1,56 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// Provides the shared category, permission, and dependencies for the Contact Center reports contributed +/// to the admin Reports area. +/// +public abstract class ContactCenterReportBase : IReport +{ + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center reporting service used to aggregate the data. + /// The string localizer used for the report labels. + protected ContactCenterReportBase( + IContactCenterReportingService reportingService, + IStringLocalizer stringLocalizer) + { + ReportingService = reportingService; + S = stringLocalizer; + } + + /// + /// Gets the Contact Center reporting service used to aggregate the data. + /// + protected IContactCenterReportingService ReportingService { get; } + + /// + /// Gets the string localizer used for the report labels. + /// + protected IStringLocalizer S { get; } + + /// + public abstract string Name { get; } + + /// + public abstract LocalizedString DisplayName { get; } + + /// + public abstract LocalizedString Description { get; } + + /// + public string Category => ReportsConstants.Categories.ContactCenter; + + /// + public Permission Permission => ContactCenterPermissions.ViewReports; + + /// + public abstract Task RunAsync(ReportContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportCells.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportCells.cs new file mode 100644 index 000000000..bc25b228d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/ContactCenterReportCells.cs @@ -0,0 +1,56 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// Builds the shared columns and cells used by the campaign summary and subject inventory reports so +/// their completed-versus-pending progress tables stay consistent. +/// +internal static class ContactCenterReportCells +{ + /// + /// Builds the progress table columns. + /// + /// The string localizer. + /// The label of the first (grouping) column. + /// The progress columns. + public static ReportColumn[] ProgressColumns(IStringLocalizer localizer, string firstColumnLabel) + { + return + [ + new ReportColumn(firstColumnLabel), + new ReportColumn(localizer["Total"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["Completed"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["Pending"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["In progress"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["Failed"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["Cancelled"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["Attempts"].Value, ReportColumnAlign.End), + new ReportColumn(localizer["Completion"].Value, ReportColumnAlign.End), + ]; + } + + /// + /// Builds the progress cells for a single group. + /// + /// The group label. + /// The progress counts. + /// The formatted cell values. + public static string[] Progress(string label, ActivityProgressCounts counts) + { + return + [ + label, + ReportFormat.Number(counts.Total), + ReportFormat.Number(counts.Completed), + ReportFormat.Number(counts.Pending), + ReportFormat.Number(counts.InProgress), + ReportFormat.Number(counts.Failed), + ReportFormat.Number(counts.Cancelled), + ReportFormat.Number(counts.TotalAttempts), + ReportFormat.Percent(counts.CompletionRate), + ]; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/QueueUsageReportProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/QueueUsageReportProvider.cs new file mode 100644 index 000000000..3aac33965 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/QueueUsageReportProvider.cs @@ -0,0 +1,68 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// The Contact Center queue usage report: per-queue handled volume, outcomes, and live waiting depth. +/// +public sealed class QueueUsageReportProvider : ContactCenterReportBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center reporting service. + /// The string localizer. + public QueueUsageReportProvider( + IContactCenterReportingService reportingService, + IStringLocalizer stringLocalizer) + : base(reportingService, stringLocalizer) + { + } + + /// + public override string Name => "contact-center-queue-usage"; + + /// + public override LocalizedString DisplayName => S["Queue usage"]; + + /// + public override LocalizedString Description => S["Per-queue handled, answered, and abandoned volume, handle time, and current waiting depth."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var report = await ReportingService.GetQueueUsageAsync(context.FromUtc, context.ToUtc, cancellationToken); + + var noQueue = S["(No queue)"].Value; + + var columns = new[] + { + new ReportColumn(S["Queue"].Value), + new ReportColumn(S["Handled"].Value, ReportColumnAlign.End), + new ReportColumn(S["Answered"].Value, ReportColumnAlign.End), + new ReportColumn(S["Abandoned"].Value, ReportColumnAlign.End), + new ReportColumn(S["Avg handle time"].Value, ReportColumnAlign.End), + new ReportColumn(S["Avg speed of answer"].Value, ReportColumnAlign.End), + new ReportColumn(S["Waiting now"].Value, ReportColumnAlign.End), + new ReportColumn(S["SLA threshold"].Value, ReportColumnAlign.End), + }; + + var rows = report.Rows.Select(row => new ReportRow( + [ + string.IsNullOrEmpty(row.QueueName) ? noQueue : row.QueueName, + ReportFormat.Number(row.InteractionsHandled), + ReportFormat.Number(row.Answered), + ReportFormat.Number(row.Abandoned), + ReportFormat.Duration(row.AverageHandleTimeSeconds), + ReportFormat.Duration(row.AverageSpeedOfAnswerSeconds), + ReportFormat.Number(row.CurrentWaiting), + row.SlaThresholdSeconds > 0 ? ReportFormat.Duration(row.SlaThresholdSeconds) : "—", + ])); + + return new ReportDocument() + .Add(ReportSection.ForTable(S["Queues"].Value, columns, rows)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/SubjectInventoryReportProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/SubjectInventoryReportProvider.cs new file mode 100644 index 000000000..b44b9434f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Reports/SubjectInventoryReportProvider.cs @@ -0,0 +1,55 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.ContactCenter.Reports; + +/// +/// The Contact Center subject inventory report: per-subject completed-versus-pending activity progress. +/// +public sealed class SubjectInventoryReportProvider : ContactCenterReportBase +{ + /// + /// Initializes a new instance of the class. + /// + /// The Contact Center reporting service. + /// The string localizer. + public SubjectInventoryReportProvider( + IContactCenterReportingService reportingService, + IStringLocalizer stringLocalizer) + : base(reportingService, stringLocalizer) + { + } + + /// + public override string Name => "contact-center-subject-inventory"; + + /// + public override LocalizedString DisplayName => S["Subject inventory"]; + + /// + public override LocalizedString Description => S["Per-subject completed-versus-pending progress across the activity inventory."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var report = await ReportingService.GetSubjectInventoryAsync(context.FromUtc, context.ToUtc, cancellationToken); + + var noSubject = S["(No subject)"].Value; + + var rows = new List(); + + foreach (var row in report.Rows) + { + rows.Add(new ReportRow(ContactCenterReportCells.Progress( + string.IsNullOrEmpty(row.SubjectContentType) ? noSubject : row.SubjectContentType, + row.Counts))); + } + + rows.Add(new ReportRow(ContactCenterReportCells.Progress(S["All subjects"].Value, report.Totals), emphasize: true)); + + return new ReportDocument() + .Add(ReportSection.ForTable(S["Subjects"].Value, ContactCenterReportCells.ProgressColumns(S, S["Subject"].Value), rows)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs index 1e5379edc..aef64ce14 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterPermissionProvider.cs @@ -20,6 +20,7 @@ internal sealed class ContactCenterPermissionProvider : IPermissionProvider ContactCenterPermissions.ManageDialer, ContactCenterPermissions.SignIntoQueues, ContactCenterPermissions.MonitorContactCenter, + ContactCenterPermissions.ViewReports, ]; /// @@ -47,6 +48,7 @@ public IEnumerable GetDefaultStereotypes() [ ContactCenterPermissions.ViewInteractions, ContactCenterPermissions.MonitorContactCenter, + ContactCenterPermissions.ViewReports, ], }, ]; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index 820a920ed..e9bbec524 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -9,11 +9,13 @@ using CrestApps.OrchardCore.ContactCenter.Indexes; using CrestApps.OrchardCore.ContactCenter.Migrations; using CrestApps.OrchardCore.ContactCenter.Recipes; +using CrestApps.OrchardCore.ContactCenter.Reports; using CrestApps.OrchardCore.ContactCenter.Services; using CrestApps.OrchardCore.ContactCenter.Workflows.Drivers; using CrestApps.OrchardCore.ContactCenter.Workflows.Models; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Managements.Models; +using CrestApps.OrchardCore.Reports; using CrestApps.OrchardCore.Telephony; using CrestApps.OrchardCore.Telephony.Models; using Microsoft.AspNetCore.Builder; @@ -324,6 +326,27 @@ public override void Configure(IApplicationBuilder app, IEndpointRouteBuilder ro } } +/// +/// Registers the reporting and analytics experience: the reporting service that aggregates interactions +/// and activities into productivity, call insights, queue usage, and campaign/subject progress reports, +/// and the Reports admin navigation. +/// +[Feature(ContactCenterConstants.Feature.Analytics)] +public sealed class AnalyticsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + + services + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped() + .AddScoped(); + } +} + /// /// Registers the optional OrchardCore Workflows bridge: a Contact Center workflow event activity and the /// handler that triggers it for every published domain event. diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs index 4bec54648..62e56565f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs @@ -708,6 +708,9 @@ SELECT MAX(a2.{completedCol}) activity.Source = sourceEntry.Source; activity.InteractionType = flowSettings.InteractionType; activity.Channel = flowSettings.Channel; + activity.AIProfileId = string.IsNullOrWhiteSpace(batch.AIProfileId) + ? flowSettings.ProfileId + : batch.AIProfileId; activity.ContactContentItemId = contact.ContentItemId; activity.ContactContentType = batch.ContactContentType; activity.SubjectContentType = batch.SubjectContentType; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj index fd2376a0d..30986bc50 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj @@ -8,6 +8,10 @@ + + + + @@ -27,6 +31,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs index 2aa1abacf..3532464b5 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs @@ -1,3 +1,6 @@ +using CrestApps.Core; +using CrestApps.Core.AI.Models; +using CrestApps.Core.AI.Profiles; using CrestApps.Core.Services; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Models; @@ -22,6 +25,7 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers; internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriver { private readonly IDisplayNameProvider _displayNameProvider; + private readonly IAIProfileManager _profileManager; private readonly IContentDefinitionManager _contentDefinitionManager; private readonly ITimeZoneSelectListProvider _timeZoneSelectListProvider; private readonly ILocalClock _localClock; @@ -36,8 +40,9 @@ internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriver class. /// /// The display name provider. + /// The AI profile manager. /// The content definition manager. - /// The clock. + /// The time zone select list provider. /// The local clock. /// The YesSql session. /// The dispositions catalog. @@ -46,6 +51,7 @@ internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriverThe string localizer. public OmnichannelActivityBatchDisplayDriver( IDisplayNameProvider displayNameProvider, + IAIProfileManager profileManager, IContentDefinitionManager contentDefinitionManager, ITimeZoneSelectListProvider timeZoneSelectListProvider, ILocalClock localClock, @@ -56,6 +62,7 @@ public OmnichannelActivityBatchDisplayDriver( IStringLocalizer stringLocalizer) { _displayNameProvider = displayNameProvider; + _profileManager = profileManager; _contentDefinitionManager = contentDefinitionManager; _timeZoneSelectListProvider = timeZoneSelectListProvider; _localClock = localClock; @@ -94,6 +101,7 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC model.ScheduleAt = context.IsNew ? (await _localClock.GetLocalNowAsync()).DateTime : batch.ScheduleAt; model.SubjectContentType = batch.SubjectContentType; model.ContactContentType = batch.ContactContentType; + model.AIProfileId = batch.AIProfileId; model.UserIds = batch.UserIds; model.IncludeDoNoCalls = batch.IncludeDoNoCalls; model.IncludeDoNoSms = batch.IncludeDoNoSms; @@ -127,6 +135,17 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC } } + var selectedAIProfileId = model.AIProfileId; + + if (string.IsNullOrWhiteSpace(selectedAIProfileId) && + !string.IsNullOrWhiteSpace(model.SubjectContentType)) + { + var flowSettings = await _subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(model.SubjectContentType); + selectedAIProfileId = flowSettings?.ProfileId; + } + + model.AIProfiles = await GetAIProfileOptionsAsync(selectedAIProfileId); + if (model.RequiresUserAssignment && batch.UserIds is { Length: > 0 }) { var users = (await _session.Query(x => x.UserId.IsIn(batch.UserIds)).ListAsync()) @@ -245,6 +264,31 @@ public override async Task UpdateAsync(OmnichannelActivityBatch context.Updater.ModelState.AddModelError(Prefix, nameof(model.Source), S["Automated subject flows must be loaded with the Automatic source."]); } + var selectedAIProfileId = string.IsNullOrWhiteSpace(model.AIProfileId) + ? flowSettings?.ProfileId + : model.AIProfileId.Trim(); + + if (string.Equals(model.Source, ActivitySources.Automatic, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(selectedAIProfileId)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.AIProfileId), S["AI profile is required for automatic activity batches."]); + } + else + { + var profile = await _profileManager.FindByIdAsync(selectedAIProfileId); + + if (profile is null || profile.Type != AIProfileType.Chat) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.AIProfileId), S["The selected AI profile is invalid."]); + } + else if (!HasInitialPrompt(profile)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.AIProfileId), S["The selected AI profile must have Add initial prompt enabled."]); + } + } + } + if (model.ScheduleAt is null) { context.Updater.ModelState.AddModelError(Prefix, nameof(model.ScheduleAt), S["Schedule at field is required."]); @@ -259,6 +303,9 @@ public override async Task UpdateAsync(OmnichannelActivityBatch batch.Source = model.Source?.Trim(); batch.SubjectContentType = model.SubjectContentType; batch.ContactContentType = model.ContactContentType; + batch.AIProfileId = string.Equals(model.Source, ActivitySources.Automatic, StringComparison.OrdinalIgnoreCase) + ? model.AIProfileId?.Trim() + : null; batch.Instructions = model.Instructions?.Trim(); batch.UrgencyLevel = model.UrgencyLevel; @@ -295,4 +342,24 @@ private ActivityBatchSourceEntry GetSourceEntry(string source) return entry; } + + private async Task> GetAIProfileOptionsAsync(string selectedProfileId) + { + var chatProfiles = await _profileManager.GetAsync(AIProfileType.Chat); + + return chatProfiles + .Where(HasInitialPrompt) + .OrderBy(profile => profile.DisplayText ?? profile.Name, StringComparer.OrdinalIgnoreCase) + .Select(profile => new SelectListItem(profile.DisplayText ?? profile.Name, profile.ItemId) + { + Selected = string.Equals(profile.ItemId, selectedProfileId, StringComparison.OrdinalIgnoreCase), + }); + } + + private static bool HasInitialPrompt(AIProfile profile) + { + var metadata = profile.GetOrCreate(); + + return !string.IsNullOrWhiteSpace(metadata.InitialPrompt); + } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs index 500dc55b4..9ab757a99 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs @@ -1,6 +1,7 @@ using CrestApps.OrchardCore; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.PhoneNumbers.Core; +using CrestApps.OrchardCore.Reports; using CrestApps.OrchardCore.TimeZones; using CrestApps.OrchardCore.Users.Core; using OrchardCore.Modules.Manifest; @@ -32,3 +33,15 @@ "CrestApps.OrchardCore.Users", ] )] + +[assembly: Feature( + Name = "Omnichannel Reports", + Id = OmnichannelConstants.Features.Reports, + Category = "Communication", + Description = "Adds Omnichannel CRM reports (activity summary, campaign performance, and disposition breakdown) to the admin Reports area.", + Dependencies = + [ + OmnichannelConstants.Features.Managements, + ReportsConstants.Feature, + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivityDailyPoint.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivityDailyPoint.cs new file mode 100644 index 000000000..ff386c580 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivityDailyPoint.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Omnichannel.Managements.Models.Reports; + +/// +/// Represents the number of activities created on a single day in the activity summary report. +/// +public sealed class OmnichannelActivityDailyPoint +{ + /// + /// Gets or sets the UTC date the activities were created on. + /// + public DateOnly Date { get; set; } + + /// + /// Gets or sets the number of activities created on the day. + /// + public long Count { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivitySummaryData.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivitySummaryData.cs new file mode 100644 index 000000000..456d0f935 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelActivitySummaryData.cs @@ -0,0 +1,32 @@ +namespace CrestApps.OrchardCore.Omnichannel.Managements.Models.Reports; + +/// +/// Represents the aggregated data behind the CRM activity summary report. +/// +public sealed class OmnichannelActivitySummaryData +{ + /// + /// Gets or sets the overall completed-versus-pending progress counts. + /// + public OmnichannelProgressCounts Counts { get; set; } = new(); + + /// + /// Gets or sets the activity counts grouped by source. + /// + public IReadOnlyDictionary BySource { get; set; } = new Dictionary(); + + /// + /// Gets or sets the activity counts grouped by channel. + /// + public IReadOnlyDictionary ByChannel { get; set; } = new Dictionary(); + + /// + /// Gets or sets the activity counts grouped by status. + /// + public IReadOnlyDictionary ByStatus { get; set; } = new Dictionary(); + + /// + /// Gets or sets the daily created-activity trend. + /// + public IList Daily { get; set; } = []; +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignPerformanceData.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignPerformanceData.cs new file mode 100644 index 000000000..1c8a5dfb8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignPerformanceData.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Omnichannel.Managements.Models.Reports; + +/// +/// Represents the aggregated data behind the CRM campaign performance report. +/// +public sealed class OmnichannelCampaignPerformanceData +{ + /// + /// Gets or sets the per-campaign rows, ordered by total activities. + /// + public IList Rows { get; set; } = []; + + /// + /// Gets or sets the combined progress counts across every campaign. + /// + public OmnichannelProgressCounts Totals { get; set; } = new(); +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignRow.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignRow.cs new file mode 100644 index 000000000..0e04a1814 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelCampaignRow.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Omnichannel.Managements.Models.Reports; + +/// +/// Represents the progress of a single campaign's activities in the campaign performance report. +/// +public sealed class OmnichannelCampaignRow +{ + /// + /// Gets or sets the campaign identifier. An empty value represents activities with no campaign. + /// + public string CampaignId { get; set; } + + /// + /// Gets or sets the progress counts for the campaign. + /// + public OmnichannelProgressCounts Counts { get; set; } = new(); +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelProgressCounts.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelProgressCounts.cs new file mode 100644 index 000000000..64959d3ab --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Models/Reports/OmnichannelProgressCounts.cs @@ -0,0 +1,53 @@ +namespace CrestApps.OrchardCore.Omnichannel.Managements.Models.Reports; + +/// +/// Represents the completed-versus-pending progress of a set of CRM activities. +/// +public sealed class OmnichannelProgressCounts +{ + /// + /// Gets or sets the total number of activities. + /// + public long Total { get; set; } + + /// + /// Gets or sets the number of completed activities. + /// + public long Completed { get; set; } + + /// + /// Gets or sets the number of activities that have not been started yet. + /// + public long Pending { get; set; } + + /// + /// Gets or sets the number of activities that are actively being worked. + /// + public long InProgress { get; set; } + + /// + /// Gets or sets the number of failed activities. + /// + public long Failed { get; set; } + + /// + /// Gets or sets the number of cancelled or purged activities. + /// + public long Cancelled { 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/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/ActivitySummaryReportProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/ActivitySummaryReportProvider.cs new file mode 100644 index 000000000..8a2cde3bb --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/ActivitySummaryReportProvider.cs @@ -0,0 +1,95 @@ +using System.Globalization; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; +using ISession = YesSql.ISession; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Reports; + +/// +/// The CRM activity summary report: activity volume and completion, broken down by source, channel, and +/// status, with a daily created-activity trend. +/// +public sealed class ActivitySummaryReportProvider : OmnichannelReportBase +{ + private readonly ISession _session; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The string localizer. + public ActivitySummaryReportProvider( + ISession session, + IStringLocalizer stringLocalizer) + : base(stringLocalizer) + { + _session = session; + } + + /// + public override string Name => "omnichannel-activity-summary"; + + /// + public override LocalizedString DisplayName => S["Activity summary"]; + + /// + public override LocalizedString Description => S["CRM activity volume and completion, broken down by source, channel, and status, with a daily trend."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var activities = await OmnichannelReportQuery.GetCreatedAsync(_session, context.FromUtc, context.ToUtc, cancellationToken); + var data = OmnichannelReportAggregator.BuildActivitySummary(activities); + + var document = new ReportDocument(); + + document.Add(ReportSection.ForMetrics(S["Summary"].Value, + [ + new ReportMetric(S["Total"].Value, ReportFormat.Number(data.Counts.Total)), + new ReportMetric(S["Completed"].Value, ReportFormat.Number(data.Counts.Completed), ReportFormat.Percent(data.Counts.CompletionRate)), + new ReportMetric(S["Pending"].Value, ReportFormat.Number(data.Counts.Pending)), + new ReportMetric(S["In progress"].Value, ReportFormat.Number(data.Counts.InProgress)), + new ReportMetric(S["Failed"].Value, ReportFormat.Number(data.Counts.Failed)), + new ReportMetric(S["Cancelled"].Value, ReportFormat.Number(data.Counts.Cancelled)), + ])); + + AddBreakdown(document, S["By source"].Value, data.BySource); + AddBreakdown(document, S["By channel"].Value, data.ByChannel); + AddBreakdown(document, S["By status"].Value, data.ByStatus); + + if (data.Daily.Count > 0) + { + var columns = new[] + { + new ReportColumn(S["Date"].Value), + new ReportColumn(S["Created"].Value, ReportColumnAlign.End), + }; + + var rows = data.Daily.Select(point => new ReportRow( + [ + point.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + ReportFormat.Number(point.Count), + ])); + + document.Add(ReportSection.ForTable(S["Daily created"].Value, columns, rows)); + } + + return document; + } + + private static void AddBreakdown(ReportDocument document, string title, IReadOnlyDictionary counts) + { + if (counts.Count == 0) + { + return; + } + + var max = counts.Values.Max(); + + document.Add(ReportSection.ForBars(title, counts + .OrderByDescending(entry => entry.Value) + .Select(entry => new ReportBar(entry.Key, ReportFormat.Number(entry.Value), max > 0 ? (double)entry.Value / max : 0)))); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/CampaignPerformanceReportProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/CampaignPerformanceReportProvider.cs new file mode 100644 index 000000000..412ddc5bc --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/CampaignPerformanceReportProvider.cs @@ -0,0 +1,103 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; +using ISession = YesSql.ISession; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Reports; + +/// +/// The CRM campaign performance report: per-campaign completed-versus-pending activity progress. +/// +public sealed class CampaignPerformanceReportProvider : OmnichannelReportBase +{ + private readonly ISession _session; + private readonly ICatalogManager _campaignManager; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The campaign manager used to resolve campaign names. + /// The string localizer. + public CampaignPerformanceReportProvider( + ISession session, + ICatalogManager campaignManager, + IStringLocalizer stringLocalizer) + : base(stringLocalizer) + { + _session = session; + _campaignManager = campaignManager; + } + + /// + public override string Name => "omnichannel-campaign-performance"; + + /// + public override LocalizedString DisplayName => S["Campaign performance"]; + + /// + public override LocalizedString Description => S["Per-campaign completed-versus-pending progress across the CRM activity inventory."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var activities = await OmnichannelReportQuery.GetCreatedAsync(_session, context.FromUtc, context.ToUtc, cancellationToken); + var data = OmnichannelReportAggregator.BuildCampaignPerformance(activities); + var campaigns = await _campaignManager.GetAllAsync(cancellationToken); + + var names = new Dictionary(StringComparer.Ordinal); + + foreach (var campaign in campaigns) + { + names[campaign.ItemId] = string.IsNullOrWhiteSpace(campaign.DisplayText) ? campaign.ItemId : campaign.DisplayText; + } + + var noCampaign = S["(No campaign)"].Value; + + var columns = new[] + { + new ReportColumn(S["Campaign"].Value), + new ReportColumn(S["Total"].Value, ReportColumnAlign.End), + new ReportColumn(S["Completed"].Value, ReportColumnAlign.End), + new ReportColumn(S["Pending"].Value, ReportColumnAlign.End), + new ReportColumn(S["In progress"].Value, ReportColumnAlign.End), + new ReportColumn(S["Failed"].Value, ReportColumnAlign.End), + new ReportColumn(S["Cancelled"].Value, ReportColumnAlign.End), + new ReportColumn(S["Completion"].Value, ReportColumnAlign.End), + }; + + var rows = new List(); + + foreach (var row in data.Rows) + { + var name = string.IsNullOrEmpty(row.CampaignId) + ? noCampaign + : names.GetValueOrDefault(row.CampaignId, row.CampaignId); + + rows.Add(new ReportRow(BuildCells(name, row.Counts))); + } + + rows.Add(new ReportRow(BuildCells(S["All campaigns"].Value, data.Totals), emphasize: true)); + + return new ReportDocument() + .Add(ReportSection.ForTable(S["Campaigns"].Value, columns, rows)); + } + + private static string[] BuildCells(string label, Models.Reports.OmnichannelProgressCounts counts) + { + return + [ + label, + ReportFormat.Number(counts.Total), + ReportFormat.Number(counts.Completed), + ReportFormat.Number(counts.Pending), + ReportFormat.Number(counts.InProgress), + ReportFormat.Number(counts.Failed), + ReportFormat.Number(counts.Cancelled), + ReportFormat.Percent(counts.CompletionRate), + ]; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/DispositionBreakdownReportProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/DispositionBreakdownReportProvider.cs new file mode 100644 index 000000000..27045aa56 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/DispositionBreakdownReportProvider.cs @@ -0,0 +1,88 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; +using ISession = YesSql.ISession; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Reports; + +/// +/// The CRM disposition breakdown report: how completed activities were dispositioned in the period. +/// +public sealed class DispositionBreakdownReportProvider : OmnichannelReportBase +{ + private readonly ISession _session; + private readonly INamedCatalogManager _dispositionManager; + + /// + /// Initializes a new instance of the class. + /// + /// The YesSql session. + /// The disposition manager used to resolve disposition names. + /// The string localizer. + public DispositionBreakdownReportProvider( + ISession session, + INamedCatalogManager dispositionManager, + IStringLocalizer stringLocalizer) + : base(stringLocalizer) + { + _session = session; + _dispositionManager = dispositionManager; + } + + /// + public override string Name => "omnichannel-disposition-breakdown"; + + /// + public override LocalizedString DisplayName => S["Disposition breakdown"]; + + /// + public override LocalizedString Description => S["How completed CRM activities were dispositioned in the reporting period."]; + + /// + public override async Task RunAsync(ReportContext context, CancellationToken cancellationToken = default) + { + var completed = await OmnichannelReportQuery.GetCompletedAsync(_session, context.FromUtc, context.ToUtc, cancellationToken); + var counts = OmnichannelReportAggregator.CountByDisposition(completed); + var dispositions = await _dispositionManager.GetAllAsync(cancellationToken); + + var names = new Dictionary(StringComparer.Ordinal); + + foreach (var disposition in dispositions) + { + names[disposition.ItemId] = string.IsNullOrWhiteSpace(disposition.Name) ? disposition.ItemId : disposition.Name; + } + + var noDisposition = S["(No disposition)"].Value; + var total = counts.Values.Sum(); + + var columns = new[] + { + new ReportColumn(S["Disposition"].Value), + new ReportColumn(S["Completed"].Value, ReportColumnAlign.End), + new ReportColumn(S["Share"].Value, ReportColumnAlign.End), + }; + + var rows = counts + .OrderByDescending(entry => entry.Value) + .Select(entry => new ReportRow( + [ + string.IsNullOrEmpty(entry.Key) ? noDisposition : names.GetValueOrDefault(entry.Key, entry.Key), + ReportFormat.Number(entry.Value), + ReportFormat.Percent(total > 0 ? (double)entry.Value / total : 0), + ])) + .ToList(); + + rows.Add(new ReportRow( + [ + S["All dispositions"].Value, + ReportFormat.Number(total), + ReportFormat.Percent(total > 0 ? 1d : 0), + ], emphasize: true)); + + return new ReportDocument() + .Add(ReportSection.ForTable(S["Dispositions"].Value, columns, rows)); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs new file mode 100644 index 000000000..41b2fa084 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Reports; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; +using OrchardCore.Security.Permissions; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Reports; + +/// +/// Provides the shared category and permission for the Omnichannel CRM reports contributed to the admin +/// Reports area. +/// +public abstract class OmnichannelReportBase : IReport +{ + /// + /// Initializes a new instance of the class. + /// + /// The string localizer used for the report labels. + protected OmnichannelReportBase(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + /// Gets the string localizer used for the report labels. + /// + protected IStringLocalizer S { get; } + + /// + public abstract string Name { get; } + + /// + public abstract LocalizedString DisplayName { get; } + + /// + public abstract LocalizedString Description { get; } + + /// + public string Category => ReportsConstants.Categories.Crm; + + /// + public Permission Permission => OmnichannelConstants.Permissions.ViewReports; + + /// + public abstract Task RunAsync(ReportContext context, CancellationToken cancellationToken = default); +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportAggregator.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportAggregator.cs new file mode 100644 index 000000000..a6d598eae --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportAggregator.cs @@ -0,0 +1,155 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Models.Reports; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Provides pure aggregation over CRM activity index rows for the Omnichannel reports. Keeping the +/// aggregation separate from the queries makes it unit-testable without a live session. +/// +internal static class OmnichannelReportAggregator +{ + /// + /// Builds the activity summary from the activities created in the reporting period. + /// + /// The activities created in the period. + /// The aggregated activity summary. + public static OmnichannelActivitySummaryData BuildActivitySummary(IReadOnlyList activities) + { + var data = new OmnichannelActivitySummaryData + { + Counts = BuildCounts(activities), + }; + + var bySource = new Dictionary(StringComparer.Ordinal); + var byChannel = new Dictionary(StringComparer.Ordinal); + var byStatus = new Dictionary(StringComparer.Ordinal); + var daily = new Dictionary(); + + foreach (var activity in activities) + { + var source = string.IsNullOrEmpty(activity.Source) ? ActivitySources.Manual : activity.Source; + bySource[source] = bySource.GetValueOrDefault(source) + 1; + + var channel = string.IsNullOrEmpty(activity.Channel) ? "—" : activity.Channel; + byChannel[channel] = byChannel.GetValueOrDefault(channel) + 1; + + var status = activity.Status.ToString(); + byStatus[status] = byStatus.GetValueOrDefault(status) + 1; + + var day = DateOnly.FromDateTime(activity.CreatedUtc); + + if (!daily.TryGetValue(day, out var point)) + { + point = new OmnichannelActivityDailyPoint { Date = day }; + daily[day] = point; + } + + point.Count++; + } + + data.BySource = bySource; + data.ByChannel = byChannel; + data.ByStatus = byStatus; + data.Daily = daily.Values.OrderBy(point => point.Date).ToList(); + + return data; + } + + /// + /// Builds the per-campaign performance from the activities created in the reporting period. + /// + /// The activities created in the period. + /// The aggregated campaign performance. + public static OmnichannelCampaignPerformanceData BuildCampaignPerformance(IReadOnlyList activities) + { + var data = new OmnichannelCampaignPerformanceData(); + + foreach (var group in activities.GroupBy(activity => activity.CampaignId ?? string.Empty, StringComparer.Ordinal)) + { + var counts = BuildCounts(group.ToArray()); + + data.Rows.Add(new OmnichannelCampaignRow + { + CampaignId = group.Key, + Counts = counts, + }); + + Accumulate(data.Totals, counts); + } + + data.Rows = data.Rows.OrderByDescending(row => row.Counts.Total).ToList(); + + return data; + } + + /// + /// Counts the completed activities grouped by disposition. + /// + /// The activities completed in the period. + /// A dictionary of disposition identifier to completed count. + public static IReadOnlyDictionary CountByDisposition(IReadOnlyList completedActivities) + { + var counts = new Dictionary(StringComparer.Ordinal); + + foreach (var activity in completedActivities) + { + var disposition = string.IsNullOrEmpty(activity.DispositionId) ? string.Empty : activity.DispositionId; + counts[disposition] = counts.GetValueOrDefault(disposition) + 1; + } + + return counts; + } + + private static OmnichannelProgressCounts BuildCounts(IReadOnlyList activities) + { + var counts = new OmnichannelProgressCounts(); + + foreach (var activity in activities) + { + counts.Total++; + + 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(OmnichannelProgressCounts totals, OmnichannelProgressCounts 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; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportQuery.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportQuery.cs new file mode 100644 index 000000000..502cf7c38 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/OmnichannelReportQuery.cs @@ -0,0 +1,56 @@ +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using YesSql; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Provides the shared CRM activity index queries used by the Omnichannel reports. +/// +internal static class OmnichannelReportQuery +{ + /// + /// Lists the activities created within the inclusive UTC period. + /// + /// The YesSql session. + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The activities created in the period. + public static async Task> GetCreatedAsync( + ISession session, + 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(); + } + + /// + /// Lists the activities completed within the inclusive UTC period. + /// + /// The YesSql session. + /// The inclusive lower UTC bound. + /// The inclusive upper UTC bound. + /// The token to monitor for cancellation requests. + /// The activities completed in the period. + public static async Task> GetCompletedAsync( + ISession session, + DateTime fromUtc, + DateTime toUtc, + CancellationToken cancellationToken) + { + var activities = await session.QueryIndex( + index => index.Status == ActivityStatus.Completed && index.CompletedUtc >= fromUtc && index.CompletedUtc <= toUtc, + collection: OmnichannelConstants.CollectionName) + .ListAsync(cancellationToken); + + return activities.ToArray(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs index f9d2ab982..6c2484011 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/PermissionProvider.cs @@ -25,6 +25,7 @@ internal sealed class PermissionProvider : IPermissionProvider OmnichannelConstants.Permissions.ManageChannelEndpoints, OmnichannelConstants.Permissions.ManageActivityBatches, OmnichannelConstants.Permissions.ManageSubjectFlows, + OmnichannelConstants.Permissions.ViewReports, ]; /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 28c94d2c1..732b4893f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -14,9 +14,11 @@ using CrestApps.OrchardCore.Omnichannel.Managements.Handlers; using CrestApps.OrchardCore.Omnichannel.Managements.Indexes; using CrestApps.OrchardCore.Omnichannel.Managements.Migrations; +using CrestApps.OrchardCore.Omnichannel.Managements.Reports; using CrestApps.OrchardCore.Omnichannel.Managements.Services; using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels; using CrestApps.OrchardCore.PhoneNumbers.Core; +using CrestApps.OrchardCore.Reports; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Routing; @@ -217,3 +219,18 @@ public override void ConfigureServices(IServiceCollection services) services.AddDisplayDriver(); } } + +/// +/// Registers the Omnichannel CRM reports contributed to the admin Reports area. +/// +[Feature(OmnichannelConstants.Features.Reports)] +public sealed class ReportsStartup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs index 583a4fe99..652a58708 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs @@ -48,6 +48,11 @@ public class OmnichannelActivityBatchViewModel /// public string ContactContentType { get; set; } + /// + /// Gets or sets the AI profile identifier used by automated activities. + /// + public string AIProfileId { get; set; } + /// /// Gets or sets the instructions. /// @@ -148,6 +153,12 @@ public class OmnichannelActivityBatchViewModel [BindNever] public IEnumerable ContactContentTypes { get; set; } + /// + /// Gets or sets the available AI profiles. + /// + [BindNever] + public IEnumerable AIProfiles { get; set; } + /// /// Gets or sets the selected users. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Activities/Complete.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Activities/Complete.cshtml index 010c47762..ff0904770 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Activities/Complete.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/Activities/Complete.cshtml @@ -5,17 +5,19 @@ @{ var activityId = ViewContext.HttpContext.Request.RouteValues["id"]; + var contactContentItem = Model.ContactContentItem; } -@{ +@if (contactContentItem is not null) +{ var shape = new ContactNavigationAdminShapeViewModel { - ContactContentItem = Model.ContactContentItem, + ContactContentItem = contactContentItem, ShowEdit = true, }; -} -@await DisplayAsync(shape) + @await DisplayAsync(shape) +}
    @Html.ValidationSummary() @@ -23,5 +25,12 @@ - @T["Cancel"] + @if (contactContentItem is null) + { + @T["Cancel"] + } + else + { + @T["Cancel"] + }
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml index 727049da7..7c541d0bb 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageActivityFilterFields.Edit.cshtml @@ -5,9 +5,10 @@ @model BulkManageActivityFilterViewModel + - + @{ var datePattern = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/CompleteOmnichannelActivityContainer.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/CompleteOmnichannelActivityContainer.cshtml index be4e2be87..23492e384 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/CompleteOmnichannelActivityContainer.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/CompleteOmnichannelActivityContainer.cshtml @@ -1,28 +1,33 @@ -@using CrestApps.OrchardCore.Omnichannel.Core @using CrestApps.OrchardCore.Omnichannel.Core.Models @using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels -@using Microsoft.AspNetCore.Authorization -@using OrchardCore.ContentManagement.Metadata -@using OrchardCore.Contents @model CompleteOmnichannelActivityContainer -@inject IContentDefinitionManager ContentDefinitionManager +@{ + var subject = Model.Subject; + var activity = Model.Activity; +} -
    -
    - @T["Subject"] +@if (subject is not null) +{ +
    +
    + @T["Subject"] +
    +
    + @await DisplayAsync(subject) +
    -
    - @await DisplayAsync(Model.Subject) -
    -
    +} -
    -
    - @T["Activity"] -
    -
    - @await DisplayAsync(Model.Activity) +@if (activity is not null) +{ +
    +
    + @T["Activity"] +
    +
    + @await DisplayAsync(activity) +
    -
    +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml index 0fc90bd06..09135b52c 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml @@ -17,9 +17,10 @@ } + - + @if (Model.Status != OmnichannelActivityBatchStatus.New) { @@ -70,6 +71,20 @@
    + @if (string.Equals(Model.Source, ActivitySources.Automatic, StringComparison.OrdinalIgnoreCase)) + { +
    + +
    + + + @T["Only chat profiles with Add initial prompt enabled are shown. The selected profile is stored on each loaded activity and starts automated SMS conversations."] +
    +
    + } +
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs index 5404c9ea2..35fe05ee9 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs @@ -170,18 +170,22 @@ public async Task HandleAsync(OmnichannelEvent omnichannelEvent, CancellationTok return; } - if (string.IsNullOrWhiteSpace(flowSettings.ProfileId)) + var profileId = string.IsNullOrWhiteSpace(activity.AIProfileId) + ? flowSettings.ProfileId + : activity.AIProfileId; + + if (string.IsNullOrWhiteSpace(profileId)) { _logger.LogWarning("The subject flow settings for subject '{SubjectContentType}' associated with Activity {ActivityId} do not have an AI profile. Cannot process incoming SMS message.", activity.SubjectContentType, activity.ItemId); return; } - var profile = await _profileManager.FindByIdAsync(flowSettings.ProfileId, cancellationToken); + var profile = await _profileManager.FindByIdAsync(profileId, cancellationToken); if (profile is null || profile.Type != AIProfileType.Chat) { - _logger.LogWarning("The AI profile '{ProfileId}' associated with Activity {ActivityId} was not found or is not a chat profile. Cannot process incoming SMS message.", flowSettings.ProfileId, activity.ItemId); + _logger.LogWarning("The AI profile '{ProfileId}' associated with Activity {ActivityId} was not found or is not a chat profile. Cannot process incoming SMS message.", profileId, activity.ItemId); return; } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs index 1ad75ed25..bd5ccabad 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs @@ -98,8 +98,12 @@ public async Task StartAsync(OmnichannelActivity activity, CancellationToken can var flowSettings = await FindFlowSettingsAsync(activity.SubjectContentType, cancellationToken) ?? throw new InvalidOperationException($"Unable to find subject flow settings for the activity '{activity.ItemId}' and subject '{activity.SubjectContentType}'."); - var profile = await _profileManager.FindByIdAsync(flowSettings.ProfileId, cancellationToken) - ?? throw new InvalidOperationException($"Unable to find the AI profile '{flowSettings.ProfileId}' for the activity '{activity.ItemId}'."); + var profileId = string.IsNullOrWhiteSpace(activity.AIProfileId) + ? flowSettings.ProfileId + : activity.AIProfileId; + + var profile = await _profileManager.FindByIdAsync(profileId, cancellationToken) + ?? throw new InvalidOperationException($"Unable to find the AI profile '{profileId}' for the activity '{activity.ItemId}'."); if (profile.Type != AIProfileType.Chat) { diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs b/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs new file mode 100644 index 000000000..8f2bf4f53 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs @@ -0,0 +1,174 @@ +using CrestApps.OrchardCore.Reports.Models; +using CrestApps.OrchardCore.Reports.Services; +using CrestApps.OrchardCore.Reports.ViewModels; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using OrchardCore.Admin; +using OrchardCore.DisplayManagement; +using OrchardCore.DisplayManagement.ModelBinding; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Reports.Controllers; + +/// +/// Serves the admin Reports area: the report list, a rendered report with its filter, and report exports. +/// +[Admin] +public sealed class ReportsController : Controller +{ + private const int DefaultRangeDays = 30; + + private readonly IReportManager _reportManager; + private readonly IReportExportManager _exportManager; + private readonly IDisplayManager _filterDisplayManager; + private readonly IAuthorizationService _authorizationService; + private readonly IUpdateModelAccessor _updateModelAccessor; + private readonly IClock _clock; + + /// + /// Initializes a new instance of the class. + /// + /// The report manager used to resolve registered reports. + /// The export manager used to resolve export formats. + /// The display manager used to build and bind the report filter. + /// The authorization service. + /// The update model accessor used to bind the filter from the request. + /// The clock used to compute the default reporting period. + public ReportsController( + IReportManager reportManager, + IReportExportManager exportManager, + IDisplayManager filterDisplayManager, + IAuthorizationService authorizationService, + IUpdateModelAccessor updateModelAccessor, + IClock clock) + { + _reportManager = reportManager; + _exportManager = exportManager; + _filterDisplayManager = filterDisplayManager; + _authorizationService = authorizationService; + _updateModelAccessor = updateModelAccessor; + _clock = clock; + } + + /// + /// Lists the reports the current user is authorized to view. + /// + /// The report list view. + [Admin("Reports", "Reports")] + public async Task Index() + { + var accessible = new List(); + + foreach (var report in _reportManager.ListReports()) + { + if (await _authorizationService.AuthorizeAsync(User, report.Permission)) + { + accessible.Add(report); + } + } + + return View(new ReportsIndexViewModel + { + Reports = accessible, + }); + } + + /// + /// Renders a report with its filter editor and the resulting document. + /// + /// The report technical name. + /// The report view. + [Admin("Reports/view/{id}", "ReportsDisplay")] + public async Task Display(string id) + { + var report = _reportManager.FindByName(id); + + if (report is null) + { + return NotFound(); + } + + if (!await _authorizationService.AuthorizeAsync(User, report.Permission)) + { + return Forbid(); + } + + var filter = await BuildFilterAsync(id); + var filterShape = await _filterDisplayManager.BuildEditorAsync(filter, _updateModelAccessor.ModelUpdater, false); + var document = await report.RunAsync(new ReportContext(filter), HttpContext.RequestAborted); + + return View(new ReportDisplayViewModel + { + Report = report, + FilterShape = filterShape, + Document = document, + FromUtc = filter.FromUtc.GetValueOrDefault(), + ToUtc = filter.ToUtc.GetValueOrDefault(), + }); + } + + /// + /// Exports a report in the requested format (CSV by default). + /// + /// The report technical name. + /// The export format technical name. + /// The exported file. + [Admin("Reports/view/{id}/export/{format?}", "ReportsExport")] + public async Task Export(string id, string format) + { + var report = _reportManager.FindByName(id); + + if (report is null) + { + return NotFound(); + } + + if (!await _authorizationService.AuthorizeAsync(User, report.Permission)) + { + return Forbid(); + } + + var exportFormat = _exportManager.FindFormat(string.IsNullOrEmpty(format) ? ReportsConstants.CsvExportFormat : format); + + if (exportFormat is null) + { + return NotFound(); + } + + var filter = await BuildFilterAsync(id); + var document = await report.RunAsync(new ReportContext(filter), HttpContext.RequestAborted); + var content = exportFormat.Serialize(document); + var fileName = $"{id}-{filter.FromUtc:yyyyMMdd}-to-{filter.ToUtc:yyyyMMdd}.{exportFormat.FileExtension}"; + + return File(content, exportFormat.ContentType, fileName); + } + + private async Task BuildFilterAsync(string reportName) + { + var filter = new ReportFilter + { + ReportName = reportName, + }; + + await _filterDisplayManager.UpdateEditorAsync(filter, _updateModelAccessor.ModelUpdater, false); + + NormalizeRange(filter); + + return filter; + } + + private void NormalizeRange(ReportFilter filter) + { + var today = _clock.UtcNow.Date; + var to = filter.ToUtc?.Date ?? today; + var from = filter.FromUtc?.Date ?? to.AddDays(-(DefaultRangeDays - 1)); + + if (from > to) + { + (from, to) = (to, from); + } + + filter.FromUtc = DateTime.SpecifyKind(from, DateTimeKind.Utc); + filter.ToUtc = DateTime.SpecifyKind(to.AddDays(1).AddTicks(-1), DateTimeKind.Utc); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj b/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj new file mode 100644 index 000000000..883409cb8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj @@ -0,0 +1,36 @@ + + + + true + CrestApps OrchardCore Reports + + $(CrestAppsDescription) + + A reusable reporting framework for OrchardCore. Provides the admin Reports area, a display-driver + extensible report filter with a from/to date range, a uniform report renderer (metrics, tables, + and bars), and pluggable exports (CSV built in) that any module can plug reports into. + + $(PackageTags) Reports Analytics + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Drivers/ReportDateRangeFilterDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Reports/Drivers/ReportDateRangeFilterDisplayDriver.cs new file mode 100644 index 000000000..5af35fdff --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Drivers/ReportDateRangeFilterDisplayDriver.cs @@ -0,0 +1,36 @@ +using CrestApps.OrchardCore.Reports.Models; +using CrestApps.OrchardCore.Reports.ViewModels; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Views; + +namespace CrestApps.OrchardCore.Reports.Drivers; + +/// +/// Contributes the built-in from/to date-range filter to every report. Because it declares no group, it +/// renders for all reports; report-specific filters restrict themselves to their report's group. +/// +public sealed class ReportDateRangeFilterDisplayDriver : DisplayDriver +{ + /// + public override IDisplayResult Edit(ReportFilter filter, BuildEditorContext context) + { + return Initialize("ReportDateRangeFilter_Edit", model => + { + model.From = filter.FromUtc; + model.To = filter.ToUtc; + }).Location("Content:1"); + } + + /// + public override async Task UpdateAsync(ReportFilter filter, UpdateEditorContext context) + { + var model = new ReportDateRangeFilterViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + filter.FromUtc = model.From; + filter.ToUtc = model.To; + + return Edit(filter, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Reports/Manifest.cs new file mode 100644 index 000000000..bfc532ccf --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Manifest.cs @@ -0,0 +1,19 @@ +using CrestApps.OrchardCore; +using CrestApps.OrchardCore.Reports; +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Reports", + Author = CrestAppsManifestConstants.Author, + Website = CrestAppsManifestConstants.Website, + Version = CrestAppsManifestConstants.Version, + Description = "Provides a reusable reporting framework with a shared admin Reports area, extensible filters, and exports.", + Category = "Reporting" +)] + +[assembly: Feature( + Id = ReportsConstants.Feature, + Name = "Reports", + Description = "Adds the admin Reports area, the extensible report filter with a from/to date range, the uniform report renderer, and CSV export. Other modules contribute reports to this area.", + Category = "Reporting" +)] diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs b/src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs new file mode 100644 index 000000000..a2dd0a1e8 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs @@ -0,0 +1,122 @@ +using System.Text; +using CrestApps.OrchardCore.Reports.Models; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.Reports.Services; + +/// +/// Serializes a report document to CSV. Every section is written in order: metrics as label/value +/// pairs, tables as a header row plus data rows, and bars as label/value pairs. +/// +public sealed class CsvReportExportFormat : IReportExportFormat +{ + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public CsvReportExportFormat(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + public string Name => ReportsConstants.CsvExportFormat; + + /// + public LocalizedString DisplayName => S["CSV"]; + + /// + public string ContentType => "text/csv"; + + /// + public string FileExtension => "csv"; + + /// + public byte[] Serialize(ReportDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + var builder = new StringBuilder(); + var first = true; + + foreach (var section in document.Sections) + { + if (!first) + { + builder.Append("\r\n"); + } + + first = false; + + if (!string.IsNullOrEmpty(section.Title)) + { + AppendRow(builder, section.Title); + } + + switch (section.Kind) + { + case ReportSectionKind.Metrics: + AppendRow(builder, "Metric", "Value"); + + foreach (var metric in section.Metrics) + { + AppendRow(builder, metric.Label, metric.Value); + } + + break; + case ReportSectionKind.Table: + AppendRow(builder, [.. section.Columns.Select(column => column.Label)]); + + foreach (var row in section.Rows) + { + AppendRow(builder, [.. row.Cells]); + } + + break; + case ReportSectionKind.Bars: + AppendRow(builder, "Label", "Value"); + + foreach (var bar in section.Bars) + { + AppendRow(builder, bar.Label, bar.Value); + } + + break; + } + } + + return new UTF8Encoding(encoderShouldEmitUTF8Identifier: true).GetBytes(builder.ToString()); + } + + private static void AppendRow(StringBuilder builder, params string[] values) + { + for (var i = 0; i < values.Length; i++) + { + if (i > 0) + { + builder.Append(','); + } + + builder.Append(Escape(values[i])); + } + + builder.Append("\r\n"); + } + + private static string Escape(string value) + { + if (string.IsNullOrEmpty(value)) + { + return string.Empty; + } + + if (value.Contains('"') || value.Contains(',') || value.Contains('\n') || value.Contains('\r')) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs b/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs new file mode 100644 index 000000000..50f762089 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.Reports.Services; + +/// +/// Resolves the report export formats registered across the application. +/// +public interface IReportExportManager +{ + /// + /// Lists every registered export format. + /// + /// The registered export formats. + IReadOnlyList ListFormats(); + + /// + /// Finds an export format by its technical name. + /// + /// The format technical name. + /// The matching format, or when none is registered. + IReportExportFormat FindFormat(string name); +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs b/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs new file mode 100644 index 000000000..6b9afd63f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs @@ -0,0 +1,20 @@ +namespace CrestApps.OrchardCore.Reports.Services; + +/// +/// Provides access to the reports registered across the application. +/// +public interface IReportManager +{ + /// + /// Lists every registered report, ordered by category and display name. + /// + /// The registered reports. + IReadOnlyList ListReports(); + + /// + /// Finds a registered report by its technical name. + /// + /// The report technical name. + /// The matching report, or when none is registered. + IReport FindByName(string name); +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportExportManager.cs b/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportExportManager.cs new file mode 100644 index 000000000..fe9a81c56 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportExportManager.cs @@ -0,0 +1,49 @@ +namespace CrestApps.OrchardCore.Reports.Services; + +/// +/// Provides the default implementation of over the registered +/// export formats. +/// +public sealed class ReportExportManager : IReportExportManager +{ + private readonly IReadOnlyList _formats; + private readonly Dictionary _byName; + + /// + /// Initializes a new instance of the class. + /// + /// The registered export formats. + public ReportExportManager(IEnumerable formats) + { + _byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var format in formats) + { + if (!string.IsNullOrEmpty(format.Name)) + { + _byName.TryAdd(format.Name, format); + } + } + + _formats = _byName.Values + .OrderBy(format => format.DisplayName.Value, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + } + + /// + public IReadOnlyList ListFormats() + { + return _formats; + } + + /// + public IReportExportFormat FindFormat(string name) + { + if (string.IsNullOrEmpty(name)) + { + return null; + } + + return _byName.GetValueOrDefault(name); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs b/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs new file mode 100644 index 000000000..151f4c269 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs @@ -0,0 +1,49 @@ +namespace CrestApps.OrchardCore.Reports.Services; + +/// +/// Provides the default implementation of over the registered reports. +/// +public sealed class ReportManager : IReportManager +{ + private readonly IReadOnlyList _reports; + private readonly Dictionary _byName; + + /// + /// Initializes a new instance of the class. + /// + /// The registered reports. + public ReportManager(IEnumerable reports) + { + _byName = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var report in reports) + { + if (!string.IsNullOrEmpty(report.Name)) + { + _byName.TryAdd(report.Name, report); + } + } + + _reports = _byName.Values + .OrderBy(report => report.Category, StringComparer.OrdinalIgnoreCase) + .ThenBy(report => report.DisplayName.Value, StringComparer.CurrentCultureIgnoreCase) + .ToArray(); + } + + /// + public IReadOnlyList ListReports() + { + return _reports; + } + + /// + public IReport FindByName(string name) + { + if (string.IsNullOrEmpty(name)) + { + return null; + } + + return _byName.GetValueOrDefault(name); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportsAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportsAdminMenu.cs new file mode 100644 index 000000000..89b813dc9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportsAdminMenu.cs @@ -0,0 +1,70 @@ +using CrestApps.OrchardCore.Reports.Services; +using Microsoft.Extensions.Localization; +using OrchardCore.Navigation; + +namespace CrestApps.OrchardCore.Reports.Services; + +/// +/// Builds the top-level admin Reports menu from the registered reports, grouped by category. Each report +/// entry links to the shared report page and is gated by the report's own permission. +/// +public sealed class ReportsAdminMenu : AdminNavigationProvider +{ + private readonly IReportManager _reportManager; + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The report manager used to enumerate registered reports. + /// The string localizer. + public ReportsAdminMenu( + IReportManager reportManager, + IStringLocalizer stringLocalizer) + { + _reportManager = reportManager; + S = stringLocalizer; + } + + /// + protected override ValueTask BuildAsync(NavigationBuilder builder) + { + var reports = _reportManager.ListReports(); + + if (reports.Count == 0) + { + return ValueTask.CompletedTask; + } + + builder + .Add(S["Reports"], "after.40", reportsNode => + { + reportsNode + .AddClass("reports") + .Id("reports"); + + foreach (var group in reports.GroupBy(report => report.Category ?? string.Empty)) + { + var categoryLabel = string.IsNullOrEmpty(group.Key) + ? S["General"] + : new LocalizedString(group.Key, group.Key); + + reportsNode.Add(categoryLabel, categoryLabel.PrefixPosition(), categoryNode => + { + categoryNode.AddClass("report-category"); + + foreach (var report in group) + { + categoryNode.Add(report.DisplayName, report.DisplayName.PrefixPosition(), item => item + .AddClass("report") + .Action("Display", "Reports", new { area = ReportsConstants.Feature, id = report.Name }) + .Permission(report.Permission) + .LocalNav()); + } + }); + } + }, priority: 1); + + return ValueTask.CompletedTask; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Startup.cs b/src/Modules/CrestApps.OrchardCore.Reports/Startup.cs new file mode 100644 index 000000000..8ae982c0e --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Startup.cs @@ -0,0 +1,28 @@ +using CrestApps.OrchardCore.Reports.Drivers; +using CrestApps.OrchardCore.Reports.Models; +using CrestApps.OrchardCore.Reports.Services; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using OrchardCore.Navigation; + +namespace CrestApps.OrchardCore.Reports; + +/// +/// Registers the reusable Reports framework: the report and export registries, the built-in date-range +/// filter, the CSV export format, and the admin Reports navigation. +/// +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services + .AddScoped() + .AddScoped() + .AddScoped(); + + services.AddDisplayDriver(); + + services.AddNavigationProvider(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDateRangeFilterViewModel.cs b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDateRangeFilterViewModel.cs new file mode 100644 index 000000000..7623eda94 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDateRangeFilterViewModel.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Reports.ViewModels; + +/// +/// The editor view model for the built-in report date-range filter. +/// +public class ReportDateRangeFilterViewModel +{ + /// + /// Gets or sets the inclusive lower bound of the reporting period. + /// + public DateTime? From { get; set; } + + /// + /// Gets or sets the inclusive upper bound of the reporting period. + /// + public DateTime? To { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs new file mode 100644 index 000000000..cea05095d --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs @@ -0,0 +1,36 @@ +using CrestApps.OrchardCore.Reports.Models; +using OrchardCore.DisplayManagement; + +namespace CrestApps.OrchardCore.Reports.ViewModels; + +/// +/// The view model for a rendered report page: the report, its filter editor, the resulting document, +/// and the resolved reporting period. +/// +public sealed class ReportDisplayViewModel +{ + /// + /// Gets or sets the report being displayed. + /// + public IReport Report { get; set; } + + /// + /// Gets or sets the rendered filter editor shape. + /// + public IShape FilterShape { get; set; } + + /// + /// Gets or sets the report document to render. + /// + public ReportDocument Document { get; set; } + + /// + /// Gets or sets the resolved inclusive lower UTC bound of the reporting period. + /// + public DateTime FromUtc { get; set; } + + /// + /// Gets or sets the resolved inclusive upper UTC bound of the reporting period. + /// + public DateTime ToUtc { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportsIndexViewModel.cs b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportsIndexViewModel.cs new file mode 100644 index 000000000..83575ea50 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportsIndexViewModel.cs @@ -0,0 +1,12 @@ +namespace CrestApps.OrchardCore.Reports.ViewModels; + +/// +/// The view model for the Reports landing page, listing the reports the current user can access. +/// +public sealed class ReportsIndexViewModel +{ + /// + /// Gets or sets the reports the current user is authorized to view, ordered by category and name. + /// + public IList Reports { get; set; } = []; +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/ReportDateRangeFilter.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/ReportDateRangeFilter.Edit.cshtml new file mode 100644 index 000000000..93bd5d688 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/ReportDateRangeFilter.Edit.cshtml @@ -0,0 +1,10 @@ +@model CrestApps.OrchardCore.Reports.ViewModels.ReportDateRangeFilterViewModel + +
    + + +
    +
    + + +
    diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/ReportFilter.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/ReportFilter.Edit.cshtml new file mode 100644 index 000000000..ad1bf3a8c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/ReportFilter.Edit.cshtml @@ -0,0 +1 @@ +@await DisplayAsync(Model.Content) diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml new file mode 100644 index 000000000..619265d20 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml @@ -0,0 +1,39 @@ +@model ReportDisplayViewModel + +

    @RenderTitleSegments(Model.Report.DisplayName)

    + +@if (!string.IsNullOrEmpty(Model.Report.Description?.Value)) +{ +

    @Model.Report.Description

    +} + +
    +
    +
    +
    + @await DisplayAsync(Model.FilterShape) +
    + +
    +
    + +
    +
    +
    +
    + +
    + @if (Model.Document is not null && Model.Document.HasContent) + { + + } + else + { +

    @T["No data found for the selected filters."]

    + } +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Index.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Index.cshtml new file mode 100644 index 000000000..ea473dd48 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Index.cshtml @@ -0,0 +1,29 @@ +@using System.Linq +@model ReportsIndexViewModel + +

    @RenderTitleSegments(T["Reports"])

    + +@if (Model.Reports.Count == 0) +{ + +} +else +{ + @foreach (var group in Model.Reports.GroupBy(report => report.Category ?? string.Empty)) + { +

    @(string.IsNullOrEmpty(group.Key) ? T["General"].Value : group.Key)

    +
    + @foreach (var report in group) + { + + } +
    + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/_ReportDocument.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/_ReportDocument.cshtml new file mode 100644 index 000000000..7dd16ed96 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/_ReportDocument.cshtml @@ -0,0 +1,93 @@ +@using System.Globalization +@using CrestApps.OrchardCore.Reports.Models +@model ReportDocument + +@foreach (var reportSection in Model.Sections) +{ +
    + @if (!string.IsNullOrEmpty(reportSection.Title)) + { +

    @reportSection.Title

    + } + @if (!string.IsNullOrEmpty(reportSection.Description)) + { +

    @reportSection.Description

    + } + + @switch (reportSection.Kind) + { + case ReportSectionKind.Metrics: +
    + @foreach (var metric in reportSection.Metrics) + { +
    +
    +
    +
    @metric.Label
    +
    @metric.Value
    + @if (!string.IsNullOrEmpty(metric.Hint)) + { +
    @metric.Hint
    + } +
    +
    +
    + } +
    + + break; + + case ReportSectionKind.Table: + { + var alignClasses = reportSection.Columns + .Select(column => column.Align == ReportColumnAlign.End ? "text-end" : column.Align == ReportColumnAlign.Center ? "text-center" : "text-start") + .ToList(); + +
    + + + + @for (var i = 0; i < reportSection.Columns.Count; i++) + { + + } + + + + @foreach (var row in reportSection.Rows) + { + + @for (var i = 0; i < row.Cells.Count; i++) + { + + } + + } + +
    @reportSection.Columns[i].Label
    @row.Cells[i]
    +
    + } + + break; + + case ReportSectionKind.Bars: +
    + @foreach (var bar in reportSection.Bars) + { + var width = (Math.Clamp(bar.Ratio, 0d, 1d) * 100d).ToString("0.###", CultureInfo.InvariantCulture); +
    +
    + @bar.Label + @bar.Value +
    +
    +
    +
    +
    + } +
    + + break; + } +
    +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/_ViewImports.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/_ViewImports.cshtml new file mode 100644 index 000000000..f6bf90112 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/_ViewImports.cshtml @@ -0,0 +1,8 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@using CrestApps.OrchardCore.Reports +@using CrestApps.OrchardCore.Reports.Models +@using CrestApps.OrchardCore.Reports.ViewModels +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers diff --git a/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj b/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj index f12635d9e..21001ec38 100644 --- a/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj +++ b/src/Targets/CrestApps.OrchardCore.Cms.Core.Targets/CrestApps.OrchardCore.Cms.Core.Targets.csproj @@ -74,6 +74,7 @@ + diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterReportingServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterReportingServiceTests.cs new file mode 100644 index 000000000..bae94b861 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ContactCenterReportingServiceTests.cs @@ -0,0 +1,267 @@ +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class ContactCenterReportingServiceTests +{ + private static readonly DateTime _from = new(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc); + private static readonly DateTime _to = new(2026, 1, 31, 23, 59, 59, DateTimeKind.Utc); + private static readonly DateTime _day = new(2026, 1, 10, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void BuildCallInsights_ComputesVolumeOutcomesAndHandleTime() + { + // Arrange + var interactions = new[] + { + Interaction(InteractionDirection.Inbound, InteractionStatus.Ended, answeredAfter: 10, endedAfter: 70), + Interaction(InteractionDirection.Inbound, InteractionStatus.Ended, answeredAfter: null, endedAfter: null), + Interaction(InteractionDirection.Outbound, InteractionStatus.Ended, answeredAfter: 5, endedAfter: 35), + Interaction(InteractionDirection.Outbound, InteractionStatus.Failed, answeredAfter: null, endedAfter: null), + }; + + // Act + var report = ContactCenterReportingService.BuildCallInsights(_from, _to, interactions); + + // Assert + Assert.Equal(4, report.Total); + Assert.Equal(2, report.Inbound); + Assert.Equal(2, report.Outbound); + Assert.Equal(2, report.Answered); + Assert.Equal(1, report.Abandoned); + Assert.Equal(1, report.Failed); + Assert.Equal(45d, report.AverageHandleTimeSeconds); + Assert.Equal(7.5d, report.AverageSpeedOfAnswerSeconds); + Assert.Equal(90d, report.TotalTalkTimeSeconds); + } + + [Fact] + public void BuildCallInsights_GroupsDailyVolume() + { + // Arrange + var interactions = new[] + { + Interaction(InteractionDirection.Inbound, InteractionStatus.Ended, answeredAfter: 3, endedAfter: 30, createdUtc: _day), + Interaction(InteractionDirection.Inbound, InteractionStatus.Ended, answeredAfter: null, endedAfter: null, createdUtc: _day), + Interaction(InteractionDirection.Outbound, InteractionStatus.Ended, answeredAfter: 3, endedAfter: 30, createdUtc: _day.AddDays(1)), + }; + + // Act + var report = ContactCenterReportingService.BuildCallInsights(_from, _to, interactions); + + // Assert + Assert.Equal(2, report.Daily.Count); + + var first = report.Daily[0]; + + Assert.Equal(DateOnly.FromDateTime(_day), first.Date); + Assert.Equal(2, first.Total); + Assert.Equal(1, first.Answered); + Assert.Equal(1, first.Abandoned); + } + + [Fact] + public void BuildAgentProductivity_AggregatesHandledAndCompleted() + { + // Arrange + var interactions = new[] + { + AgentInteraction("agent-1", InteractionDirection.Inbound, answeredAfter: 5, endedAfter: 65), + AgentInteraction("agent-1", InteractionDirection.Outbound, answeredAfter: 5, endedAfter: 35), + AgentInteraction("agent-2", InteractionDirection.Inbound, answeredAfter: null, endedAfter: null), + }; + + var completedByUser = new Dictionary(StringComparer.Ordinal) + { + ["user-1"] = 4, + }; + + var agents = new[] + { + new AgentProfile { ItemId = "agent-1", UserId = "user-1", DisplayName = "Agent One" }, + new AgentProfile { ItemId = "agent-2", UserId = "user-2", DisplayName = "Agent Two" }, + }; + + // Act + var report = ContactCenterReportingService.BuildAgentProductivity(_from, _to, interactions, completedByUser, agents); + + // Assert + var top = report.Rows[0]; + + Assert.Equal("Agent One", top.DisplayName); + Assert.Equal(2, top.InteractionsHandled); + Assert.Equal(1, top.InboundHandled); + Assert.Equal(1, top.OutboundHandled); + Assert.Equal(90d, top.TotalTalkTimeSeconds); + Assert.Equal(45d, top.AverageHandleTimeSeconds); + Assert.Equal(4, top.ActivitiesCompleted); + + // The second agent never answered an interaction and completed no activity, so is excluded. + Assert.Single(report.Rows); + } + + [Fact] + public void BuildQueueUsage_AggregatesPerQueueAndIncludesWaiting() + { + // Arrange + var interactions = new[] + { + QueueInteraction("queue-1", InteractionDirection.Inbound, InteractionStatus.Ended, answeredAfter: 4, endedAfter: 64), + QueueInteraction("queue-1", InteractionDirection.Inbound, InteractionStatus.Ended, answeredAfter: null, endedAfter: null), + }; + + var queues = new[] + { + new ActivityQueue { ItemId = "queue-1", Name = "Support", SlaThresholdSeconds = 120 }, + new ActivityQueue { ItemId = "queue-2", Name = "Sales", SlaThresholdSeconds = 60 }, + }; + + var waiting = new Dictionary(StringComparer.Ordinal) + { + ["queue-1"] = 0, + ["queue-2"] = 3, + }; + + // Act + var report = ContactCenterReportingService.BuildQueueUsage(_from, _to, interactions, queues, waiting); + + // Assert + Assert.Equal(2, report.Rows.Count); + + var support = report.Rows.Single(row => row.QueueId == "queue-1"); + + Assert.Equal("Support", support.QueueName); + Assert.Equal(2, support.InteractionsHandled); + Assert.Equal(1, support.Answered); + Assert.Equal(1, support.Abandoned); + Assert.Equal(60d, support.AverageHandleTimeSeconds); + + var sales = report.Rows.Single(row => row.QueueId == "queue-2"); + + Assert.Equal(0, sales.InteractionsHandled); + Assert.Equal(3, sales.CurrentWaiting); + } + + [Fact] + public void BuildCampaignSummary_BucketsCompletedVersusPending() + { + // Arrange + var activities = new[] + { + ActivityIndex("camp-1", ActivityStatus.Completed), + ActivityIndex("camp-1", ActivityStatus.NotStated), + ActivityIndex("camp-1", ActivityStatus.InProgress), + ActivityIndex("camp-1", ActivityStatus.Failed), + ActivityIndex("camp-1", ActivityStatus.Cancelled), + }; + + var names = new Dictionary(StringComparer.Ordinal) + { + ["camp-1"] = "Winback", + }; + + // Act + var report = ContactCenterReportingService.BuildCampaignSummary(_from, _to, activities, names); + + // Assert + var row = Assert.Single(report.Rows); + + Assert.Equal("Winback", row.CampaignName); + Assert.Equal(5, row.Counts.Total); + Assert.Equal(1, row.Counts.Completed); + Assert.Equal(1, row.Counts.Pending); + Assert.Equal(1, row.Counts.InProgress); + Assert.Equal(1, row.Counts.Failed); + Assert.Equal(1, row.Counts.Cancelled); + Assert.Equal(0.2d, row.Counts.CompletionRate); + Assert.Equal(5, report.Totals.Total); + } + + [Fact] + public void BuildSubjectInventory_GroupsBySubjectType() + { + // Arrange + var activities = new[] + { + SubjectActivity("Lead", ActivityStatus.Completed), + SubjectActivity("Lead", ActivityStatus.NotStated), + SubjectActivity("Ticket", ActivityStatus.Completed), + }; + + // Act + var report = ContactCenterReportingService.BuildSubjectInventory(_from, _to, activities); + + // Assert + Assert.Equal(2, report.Rows.Count); + + var lead = report.Rows.Single(row => row.SubjectContentType == "Lead"); + + Assert.Equal(2, lead.Counts.Total); + Assert.Equal(1, lead.Counts.Completed); + Assert.Equal(1, lead.Counts.Pending); + Assert.Equal(3, report.Totals.Total); + } + + private static Interaction Interaction( + InteractionDirection direction, + InteractionStatus status, + int? answeredAfter, + int? endedAfter, + DateTime? createdUtc = null) + { + var created = createdUtc ?? _day; + + return new Interaction + { + ItemId = Guid.NewGuid().ToString("n"), + Channel = InteractionChannel.Voice, + Direction = direction, + Status = status, + CreatedUtc = created, + AnsweredUtc = answeredAfter.HasValue ? created.AddSeconds(answeredAfter.Value) : null, + EndedUtc = endedAfter.HasValue ? created.AddSeconds(endedAfter.Value) : null, + }; + } + + private static Interaction AgentInteraction(string agentId, InteractionDirection direction, int? answeredAfter, int? endedAfter) + { + var interaction = Interaction(direction, InteractionStatus.Ended, answeredAfter, endedAfter); + interaction.AgentId = agentId; + + return interaction; + } + + private static Interaction QueueInteraction(string queueId, InteractionDirection direction, InteractionStatus status, int? answeredAfter, int? endedAfter) + { + var interaction = Interaction(direction, status, answeredAfter, endedAfter); + interaction.QueueId = queueId; + + return interaction; + } + + private static OmnichannelActivityIndex ActivityIndex(string campaignId, ActivityStatus status) + { + return new OmnichannelActivityIndex + { + CampaignId = campaignId, + Status = status, + CreatedUtc = _day, + Attempts = 1, + }; + } + + private static OmnichannelActivityIndex SubjectActivity(string subjectContentType, ActivityStatus status) + { + return new OmnichannelActivityIndex + { + SubjectContentType = subjectContentType, + Status = status, + CreatedUtc = _day, + Attempts = 1, + }; + } +} diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Reports/OmnichannelReportAggregatorTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Reports/OmnichannelReportAggregatorTests.cs new file mode 100644 index 000000000..47a1281ba --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Omnichannel/Managements/Reports/OmnichannelReportAggregatorTests.cs @@ -0,0 +1,118 @@ +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Managements.Services; + +namespace CrestApps.OrchardCore.Tests.Modules.Omnichannel.Managements.Reports; + +public sealed class OmnichannelReportAggregatorTests +{ + private static readonly DateTime _day = new(2026, 2, 10, 9, 0, 0, DateTimeKind.Utc); + + [Fact] + public void BuildActivitySummary_ComputesCountsAndBreakdowns() + { + // Arrange + var activities = new[] + { + Activity(ActivityStatus.Completed, ActivitySources.Inbound, OmnichannelConstants.Channels.Phone), + Activity(ActivityStatus.NotStated, ActivitySources.Dialer, OmnichannelConstants.Channels.Phone), + Activity(ActivityStatus.InProgress, ActivitySources.Manual, OmnichannelConstants.Channels.Sms), + Activity(ActivityStatus.Failed, ActivitySources.Manual, OmnichannelConstants.Channels.Email), + }; + + // Act + var data = OmnichannelReportAggregator.BuildActivitySummary(activities); + + // Assert + Assert.Equal(4, data.Counts.Total); + Assert.Equal(1, data.Counts.Completed); + Assert.Equal(1, data.Counts.Pending); + Assert.Equal(1, data.Counts.InProgress); + Assert.Equal(1, data.Counts.Failed); + Assert.Equal(2, data.BySource[ActivitySources.Manual]); + Assert.Equal(2, data.ByChannel[OmnichannelConstants.Channels.Phone]); + Assert.Single(data.Daily); + Assert.Equal(4, data.Daily[0].Count); + } + + [Fact] + public void BuildCampaignPerformance_GroupsByCampaignWithTotals() + { + // Arrange + var activities = new[] + { + Campaign("camp-1", ActivityStatus.Completed), + Campaign("camp-1", ActivityStatus.NotStated), + Campaign("camp-2", ActivityStatus.Completed), + }; + + // Act + var data = OmnichannelReportAggregator.BuildCampaignPerformance(activities); + + // Assert + Assert.Equal(2, data.Rows.Count); + + var top = data.Rows[0]; + + Assert.Equal("camp-1", top.CampaignId); + Assert.Equal(2, top.Counts.Total); + Assert.Equal(1, top.Counts.Completed); + Assert.Equal(1, top.Counts.Pending); + Assert.Equal(3, data.Totals.Total); + Assert.Equal(2, data.Totals.Completed); + } + + [Fact] + public void CountByDisposition_CountsCompletedByDisposition() + { + // Arrange + var completed = new[] + { + Disposition("won"), + Disposition("won"), + Disposition("lost"), + Disposition(null), + }; + + // Act + var counts = OmnichannelReportAggregator.CountByDisposition(completed); + + // Assert + Assert.Equal(2, counts["won"]); + Assert.Equal(1, counts["lost"]); + Assert.Equal(1, counts[string.Empty]); + } + + private static OmnichannelActivityIndex Activity(ActivityStatus status, string source, string channel) + { + return new OmnichannelActivityIndex + { + Status = status, + Source = source, + Channel = channel, + CreatedUtc = _day, + }; + } + + private static OmnichannelActivityIndex Campaign(string campaignId, ActivityStatus status) + { + return new OmnichannelActivityIndex + { + CampaignId = campaignId, + Status = status, + CreatedUtc = _day, + }; + } + + private static OmnichannelActivityIndex Disposition(string dispositionId) + { + return new OmnichannelActivityIndex + { + Status = ActivityStatus.Completed, + DispositionId = dispositionId, + CompletedUtc = _day, + CreatedUtc = _day, + }; + } +} From ecb83feb25246291b7cc0a2f3cabb6ac8c0ed968 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Thu, 2 Jul 2026 16:38:10 -0700 Subject: [PATCH 35/56] Add activity loading --- .../Models/ActivityBatchLoadContext.cs | 38 ++ .../Services/IActivityBatchLoader.cs | 27 ++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 1 + .../docs/omnichannel/management.md | 14 + .../Controllers/ActivityBatchesController.cs | 397 +-------------- .../DefaultActivityBatchLoadCoordinator.cs | 97 ++++ .../DefaultContactActivityBatchLoader.cs | 453 ++++++++++++++++++ .../Services/IActivityBatchLoadCoordinator.cs | 22 + .../Startup.cs | 3 + 9 files changed, 664 insertions(+), 388 deletions(-) create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchLoadContext.cs create mode 100644 src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityBatchLoader.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityBatchLoadCoordinator.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/IActivityBatchLoadCoordinator.cs diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchLoadContext.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchLoadContext.cs new file mode 100644 index 000000000..6197b0c08 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchLoadContext.cs @@ -0,0 +1,38 @@ +namespace CrestApps.OrchardCore.Omnichannel.Core.Models; + +/// +/// Provides contextual information to an IActivityBatchLoader while it loads a batch. +/// +public sealed class ActivityBatchLoadContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The batch being loaded. It is already transitioned to the loading state. + /// The identifier of the user that initiated the load. + /// The username of the user that initiated the load. + public ActivityBatchLoadContext( + OmnichannelActivityBatch batch, + string loaderId, + string loaderUserName) + { + Batch = batch; + LoaderId = loaderId; + LoaderUserName = loaderUserName; + } + + /// + /// Gets the batch being loaded. + /// + public OmnichannelActivityBatch Batch { get; } + + /// + /// Gets the identifier of the user that initiated the load. + /// + public string LoaderId { get; } + + /// + /// Gets the username of the user that initiated the load. + /// + public string LoaderUserName { get; } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityBatchLoader.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityBatchLoader.cs new file mode 100644 index 000000000..fd2c557f0 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/IActivityBatchLoader.cs @@ -0,0 +1,27 @@ +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Omnichannel.Core.Services; + +/// +/// Defines a strategy that loads activities into an for a +/// specific activity batch source. Implement and register this interface to add a new source that +/// controls how leads are queried, filtered, and turned into activities. +/// +public interface IActivityBatchLoader +{ + /// + /// Gets the activity batch source this loader is responsible for. The value must match a + /// registered ActivityBatchSourceEntry.Source value. + /// + string Source { get; } + + /// + /// Loads the activities for the batch described by the supplied context. + /// The implementation owns its own filtering and activity creation, updates + /// , and sets the terminal + /// (typically loaded, or new when it aborts). + /// + /// The context describing the batch to load and the initiating user. + /// The token to monitor for cancellation requests. + Task LoadAsync(ActivityBatchLoadContext context, CancellationToken cancellationToken = default); +} diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index ceeca158f..9263de28d 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -405,6 +405,7 @@ At a high level, the platform changes are: - Automatic activity batches now load unassigned AI-ready activities, and automated subject flows require a chat AI profile with **Add initial prompt** enabled so the first outbound message can start the conversation reliably. - Automatic activity batches can now 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. - Activity batch sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. +- Activity batch 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 batch to the `New` state so it can be retried. - 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. - 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 Activity Batch time-zone multiselects now load the registered `crestapps-bootstrap-select` script/style pair so only one selector is visible. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index c3762c12e..ce87825e5 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -215,6 +215,20 @@ Subjects without any actions show a **Missing flow** badge in the Subject Flows The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the campaign, interaction type, channel, and channel endpoint. For Automatic batches, the loader stores the selected batch AI profile on each activity; if no batch profile is selected, the activity uses the subject flow's AI profile. The automated activity processor then uses that profile's initial prompt to send the first outbound SMS and to continue the AI conversation when the contact replies. Manual batches assign each created activity to a selected user. Automatic batches leave activities unassigned but immediately eligible for the automated activity processor when their schedule is due. Dialer batches leave activities unassigned with assignment status `Available` so dialers can reserve and assign them safely later. +### Extending activity batch sources + +Activity batch loading is extensible. Each batch has a **source**, and the source controls how the batch resolves and loads activities. There are two layers of extensibility: + +1. **Registering a source** — register sources through `ActivityBatchSourceOptions` in a feature `Startup`. Each `ActivityBatchSourceEntry` provides the display name, description, and whether the source requires user assignment. Registered sources appear as creation cards, and display drivers can add source-specific editor sections. + +2. **Controlling the load** — implement `IActivityBatchLoader` (from `CrestApps.OrchardCore.Omnichannel.Core.Services`) to fully own how a source queries leads, applies filters, and creates activities. The loader's `Source` property must match the registered source. Register the loader as a scoped service: + + ```csharp + services.AddScoped(); + ``` + +When a batch is loaded, the `IActivityBatchLoadCoordinator` transitions the batch to the loading state, resolves the loader whose `Source` matches the batch source, and delegates to it. Sources **without** a dedicated loader fall back to the built-in `DefaultContactActivityBatchLoader`, which pages over contacts of the batch contact content type, applies the standard lead filters (created range, phone number, time zone, last completed activity), and creates activities from the subject flow settings. The default loader is not sealed, so a custom loader can inherit from it to reuse the contact-paging pipeline while overriding individual stages. If a loader throws, the coordinator logs the error and returns the batch to the `New` state so it can be retried. + ### 9) Complete Activities 1. Open an activity from the activities list. diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs index 62e56565f..69715d070 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs @@ -2,36 +2,24 @@ using CrestApps.Core.Services; using CrestApps.OrchardCore.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core; -using CrestApps.OrchardCore.Omnichannel.Core.Indexes; using CrestApps.OrchardCore.Omnichannel.Core.Models; -using CrestApps.OrchardCore.Omnichannel.Core.Services; using CrestApps.OrchardCore.Omnichannel.Managements.Services; using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels; -using Dapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; -using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OrchardCore.Admin; using OrchardCore.BackgroundJobs; -using OrchardCore.ContentManagement; -using OrchardCore.ContentManagement.Records; -using OrchardCore.Data; using OrchardCore.DisplayManagement; using OrchardCore.DisplayManagement.ModelBinding; using OrchardCore.DisplayManagement.Notify; using OrchardCore.Environment.Shell.Scope; -using OrchardCore.Modules; using OrchardCore.Navigation; using OrchardCore.Routing; -using OrchardCore.Users.Indexes; -using OrchardCore.Users.Models; -using YesSql; -using YesSql.Services; using QueryContext = CrestApps.Core.Models.QueryContext; namespace CrestApps.OrchardCore.Omnichannel.Managements.Controllers; @@ -42,15 +30,12 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Controllers; [Admin] public sealed class ActivityBatchesController : Controller { - private const int _batchSize = 100; - private const string _optionsSearch = "Options.Search"; private readonly ICatalogManager _manager; private readonly IAuthorizationService _authorizationService; private readonly IUpdateModelAccessor _updateModelAccessor; private readonly IDisplayManager _batchDisplayDriver; - private readonly IClock _clock; private readonly INotifier _notifier; private readonly ActivityBatchSourceOptions _activityBatchSourceOptions; @@ -64,7 +49,6 @@ public sealed class ActivityBatchesController : Controller /// The authorization service. /// The update model accessor. /// The batch display manager. - /// The clock. /// The notifier. /// The configured activity batch sources. /// The html localizer. @@ -74,7 +58,6 @@ public ActivityBatchesController( IAuthorizationService authorizationService, IUpdateModelAccessor updateModelAccessor, IDisplayManager batchDisplayManager, - IClock clock, INotifier notifier, IOptions activityBatchSourceOptions, IHtmlLocalizer htmlLocalizer, @@ -84,7 +67,6 @@ public ActivityBatchesController( _authorizationService = authorizationService; _updateModelAccessor = updateModelAccessor; _batchDisplayDriver = batchDisplayManager; - _clock = clock; _notifier = notifier; _activityBatchSourceOptions = activityBatchSourceOptions.Value; H = htmlLocalizer; @@ -408,360 +390,19 @@ public async Task Load(string id) model.Status = OmnichannelActivityBatchStatus.Started; await _manager.UpdateAsync(model); + var loaderId = User.FindFirstValue(ClaimTypes.NameIdentifier); + var loaderUserName = User.Identity.Name; + var batchId = model.ItemId; + ShellScope.AddDeferredTask(async s => { - // Query the contacts, and find the ones that do not already have an activity assigned. - // Then, load the activities and assign them to the agents. - await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync("load-activity-batch", User.FindFirstValue(ClaimTypes.NameIdentifier), User.Identity.Name, model.ItemId, async (scope, loaderId, loaderUserName, batchId) => + // Resolve the loader that matches the batch source and let it query the contacts, + // apply the source-specific filters, and create the activities in the background. + await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync("load-activity-batch", loaderId, loaderUserName, batchId, async (scope, userId, userName, id) => { - var catalog = scope.ServiceProvider.GetRequiredService>(); - - long documentId = 0; - var batch = await catalog.FindByIdAsync(batchId); - - if (batch.Status != OmnichannelActivityBatchStatus.Started) - { - throw new InvalidOperationException($"Unable to load activities for batch with the ID '{batch.ItemId}' since it's status is not '{nameof(OmnichannelActivityBatchStatus.Started)}'."); - } - - batch.Status = OmnichannelActivityBatchStatus.Loading; - batch.TotalLoaded = 0; - - var logger = scope.ServiceProvider.GetRequiredService>(); - var session = scope.ServiceProvider.GetRequiredService(); - - await using var readonlySession = session.Store.CreateSession(withTracking: false); - - var sourceOptions = scope.ServiceProvider.GetRequiredService>().Value; - - if (!TryGetActivityBatchSource(batch.Source, sourceOptions, out var sourceEntry)) - { - batch.Status = OmnichannelActivityBatchStatus.New; - - await catalog.UpdateAsync(batch); - - logger.LogError("No valid activity batch source was found for the batch with ID '{BatchId}' and source '{Source}'.", batch.ItemId, batch.Source); - return; - } - - var requiresUserAssignment = sourceEntry.RequiresUserAssignment; - var users = requiresUserAssignment - ? (await readonlySession.Query(x => x.IsEnabled && x.UserId.IsIn(batch.UserIds)).ListAsync()).ToArray() - : []; - - if (requiresUserAssignment && users.Length == 0) - { - batch.Status = OmnichannelActivityBatchStatus.New; - - await catalog.UpdateAsync(batch); - - logger.LogError("No valid users were found to assign the activities for the batch with ID '{BatchId}'.", batch.ItemId); - return; - } - - var localClock = scope.ServiceProvider.GetRequiredService(); - var subjectFlowSettingsService = scope.ServiceProvider.GetRequiredService(); - var flowSettings = await subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(batch.SubjectContentType); - - if (flowSettings is null) - { - batch.Status = OmnichannelActivityBatchStatus.New; - - await catalog.UpdateAsync(batch); - - logger.LogError("Configured subject flow settings are required before loading the batch with ID '{BatchId}' for subject '{SubjectContentType}'.", batch.ItemId, batch.SubjectContentType); - return; - } - - var activityManager = scope.ServiceProvider.GetRequiredService(); - - DateTime? leadCreatedFrom = batch.LeadCreatedFrom.HasValue - ? await localClock.ConvertToUtcAsync(batch.LeadCreatedFrom.Value) - : null; - - DateTime? leadCreatedTo = batch.LeadCreatedTo.HasValue - ? await localClock.ConvertToUtcAsync(batch.LeadCreatedTo.Value) - : null; - - // Pre-compute contact-level filter sets (phone, timezone, last activity). - HashSet eligibleContactIds = null; - - var hasPhoneFilter = !string.IsNullOrEmpty(batch.PhoneNumber); - var hasTimeZoneFilter = batch.TimeZoneIds is { Length: > 0 }; - var hasLastActivityFilter = !string.IsNullOrEmpty(batch.LastActivitySubjectContentType); - - if (hasPhoneFilter || hasTimeZoneFilter || hasLastActivityFilter) - { - HashSet phoneIds = null; - HashSet timeZoneIds = null; - HashSet lastActivityIds = null; - - if (hasPhoneFilter) - { - var phoneQuery = batch.PhoneNumberMatchType switch - { - PhoneNumberMatchType.Exact => readonlySession.QueryIndex(index => - index.NormalizedPrimaryCellPhoneNumber == batch.PhoneNumber || - index.NormalizedPrimaryHomePhoneNumber == batch.PhoneNumber), - PhoneNumberMatchType.EndsWith => readonlySession.QueryIndex(index => - index.NormalizedPrimaryCellPhoneNumber.EndsWith(batch.PhoneNumber) || - index.NormalizedPrimaryHomePhoneNumber.EndsWith(batch.PhoneNumber)), - _ => readonlySession.QueryIndex(index => - index.NormalizedPrimaryCellPhoneNumber.StartsWith(batch.PhoneNumber) || - index.NormalizedPrimaryHomePhoneNumber.StartsWith(batch.PhoneNumber)), - }; - - var phoneContacts = await phoneQuery.ListAsync(); - phoneIds = phoneContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); - } - - if (hasTimeZoneFilter) - { - var tzContacts = await readonlySession.QueryIndex( - index => index.TimeZoneId.IsIn(batch.TimeZoneIds)) - .ListAsync(); + var coordinator = scope.ServiceProvider.GetRequiredService(); - timeZoneIds = tzContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); - } - - if (hasLastActivityFilter) - { - // Use raw SQL to find contacts whose most recent completed activity - // matches the given subject (and optional disposition). This avoids - // materializing all completed activities in memory. - var store = scope.ServiceProvider.GetRequiredService(); - var dbConnectionAccessor = scope.ServiceProvider.GetRequiredService(); - - var dialect = store.Configuration.SqlDialect; - var dbSchema = store.Configuration.Schema; - var activityTableName = store.Configuration.TableNameConvention.GetIndexTable( - typeof(OmnichannelActivityIndex), - OmnichannelConstants.CollectionName); - var activityTable = dialect.QuoteForTableName( - $"{store.Configuration.TablePrefix}{activityTableName}", - dbSchema); - var contactCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.ContactContentItemId)); - var statusCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.Status)); - var subjectCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.SubjectContentType)); - var dispositionCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.DispositionId)); - var completedCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.CompletedUtc)); - - var completedStatus = (int)ActivityStatus.Completed; - - // Find contacts where the most recent completed activity matches the subject/disposition. - // Uses a correlated subquery to find the "latest per group" server-side. - var sql = $@"SELECT DISTINCT a.{contactCol} - FROM {activityTable} a - WHERE a.{statusCol} = @CompletedStatus - AND a.{subjectCol} = @Subject - AND a.{completedCol} = ( - SELECT MAX(a2.{completedCol}) - FROM {activityTable} a2 - WHERE a2.{contactCol} = a.{contactCol} - AND a2.{statusCol} = @CompletedStatus - )"; - - var parameters = new DynamicParameters(); - parameters.Add("@CompletedStatus", completedStatus); - parameters.Add("@Subject", batch.LastActivitySubjectContentType); - - if (!string.IsNullOrEmpty(batch.LastActivityDispositionId)) - { - sql += $"\n AND a.{dispositionCol} = @Disposition"; - parameters.Add("@Disposition", batch.LastActivityDispositionId); - } - - await using var sqlConnection = dbConnectionAccessor.CreateConnection(); - await sqlConnection.OpenAsync(); - - var command = new CommandDefinition(sql, parameters); - var results = await sqlConnection.QueryAsync(command); - - lastActivityIds = results - .Where(id => !string.IsNullOrEmpty(id)) - .ToHashSet(StringComparer.OrdinalIgnoreCase); - } - - // Intersect all non-null filter sets. - foreach (var set in new[] { phoneIds, timeZoneIds, lastActivityIds }) - { - if (set is null) - { - continue; - } - - if (eligibleContactIds is null) - { - eligibleContactIds = set; - } - else - { - eligibleContactIds.IntersectWith(set); - } - } - - // If filters are applied but no contacts match, mark as loaded immediately. - if (eligibleContactIds is not null && eligibleContactIds.Count == 0) - { - batch.Status = OmnichannelActivityBatchStatus.Loaded; - - await catalog.UpdateAsync(batch); - await session.SaveChangesAsync(); - return; - } - } - - var activityCounter = 0; - - while (true) - { - var contactQuery = readonlySession.Query(index => - index.ContentType == batch.ContactContentType && - index.DocumentId > documentId); - - if (leadCreatedFrom.HasValue) - { - contactQuery = contactQuery.Where(index => index.CreatedUtc >= leadCreatedFrom); - } - - if (leadCreatedTo.HasValue) - { - contactQuery = contactQuery.Where(index => index.CreatedUtc <= leadCreatedTo); - } - - if (batch.OnlyPublishedLeads) - { - contactQuery = contactQuery.Where(contact => contact.Published); - } - else - { - contactQuery = contactQuery.Where(contact => contact.Latest); - } - - var contacts = await contactQuery - .OrderBy(x => x.DocumentId) - .Take(_batchSize) - .ListAsync(); - - if (!contacts.Any()) - { - batch.Status = OmnichannelActivityBatchStatus.Loaded; - - await catalog.UpdateAsync(batch); - break; - } - - var preventDuplicates = batch.PreventDuplicates; - - HashSet inQueueActivities = null; - - if (preventDuplicates) - { - var contentItemsIds = contacts.Select(x => x.ContentItemId).ToArray(); - - inQueueActivities = (await readonlySession.QueryIndex(index => - index.ContactContentType == batch.ContactContentType && - index.ContactContentItemId.IsIn(contentItemsIds) && - index.Status != ActivityStatus.Completed && - index.Status != ActivityStatus.Purged, collection: OmnichannelConstants.CollectionName) - .ListAsync()) - .Select(x => x.ContactContentItemId) - .ToHashSet(); - } - - var now = _clock.UtcNow; - - var scheduledUtc = await localClock.ConvertToUtcAsync(batch.ScheduleAt); - - foreach (var contact in contacts) - { - documentId = Math.Max(documentId, contact.Id); - - // Skip contacts not in the pre-computed eligible set. - if (eligibleContactIds is not null && !eligibleContactIds.Contains(contact.ContentItemId)) - { - continue; - } - - if (preventDuplicates && inQueueActivities.Contains(contact.ContentItemId)) - { - continue; - } - - // Respect the limit if specified. - if (batch.Limit.HasValue && batch.Limit.Value > 0 && batch.TotalLoaded >= batch.Limit.Value) - { - batch.Status = OmnichannelActivityBatchStatus.Loaded; - - await catalog.UpdateAsync(batch); - await session.SaveChangesAsync(); - return; - } - - var user = requiresUserAssignment - ? users[activityCounter++ % users.Length] - : null; - - var activity = await activityManager.NewAsync(); - - activity.Kind = GetActivityKind(flowSettings.Channel); - activity.Source = sourceEntry.Source; - activity.InteractionType = flowSettings.InteractionType; - activity.Channel = flowSettings.Channel; - activity.AIProfileId = string.IsNullOrWhiteSpace(batch.AIProfileId) - ? flowSettings.ProfileId - : batch.AIProfileId; - activity.ContactContentItemId = contact.ContentItemId; - activity.ContactContentType = batch.ContactContentType; - activity.SubjectContentType = batch.SubjectContentType; - activity.PreferredDestination = OmnichannelHelper.GetPreferredDestenation(contact, activity.Channel); - - if (activity.InteractionType == ActivityInteractionType.Automated && - string.IsNullOrWhiteSpace(activity.PreferredDestination)) - { - continue; - } - - activity.ChannelEndpointId = flowSettings.ChannelEndpointId; - activity.CampaignId = flowSettings.CampaignId; - activity.ScheduledUtc = scheduledUtc; - if (user is not null) - { - activity.AssignedToId = user.UserId; - activity.AssignedToUsername = user.UserName; - activity.AssignedToUtc = now; - activity.AssignmentStatus = ActivityAssignmentStatus.Assigned; - } - else - { - activity.AssignmentStatus = ActivityAssignmentStatus.Available; - } - - activity.Instructions = batch.Instructions; - activity.CreatedUtc = now; - activity.CreatedById = loaderId; - activity.CreatedByUsername = loaderUserName; - activity.UrgencyLevel = batch.UrgencyLevel; - activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus( - activity.InteractionType, - user is not null); - - batch.TotalLoaded++; - - await activityManager.CreateAsync(activity); - await session.SaveAsync(activity, collection: OmnichannelConstants.CollectionName); - } - - await catalog.UpdateAsync(batch); - - // Flush the session to release memory. - await session.FlushAsync(); - } - - // Complete the batch loading. - batch.Status = OmnichannelActivityBatchStatus.Loaded; - await catalog.UpdateAsync(batch); - await session.SaveChangesAsync(); + await coordinator.LoadAsync(id, userId, userName); }); }); @@ -841,24 +482,4 @@ private static bool TryGetActivityBatchSource(string source, ActivityBatchSource return options.Sources.TryGetValue(normalizedSource, out sourceEntry); } - - private static ActivityKind GetActivityKind(string channel) - { - if (string.Equals(channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase)) - { - return ActivityKind.Call; - } - - if (string.Equals(channel, OmnichannelConstants.Channels.Sms, StringComparison.OrdinalIgnoreCase)) - { - return ActivityKind.Sms; - } - - if (string.Equals(channel, OmnichannelConstants.Channels.Email, StringComparison.OrdinalIgnoreCase)) - { - return ActivityKind.Email; - } - - return ActivityKind.Task; - } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityBatchLoadCoordinator.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityBatchLoadCoordinator.cs new file mode 100644 index 000000000..73126b621 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultActivityBatchLoadCoordinator.cs @@ -0,0 +1,97 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Microsoft.Extensions.Logging; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Default implementation of that selects a source-specific +/// when one is registered, and otherwise falls back to the +/// . +/// +public sealed class DefaultActivityBatchLoadCoordinator : IActivityBatchLoadCoordinator +{ + private readonly ICatalog _catalog; + private readonly DefaultContactActivityBatchLoader _defaultLoader; + private readonly ILogger _logger; + private readonly Dictionary _loaders; + + /// + /// Initializes a new instance of the class. + /// + /// The activity batch catalog. + /// The registered source-specific activity batch loaders. + /// The default contact-based activity batch loader used as a fallback. + /// The logger. + public DefaultActivityBatchLoadCoordinator( + ICatalog catalog, + IEnumerable loaders, + DefaultContactActivityBatchLoader defaultLoader, + ILogger logger) + { + _catalog = catalog; + _defaultLoader = defaultLoader; + _logger = logger; + _loaders = loaders + .Where(loader => !string.IsNullOrEmpty(loader.Source)) + .GroupBy(loader => loader.Source, StringComparer.OrdinalIgnoreCase) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.OrdinalIgnoreCase); + } + + /// + public async Task LoadAsync( + string batchId, + string loaderId, + string loaderUserName, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(batchId); + + var batch = await _catalog.FindByIdAsync(batchId, cancellationToken); + + if (batch is null) + { + _logger.LogError("Unable to load activities. No activity batch was found with the ID '{BatchId}'.", batchId); + + return; + } + + if (batch.Status != OmnichannelActivityBatchStatus.Started) + { + throw new InvalidOperationException($"Unable to load activities for batch with the ID '{batch.ItemId}' since its status is not '{nameof(OmnichannelActivityBatchStatus.Started)}'."); + } + + var loader = ResolveLoader(batch.Source); + + batch.Status = OmnichannelActivityBatchStatus.Loading; + batch.TotalLoaded = 0; + + await _catalog.UpdateAsync(batch, cancellationToken); + + var context = new ActivityBatchLoadContext(batch, loaderId, loaderUserName); + + try + { + await loader.LoadAsync(context, cancellationToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while loading activities for the batch with ID '{BatchId}'.", batch.ItemId); + + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + } + } + + private IActivityBatchLoader ResolveLoader(string source) + { + if (!string.IsNullOrEmpty(source) && _loaders.TryGetValue(source, out var loader)) + { + return loader; + } + + return _defaultLoader; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs new file mode 100644 index 000000000..60df7dd76 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs @@ -0,0 +1,453 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core; +using CrestApps.OrchardCore.Omnichannel.Core.Indexes; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Services; +using Dapper; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.ContentManagement; +using OrchardCore.ContentManagement.Records; +using OrchardCore.Data; +using OrchardCore.Modules; +using OrchardCore.Users.Indexes; +using OrchardCore.Users.Models; +using YesSql; +using YesSql.Services; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// The default activity batch loader. It pages over contacts of the batch contact content type, +/// applies the batch filters, and creates activities using the configured subject flow settings. +/// This loader is used as the fallback for any source that does not register a dedicated +/// . It is not sealed so specialized sources can inherit and +/// customize individual stages of the load. +/// +public class DefaultContactActivityBatchLoader : IActivityBatchLoader +{ + private const int _batchSize = 100; + + private readonly ICatalog _catalog; + private readonly ISession _session; + private readonly ILocalClock _localClock; + private readonly IClock _clock; + private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; + private readonly IOmnichannelActivityManager _activityManager; + private readonly IStore _store; + private readonly IDbConnectionAccessor _dbConnectionAccessor; + private readonly ActivityBatchSourceOptions _sourceOptions; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The activity batch catalog. + /// The session used to persist activities. + /// The local clock. + /// The clock. + /// The subject flow settings service. + /// The activity manager. + /// The store. + /// The database connection accessor. + /// The configured activity batch sources. + /// The logger. + public DefaultContactActivityBatchLoader( + ICatalog catalog, + ISession session, + ILocalClock localClock, + IClock clock, + ISubjectFlowSettingsService subjectFlowSettingsService, + IOmnichannelActivityManager activityManager, + IStore store, + IDbConnectionAccessor dbConnectionAccessor, + IOptions sourceOptions, + ILogger logger) + { + _catalog = catalog; + _session = session; + _localClock = localClock; + _clock = clock; + _subjectFlowSettingsService = subjectFlowSettingsService; + _activityManager = activityManager; + _store = store; + _dbConnectionAccessor = dbConnectionAccessor; + _sourceOptions = sourceOptions.Value; + _logger = logger; + } + + /// + public virtual string Source + => null; + + /// + public virtual async Task LoadAsync(ActivityBatchLoadContext context, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(context); + + var batch = context.Batch; + + if (!TryGetActivityBatchSource(batch.Source, _sourceOptions, out var sourceEntry)) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("No valid activity batch source was found for the batch with ID '{BatchId}' and source '{Source}'.", batch.ItemId, batch.Source); + return; + } + + await using var readonlySession = _session.Store.CreateSession(withTracking: false); + + var requiresUserAssignment = sourceEntry.RequiresUserAssignment; + var users = requiresUserAssignment + ? (await readonlySession.Query(x => x.IsEnabled && x.UserId.IsIn(batch.UserIds)).ListAsync(cancellationToken)).ToArray() + : []; + + if (requiresUserAssignment && users.Length == 0) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("No valid users were found to assign the activities for the batch with ID '{BatchId}'.", batch.ItemId); + return; + } + + var flowSettings = await _subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(batch.SubjectContentType, cancellationToken); + + if (flowSettings is null) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("Configured subject flow settings are required before loading the batch with ID '{BatchId}' for subject '{SubjectContentType}'.", batch.ItemId, batch.SubjectContentType); + return; + } + + long documentId = 0; + + DateTime? leadCreatedFrom = batch.LeadCreatedFrom.HasValue + ? await _localClock.ConvertToUtcAsync(batch.LeadCreatedFrom.Value) + : null; + + DateTime? leadCreatedTo = batch.LeadCreatedTo.HasValue + ? await _localClock.ConvertToUtcAsync(batch.LeadCreatedTo.Value) + : null; + + // Pre-compute contact-level filter sets (phone, timezone, last activity). + HashSet eligibleContactIds = null; + + var hasPhoneFilter = !string.IsNullOrEmpty(batch.PhoneNumber); + var hasTimeZoneFilter = batch.TimeZoneIds is { Length: > 0 }; + var hasLastActivityFilter = !string.IsNullOrEmpty(batch.LastActivitySubjectContentType); + + if (hasPhoneFilter || hasTimeZoneFilter || hasLastActivityFilter) + { + HashSet phoneIds = null; + HashSet timeZoneIds = null; + HashSet lastActivityIds = null; + + if (hasPhoneFilter) + { + var phoneQuery = batch.PhoneNumberMatchType switch + { + PhoneNumberMatchType.Exact => readonlySession.QueryIndex(index => + index.NormalizedPrimaryCellPhoneNumber == batch.PhoneNumber || + index.NormalizedPrimaryHomePhoneNumber == batch.PhoneNumber), + PhoneNumberMatchType.EndsWith => readonlySession.QueryIndex(index => + index.NormalizedPrimaryCellPhoneNumber.EndsWith(batch.PhoneNumber) || + index.NormalizedPrimaryHomePhoneNumber.EndsWith(batch.PhoneNumber)), + _ => readonlySession.QueryIndex(index => + index.NormalizedPrimaryCellPhoneNumber.StartsWith(batch.PhoneNumber) || + index.NormalizedPrimaryHomePhoneNumber.StartsWith(batch.PhoneNumber)), + }; + + var phoneContacts = await phoneQuery.ListAsync(cancellationToken); + phoneIds = phoneContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + if (hasTimeZoneFilter) + { + var tzContacts = await readonlySession.QueryIndex( + index => index.TimeZoneId.IsIn(batch.TimeZoneIds)) + .ListAsync(cancellationToken); + + timeZoneIds = tzContacts.Select(c => c.ContentItemId).ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + if (hasLastActivityFilter) + { + // Use raw SQL to find contacts whose most recent completed activity + // matches the given subject (and optional disposition). This avoids + // materializing all completed activities in memory. + var dialect = _store.Configuration.SqlDialect; + var dbSchema = _store.Configuration.Schema; + var activityTableName = _store.Configuration.TableNameConvention.GetIndexTable( + typeof(OmnichannelActivityIndex), + OmnichannelConstants.CollectionName); + var activityTable = dialect.QuoteForTableName( + $"{_store.Configuration.TablePrefix}{activityTableName}", + dbSchema); + var contactCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.ContactContentItemId)); + var statusCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.Status)); + var subjectCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.SubjectContentType)); + var dispositionCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.DispositionId)); + var completedCol = dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.CompletedUtc)); + + var completedStatus = (int)ActivityStatus.Completed; + + // Find contacts where the most recent completed activity matches the subject/disposition. + // Uses a correlated subquery to find the "latest per group" server-side. + var sql = $@"SELECT DISTINCT a.{contactCol} + FROM {activityTable} a + WHERE a.{statusCol} = @CompletedStatus + AND a.{subjectCol} = @Subject + AND a.{completedCol} = ( + SELECT MAX(a2.{completedCol}) + FROM {activityTable} a2 + WHERE a2.{contactCol} = a.{contactCol} + AND a2.{statusCol} = @CompletedStatus + )"; + + var parameters = new DynamicParameters(); + parameters.Add("@CompletedStatus", completedStatus); + parameters.Add("@Subject", batch.LastActivitySubjectContentType); + + if (!string.IsNullOrEmpty(batch.LastActivityDispositionId)) + { + sql += $"\n AND a.{dispositionCol} = @Disposition"; + parameters.Add("@Disposition", batch.LastActivityDispositionId); + } + + await using var sqlConnection = _dbConnectionAccessor.CreateConnection(); + await sqlConnection.OpenAsync(cancellationToken); + + var command = new CommandDefinition(sql, parameters, cancellationToken: cancellationToken); + var results = await sqlConnection.QueryAsync(command); + + lastActivityIds = results + .Where(id => !string.IsNullOrEmpty(id)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + // Intersect all non-null filter sets. + foreach (var set in new[] { phoneIds, timeZoneIds, lastActivityIds }) + { + if (set is null) + { + continue; + } + + if (eligibleContactIds is null) + { + eligibleContactIds = set; + } + else + { + eligibleContactIds.IntersectWith(set); + } + } + + // If filters are applied but no contacts match, mark as loaded immediately. + if (eligibleContactIds is not null && eligibleContactIds.Count == 0) + { + batch.Status = OmnichannelActivityBatchStatus.Loaded; + + await _catalog.UpdateAsync(batch, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + return; + } + } + + var activityCounter = 0; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var contactQuery = readonlySession.Query(index => + index.ContentType == batch.ContactContentType && + index.DocumentId > documentId); + + if (leadCreatedFrom.HasValue) + { + contactQuery = contactQuery.Where(index => index.CreatedUtc >= leadCreatedFrom); + } + + if (leadCreatedTo.HasValue) + { + contactQuery = contactQuery.Where(index => index.CreatedUtc <= leadCreatedTo); + } + + if (batch.OnlyPublishedLeads) + { + contactQuery = contactQuery.Where(contact => contact.Published); + } + else + { + contactQuery = contactQuery.Where(contact => contact.Latest); + } + + var contacts = await contactQuery + .OrderBy(x => x.DocumentId) + .Take(_batchSize) + .ListAsync(cancellationToken); + + if (!contacts.Any()) + { + batch.Status = OmnichannelActivityBatchStatus.Loaded; + + await _catalog.UpdateAsync(batch, cancellationToken); + break; + } + + var preventDuplicates = batch.PreventDuplicates; + + HashSet inQueueActivities = null; + + if (preventDuplicates) + { + var contentItemsIds = contacts.Select(x => x.ContentItemId).ToArray(); + + inQueueActivities = (await readonlySession.QueryIndex(index => + index.ContactContentType == batch.ContactContentType && + index.ContactContentItemId.IsIn(contentItemsIds) && + index.Status != ActivityStatus.Completed && + index.Status != ActivityStatus.Purged, collection: OmnichannelConstants.CollectionName) + .ListAsync(cancellationToken)) + .Select(x => x.ContactContentItemId) + .ToHashSet(); + } + + var now = _clock.UtcNow; + + var scheduledUtc = await _localClock.ConvertToUtcAsync(batch.ScheduleAt); + + foreach (var contact in contacts) + { + documentId = Math.Max(documentId, contact.Id); + + // Skip contacts not in the pre-computed eligible set. + if (eligibleContactIds is not null && !eligibleContactIds.Contains(contact.ContentItemId)) + { + continue; + } + + if (preventDuplicates && inQueueActivities.Contains(contact.ContentItemId)) + { + continue; + } + + // Respect the limit if specified. + if (batch.Limit.HasValue && batch.Limit.Value > 0 && batch.TotalLoaded >= batch.Limit.Value) + { + batch.Status = OmnichannelActivityBatchStatus.Loaded; + + await _catalog.UpdateAsync(batch, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + return; + } + + var user = requiresUserAssignment + ? users[activityCounter++ % users.Length] + : null; + + var activity = await _activityManager.NewAsync(cancellationToken: cancellationToken); + + activity.Kind = GetActivityKind(flowSettings.Channel); + activity.Source = sourceEntry.Source; + activity.InteractionType = flowSettings.InteractionType; + activity.Channel = flowSettings.Channel; + activity.AIProfileId = string.IsNullOrWhiteSpace(batch.AIProfileId) + ? flowSettings.ProfileId + : batch.AIProfileId; + activity.ContactContentItemId = contact.ContentItemId; + activity.ContactContentType = batch.ContactContentType; + activity.SubjectContentType = batch.SubjectContentType; + activity.PreferredDestination = OmnichannelHelper.GetPreferredDestenation(contact, activity.Channel); + + if (activity.InteractionType == ActivityInteractionType.Automated && + string.IsNullOrWhiteSpace(activity.PreferredDestination)) + { + continue; + } + + activity.ChannelEndpointId = flowSettings.ChannelEndpointId; + activity.CampaignId = flowSettings.CampaignId; + activity.ScheduledUtc = scheduledUtc; + if (user is not null) + { + activity.AssignedToId = user.UserId; + activity.AssignedToUsername = user.UserName; + activity.AssignedToUtc = now; + activity.AssignmentStatus = ActivityAssignmentStatus.Assigned; + } + else + { + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + } + + activity.Instructions = batch.Instructions; + activity.CreatedUtc = now; + activity.CreatedById = context.LoaderId; + activity.CreatedByUsername = context.LoaderUserName; + activity.UrgencyLevel = batch.UrgencyLevel; + activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus( + activity.InteractionType, + user is not null); + + batch.TotalLoaded++; + + await _activityManager.CreateAsync(activity, cancellationToken); + await _session.SaveAsync(activity, collection: OmnichannelConstants.CollectionName, cancellationToken: cancellationToken); + } + + await _catalog.UpdateAsync(batch, cancellationToken); + + // Flush the session to release memory. + await _session.FlushAsync(cancellationToken); + } + + // Complete the batch loading. + batch.Status = OmnichannelActivityBatchStatus.Loaded; + + await _catalog.UpdateAsync(batch, cancellationToken); + await _session.SaveChangesAsync(cancellationToken); + } + + private static bool TryGetActivityBatchSource(string source, ActivityBatchSourceOptions options, out ActivityBatchSourceEntry sourceEntry) + { + if (string.IsNullOrWhiteSpace(source)) + { + sourceEntry = null; + + return false; + } + + var normalizedSource = source.Trim(); + + return options.Sources.TryGetValue(normalizedSource, out sourceEntry); + } + + private static ActivityKind GetActivityKind(string channel) + { + if (string.Equals(channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase)) + { + return ActivityKind.Call; + } + + if (string.Equals(channel, OmnichannelConstants.Channels.Sms, StringComparison.OrdinalIgnoreCase)) + { + return ActivityKind.Sms; + } + + if (string.Equals(channel, OmnichannelConstants.Channels.Email, StringComparison.OrdinalIgnoreCase)) + { + return ActivityKind.Email; + } + + return ActivityKind.Task; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/IActivityBatchLoadCoordinator.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/IActivityBatchLoadCoordinator.cs new file mode 100644 index 000000000..b7e0cd3f0 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/IActivityBatchLoadCoordinator.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Coordinates the loading of an activity batch by resolving the appropriate activity batch loader +/// for the batch source and managing the batch loading lifecycle. +/// +public interface IActivityBatchLoadCoordinator +{ + /// + /// Loads the activities for the batch with the given identifier. The batch must be in the started + /// state; it is transitioned to the loading state before the resolved loader runs. + /// + /// The identifier of the batch to load. + /// The identifier of the user that initiated the load. + /// The username of the user that initiated the load. + /// The token to monitor for cancellation requests. + Task LoadAsync( + string batchId, + string loaderId, + string loaderUserName, + CancellationToken cancellationToken = default); +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 732b4893f..d88ba3fe5 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -55,6 +55,9 @@ public override void ConfigureServices(IServiceCollection services) services.AddCatalogs() .AddCatalogManagers(); + services.AddScoped(); + services.AddScoped(); + services.AddSingleton(); services From d8c2038804c45d70b3eb2dfa80212bdc8f658617 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Fri, 3 Jul 2026 17:00:39 -0700 Subject: [PATCH 36/56] fix NRC --- CrestApps.OrchardCore.slnx | 8 ++++++-- .../Views/ContactNavigationAdmin.cshtml | 6 ++++++ 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index c3faf2d0b..612191ed2 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -21,7 +21,9 @@ - + + + @@ -82,7 +84,9 @@ - + + + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ContactNavigationAdmin.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ContactNavigationAdmin.cshtml index 5d0c9547b..19580b640 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ContactNavigationAdmin.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ContactNavigationAdmin.cshtml @@ -8,6 +8,12 @@ @inject IAuthorizationService AuthorizationService @inject IContentDefinitionManager ContentDefinitionManager +@{ + if (Model.ContactContentItem is null) + { + return; + } +}
    From c363440e7157ae17b5d1e3a415995eb369cae15a Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 6 Jul 2026 15:56:26 -0700 Subject: [PATCH 37/56] Use ai-chat-ui preview.98 in Resources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ResourceManagementOptionsConfiguration.cs | 20 ++-- .../package-lock.json | 8 +- .../package.json | 2 +- .../vendors/crestapps/document-drop-zone.css | 20 ++-- .../vendors/crestapps/document-drop-zone.js | 106 +++++++++++++++--- .../crestapps/document-drop-zone.min.css | 2 +- .../crestapps/document-drop-zone.min.js | 2 +- 7 files changed, 119 insertions(+), 41 deletions(-) diff --git a/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs b/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs index 1b077faef..1ee4b0270 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs +++ b/src/Modules/CrestApps.OrchardCore.Resources/ResourceManagementOptionsConfiguration.cs @@ -149,8 +149,8 @@ static ResourceManagementOptionsConfiguration() "~/CrestApps.OrchardCore.Resources/vendors/crestapps/technical-name-generator.min.js", "~/CrestApps.OrchardCore.Resources/vendors/crestapps/technical-name-generator.js") .SetCdn( - "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-rc.7/dist/technical-name-generator.min.js", - "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-rc.7/dist/technical-name-generator.js") + "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-preview.98/dist/technical-name-generator.min.js", + "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-preview.98/dist/technical-name-generator.js") .SetCdnIntegrity( "sha384-vk5MiCC6biz7ygKi3CY+whjnNoLe2Ol+ZWoxUr/aoifSyfm9c2WFazGMhNLi8g7I", "sha384-9cJ5WEY0z1tJkCLND8ZMhN+rT6IySJKbK/R1yJcaSqmWgiCMuOyZJ+UUobxuScNs") @@ -162,11 +162,11 @@ static ResourceManagementOptionsConfiguration() "~/CrestApps.OrchardCore.Resources/vendors/crestapps/document-drop-zone.min.js", "~/CrestApps.OrchardCore.Resources/vendors/crestapps/document-drop-zone.js") .SetCdn( - "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-rc.7/dist/document-drop-zone.min.js", - "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-rc.7/dist/document-drop-zone.js") + "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-preview.98/dist/document-drop-zone.min.js", + "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-preview.98/dist/document-drop-zone.js") .SetCdnIntegrity( - "sha384-KFkL+SiquJoYpKSCo7vyhEATgbpE+FKizywY9qfjbI5QLNFnZxIz9sFQjqh3yMfa", - "sha384-h8+pletFGnYtxUtoPyQVEfBY0O3gz/DwxtjsDGQq7xX93GOpcx0LZlDwdguQkV1B") + "sha384-AvXYh7cCLTVJu3IoIikt5045awzgrmZ4S6e8Z5mHQydf5f9mHIPAbZ2xTP+LT5BC", + "sha384-8W/wOs7j6d1l50bR3wLRiY6M3/yf0acllYpEJRFraFBwtAXwvYcoyITxQ6FxyNkb") .SetVersion("1.0.0"); _manifest @@ -175,11 +175,11 @@ static ResourceManagementOptionsConfiguration() "~/CrestApps.OrchardCore.Resources/vendors/crestapps/document-drop-zone.min.css", "~/CrestApps.OrchardCore.Resources/vendors/crestapps/document-drop-zone.css") .SetCdn( - "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-rc.7/dist/document-drop-zone.min.css", - "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-rc.7/dist/document-drop-zone.css") + "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-preview.98/dist/document-drop-zone.min.css", + "https://cdn.jsdelivr.net/npm/@crestapps/ai-chat-ui@1.0.0-preview.98/dist/document-drop-zone.css") .SetCdnIntegrity( - "sha384-rQeaRUVX3mFuO9odBEzwKV4akVEHz3MLSl5+e0h43rKLtxKQnRwh0ARsiJMXBlnZ", - "sha384-Nej+SC8Gi+UVsc5GZ9b4NlJ7tGxoqyyAuoB3lMOur7MN0vjxNxpEa3N4qNh7peOO") + "sha384-cTjcD1YHMzaJ5FIvmpJhm3VZDBheTcbiNfGCQfFvBTDg1pZi7PWE5lO6VHRYX9zq", + "sha384-NLPKccGh39Ymb5v2aC3tD6zdtg+MhT/Sa+QpCRmDVY2xXSC10rxBNBh0iRqLUQkK") .SetVersion("1.0.0"); _manifest diff --git a/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json b/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json index 79ea3e079..e289bb199 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/package-lock.json @@ -6,7 +6,7 @@ "": { "name": "crestapps.orchardcore.resources", "dependencies": { - "@crestapps/ai-chat-ui": "1.0.0-rc.7", + "@crestapps/ai-chat-ui": "1.0.0-preview.98", "@crestapps/bootstrap-select": "1.2.1", "chart.js": "^4.5.1", "intl-tel-input": "29.1.0" @@ -20,9 +20,9 @@ } }, "node_modules/@crestapps/ai-chat-ui": { - "version": "1.0.0-rc.7", - "resolved": "https://registry.npmjs.org/@crestapps/ai-chat-ui/-/ai-chat-ui-1.0.0-rc.7.tgz", - "integrity": "sha512-KEAK7djkMUXUJ4qTadR5ke/aHGPE1x/urahj+5iV8bOad20JNoipf03b0LkI9eYd1+O003k1/CoutnczmTaaAw==", + "version": "1.0.0-preview.98", + "resolved": "https://registry.npmjs.org/@crestapps/ai-chat-ui/-/ai-chat-ui-1.0.0-preview.98.tgz", + "integrity": "sha512-EqjC87j0I/x5SLk0yhg9Xa0Khz0s8Xg0E7+KhU+HDxydqlTjxf/a9/fIqgK0ho108zIat0N5YSgGZPpyawgdbA==", "license": "MIT", "peerDependencies": { "chart.js": ">=3.0.0" diff --git a/src/Modules/CrestApps.OrchardCore.Resources/package.json b/src/Modules/CrestApps.OrchardCore.Resources/package.json index 838f32626..f336bddce 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/package.json +++ b/src/Modules/CrestApps.OrchardCore.Resources/package.json @@ -9,7 +9,7 @@ "marked": "^18.0.5" }, "dependencies": { - "@crestapps/ai-chat-ui": "1.0.0-rc.7", + "@crestapps/ai-chat-ui": "1.0.0-preview.98", "@crestapps/bootstrap-select": "1.2.1", "chart.js": "^4.5.1", "intl-tel-input": "29.1.0" diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.css b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.css index 50264bc07..e34c84d1d 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.css +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.css @@ -1,27 +1,27 @@ .attached-documents-panel { border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 0.75rem; - background: linear-gradient(180deg, rgba(13, 110, 253, 0.04), rgba(13, 110, 253, 0.01)); + background: linear-gradient(180deg, rgba(var(--bs-primary-rgb, 13, 110, 253), 0.04), rgba(var(--bs-primary-rgb, 13, 110, 253), 0.01)); padding: 1rem; } .document-drop-zone { position: relative; - border: 2px dashed rgba(13, 110, 253, 0.35); + border: 2px dashed rgba(var(--bs-primary-rgb, 13, 110, 253), 0.35); border-radius: 1rem; - background: rgba(248, 249, 250, 0.8); + background: rgba(var(--bs-tertiary-bg-rgb, 248, 249, 250), 0.8); transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; } .document-drop-zone:hover { - border-color: rgba(13, 110, 253, 0.55); - background: rgba(248, 249, 250, 0.95); + border-color: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.55); + background: rgba(var(--bs-tertiary-bg-rgb, 248, 249, 250), 0.95); } .document-drop-zone--dragover { border-color: var(--bs-primary, #0d6efd); - background: rgba(13, 110, 253, 0.08); - box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.12); + background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08); + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb, 13, 110, 253), 0.12); transform: translateY(-1px); } @@ -41,7 +41,7 @@ align-items: center; justify-content: center; border-radius: 50%; - background: rgba(13, 110, 253, 0.12); + background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.12); color: var(--bs-primary, #0d6efd); font-size: 1.5rem; } @@ -71,7 +71,7 @@ padding: 0.75rem 0.875rem; border: 1px solid var(--bs-border-color, #dee2e6); border-radius: 0.75rem; - background: #fff; + background: var(--bs-body-bg, #fff); } .attached-document-icon { @@ -81,7 +81,7 @@ align-items: center; justify-content: center; border-radius: 0.65rem; - background: rgba(13, 110, 253, 0.1); + background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.1); color: var(--bs-primary, #0d6efd); flex-shrink: 0; } diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.js b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.js index 2a3dbe6d4..de80b8a79 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.js +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.js @@ -6,6 +6,7 @@ (function (window, document) { 'use strict'; + var dropZoneStateKey = '__documentDropZoneState'; function assignFiles(fileInput, files) { if (!fileInput || !files) { return; @@ -29,6 +30,9 @@ if (!dropZone || !fileInput) { return; } + if (dropZone[dropZoneStateKey] && typeof dropZone[dropZoneStateKey].dispose === 'function') { + dropZone[dropZoneStateKey].dispose(); + } function openPicker() { fileInput.click(); } @@ -39,40 +43,114 @@ function setDragState(isActive) { dropZone.classList.toggle('document-drop-zone--dragover', isActive); } - ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) { - dropZone.addEventListener(eventName, preventDefaults); - }); - dropZone.addEventListener('dragenter', function () { + function handleDragEnter() { setDragState(true); - }); - dropZone.addEventListener('dragover', function () { + } + function handleDragOver() { setDragState(true); - }); - dropZone.addEventListener('dragleave', function (event) { + } + function handleDragLeave(event) { if (!dropZone.contains(event.relatedTarget)) { setDragState(false); } - }); - dropZone.addEventListener('drop', function (event) { + } + function handleDrop(event) { setDragState(false); var files = event.dataTransfer ? event.dataTransfer.files : null; if (files && files.length > 0) { assignFiles(fileInput, files); } + } + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) { + dropZone.addEventListener(eventName, preventDefaults); }); + dropZone.addEventListener('dragenter', handleDragEnter); + dropZone.addEventListener('dragover', handleDragOver); + dropZone.addEventListener('dragleave', handleDragLeave); + dropZone.addEventListener('drop', handleDrop); + var handleBrowseClick = null; if (browseButton) { - browseButton.addEventListener('click', function (event) { + handleBrowseClick = function handleBrowseClick(event) { event.preventDefault(); event.stopPropagation(); openPicker(); - }); + }; + browseButton.addEventListener('click', handleBrowseClick); } - dropZone.addEventListener('click', function (event) { + function handleClick(event) { if (event.target.closest('[data-drop-zone-browse]')) { return; } openPicker(); - }); + } + dropZone.addEventListener('click', handleClick); + dropZone[dropZoneStateKey] = { + dispose: function dispose() { + ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(function (eventName) { + dropZone.removeEventListener(eventName, preventDefaults); + }); + dropZone.removeEventListener('dragenter', handleDragEnter); + dropZone.removeEventListener('dragover', handleDragOver); + dropZone.removeEventListener('dragleave', handleDragLeave); + dropZone.removeEventListener('drop', handleDrop); + dropZone.removeEventListener('click', handleClick); + if (browseButton && handleBrowseClick) { + browseButton.removeEventListener('click', handleBrowseClick); + } + } + }; + return dropZone[dropZoneStateKey]; }; + function initializeFromElement(element) { + if (!element || element.dataset.documentDropZoneInitialized === 'true') { + return element ? element[dropZoneStateKey] || null : null; + } + var options = { + dropZone: element, + fileInput: element.getAttribute('data-document-drop-zone-file-input'), + browseButton: element.getAttribute('data-document-drop-zone-browse-button') + }; + var state = window.initDocumentDropZone(options); + if (!state) { + return null; + } + element.dataset.documentDropZoneInitialized = 'true'; + return state; + } + function scanForAutoInitialization(root) { + if (!root || typeof root.querySelectorAll !== 'function') { + return; + } + if (typeof root.matches === 'function' && root.matches('[data-document-drop-zone-file-input]')) { + initializeFromElement(root); + } + root.querySelectorAll('[data-document-drop-zone-file-input]').forEach(initializeFromElement); + } + function startAutoInitialization() { + scanForAutoInitialization(document); + if (typeof MutationObserver === 'undefined') { + return; + } + var observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + mutation.addedNodes.forEach(function (node) { + if (node && node.nodeType === 1) { + scanForAutoInitialization(node); + } + }); + }); + }); + observer.observe(document.body, { + childList: true, + subtree: true + }); + } + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', startAutoInitialization, { + once: true + }); + } else { + startAutoInitialization(); + } })(window, document); //# sourceMappingURL=document-drop-zone.js.map diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.css b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.css index fcb92811b..0195d987f 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.css +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.css @@ -1 +1 @@ -.attached-documents-panel{border:1px solid var(--bs-border-color,#dee2e6);border-radius:.75rem;background:linear-gradient(180deg,rgba(13,110,253,.04),rgba(13,110,253,.01));padding:1rem}.document-drop-zone{position:relative;border:2px dashed rgba(13,110,253,.35);border-radius:1rem;background:rgba(248,249,250,.8);transition:border-color .15s ease,background-color .15s ease,box-shadow .15s ease,transform .15s ease}.document-drop-zone:hover{border-color:rgba(13,110,253,.55);background:rgba(248,249,250,.95)}.document-drop-zone--dragover{border-color:var(--bs-primary,#0d6efd);background:rgba(13,110,253,.08);box-shadow:0 0 0 .2rem rgba(13,110,253,.12);transform:translateY(-1px)}.document-drop-zone__body{display:flex;flex-direction:column;align-items:center;gap:.75rem;padding:1.5rem 1rem;text-align:center}.document-drop-zone__icon{width:3.5rem;height:3.5rem;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(13,110,253,.12);color:var(--bs-primary,#0d6efd);font-size:1.5rem}.document-drop-zone__title{font-weight:600}.document-drop-zone__actions{display:flex;flex-wrap:wrap;justify-content:center;gap:.75rem}.attached-documents-list{display:flex;flex-direction:column;gap:.5rem}.attached-document-card,.pending-doc-row{display:flex;align-items:center;gap:.75rem;padding:.75rem .875rem;border:1px solid var(--bs-border-color,#dee2e6);border-radius:.75rem;background:#fff}.attached-document-icon{width:2.25rem;height:2.25rem;display:inline-flex;align-items:center;justify-content:center;border-radius:.65rem;background:rgba(13,110,253,.1);color:var(--bs-primary,#0d6efd);flex-shrink:0}.attached-document-meta{min-width:0}.attached-document-name{font-weight:500;word-break:break-word}.attached-document-size{color:var(--bs-secondary-color,#6c757d);font-size:.875rem} +.attached-documents-panel{border:1px solid var(--bs-border-color,#dee2e6);border-radius:.75rem;background:linear-gradient(180deg,rgba(var(--bs-primary-rgb,13,110,253),.04),rgba(var(--bs-primary-rgb,13,110,253),.01));padding:1rem}.document-drop-zone{position:relative;border:2px dashed rgba(var(--bs-primary-rgb,13,110,253),.35);border-radius:1rem;background:rgba(var(--bs-tertiary-bg-rgb,248,249,250),.8);transition:border-color .15s ease,background-color .15s ease,box-shadow .15s ease,transform .15s ease}.document-drop-zone:hover{border-color:rgba(var(--bs-primary-rgb,13,110,253),.55);background:rgba(var(--bs-tertiary-bg-rgb,248,249,250),.95)}.document-drop-zone--dragover{border-color:var(--bs-primary,#0d6efd);background:rgba(var(--bs-primary-rgb,13,110,253),.08);box-shadow:0 0 0 .2rem rgba(var(--bs-primary-rgb,13,110,253),.12);transform:translateY(-1px)}.document-drop-zone__body{display:flex;flex-direction:column;align-items:center;gap:.75rem;padding:1.5rem 1rem;text-align:center}.document-drop-zone__icon{width:3.5rem;height:3.5rem;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;background:rgba(var(--bs-primary-rgb,13,110,253),.12);color:var(--bs-primary,#0d6efd);font-size:1.5rem}.document-drop-zone__title{font-weight:600}.document-drop-zone__actions{display:flex;flex-wrap:wrap;justify-content:center;gap:.75rem}.attached-documents-list{display:flex;flex-direction:column;gap:.5rem}.attached-document-card,.pending-doc-row{display:flex;align-items:center;gap:.75rem;padding:.75rem .875rem;border:1px solid var(--bs-border-color,#dee2e6);border-radius:.75rem;background:var(--bs-body-bg,#fff)}.attached-document-icon{width:2.25rem;height:2.25rem;display:inline-flex;align-items:center;justify-content:center;border-radius:.65rem;background:rgba(var(--bs-primary-rgb,13,110,253),.1);color:var(--bs-primary,#0d6efd);flex-shrink:0}.attached-document-meta{min-width:0}.attached-document-name{font-weight:500;word-break:break-word}.attached-document-size{color:var(--bs-secondary-color,#6c757d);font-size:.875rem} diff --git a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.js b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.js index eb179f5f7..44025d6f5 100644 --- a/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.js +++ b/src/Modules/CrestApps.OrchardCore.Resources/wwwroot/vendors/crestapps/document-drop-zone.min.js @@ -1,2 +1,2 @@ -!function(e,t){"use strict";e.initDocumentDropZone=function(e){if(e){var n="string"==typeof e.dropZone?t.querySelector(e.dropZone):e.dropZone,r="string"==typeof e.fileInput?t.querySelector(e.fileInput):e.fileInput,o="string"==typeof e.browseButton?t.querySelector(e.browseButton):e.browseButton;n&&r&&(["dragenter","dragover","dragleave","drop"].forEach(function(e){n.addEventListener(e,i)}),n.addEventListener("dragenter",function(){d(!0)}),n.addEventListener("dragover",function(){d(!0)}),n.addEventListener("dragleave",function(e){n.contains(e.relatedTarget)||d(!1)}),n.addEventListener("drop",function(e){d(!1);var t=e.dataTransfer?e.dataTransfer.files:null;t&&t.length>0&&function(e,t){if(e&&t){var n=new DataTransfer;Array.prototype.forEach.call(t,function(e){n.items.add(e)}),e.files=n.files,e.dispatchEvent(new Event("change",{bubbles:!0}))}}(r,t)}),o&&o.addEventListener("click",function(e){e.preventDefault(),e.stopPropagation(),a()}),n.addEventListener("click",function(e){e.target.closest("[data-drop-zone-browse]")||a()}))}function a(){r.click()}function i(e){e.preventDefault(),e.stopPropagation()}function d(e){n.classList.toggle("document-drop-zone--dragover",e)}}}(window,document); +!function(e,t){"use strict";var n="__documentDropZoneState";function o(t){if(!t||"true"===t.dataset.documentDropZoneInitialized)return t&&t[n]||null;var o={dropZone:t,fileInput:t.getAttribute("data-document-drop-zone-file-input"),browseButton:t.getAttribute("data-document-drop-zone-browse-button")},r=e.initDocumentDropZone(o);return r?(t.dataset.documentDropZoneInitialized="true",r):null}function r(e){e&&"function"==typeof e.querySelectorAll&&("function"==typeof e.matches&&e.matches("[data-document-drop-zone-file-input]")&&o(e),e.querySelectorAll("[data-document-drop-zone-file-input]").forEach(o))}function i(){(r(t),"undefined"!=typeof MutationObserver)&&new MutationObserver(function(e){e.forEach(function(e){e.addedNodes.forEach(function(e){e&&1===e.nodeType&&r(e)})})}).observe(t.body,{childList:!0,subtree:!0})}e.initDocumentDropZone=function(e){if(e){var o="string"==typeof e.dropZone?t.querySelector(e.dropZone):e.dropZone,r="string"==typeof e.fileInput?t.querySelector(e.fileInput):e.fileInput,i="string"==typeof e.browseButton?t.querySelector(e.browseButton):e.browseButton;if(o&&r){o[n]&&"function"==typeof o[n].dispose&&o[n].dispose(),["dragenter","dragover","dragleave","drop"].forEach(function(e){o.addEventListener(e,u)}),o.addEventListener("dragenter",s),o.addEventListener("dragover",f),o.addEventListener("dragleave",l),o.addEventListener("drop",p);var a=null;return i&&(a=function(e){e.preventDefault(),e.stopPropagation(),d()},i.addEventListener("click",a)),o.addEventListener("click",v),o[n]={dispose:function(){["dragenter","dragover","dragleave","drop"].forEach(function(e){o.removeEventListener(e,u)}),o.removeEventListener("dragenter",s),o.removeEventListener("dragover",f),o.removeEventListener("dragleave",l),o.removeEventListener("drop",p),o.removeEventListener("click",v),i&&a&&i.removeEventListener("click",a)}},o[n]}}function d(){r.click()}function u(e){e.preventDefault(),e.stopPropagation()}function c(e){o.classList.toggle("document-drop-zone--dragover",e)}function s(){c(!0)}function f(){c(!0)}function l(e){o.contains(e.relatedTarget)||c(!1)}function p(e){c(!1);var t=e.dataTransfer?e.dataTransfer.files:null;t&&t.length>0&&function(e,t){if(e&&t){var n=new DataTransfer;Array.prototype.forEach.call(t,function(e){n.items.add(e)}),e.files=n.files,e.dispatchEvent(new Event("change",{bubbles:!0}))}}(r,t)}function v(e){e.target.closest("[data-drop-zone-browse]")||d()}},"loading"===t.readyState?t.addEventListener("DOMContentLoaded",i,{once:!0}):i()}(window,document); //# sourceMappingURL=document-drop-zone.min.js.map From add5e71c6993a6b2ebdab1bf39c2dae1c99f30b6 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 6 Jul 2026 16:35:17 -0700 Subject: [PATCH 38/56] cleanup --- .github/copilot-instructions.md | 1 + CrestApps.OrchardCore.slnx | 2 + .../IContactCenterRealTimeNotifier.cs | 8 +- .../Models}/AgentOfferNotification.cs | 4 +- .../Models}/AgentOfferRevokedNotification.cs | 2 +- .../Models}/AgentOfferRevokedReason.cs | 2 +- .../Models}/AgentPresenceNotification.cs | 2 +- .../Models}/QueueStatsNotification.cs | 2 +- .../ReportsConstants.cs | 10 + .../Services/IReportExportManager.cs | 2 + .../Services/IReportManager.cs | 2 + .../CrestApps.OrchardCore.Reports.Core.csproj | 22 ++ .../Services/CsvReportExportFormat.cs | 1 + .../Services/ReportExportManager.cs | 0 .../Services/ReportManager.cs | 2 + src/CrestApps.Docs/docs/changelog/v2.0.0.md | 9 +- .../docs/contact-center/agent-desktop.md | 193 +++------- .../contact-center/agents-queues-dialer.md | 172 ++------- .../docs/contact-center/index.md | 332 +++++------------- src/CrestApps.Docs/docs/modules/reports.md | 54 ++- src/CrestApps.Docs/docs/telephony/index.md | 15 +- .../ContactCenterRealTimeEventHandler.cs | 3 +- .../Hubs/IContactCenterHubClient.cs | 2 + .../Services/ContactCenterAdminMenu.cs | 3 +- .../Services/ContactCenterAgentsAdminMenu.cs | 3 +- .../Services/ContactCenterDialerAdminMenu.cs | 3 +- .../ContactCenterRealTimeAdminMenu.cs | 6 +- .../Services/ContactCenterRealTimeNotifier.cs | 2 + ...estApps.OrchardCore.Reports.OpenXml.csproj | 28 ++ .../Manifest.cs | 23 ++ .../README.md | 31 ++ .../Services/ExcelReportExportFormat.cs | 233 ++++++++++++ .../Startup.cs | 17 + .../Controllers/ReportsController.cs | 1 + .../CrestApps.OrchardCore.Reports.csproj | 1 + .../ViewModels/ReportDisplayViewModel.cs | 5 + .../NavigationItemText-reports.Id.cshtml | 0 .../Views/Reports/Display.cshtml | 9 +- .../Assets/scss/soft-phone.scss | 28 +- .../Views/SoftPhoneWidget.cshtml | 16 +- .../wwwroot/styles/soft-phone.css | 27 +- .../wwwroot/styles/soft-phone.min.css | 2 +- ...stApps.OrchardCore.Cms.Core.Targets.csproj | 1 + .../CrestApps.OrchardCore.Tests.csproj | 1 + .../Services/ExcelReportExportFormatTests.cs | 92 +++++ 45 files changed, 758 insertions(+), 616 deletions(-) rename src/{Modules/CrestApps.OrchardCore.ContactCenter/Services => Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions}/IContactCenterRealTimeNotifier.cs (88%) rename src/{Modules/CrestApps.OrchardCore.ContactCenter/Hubs => Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models}/AgentOfferNotification.cs (90%) rename src/{Modules/CrestApps.OrchardCore.ContactCenter/Hubs => Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models}/AgentOfferRevokedNotification.cs (95%) rename src/{Modules/CrestApps.OrchardCore.ContactCenter/Hubs => Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models}/AgentOfferRevokedReason.cs (90%) rename src/{Modules/CrestApps.OrchardCore.ContactCenter/Hubs => Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models}/AgentPresenceNotification.cs (95%) rename src/{Modules/CrestApps.OrchardCore.ContactCenter/Hubs => Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models}/QueueStatsNotification.cs (92%) rename src/{Modules/CrestApps.OrchardCore.Reports => Abstractions/CrestApps.OrchardCore.Reports.Abstractions}/Services/IReportExportManager.cs (94%) rename src/{Modules/CrestApps.OrchardCore.Reports => Abstractions/CrestApps.OrchardCore.Reports.Abstractions}/Services/IReportManager.cs (94%) create mode 100644 src/Core/CrestApps.OrchardCore.Reports.Core/CrestApps.OrchardCore.Reports.Core.csproj rename src/{Modules/CrestApps.OrchardCore.Reports => Core/CrestApps.OrchardCore.Reports.Core}/Services/CsvReportExportFormat.cs (98%) rename src/{Modules/CrestApps.OrchardCore.Reports => Core/CrestApps.OrchardCore.Reports.Core}/Services/ReportExportManager.cs (100%) rename src/{Modules/CrestApps.OrchardCore.Reports => Core/CrestApps.OrchardCore.Reports.Core}/Services/ReportManager.cs (97%) create mode 100644 src/Modules/CrestApps.OrchardCore.Reports.OpenXml/CrestApps.OrchardCore.Reports.OpenXml.csproj create mode 100644 src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Manifest.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports.OpenXml/README.md create mode 100644 src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Services/ExcelReportExportFormat.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Startup.cs rename src/Modules/{CrestApps.OrchardCore.PhoneNumbers.Verifications => CrestApps.OrchardCore.Reports}/Views/NavigationItemText-reports.Id.cshtml (100%) create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/Reports/OpenXml/Services/ExcelReportExportFormatTests.cs diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 6df9a8301..ee1289ac1 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -150,6 +150,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/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index 612191ed2..fad15ee25 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -37,6 +37,7 @@ + @@ -87,6 +88,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IContactCenterRealTimeNotifier.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterRealTimeNotifier.cs similarity index 88% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IContactCenterRealTimeNotifier.cs rename to src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterRealTimeNotifier.cs index 047c08413..64469fda3 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/IContactCenterRealTimeNotifier.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/IContactCenterRealTimeNotifier.cs @@ -1,10 +1,10 @@ -using CrestApps.OrchardCore.ContactCenter.Hubs; +using CrestApps.OrchardCore.ContactCenter.Models; -namespace CrestApps.OrchardCore.ContactCenter.Services; +namespace CrestApps.OrchardCore.ContactCenter; /// -/// Broadcasts Contact Center real-time updates to the appropriate SignalR audiences (the affected agent, -/// queue watchers, and supervisors) without exposing the underlying hub or group naming to callers. +/// Broadcasts Contact Center real-time updates to the appropriate audiences without exposing the +/// underlying transport or group naming details to callers. /// public interface IContactCenterRealTimeNotifier { diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferNotification.cs similarity index 90% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferNotification.cs rename to src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferNotification.cs index 14532f14e..bb24e0a8e 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferNotification.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferNotification.cs @@ -1,8 +1,8 @@ -namespace CrestApps.OrchardCore.ContactCenter.Hubs; +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 (with its accept/decline countdown) without waiting for the telephony ring event. +/// the offer without waiting for the telephony ring event. /// public sealed class AgentOfferNotification { diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedNotification.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedNotification.cs rename to src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedNotification.cs index 18ffbb1c8..c44865dae 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedNotification.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedNotification.cs @@ -1,4 +1,4 @@ -namespace CrestApps.OrchardCore.ContactCenter.Hubs; +namespace CrestApps.OrchardCore.ContactCenter.Models; /// /// Describes an offer that is no longer presented to an agent so the agent desktop can dismiss the diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedReason.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedReason.cs similarity index 90% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedReason.cs rename to src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedReason.cs index 36d629189..9f5ff2755 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentOfferRevokedReason.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentOfferRevokedReason.cs @@ -1,4 +1,4 @@ -namespace CrestApps.OrchardCore.ContactCenter.Hubs; +namespace CrestApps.OrchardCore.ContactCenter.Models; /// /// Identifies why an offer presented to an agent was revoked. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentPresenceNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceNotification.cs similarity index 95% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentPresenceNotification.cs rename to src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceNotification.cs index ee9bdd924..d1736448c 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/AgentPresenceNotification.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/AgentPresenceNotification.cs @@ -1,4 +1,4 @@ -namespace CrestApps.OrchardCore.ContactCenter.Hubs; +namespace CrestApps.OrchardCore.ContactCenter.Models; /// /// Describes a real-time change to an agent's presence broadcast to the agent's own connections and to diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/QueueStatsNotification.cs b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueStatsNotification.cs similarity index 92% rename from src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/QueueStatsNotification.cs rename to src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueStatsNotification.cs index 5cd454bc5..fccde865a 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/QueueStatsNotification.cs +++ b/src/Abstractions/CrestApps.OrchardCore.ContactCenter.Abstractions/Models/QueueStatsNotification.cs @@ -1,4 +1,4 @@ -namespace CrestApps.OrchardCore.ContactCenter.Hubs; +namespace CrestApps.OrchardCore.ContactCenter.Models; /// /// Describes the current depth of a queue broadcast to the queue's watchers and supervisor dashboards so diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs index 1daef864b..622394457 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs @@ -15,6 +15,16 @@ public static class ReportsConstants /// public const string CsvExportFormat = "csv"; + /// + /// The technical name of the Open XML Excel workbook export format. + /// + public const string XlsxExportFormat = "xlsx"; + + /// + /// The identifier of the optional Open XML export feature for reports. + /// + public const string OpenXmlFeature = "CrestApps.OrchardCore.Reports.OpenXml"; + /// /// Contains the well-known report category names used to group reports in the admin navigation. /// diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Services/IReportExportManager.cs similarity index 94% rename from src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs rename to src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Services/IReportExportManager.cs index 50f762089..5e8ca5f07 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportExportManager.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Services/IReportExportManager.cs @@ -1,3 +1,5 @@ +using CrestApps.OrchardCore.Reports; + namespace CrestApps.OrchardCore.Reports.Services; /// diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Services/IReportManager.cs similarity index 94% rename from src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs rename to src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Services/IReportManager.cs index 6b9afd63f..4f32f8c08 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/Services/IReportManager.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/Services/IReportManager.cs @@ -1,3 +1,5 @@ +using CrestApps.OrchardCore.Reports; + namespace CrestApps.OrchardCore.Reports.Services; /// diff --git a/src/Core/CrestApps.OrchardCore.Reports.Core/CrestApps.OrchardCore.Reports.Core.csproj b/src/Core/CrestApps.OrchardCore.Reports.Core/CrestApps.OrchardCore.Reports.Core.csproj new file mode 100644 index 000000000..bd784de8e --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.Reports.Core/CrestApps.OrchardCore.Reports.Core.csproj @@ -0,0 +1,22 @@ + + + + CrestApps.OrchardCore.Reports + CrestApps OrchardCore Reports Core + + $(CrestAppsDescription) + + Core implementations for the CrestApps OrchardCore Reports framework. + + $(PackageTags) Reports Analytics + + + + + + + + + + + diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs b/src/Core/CrestApps.OrchardCore.Reports.Core/Services/CsvReportExportFormat.cs similarity index 98% rename from src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs rename to src/Core/CrestApps.OrchardCore.Reports.Core/Services/CsvReportExportFormat.cs index a2dd0a1e8..1fbc9b60a 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/Services/CsvReportExportFormat.cs +++ b/src/Core/CrestApps.OrchardCore.Reports.Core/Services/CsvReportExportFormat.cs @@ -1,4 +1,5 @@ using System.Text; +using CrestApps.OrchardCore.Reports; using CrestApps.OrchardCore.Reports.Models; using Microsoft.Extensions.Localization; diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportExportManager.cs b/src/Core/CrestApps.OrchardCore.Reports.Core/Services/ReportExportManager.cs similarity index 100% rename from src/Modules/CrestApps.OrchardCore.Reports/Services/ReportExportManager.cs rename to src/Core/CrestApps.OrchardCore.Reports.Core/Services/ReportExportManager.cs diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs b/src/Core/CrestApps.OrchardCore.Reports.Core/Services/ReportManager.cs similarity index 97% rename from src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs rename to src/Core/CrestApps.OrchardCore.Reports.Core/Services/ReportManager.cs index 151f4c269..05d0dcf3a 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/Services/ReportManager.cs +++ b/src/Core/CrestApps.OrchardCore.Reports.Core/Services/ReportManager.cs @@ -1,3 +1,5 @@ +using CrestApps.OrchardCore.Reports; + namespace CrestApps.OrchardCore.Reports.Services; /// diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index e1b033ad9..adced63d4 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -179,6 +179,7 @@ At a high level, the platform changes are: - 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). @@ -536,7 +537,7 @@ At a high level, the platform changes are: - The optional **Telephony Soft Phone** feature (`CrestApps.OrchardCore.Telephony.SoftPhone`) can display the floating soft phone on the admin dashboard, the front end, or both, and the `SoftPhoneWidget` shape can be placed manually in a theme or template. - The soft phone is shown on the admin dashboard by default, and modules can contribute Display Management tabs/views to the widget. - The soft phone supports multiple per-user authentication scenarios: providers that use shared, account-level credentials work without a per-user step, while providers that require OAuth 2.0 show a **Connect to provider** button that runs the authorization code flow and stores the user's tokens encrypted on their account. The widget authentication experience is extensible through `window.telephonySoftPhone.authHandlers`. -- The widget reports a live connection status and only enables the dial pad and call controls when the configured provider is available, connected, and authenticated. When no provider is enabled the header status reads **Not Ready** instead of a misleading **Ready** status, and during an active call the toggle button turns red and switches to a hang-up icon. +- The widget reports a live connection status and only enables the dial pad and call controls when the configured provider is available, connected, and authenticated. When no provider is enabled, the keypad area now shows an inline warning that tells operators to enable a provider and set the default phone provider in site settings, while the status text moves into the keypad body as small muted text instead of occupying header space. During an active call the toggle button turns red and switches to a hang-up icon. - The soft phone is draggable and can be moved anywhere on the screen, including the far right edge and on top of other widgets. Its position and open/closed state are persisted in `localStorage` and restored without a visible flash on the next page load, and by default it offsets itself to sit side by side with the AI chat widget when both are present. - The soft phone now follows Bootstrap light/dark theme variables for the panel body, keypad, history, and footer while preserving the configured accent color for the header and call controls. Its saved position is also restored relative to the current viewport after browser resizes. - The widget footer is an extensible tab bar that switches between the **Keypad** view (dial pad and call controls) and the **Recent** view (call history). The hub exposes additional call controls (mute, unmute, and hold) and a call-history feed that surfaces active calls, recent inbound and outbound interactions, and missed calls highlighted in red with direction icons. @@ -551,6 +552,12 @@ At a high level, the platform changes are: - Other modules enrich the modal by contributing `IncomingCallCard` records (title, subtitle, badges, links, and an open URL). Each card renders an **Answer & open** shortcut, and a provider can supply `acceptUrl`/`declineUrl` properties so the modal reports the offer lifecycle. The Contact Center Voice feature uses this seam to list customers matched by the caller's number. - See [Incoming calls](../telephony/index.md#incoming-calls). +### Reports + +- The shared Reports contracts now live in `CrestApps.OrchardCore.Reports.Abstractions`, including `IReportManager` and `IReportExportManager`, so consumers can depend on the reporting service contracts without referencing the Orchard module assembly. +- The default non-Orchard-specific Reports implementations now live in `CrestApps.OrchardCore.Reports.Core`, while Orchard-specific pieces such as the admin menu, controller, views, and display-driver filter UI remain in the `CrestApps.OrchardCore.Reports` module. +- Added `CrestApps.OrchardCore.Reports.OpenXml`, an optional add-on module that registers an Excel (`.xlsx`) `IReportExportFormat` using `DocumentFormat.OpenXml` so report pages can export the current filter to CSV or Excel. + #### DialPad provider - `v2.0.0` adds the `CrestApps.OrchardCore.DialPad` module, which integrates the DialPad platform as a telephony provider through its REST API and contributes a **DialPad** tab to the telephony settings. diff --git a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md index 6c9df5586..e565baa28 100644 --- a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md +++ b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md @@ -7,143 +7,80 @@ description: How contact center agents work inbound and outbound interactions in This guide covers the two day-to-day Contact Center surfaces: -- The **Agent Workspace** - the full-screen desktop where a **contact center agent** spends the shift: - it presents work, connects calls, shows customer context, and captures the outcome. -- The **Supervisor Dashboard** - the live wallboard a **contact center manager** uses to monitor queue - health and agent presence in real time. +- The **Agent Workspace** - the full-screen desktop where a **contact center agent** spends the shift: it presents work, connects calls, shows customer context, and captures the outcome. +- The **Supervisor Dashboard** - the live wallboard a **contact center manager** uses to monitor queue health and agent presence in real time. -Both build on the [real-time SignalR layer](index.md#real-time-experience) and the -[Telephony](../telephony/index.md) soft phone. The CRM still owns the work (activities, contacts, -subjects, dispositions), the Contact Center orchestrates it, and Telephony executes the media. +Both build on the [real-time SignalR layer](index.md#real-time-experience) and the [Telephony](../telephony/index.md) soft phone. The CRM still owns the work (activities, contacts, subjects, dispositions), the Contact Center orchestrates it, and Telephony executes the media. ## Enabling the surfaces -Enable the **Contact Center Real-Time** feature (`CrestApps.OrchardCore.ContactCenter.RealTime`). It -depends on **Contact Center Queues** (and therefore **Agents** and the base **Contact Center** -feature) and the **SignalR** module, so enabling it pulls in the routing, presence, and reservation -core. For voice work, also enable **Contact Center Voice** and a voice provider (for example -[DialPad](../telephony/dialpad.md)). +Enable the **Contact Center Real-Time** feature (`CrestApps.OrchardCore.ContactCenter.RealTime`). It depends on **Contact Center Queues** (and therefore **Agents** and the base **Contact Center** feature) and the **SignalR** module, so enabling it pulls in the routing, presence, and reservation core. For voice work, also enable **Contact Center Voice** and a voice provider (for example [DialPad](../telephony/dialpad.md)). Once enabled, two entries appear under **Interaction Center** in the admin menu: -- **My workspace** - the Agent Workspace, available to anyone with the - `ContactCenterSignIntoQueues` permission. -- **Live dashboard** - the Supervisor Dashboard, available to anyone with the `MonitorContactCenter` - permission (granted to the built-in **Supervisor** role). +- **My workspace** - the Agent Workspace, available to anyone with the `ContactCenterSignIntoQueues` permission. +- **Live dashboard** - the Supervisor Dashboard, available to anyone with the `MonitorContactCenter` permission (granted to the built-in **Supervisor** role). ## For contact center managers: preparing the environment -Agents can only receive work once the routing environment exists. Configure these in the -**Interaction Center** before your team signs in. Each item links to its detailed reference in -[Agents, Queues & Dialer](agents-queues-dialer.md). - -1. **Skills** (*Interaction Center → Skills*) - define the competencies routing can require, for - example `Spanish`, `Billing`, or `Tier2`. -2. **Queues** (*Interaction Center → Queues*) - create a queue per line of business. Choose the - routing strategy (longest-idle, round-robin, or least-busy), optional sticky-agent preference, the - SLA threshold, the reservation timeout, required skills, and - for inbound voice - the dialed number - (DID) that feeds the queue. See [Queues, reservations, and assignment](agents-queues-dialer.md#queues-reservations-and-assignment). -3. **Business hours** (*Interaction Center → Business hours*) - attach a calendar to a queue so it - pauses routing (or overflows) when closed. -4. **Entry points** (*Interaction Center → Entry points*, Voice feature) - map an inbound DID to a - queue with a priority, business-hours gating, and a closed-hours action (hold, voicemail, overflow, - or reject). -5. **Agent state reason codes** (*Interaction Center → Agent states*) - define the not-ready presence - reasons agents can choose (for example `Lunch`, `Coaching`, `Admin`). These appear in the agent - presence menu. -6. **Campaigns and dispositions** - campaigns and dispositions live in the - [Omnichannel](../omnichannel/index.md) **Interaction Center**. Every activity carries a **Subject** - whose **Subject Flow** is the single decision controller: it defines the dispositions an agent can - choose and the follow-up actions each disposition triggers. See - [Subject Flow is the single decision controller](index.md#subject-flow-is-the-single-decision-controller). -7. **Dialer profiles** (*Interaction Center → Dialer Profiles*, Dialer feature) - for outbound work, - tie a campaign's activities to a queue, a dialing mode (manual, preview, power, or progressive), - pacing, and compliance rules. See [Dialer](agents-queues-dialer.md#dialer). -8. **Callbacks** - use the callback service or workflow bridge to schedule callback requests against a - contact, destination, due window, and optional queue. Due callbacks are promoted into outbound - callback activities and, when a queue is set, enter the same routing path as other work. -9. **Assign agents** (*Interaction Center → Agents*) - create an agent profile per user, set the - maximum concurrent interactions (capacity), and grant the routing skills. Skills are - administrator-owned; agents choose only which of their allowed queues and campaigns to sign in to. - -Grant agents the `ContactCenterSignIntoQueues` permission (or a role that includes it), and grant -supervisors the built-in **Supervisor** role (or the `MonitorContactCenter` permission). +Agents can only receive work once the routing environment exists. Configure these in the **Interaction Center** before your team signs in. Each item links to its detailed reference in [Agents, Queues & Dialer](agents-queues-dialer.md). + +1. **Skills** (*Interaction Center → Skills*) - define the competencies routing can require, for example `Spanish`, `Billing`, or `Tier2`. +2. **Queues** (*Interaction Center → Queues*) - create a queue per line of business. Choose the routing strategy (longest-idle, round-robin, or least-busy), optional sticky-agent preference, the SLA threshold, the reservation timeout, required skills, and - for inbound voice - the dialed number (DID) that feeds the queue. See [Queues, reservations, and assignment](agents-queues-dialer.md#queues-reservations-and-assignment). +3. **Business hours** (*Interaction Center → Business hours*) - attach a calendar to a queue so it pauses routing (or overflows) when closed. +4. **Entry points** (*Interaction Center → Entry points*, Voice feature) - map an inbound DID to a queue with a priority, business-hours gating, and a closed-hours action (hold, voicemail, overflow, or reject). +5. **Agent state reason codes** (*Interaction Center → Agent states*) - define the not-ready presence reasons agents can choose (for example `Lunch`, `Coaching`, `Admin`). These appear in the agent presence menu. +6. **Campaigns and dispositions** - campaigns and dispositions live in the [Omnichannel](../omnichannel/index.md) **Interaction Center**. Every activity carries a **Subject** whose **Subject Flow** is the single decision controller: it defines the dispositions an agent can choose and the follow-up actions each disposition triggers. See [Subject Flow is the single decision controller](index.md#subject-flow-is-the-single-decision-controller). +7. **Dialer profiles** (*Interaction Center → Dialer Profiles*, Dialer feature) - for outbound work, tie a campaign's activities to a queue, a dialing mode (manual, preview, power, or progressive), pacing, and compliance rules. See [Dialer](agents-queues-dialer.md#dialer). +8. **Callbacks** - use the callback service or workflow bridge to schedule callback requests against a contact, destination, due window, and optional queue. Due callbacks are promoted into outbound callback activities and, when a queue is set, enter the same routing path as other work. +9. **Assign agents** (*Interaction Center → Agents*) - create an agent profile per user, set the maximum concurrent interactions (capacity), and grant the routing skills. Skills are administrator-owned; agents choose only which of their allowed queues and campaigns to sign in to. + +Grant agents the `ContactCenterSignIntoQueues` permission (or a role that includes it), and grant supervisors the built-in **Supervisor** role (or the `MonitorContactCenter` permission). ## For contact center managers: the Live Dashboard -Open **Interaction Center → Live dashboard**. The dashboard connects to the real-time hub and refreshes -automatically as work and presence change (with a periodic safety refresh), so it can be left open on a -wallboard. +Open **Interaction Center → Live dashboard**. The dashboard connects to the real-time hub and refreshes automatically as work and presence change (with a periodic safety refresh), so it can be left open on a wallboard. It shows three sections: -- **Summary metrics** - total items waiting across all queues, the number of available agents, the - total agent count, and the queue count. -- **Queue tiles** - one tile per enabled queue showing the waiting count, the longest current wait, and - the number of items that have breached the queue's SLA threshold. Tiles turn amber as waits approach - the SLA and red once items breach it, so hotspots stand out at a glance. -- **Agent board** - every agent with a live presence dot (available, busy, wrap-up, break, and so on), - their current reason, and how many interactions they are handling. +- **Summary metrics** - total items waiting across all queues, the number of available agents, the total agent count, and the queue count. +- **Queue tiles** - one tile per enabled queue showing the waiting count, the longest current wait, and the number of items that have breached the queue's SLA threshold. Tiles turn amber as waits approach the SLA and red once items breach it, so hotspots stand out at a glance. +- **Agent board** - every agent with a live presence dot (available, busy, wrap-up, break, and so on), their current reason, and how many interactions they are handling. -Use it to spot a backing-up queue, an SLA breach, or too few available agents, and then rebalance -staffing, adjust queue priorities, or open a campaign. +Use it to spot a backing-up queue, an SLA breach, or too few available agents, and then rebalance staffing, adjust queue priorities, or open a campaign. -When an agent has a live interaction and the voice provider advertises the matching capability, the -agent card shows **Monitor**, **Whisper**, **Barge**, and **Take over** actions. Each action posts to the -audited monitoring service, which refuses unsupported modes and records the engagement as a Contact -Center domain event. Providers still own the media execution, so buttons are effective only when the -active provider implements the requested capability. +When an agent has a live interaction and the voice provider advertises the matching capability, the agent card shows **Monitor**, **Whisper**, **Barge**, and **Take over** actions. Each action posts to the audited monitoring service, which refuses unsupported modes and records the engagement as a Contact Center domain event. Providers still own the media execution, so buttons are effective only when the active provider implements the requested capability. ## For contact center managers: inbound routing runbook Use this checklist before publishing a new inbound line: 1. Create or confirm the Omnichannel **channel endpoint** for the dialed number. -2. Configure the Subject Flow for that endpoint so inbound activities get the right subject, campaign, - disposition list, required-disposition policy, and follow-up subject actions. -3. Create the target queue, set its SLA, reservation timeout, routing strategy, required skills, and - overflow queue. +2. Configure the Subject Flow for that endpoint so inbound activities get the right subject, campaign, disposition list, required-disposition policy, and follow-up subject actions. +3. Create the target queue, set its SLA, reservation timeout, routing strategy, required skills, and overflow queue. 4. Attach a business-hours calendar when the queue should pause or overflow outside staffed hours. -5. Create an **Entry point** for the DID. Set the target queue, priority, optional welcome/closed - messages, and the closed action: hold, voicemail, overflow, or reject. -6. Sign at least one skilled agent in to the queue, then place a test call. The expected path is - **provider webhook → entry point → queue → reservation → Agent Workspace offer → soft-phone media**. -7. Watch **Live dashboard** while testing. The queue waiting count should increase before assignment, - then the selected agent should move from available to reserved/busy/wrap-up as the call progresses. +5. Create an **Entry point** for the DID. Set the target queue, priority, optional welcome/closed messages, and the closed action: hold, voicemail, overflow, or reject. +6. Sign at least one skilled agent in to the queue, then place a test call. The expected path is **provider webhook → entry point → queue → reservation → Agent Workspace offer → soft-phone media**. +7. Watch **Live dashboard** while testing. The queue waiting count should increase before assignment, then the selected agent should move from available to reserved/busy/wrap-up as the call progresses. ## For contact center managers: outbound and callback runbook -Use CRM campaigns and activities as the source of outbound work; the dialer profile only controls -execution. - -1. Create the campaign and Subject Flow in Omnichannel. Configure dispositions and subject actions first - so every outcome has a business result. -2. Load activities through an Activity Batch. Choose a dialer source for dialer inventory so activities - are loaded unassigned and available for reservation. -3. Create a dialer profile that points to the campaign, queue, voice provider, dialing mode, pacing, and - compliance settings. -4. Confirm do-not-call, retry delay, calling window, and national registry settings before enabling an - automated mode. -5. For callbacks, schedule a callback request with the destination, due time, queue, and notes. The - callback dispatcher promotes due callbacks into outbound callback activities and enqueues them when a - queue is set. -6. Agents receive preview/manual work from their signed-in campaign or automated power/progressive work - from the queue, then complete it with the same disposition flow used for inbound work. +Use CRM campaigns and activities as the source of outbound work; the dialer profile only controls execution. + +1. Create the campaign and Subject Flow in Omnichannel. Configure dispositions and subject actions first so every outcome has a business result. +2. Load activities through an Activity Batch. Choose a dialer source for dialer inventory so activities are loaded unassigned and available for reservation. +3. Create a dialer profile that points to the campaign, queue, voice provider, dialing mode, pacing, and compliance settings. +4. Confirm do-not-call, retry delay, calling window, and national registry settings before enabling an automated mode. +5. For callbacks, schedule a callback request with the destination, due time, queue, and notes. The callback dispatcher promotes due callbacks into outbound callback activities and enqueues them when a queue is set. +6. Agents receive preview/manual work from their signed-in campaign or automated power/progressive work from the queue, then complete it with the same disposition flow used for inbound work. ## For contact center managers: workflow automation -The Subject Flow is the primary business workflow for work completion. Use it for required -dispositions and disposition-driven actions such as finish, retry, new activity, or communication- -preference updates. Enable the optional **OrchardCore.Workflows** bridge only when you need automation -from Contact Center domain events such as routing decisions, offer acceptance, call connected/ended, -callback scheduled/promoted, or SLA/analytics events. Workflow automation should enrich or react to -activity state; it should not bypass queues, reservations, or the source-neutral disposition service. +The Subject Flow is the primary business workflow for work completion. Use it for required dispositions and disposition-driven actions such as finish, retry, new activity, or communication-preference updates. Enable the optional **OrchardCore.Workflows** bridge only when you need automation from Contact Center domain events such as routing decisions, offer acceptance, call connected/ended, callback scheduled/promoted, or SLA/analytics events. Workflow automation should enrich or react to activity state; it should not bypass queues, reservations, or the source-neutral disposition service. ## For contact center agents: the Agent Workspace -Open **Interaction Center → My workspace**. This is the screen an agent keeps open for the whole shift. -Keep the [Telephony soft phone](../telephony/index.md) available too - it is where the call audio and -device controls live. +Open **Interaction Center → My workspace**. This is the screen an agent keeps open for the whole shift. Keep the [Telephony soft phone](../telephony/index.md) available too - it is where the call audio and device controls live. ### Shift checklist @@ -156,26 +93,16 @@ device controls live. ### 1. Sign in and set your presence -- **Sign in to queues and campaigns** from the soft phone's **Work** tab. You can only choose queues and - campaigns you are allowed to handle. -- **Set your presence** from the presence button at the top of the workspace. Choose **Available** to - receive work, pick a **reason code** (for example *Lunch* or *Coaching*) to go not-ready, or choose - **Request break**. A break is granted immediately when nothing is being routed to you; if an offer is - already in flight, you finish it and the break is granted automatically afterward. +- **Sign in to queues and campaigns** from the soft phone's **Work** tab. You can only choose queues and campaigns you are allowed to handle. +- **Set your presence** from the presence button at the top of the workspace. Choose **Available** to receive work, pick a **reason code** (for example *Lunch* or *Coaching*) to go not-ready, or choose **Request break**. A break is granted immediately when nothing is being routed to you; if an offer is already in flight, you finish it and the break is granted automatically afterward. -The top bar also shows a live chip per signed-in queue with its current waiting count, so you can see -where the pressure is. +The top bar also shows a live chip per signed-in queue with its current waiting count, so you can see where the pressure is. ### 2. Receive and answer an offer -When routing selects you for a piece of work, a **ringing offer card** appears with the customer name -(or number), the queue, and a countdown showing how long you have to respond. You have two choices: +When routing selects you for a piece of work, a **ringing offer card** appears with the customer name (or number), the queue, and a countdown showing how long you have to respond. You have two choices: -- **Accept** - accepts the reservation, connects the media, and moves the work into your active panel. - For providers that ring your device (such as DialPad's soft phone), your device rings and you answer - there; the workspace and the incoming-call modal coordinate so the call is only answered after the - reservation is confirmed - you will never pick up a call that has already been re-offered to someone - else. +- **Accept** - accepts the reservation, connects the media, and moves the work into your active panel. For providers that ring your device (such as DialPad's soft phone), your device rings and you answer there; the workspace and the incoming-call modal coordinate so the call is only answered after the reservation is confirmed - you will never pick up a call that has already been re-offered to someone else. - **Decline** - releases the offer so it is immediately re-offered to the next available agent. If you do not respond before the countdown ends, the offer is revoked and routed elsewhere. @@ -187,17 +114,13 @@ Once you accept, the **active interaction** panel shows: - The **customer**, with a link to open the full CRM contact record (customer 360). - The **direction** (inbound or outbound), the current **call status**, and a **live talk timer**. - The **queue** the work came from. -- A **Complete activity** link that opens the same Omnichannel CRM completion page used by manual - activities. +- A **Complete activity** link that opens the same Omnichannel CRM completion page used by manual activities. -Use the soft phone for hold, mute, transfer, and hang-up. The workspace reflects the call state in real -time. +Use the soft phone for hold, mute, transfer, and hang-up. The workspace reflects the call state in real time. ### 4. Complete the activity in the CRM -When the conversation ends, click **Complete activity** in the active panel. This opens the shared -Omnichannel completion page for the assigned activity, so contact-center work follows the same CRM -experience as manual activities: +When the conversation ends, click **Complete activity** in the active panel. This opens the shared Omnichannel completion page for the assigned activity, so contact-center work follows the same CRM experience as manual activities: 1. Review the customer/contact context and open the customer record when details need correction. 2. Review activity details such as campaign, channel, urgency, schedule, instructions, and assignee. @@ -205,27 +128,17 @@ experience as manual activities: 4. Choose a **disposition** from the list defined by the activity's subject flow. 5. Add notes when needed and submit the completion form. -Completing routes through the shared disposition path, which applies the disposition, marks the activity -completed, and runs the subject flow's follow-up actions - the same path used everywhere in the CRM, so -inbound, outbound, and manual work all behave consistently. If the subject flow **requires** a -disposition, completion is blocked until you pick one and the completion page shows why. +Completing routes through the shared disposition path, which applies the disposition, marks the activity completed, and runs the subject flow's follow-up actions - the same path used everywhere in the CRM, so inbound, outbound, and manual work all behave consistently. If the subject flow **requires** a disposition, completion is blocked until you pick one and the completion page shows why. ### 5. Review recent activity -The **Recent activity** panel lists your most recent interactions with their direction, outcome, and -time, so you can quickly refer back to your last few contacts. +The **Recent activity** panel lists your most recent interactions with their direction, outcome, and time, so you can quickly refer back to your last few contacts. ## How it works -- The workspace loads a **state snapshot** from the server and then keeps itself current from the - real-time hub's presence, offer, and queue events. It re-reads the authoritative state after you act, - so what you see always matches the server. -- **Accept** calls a single server-side command that accepts the reservation, tells the voice provider - to connect the call to you, and advances the interaction and call session together - one atomic, - audited transition rather than several best-effort client actions. -- **Complete** goes through the source-neutral `IActivityDispositionService`, so dispositions, - required-disposition rules, and subject-flow actions behave identically across every channel and - source. +- The workspace loads a **state snapshot** from the server and then keeps itself current from the real-time hub's presence, offer, and queue events. It re-reads the authoritative state after you act, so what you see always matches the server. +- **Accept** calls a single server-side command that accepts the reservation, tells the voice provider to connect the call to you, and advances the interaction and call session together - one atomic, audited transition rather than several best-effort client actions. +- **Complete** goes through the source-neutral `IActivityDispositionService`, so dispositions, required-disposition rules, and subject-flow actions behave identically across every channel and source. ## Permissions and roles diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index de9edc0e0..5fc4ad49b 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -5,10 +5,7 @@ title: Agents, Queues, Routing, and Dialer description: Contact Center agent presence, queues, skill-aware routing, reservations, availability-based assignment, and voice-routed outbound dialing. --- -This phase adds the operational core of the Contact Center: agent presence, work queues, -reservations, skill-aware routing, availability-based assignment, and an outbound dialer that routes -voice calls through Contact Center Voice providers. Each capability is a separate, feature-gated -module so tenants enable only what they need. +This phase adds the operational core of the Contact Center: agent presence, work queues, reservations, skill-aware routing, availability-based assignment, and an outbound dialer that routes voice calls through Contact Center Voice providers. Each capability is a separate, feature-gated module so tenants enable only what they need. ## Features @@ -23,131 +20,65 @@ module so tenants enable only what they need. ## Agents and presence -An **agent profile** links an Orchard user to Contact Center configuration: display name, capacity, -administrator-assigned skills, queue membership, campaign membership, and live presence. Presence -states include `Offline`, `Available`, `Break`, `Away`, `DoNotDisturb`, `Meeting`, `Training`, -`AfterHoursUnavailable`, and system-managed states such as `Reserved`, `Busy`, and `WrapUp`. +An **agent profile** links an Orchard user to Contact Center configuration: display name, capacity, administrator-assigned skills, queue membership, campaign membership, and live presence. Presence states include `Offline`, `Available`, `Break`, `Away`, `DoNotDisturb`, `Meeting`, `Training`, `AfterHoursUnavailable`, and system-managed states such as `Reserved`, `Busy`, and `WrapUp`. -Agents sign in from the floating Telephony soft phone. When the Contact Center queues feature is -enabled, Contact Center contributes a **Work** tab where agents select the queues and campaigns they -want to receive work from and sign out. Signing in sets presence to `Available`; signing out sets it -to `Offline`. The `SignIntoQueues` permission grants self-service sign-in. +Agents sign in from the floating Telephony soft phone. When the Contact Center queues feature is enabled, Contact Center contributes a **Work** tab where agents select the queues and campaigns they want to receive work from and sign out. Signing in sets presence to `Available`; signing out sets it to `Offline`. The `SignIntoQueues` permission grants self-service sign-in. -Presence is a dropdown in the soft-phone header so agents can change availability without switching -tabs. **Request break** is system-approved: if no assignment is in progress, the request is granted -immediately and the agent enters `Break`; if a route/reservation is already in progress, the request is -kept pending while the call continues, and the system grants `Break` automatically when that in-flight -work is released. Agents in `RequestBreak` or `Break` are not eligible for new routing decisions. +Presence is a dropdown in the soft-phone header so agents can change availability without switching tabs. **Request break** is system-approved: if no assignment is in progress, the request is granted immediately and the agent enters `Break`; if a route/reservation is already in progress, the request is kept pending while the call continues, and the system grants `Break` automatically when that in-flight work is released. Agents in `RequestBreak` or `Break` are not eligible for new routing decisions. ## Agent state reason codes -Administrators define **reason codes** from **Interaction Center → Agent states** so agents pick an -auditable, standardized reason when they go not ready. A reason code has a unique name, an optional -description, the presence state it places the agent in (`AppliesTo` — `Break`, `Away`, `DoNotDisturb`, -`Meeting`, `Training`, or `AfterHoursUnavailable`), a sort order, and an enabled flag. The catalog is -managed with the same display-driver CRUD pattern as Skills and queues, and the `ManageContactCenterAgents` -permission gates it. +Administrators define **reason codes** from **Interaction Center → Agent states** so agents pick an auditable, standardized reason when they go not ready. A reason code has a unique name, an optional description, the presence state it places the agent in (`AppliesTo` — `Break`, `Away`, `DoNotDisturb`, `Meeting`, `Training`, or `AfterHoursUnavailable`), a sort order, and an enabled flag. The catalog is managed with the same display-driver CRUD pattern as Skills and queues, and the `ManageContactCenterAgents` permission gates it. -When reason codes are configured, the soft-phone presence dropdown lists them (ordered by sort order) -in place of the fixed not-ready states; selecting one sets the agent's presence to the reason's -`AppliesTo` state and records the reason on the agent profile and the `AgentPresenceChanged` event. -If no reason codes exist, the dropdown falls back to the built-in not-ready states. +When reason codes are configured, the soft-phone presence dropdown lists them (ordered by sort order) in place of the fixed not-ready states; selecting one sets the agent's presence to the reason's `AppliesTo` state and records the reason on the agent profile and the `AgentPresenceChanged` event. If no reason codes exist, the dropdown falls back to the built-in not-ready states. -The Agents feature seeds a standard set of reason codes at setup (short break, lunch, away from desk, -team meeting, training, coaching, and system issue) by running the `agent-state-reason-codes` module -recipe. Reason codes are also importable through the `AgentStateReasonCode` recipe step so they can be -seeded or moved between tenants in deployment recipes. +The Agents feature seeds a standard set of reason codes at setup (short break, lunch, away from desk, team meeting, training, coaching, and system issue) by running the `agent-state-reason-codes` module recipe. Reason codes are also importable through the `AgentStateReasonCode` recipe step so they can be seeded or moved between tenants in deployment recipes. ## Skills -Administrators manage routeable capabilities from **Interaction Center → Skills**. A skill has a -unique name, description, and enabled state. Enabled skills appear in admin assignment surfaces and -queue editor selectors; disabled skills remain on existing agents and queues but are hidden from new -selections. Agents do not self-select skills from the soft phone because skills are routing -eligibility data owned by supervisors/administrators. +Administrators manage routeable capabilities from **Interaction Center → Skills**. A skill has a unique name, description, and enabled state. Enabled skills appear in admin assignment surfaces and queue editor selectors; disabled skills remain on existing agents and queues but are hidden from new selections. Agents do not self-select skills from the soft phone because skills are routing eligibility data owned by supervisors/administrators. -Queues can require one or more skills. Agents must have every required skill assigned on their agent -profile to be eligible for that queue, and the default routing strategy filters out agents missing any -required skill before longest-idle scoring runs. +Queues can require one or more skills. Agents must have every required skill assigned on their agent profile to be eligible for that queue, and the default routing strategy filters out agents missing any required skill before longest-idle scoring runs. ## Queues, reservations, and assignment -A **queue** holds activities waiting for an agent, with a default priority, an SLA threshold, required -skills, an optional inbound channel endpoint mapping, a reservation timeout, a routing policy, an -optional business-hours calendar, and optional overflow settings. Activities enter a queue as **queue -items**; the system pairs the highest-priority, oldest waiting item with an eligible available agent -signed in to that queue and creates a short-lived **reservation**. +A **queue** holds activities waiting for an agent, with a default priority, an SLA threshold, required skills, an optional inbound channel endpoint mapping, a reservation timeout, a routing policy, an optional business-hours calendar, and optional overflow settings. Activities enter a queue as **queue items**; the system pairs the highest-priority, oldest waiting item with an eligible available agent signed in to that queue and creates a short-lived **reservation**. -Routing is strategy-based. The strategy chain first rejects agents that do not have every required -queue skill, then rejects agents that are already handling their maximum number of concurrent -interactions, then applies the queue's selected scoring strategy. Each assignment publishes an -auditable routing-decision event that records the queue item, selected agent, candidate scores, and -reasons, so later supervisor and analytics features can explain why work was offered to an agent. +Routing is strategy-based. The strategy chain first rejects agents that do not have every required queue skill, then rejects agents that are already handling their maximum number of concurrent interactions, then applies the queue's selected scoring strategy. Each assignment publishes an auditable routing-decision event that records the queue item, selected agent, candidate scores, and reasons, so later supervisor and analytics features can explain why work was offered to an agent. ### Routing policy -Each queue selects a primary **routing strategy** that decides which available, eligible agent receives -the next item: +Each queue selects a primary **routing strategy** that decides which available, eligible agent receives the next item: - **Longest idle** (default) — offers work to the agent who has been available the longest. -- **Round robin** — distributes work fairly by offering to the agent who least recently received an - assignment (tracked on the agent's `LastAssignedUtc`, stamped when a reservation is created). +- **Round robin** — distributes work fairly by offering to the agent who least recently received an assignment (tracked on the agent's `LastAssignedUtc`, stamped when a reservation is created). - **Least busy** — offers work to the agent currently handling the fewest active interactions. Only the selected strategy scores candidates; the other primary strategies stay inert for that queue. -When a queue enables **prefer sticky agent**, routing boosts the eligible candidate who most recently -owned the activity (captured from the activity's assigned user when it is enqueued), so returning work -prefers the agent the customer already worked with. The sticky preference is additive and never -overrides skill or capacity eligibility. +When a queue enables **prefer sticky agent**, routing boosts the eligible candidate who most recently owned the activity (captured from the activity's assigned user when it is enqueued), so returning work prefers the agent the customer already worked with. The sticky preference is additive and never overrides skill or capacity eligibility. -When a queue enables **SLA aging**, a waiting item's effective priority increases by one step for every -SLA-threshold interval it waits beyond the threshold, so aging work is routed ahead of newer -higher-priority work instead of starving. +When a queue enables **SLA aging**, a waiting item's effective priority increases by one step for every SLA-threshold interval it waits beyond the threshold, so aging work is routed ahead of newer higher-priority work instead of starving. -Agent capacity is enforced during candidate selection. Each agent profile defines -`MaxConcurrentInteractions` (default `1`), and the capacity routing strategy counts the agent's active -(not ended and not failed) interactions before they can be offered new work, so an agent is never -offered more concurrent interactions than they are configured to handle. +Agent capacity is enforced during candidate selection. Each agent profile defines `MaxConcurrentInteractions` (default `1`), and the capacity routing strategy counts the agent's active (not ended and not failed) interactions before they can be offered new work, so an agent is never offered more concurrent interactions than they are configured to handle. ### Business hours and overflow -A queue can reference a reusable **business-hours calendar** (managed from **Interaction Center → -Business hours**). A calendar defines a time zone, a weekly open window per day, and all-day holiday -dates. While the calendar reports the queue closed, assignment pauses. The queue's **after-hours -action** decides what happens to waiting items: *Hold in queue* keeps them until the queue reopens, and -*Overflow* moves them to the configured overflow queue. +A queue can reference a reusable **business-hours calendar** (managed from **Interaction Center → Business hours**). A calendar defines a time zone, a weekly open window per day, and all-day holiday dates. While the calendar reports the queue closed, assignment pauses. The queue's **after-hours action** decides what happens to waiting items: *Hold in queue* keeps them until the queue reopens, and *Overflow* moves them to the configured overflow queue. -Independently of business hours, a queue may set an **overflow queue** and an **overflow-after** -threshold. Waiting items that exceed the threshold are moved to the overflow queue so long-waiting work -can be picked up by a broader team. Overflow moves run each minute alongside reservation expiry and -assignment. +Independently of business hours, a queue may set an **overflow queue** and an **overflow-after** threshold. Waiting items that exceed the threshold are moved to the overflow queue so long-waiting work can be picked up by a broader team. Overflow moves run each minute alongside reservation expiry and assignment. -A reservation locks the activity for one agent and can be accepted, rejected, canceled, or expired. -The CRM activity moves through `Available → Reserved → Assigned`, mirrored on the queue item and -agent presence. Expired or canceled reservations return the item to the queue automatically. A -background task expires stale reservations and assigns waiting work every minute. +A reservation locks the activity for one agent and can be accepted, rejected, canceled, or expired. The CRM activity moves through `Available → Reserved → Assigned`, mirrored on the queue item and agent presence. Expired or canceled reservations return the item to the queue automatically. A background task expires stale reservations and assigns waiting work every minute. -Assignment is concurrency-safe. Each queue's assignment runs under a per-queue distributed lock, so -two nodes — or the reservation-expiry background task running alongside an inbound call — cannot -double-assign the same item or reserve the same agent twice. +Assignment is concurrency-safe. Each queue's assignment runs under a per-queue distributed lock, so two nodes — or the reservation-expiry background task running alongside an inbound call — cannot double-assign the same item or reserve the same agent twice. ## Dialer -A **dialer profile** is an execution policy, not the source of CRM work. Activities, campaigns, -subjects, batches, dispositions, and contact context still come from Omnichannel. The profile tells -the Contact Center how a specific outbound campaign should be dialed: which queue supplies agents, -which dialing mode is used, which Contact Center voice provider places calls, how pacing works, and -how attempts/retries and compliance are bounded. Power and progressive profiles run automatically -each minute: the Contact Center reserves an available agent, evaluates the compliance gate, creates -an outbound interaction, and asks the Voice Contact Center Call Router to place the call. Manual and -preview profiles wait for agent action. Dialer activity batches load **unassigned** inventory the -dialer reserves later. +A **dialer profile** is an execution policy, not the source of CRM work. Activities, campaigns, subjects, batches, dispositions, and contact context still come from Omnichannel. The profile tells the Contact Center how a specific outbound campaign should be dialed: which queue supplies agents, which dialing mode is used, which Contact Center voice provider places calls, how pacing works, and how attempts/retries and compliance are bounded. Power and progressive profiles run automatically each minute: the Contact Center reserves an available agent, evaluates the compliance gate, creates an outbound interaction, and asks the Voice Contact Center Call Router to place the call. Manual and preview profiles wait for agent action. Dialer activity batches load **unassigned** inventory the dialer reserves later. ### Dialing modes and safety -Each automated mode is implemented as a dedicated `IDialerStrategy`, so unsupported modes are -withheld rather than falling through to an unsafe default: +Each automated mode is implemented as a dedicated `IDialerStrategy`, so unsupported modes are withheld rather than falling through to an unsafe default: | Mode | Behavior | | --- | --- | @@ -159,66 +90,33 @@ withheld rather than falling through to an unsafe default: ### Outbound compliance gate -Before every attempt, `IDialerEligibilityService` runs and records an auditable `DialSuppressed` -event when an attempt must be blocked. The default gate enforces, in order: +Before every attempt, `IDialerEligibilityService` runs and records an auditable `DialSuppressed` event when an attempt must be blocked. The default gate enforces, in order: - **Destination present** and the **maximum attempt count** has not been reached. - **Retry cool-down** - a previous attempt must be older than `RetryDelayMinutes`. -- **Do-not-call / communication preferences** - the contact's `DoNotCall` opt-out (when - *Respect do-not-call and communication preferences* is enabled). -- **Calling window** - when *Enforce a calling window* is enabled, the contact is only dialed while - their local time (from the contact's time zone, or the profile's default time zone) is within the - configured start/end hours. -- **National do-not-call registries** - any registered `INationalDoNotCallRegistry` (for example the - USA FTC or Canada DNCL registries) is scrubbed when *Respect do-not-call* is enabled. - -Do-not-call and registry suppressions cancel the activity; calling-window and cool-down suppressions -release the reservation and leave the activity available for a later cycle. Full calling-window -calendars, abandonment caps, and answering-machine detection are hardened in a later compliance -phase. +- **Do-not-call / communication preferences** - the contact's `DoNotCall` opt-out (when *Respect do-not-call and communication preferences* is enabled). +- **Calling window** - when *Enforce a calling window* is enabled, the contact is only dialed while their local time (from the contact's time zone, or the profile's default time zone) is within the configured start/end hours. +- **National do-not-call registries** - any registered `INationalDoNotCallRegistry` (for example the USA FTC or Canada DNCL registries) is scrubbed when *Respect do-not-call* is enabled. + +Do-not-call and registry suppressions cancel the activity; calling-window and cool-down suppressions release the reservation and leave the activity available for a later cycle. Full calling-window calendars, abandonment caps, and answering-machine detection are hardened in a later compliance phase. ### Callback operations -Callbacks use the same Activity, queue, routing, and disposition path as outbound campaign calls. A -`CallbackRequest` records the contact, destination, optional campaign and queue, requested/due window, -attempt count, status, and notes. The callback dispatcher runs every minute and promotes each due -pending callback into an outbound `Callback` activity. When the request has a queue, that activity is -enqueued so the next eligible signed-in agent receives it through the Agent Workspace. +Callbacks use the same Activity, queue, routing, and disposition path as outbound campaign calls. A `CallbackRequest` records the contact, destination, optional campaign and queue, requested/due window, attempt count, status, and notes. The callback dispatcher runs every minute and promotes each due pending callback into an outbound `Callback` activity. When the request has a queue, that activity is enqueued so the next eligible signed-in agent receives it through the Agent Workspace. -Use callbacks when an agent schedules a later follow-up, an inbound entry point offers a callback -instead of waiting in queue, or workflow automation decides the next best action is a phone callback. -Managers should configure a dedicated callback queue when callbacks need different SLA, skills, or -priority from live inbound calls. Agents handle the promoted callback like any other outbound call: -answer the offer, complete the conversation, select a disposition, and finish wrap-up through the -Subject Flow. +Use callbacks when an agent schedules a later follow-up, an inbound entry point offers a callback instead of waiting in queue, or workflow automation decides the next best action is a phone callback. Managers should configure a dedicated callback queue when callbacks need different SLA, skills, or priority from live inbound calls. Agents handle the promoted callback like any other outbound call: answer the offer, complete the conversation, select a disposition, and finish wrap-up through the Subject Flow. ## Voice Contact Center Call Router -The dialer never talks to a telephony platform directly. It calls `IVoiceContactCenterCallRouter`, -which resolves the configured `IContactCenterVoiceProvider`, so the Contact Center keeps assignment, -queue, pacing, and compliance logic while the provider executes call operations. The -`DialPad.Dialer` feature implements `IContactCenterVoiceProvider` over the DialPad telephony -provider; enable it to dial through DialPad. +The dialer never talks to a telephony platform directly. It calls `IVoiceContactCenterCallRouter`, which resolves the configured `IContactCenterVoiceProvider`, so the Contact Center keeps assignment, queue, pacing, and compliance logic while the provider executes call operations. The `DialPad.Dialer` feature implements `IContactCenterVoiceProvider` over the DialPad telephony provider; enable it to dial through DialPad. -Voice providers that support contact-center orchestration beyond soft-phone call control can also -register `IContactCenterVoiceProvider`. The `IContactCenterVoiceProviderResolver` resolves those -providers by technical name so future PBX integrations can participate in provider-side queueing, -call assignment, and voice-specific orchestration without coupling Contact Center to one provider. +Voice providers that support contact-center orchestration beyond soft-phone call control can also register `IContactCenterVoiceProvider`. The `IContactCenterVoiceProviderResolver` resolves those providers by technical name so future PBX integrations can participate in provider-side queueing, call assignment, and voice-specific orchestration without coupling Contact Center to one provider. ## Admin UX and extensibility -Contact Center management entries live under **Interaction Center**. Skills, queues, business-hours -calendars, and dialer profile CRUD screens match the Omnichannel Campaigns UI: searchable list pages -render summary shapes, and create/edit screens render display-driver editor shapes with the required -root edit wrapper templates. Agent sign-in and presence are injected into the Telephony soft phone -through `DisplayDriver`, so the operational controls stay with the phone while -management screens remain catalog-focused. - -Agent state reason codes are a catalog-backed admin surface (**Interaction Center → Agent states**), -not a provider-specific dialer setting. The Agents feature seeds standard reason codes during tenant -setup by executing the `agent-state-reason-codes` module recipe, and the `AgentStateReasonCode` recipe -step lets reason codes be imported or moved between tenants. A dedicated deployment-plan step is a -planned follow-up. +Contact Center management entries live under **Interaction Center**. Skills, queues, business-hours calendars, and dialer profile CRUD screens match the Omnichannel Campaigns UI: searchable list pages render summary shapes, and create/edit screens render display-driver editor shapes with the required root edit wrapper templates. Agent sign-in and presence are injected into the Telephony soft phone through `DisplayDriver`, so the operational controls stay with the phone while management screens remain catalog-focused. + +Agent state reason codes are a catalog-backed admin surface (**Interaction Center → Agent states**), not a provider-specific dialer setting. The Agents feature seeds standard reason codes during tenant setup by executing the `agent-state-reason-codes` module recipe, and the `AgentStateReasonCode` recipe step lets reason codes be imported or moved between tenants. A dedicated deployment-plan step is a planned follow-up. ## Enable via recipe diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index aa5cdc39f..faa414d94 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -10,16 +10,9 @@ description: Provider-agnostic contact center orchestration for Orchard Core - i | **Feature Name** | Contact Center | | **Feature ID** | `CrestApps.OrchardCore.ContactCenter` | -The **Contact Center** module set turns the CRM into a full contact center that agents and -supervisors operate without leaving Orchard Core. It extends the [Omnichannel](../omnichannel/index.md) -CRM instead of introducing a second work model, and it sits between the CRM and the -[Telephony](../telephony/index.md) soft phone: the CRM owns business work data, the Contact Center -owns orchestration, and Telephony owns media execution. +The **Contact Center** module set turns the CRM into a full contact center that agents and supervisors operate without leaving Orchard Core. It extends the [Omnichannel](../omnichannel/index.md) CRM instead of introducing a second work model, and it sits between the CRM and the [Telephony](../telephony/index.md) soft phone: the CRM owns business work data, the Contact Center owns orchestration, and Telephony owns media execution. -The CRM **Activity** remains the universal unit of work. Activities can be created before an owner -exists, then later reserved and assigned by a dialer, queue, or agent workflow. An **Interaction** is -communication history for a single attempt on that activity - for example a busy call attempt, a -no-answer attempt, or a connected call - and it never owns workflow or disposition. +The CRM **Activity** remains the universal unit of work. Activities can be created before an owner exists, then later reserved and assigned by a dialer, queue, or agent workflow. An **Interaction** is communication history for a single attempt on that activity - for example a busy call attempt, a no-answer attempt, or a connected call - and it never owns workflow or disposition. ## Layer boundaries @@ -36,64 +29,42 @@ Telephony Soft phone, provider resolver, provider call execution an Telephony providers Dial, answer, transfer, hold, resume, conference, hangup, provider webhooks ``` -- **CRM (Omnichannel)** answers *who the customer is, which Activity is the work item, which Subject - defines workflow, and which Disposition was selected*. -- **Contact Center** answers *which activity happens next, which queue owns it, which agent should - reserve it, when a dial occurs, what communication history exists for the activity, and what - real-time event the UI should see*. -- **Telephony** answers *how the configured provider executes a call action and what the provider's - current call state is*. +- **CRM (Omnichannel)** answers *who the customer is, which Activity is the work item, which Subject defines workflow, and which Disposition was selected*. +- **Contact Center** answers *which activity happens next, which queue owns it, which agent should reserve it, when a dial occurs, what communication history exists for the activity, and what real-time event the UI should see*. +- **Telephony** answers *how the configured provider executes a call action and what the provider's current call state is*. -The Contact Center never handles telephony media and never directly bypasses the CRM disposition -path. It orchestrates behavior, records communication history, and projects operational state. +The Contact Center never handles telephony media and never directly bypasses the CRM disposition path. It orchestrates behavior, records communication history, and projects operational state. ## CRM activity extensions Contact Center extends `OmnichannelActivity` with metadata needed by queues and dialers: -- Nullable ownership so preview, power, progressive, and predictive dialing can create activities - before an agent is selected. -- Activity kind and extensible source metadata so the same Activity model can represent calls, SMS, - email, meetings, tasks, callbacks, inbound work, workflow-created work, API-created work, and - dialer inventory. -- Assignment and reservation metadata so multiple dialer or routing instances do not claim the same - record concurrently. -- Activity batches can load either user-assigned manual work or unassigned dialer work. The batch - creation dialog selects a source first, then display drivers render source-specific UI. +- Nullable ownership so preview, power, progressive, and predictive dialing can create activities before an agent is selected. +- Activity kind and extensible source metadata so the same Activity model can represent calls, SMS, email, meetings, tasks, callbacks, inbound work, workflow-created work, API-created work, and dialer inventory. +- Assignment and reservation metadata so multiple dialer or routing instances do not claim the same record concurrently. +- Activity batches can load either user-assigned manual work or unassigned dialer work. The batch creation dialog selects a source first, then display drivers render source-specific UI. -Dispositions are applied to Activities, not Interactions. Agent, provider, AI, workflow, and system -outcomes converge through the activity disposition service before Subject Actions or workflow -automation runs. +Dispositions are applied to Activities, not Interactions. Agent, provider, AI, workflow, and system outcomes converge through the activity disposition service before Subject Actions or workflow automation runs. ## Capabilities -The Contact Center is delivered as a set of feature-gated modules so tenants enable only the -capabilities they need, similar to how commercial platforms separate licensed capabilities: +The Contact Center is delivered as a set of feature-gated modules so tenants enable only the capabilities they need, similar to how commercial platforms separate licensed capabilities: - **Interaction management** - communication history for activity attempts. - **Queues** - inbound, outbound, callback, and dynamic activity queues with priority, SLA, and overflow. -- **Routing** - skills-based, priority, sticky-agent, round robin, longest-idle, and business-hours - routing with auditable routing decisions. -- **Agents and presence** - agent profiles, real-time presence and reason codes, skills, capacity, - and queue membership. -- **Voice** - a voice channel adapter over the Telephony module that maps provider calls to - interactions. +- **Routing** - skills-based, priority, sticky-agent, round robin, longest-idle, and business-hours routing with auditable routing decisions. +- **Agents and presence** - agent profiles, real-time presence and reason codes, skills, capacity, and queue membership. +- **Voice** - a voice channel adapter over the Telephony module that maps provider calls to interactions. - **Dialer** - outbound manual, preview, power, and progressive dialing driven by CRM activities. -- **Wrap-up** - disposition-based after-call work, required activity dispositions, CRM activity - completion, agent presence release, and post-communication automation. +- **Wrap-up** - disposition-based after-call work, required activity dispositions, CRM activity completion, agent presence release, and post-communication automation. - **Supervision** - live queue and agent monitoring with audited supervisor call-control intents. - **Analytics** - queue, agent, and campaign metrics and historical reporting. -Inbound entry points, call recording orchestration, outbound compliance gates, the optional workflow -bridge, live dashboards, and AI-assist extension points are now present. Multi-step IVR decision trees, -provider-side recording storage, quality scorecards, abandonment caps, and predictive dialing remain -advanced roadmap items. +Inbound entry points, call recording orchestration, outbound compliance gates, the optional workflow bridge, live dashboards, and AI-assist extension points are now present. Multi-step IVR decision trees, provider-side recording storage, quality scorecards, abandonment caps, and predictive dialing remain advanced roadmap items. ## Interaction Center admin menu concepts -Contact Center administration is intentionally split into focused menu entries under -**Interaction Center**. Each entry owns one concept so a new tenant can configure routing in a predictable -order instead of mixing CRM, routing, and telephony settings on one screen. +Contact Center administration is intentionally split into focused menu entries under **Interaction Center**. Each entry owns one concept so a new tenant can configure routing in a predictable order instead of mixing CRM, routing, and telephony settings on one screen. | Menu entry | What it controls | Example | | --- | --- | --- | @@ -109,43 +80,21 @@ order instead of mixing CRM, routing, and telephony settings on one screen. | **My workspace** | The agent desktop where agents receive offers, accept or decline work, see active customer context, and complete the activity with a disposition. | An agent accepts an inbound support offer, handles the call, selects `Resolved`, and completes the activity. | | **Live dashboard** | Supervisor wallboard for queue depth, SLA health, and live agent presence. | A supervisor sees Billing Voice breaching SLA and asks another skilled agent to sign in. | -The practical setup sequence is: create channel endpoints and campaigns in Omnichannel, configure subject -flows and dispositions, define skills and business hours, create queues, map inbound entry points or -dialer profiles, assign agents, then use My workspace and Live dashboard for daily operations. +The practical setup sequence is: create channel endpoints and campaigns in Omnichannel, configure subject flows and dispositions, define skills and business hours, create queues, map inbound entry points or dialer profiles, assign agents, then use My workspace and Live dashboard for daily operations. ## Real-time experience -The Contact Center publishes its own real-time event stream over SignalR for agent desktops, -supervisor dashboards, and queue monitors. It does not reuse the Telephony soft-phone hub for routing, -queue, or supervisor data; voice call state continues to flow through Telephony and is projected into -the interaction. - -The **Contact Center Real-Time** feature (`CrestApps.OrchardCore.ContactCenter.RealTime`, depends on -**Queues** and the **SignalR** module) adds: - -- **`ContactCenterHub`** - a SignalR hub mapped through the SignalR module's `HubRouteManager`. Agents - connect with the `ContactCenterSignIntoQueues` permission and join their own user group plus a group - per signed-in queue; supervisors connect with the new `MonitorContactCenter` permission and join the - `cc:supervisors` group. Supervisors can also call `WatchQueue`/`UnwatchQueue` to subscribe to a - single queue's group for a wallboard. -- **Live agent sessions** - a volatile `AgentSession` aggregate, separate from the administrator-owned - `AgentProfile`, that tracks an agent's open SignalR connections and last heartbeat. Splitting live - state from configuration means a closed browser no longer leaves an agent `Available`. -- **Heartbeat and stale-session cleanup** - 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 deletes - the session, so routing stops targeting a dead client. A brief disconnect (for example a page - refresh) is tolerated by the grace window. -- **Reconnect snapshots** - the hub's `GetSnapshot` method 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. -- **Event projection** - `ContactCenterRealTimeEventHandler` listens to the durable domain event log - and broadcasts presence changes (`PresenceChanged`), work offers (`OfferReceived`/`OfferRevoked`), - and queue depth (`QueueStatsChanged`) to the affected agent, queue watchers, and supervisors. The - projection is read-only with respect to domain state. - -A small client helper (`contact-center-realtime` script resource, depending on the `signalr` resource) -wraps the SignalR client to connect, send heartbeats, fetch the reconnect snapshot, and dispatch the -strongly-typed callbacks: +The Contact Center publishes its own real-time event stream over SignalR for agent desktops, supervisor dashboards, and queue monitors. It does not reuse the Telephony soft-phone hub for routing, queue, or supervisor data; voice call state continues to flow through Telephony and is projected into the interaction. + +The **Contact Center Real-Time** feature (`CrestApps.OrchardCore.ContactCenter.RealTime`, depends on **Queues** and the **SignalR** module) adds: + +- **`ContactCenterHub`** - a SignalR hub mapped through the SignalR module's `HubRouteManager`. Agents connect with the `ContactCenterSignIntoQueues` permission and join their own user group plus a group per signed-in queue; supervisors connect with the new `MonitorContactCenter` permission and join the `cc:supervisors` group. Supervisors can also call `WatchQueue`/`UnwatchQueue` to subscribe to a single queue's group for a wallboard. +- **Live agent sessions** - a volatile `AgentSession` aggregate, separate from the administrator-owned `AgentProfile`, that tracks an agent's open SignalR connections and last heartbeat. Splitting live state from configuration means a closed browser no longer leaves an agent `Available`. +- **Heartbeat and stale-session cleanup** - 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 deletes the session, so routing stops targeting a dead client. A brief disconnect (for example a page refresh) is tolerated by the grace window. +- **Reconnect snapshots** - the hub's `GetSnapshot` method 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. +- **Event projection** - `ContactCenterRealTimeEventHandler` listens to the durable domain event log and broadcasts presence changes (`PresenceChanged`), work offers (`OfferReceived`/`OfferRevoked`), and queue depth (`QueueStatsChanged`) to the affected agent, queue watchers, and supervisors. The projection is read-only with respect to domain state. + +A small client helper (`contact-center-realtime` script resource, depending on the `signalr` resource) wraps the SignalR client to connect, send heartbeats, fetch the reconnect snapshot, and dispatch the strongly-typed callbacks: ```html @@ -165,246 +114,125 @@ The [agent desktop and supervisor dashboard](agent-desktop.md) build directly on ## 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. +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, then hands it to -`IContactCenterOutbox`, which runs every handler inline for low latency. If a handler throws, the outbox -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 every handler for the event, so **handlers must be idempotent** — the -existing handlers (such as the real-time broadcaster) already are, and provider-sourced events carry an -idempotency key so duplicate or out-of-order deliveries are ignored. +Dispatch is **at-least-once**. The publisher records the event, then hands it to `IContactCenterOutbox`, which runs every handler inline for low latency. If a handler throws, the outbox 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 every handler for the event, so **handlers must be idempotent** — the existing handlers (such as the real-time broadcaster) already are, and provider-sourced events carry an idempotency key so duplicate or out-of-order deliveries are ignored. -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 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. ## 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** campaign catalog. Routing skills come from -**Interaction Center → 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. +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** campaign catalog. Routing skills come from **Interaction Center → 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: +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. +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. +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: +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). +- `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`. +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. +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. +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. Events that carry an already-seen idempotency key are ignored, so duplicate or out-of-order -webhook deliveries are safe. +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. Events that carry an already-seen idempotency key are ignored, so duplicate or out-of-order webhook deliveries are safe. ## Inbound voice > **Feature ID** `CrestApps.OrchardCore.ContactCenter.Voice` -The **Contact Center Voice** feature adds the Voice Contact Center Call Router for inbound and -outbound voice work. For inbound calls, it routes provider calls to an available agent and offers them -through the [Telephony](../telephony/index.md) soft-phone incoming-call modal. It depends on the -Queues feature and the Telephony soft phone. +The **Contact Center Voice** feature adds the Voice Contact Center Call Router for inbound and outbound voice work. For inbound calls, it routes provider calls to an available agent and offers them through the [Telephony](../telephony/index.md) soft-phone incoming-call modal. It depends on the Queues feature and the Telephony soft phone. 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. +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. +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. +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: +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. +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. ### 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. +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 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 with the `OmnichannelSubject` stereotype) 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. +- The **Subject** (any content type with the `OmnichannelSubject` stereotype) 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. +- **`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). +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. - -- **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. +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. + +- **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. +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. ## 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. +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, -provider-specific recording storage, and projection rebuild tooling for later phases. +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, provider-specific recording storage, and projection rebuild tooling for later phases. -See [Agents, Queues & Dialer](agents-queues-dialer.md) for the setup reference and -[Agent desktop & supervisor dashboard](agent-desktop.md) for day-to-day agent and manager workflows. +See [Agents, Queues & Dialer](agents-queues-dialer.md) for the setup reference and [Agent desktop & supervisor dashboard](agent-desktop.md) for day-to-day agent and manager workflows. diff --git a/src/CrestApps.Docs/docs/modules/reports.md b/src/CrestApps.Docs/docs/modules/reports.md index 913da74c4..15cae56af 100644 --- a/src/CrestApps.Docs/docs/modules/reports.md +++ b/src/CrestApps.Docs/docs/modules/reports.md @@ -10,38 +10,35 @@ description: A reusable reporting framework for OrchardCore with a shared admin | **Feature Name** | Reports | | **Feature ID** | `CrestApps.OrchardCore.Reports` | -The **Reports** module is a reusable reporting framework. It provides a single admin **Reports** area -and a small contract that any module can implement to surface an industry-standard report — with a -shared from/to date-range filter, extensible filters, a uniform renderer (metric cards, tables, and -bars), and pluggable exports (CSV built in). Modules such as the -[Contact Center](../contact-center/index.md) and [Omnichannel](../omnichannel/index.md) CRM contribute -their reports through this framework so every report looks and behaves the same. +The **Reports** module is a reusable reporting framework. It provides a single admin **Reports** area and a small contract that any module can implement to surface an industry-standard report — with a shared from/to date-range filter, extensible filters, a uniform renderer (metric cards, tables, and bars), and pluggable exports (CSV built in). Modules such as the [Contact Center](../contact-center/index.md) and [Omnichannel](../omnichannel/index.md) CRM contribute their reports through this framework so every report looks and behaves the same. + +| | | +| --- | --- | +| **Add-on Feature Name** | Reports (OpenXml) | +| **Add-on Feature ID** | `CrestApps.OrchardCore.Reports.OpenXml` | + +The optional **Reports (OpenXml)** add-on extends the Reports area with Excel workbook (`.xlsx`) exports using the `DocumentFormat.OpenXml` library. When the add-on is enabled, report pages show an additional export button alongside CSV. + +The implementation is split into three layers: + +- **`CrestApps.OrchardCore.Reports.Abstractions`** defines the shared report contracts and document models, including `IReport`, `IReportExportFormat`, `IReportManager`, and `IReportExportManager`. +- **`CrestApps.OrchardCore.Reports.Core`** contains the default non-Orchard-specific implementations such as the report/export registries and the built-in CSV export formatter. +- **`CrestApps.OrchardCore.Reports`** contains Orchard-specific wiring such as the admin menu, controller, views, and the display-driver-based filter UI. ## Concepts -- **`IReport`** — a report definition. It declares a technical `Name`, a `DisplayName`, a `Description`, - a `Category` (used to group reports in the menu), a `Permission`, and a `RunAsync` method that returns - a `ReportDocument` for a given `ReportContext`. -- **`ReportFilter`** — the filter applied when a report runs. Every report shares the built-in - from/to date range; additional, report-specific filters are contributed with display drivers. -- **`ReportDocument`** — the uniform result. It is an ordered list of **sections**, where each section - is a set of metric cards, a table (with optional emphasized totals rows for aggregated reports), or a - set of horizontal bars. The same document is rendered in the browser and serialized by every exporter. -- **`IReportExportFormat`** — an export format. CSV ships in the box; a module can add Excel, PDF, or - any other format by registering another implementation. +- **`IReport`** — a report definition. It declares a technical `Name`, a `DisplayName`, a `Description`, a `Category` (used to group reports in the menu), a `Permission`, and a `RunAsync` method that returns a `ReportDocument` for a given `ReportContext`. +- **`ReportFilter`** — the filter applied when a report runs. Every report shares the built-in from/to date range; additional, report-specific filters are contributed with display drivers. +- **`ReportDocument`** — the uniform result. It is an ordered list of **sections**, where each section is a set of metric cards, a table (with optional emphasized totals rows for aggregated reports), or a set of horizontal bars. The same document is rendered in the browser and serialized by every exporter. +- **`IReportExportFormat`** — an export format. CSV ships in the box; the optional **Reports (OpenXml)** add-on adds Excel (`.xlsx`); and any module can add more formats by registering another implementation. ## Reports area -Enabling the feature adds a top-level **Reports** item to the admin menu. Reports are grouped by their -category, and each entry is gated by the report's own permission, so a user only sees the reports they -are allowed to run. Selecting a report opens a page with the filter form, the rendered document, and an -**Export CSV** button that exports the current filter. +Enabling the feature adds a top-level **Reports** item to the admin menu. Reports are grouped by their category, and each entry is gated by the report's own permission, so a user only sees the reports they are allowed to run. Selecting a report opens a page with the filter form, the rendered document, and one export button per enabled format so the current filter can be downloaded as CSV and, when the add-on is enabled, Excel (`.xlsx`). ## Extensible filters -Every report automatically gets the from/to date range. To add a report-specific filter (for example a -queue, campaign, or channel selector), register a display driver for `ReportFilter` and gate it to your -report by checking `filter.ReportName`: +Every report automatically gets the from/to date range. To add a report-specific filter (for example a queue, campaign, or channel selector), register a display driver for `ReportFilter` and gate it to your report by checking `filter.ReportName`: ```csharp public sealed class MyQueueFilterDisplayDriver : DisplayDriver @@ -83,10 +80,7 @@ Implement `IReport` and register it as a scoped service: services.AddScoped(); ``` -`RunAsync` builds a `ReportDocument` from the resolved period (`context.FromUtc` / `context.ToUtc`) and -any report-specific filter values. Use `ReportSection.ForMetrics`, `ReportSection.ForTable`, and -`ReportSection.ForBars` to compose the document, and `ReportFormat` to format numbers, durations, and -percentages consistently. +`RunAsync` builds a `ReportDocument` from the resolved period (`context.FromUtc` / `context.ToUtc`) and any report-specific filter values. Use `ReportSection.ForMetrics`, `ReportSection.ForTable`, and `ReportSection.ForBars` to compose the document, and `ReportFormat` to format numbers, durations, and percentages consistently. ## Enable via recipe @@ -96,12 +90,12 @@ percentages consistently. { "name": "Feature", "enable": [ - "CrestApps.OrchardCore.Reports" + "CrestApps.OrchardCore.Reports", + "CrestApps.OrchardCore.Reports.OpenXml" ] } ] } ``` -The Reports feature is enabled automatically when you enable a feature that contributes reports, such as -**Contact Center Reports & Analytics** or **Omnichannel Reports**. +Enable `CrestApps.OrchardCore.Reports.OpenXml` only when you want Excel workbook exports. The base Reports feature is enabled automatically when you enable a feature that contributes reports, such as **Contact Center Reports & Analytics** or **Omnichannel Reports**. diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index 5aa4b75f3..766fe0c89 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -124,16 +124,11 @@ phone automatically offsets itself so the two widgets sit side by side instead o ### 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**: - -- When no provider is enabled, the header status reads **Not Ready** instead of a misleading - **Ready** status, the body shows an **unavailable** message, and the number pad and buttons are - hidden. -- 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 toggle button turns red and switches to a hang-up icon, and the - widget exposes mute, hold, transfer, and merge controls based on the provider's capabilities. +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 an inline warning that explains how to fix the setup: enable at least one provider and set the default phone provider in site settings. The keypad and call buttons stay hidden, and the live status text is shown as small muted text inside the keypad container instead of cluttering the header. +- 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 toggle button turns red and switches to a hang-up icon, and the widget exposes mute, hold, transfer, and merge controls based on the provider's capabilities. ### Keypad, recent calls, and extension tabs diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs index 1bd4333dd..a444fa677 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Handlers/ContactCenterRealTimeEventHandler.cs @@ -1,8 +1,7 @@ +using CrestApps.OrchardCore.ContactCenter; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; -using CrestApps.OrchardCore.ContactCenter.Hubs; using CrestApps.OrchardCore.ContactCenter.Models; -using CrestApps.OrchardCore.ContactCenter.Services; using CrestApps.OrchardCore.Users; using Microsoft.AspNetCore.Identity; using OrchardCore.Modules; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs index 81051abde..7bd168083 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Hubs/IContactCenterHubClient.cs @@ -1,3 +1,5 @@ +using CrestApps.OrchardCore.ContactCenter.Models; + namespace CrestApps.OrchardCore.ContactCenter.Hubs; /// diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs index 7719c99d3..de4276fa6 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAdminMenu.cs @@ -44,7 +44,8 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("contactCenterBusinessHours") .Action("Index", "BusinessHoursCalendars", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.ManageQueues) - .LocalNav()), + .LocalNav() + ), priority: 1); return ValueTask.CompletedTask; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs index 21be3db0a..481b34776 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterAgentsAdminMenu.cs @@ -32,7 +32,8 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("contactCenterAgentStates") .Action("Index", "AgentStateReasonCodes", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.ManageAgents) - .LocalNav()), + .LocalNav() + ), priority: 1); return ValueTask.CompletedTask; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs index 75c208914..7d196b0ce 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterDialerAdminMenu.cs @@ -32,7 +32,8 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("dialerProfiles") .Action("Index", "DialerProfiles", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.ManageDialer) - .LocalNav()), + .LocalNav() + ), priority: 1); return ValueTask.CompletedTask; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeAdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeAdminMenu.cs index 70ee4b201..090c9aa23 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeAdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeAdminMenu.cs @@ -33,13 +33,15 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Id("contactCenterWorkspace") .Action("Index", "AgentWorkspace", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.SignIntoQueues) - .LocalNav()) + .LocalNav() + ) .Add(S["Live dashboard"], S["Live dashboard"].PrefixPosition("1"), dashboard => dashboard .AddClass("contact-center-dashboard") .Id("contactCenterDashboard") .Action("Index", "SupervisorDashboard", "CrestApps.OrchardCore.ContactCenter") .Permission(ContactCenterPermissions.MonitorContactCenter) - .LocalNav()), + .LocalNav() + ), priority: 2); return ValueTask.CompletedTask; diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs index 522761f7e..6f3d713ce 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Services/ContactCenterRealTimeNotifier.cs @@ -1,3 +1,5 @@ +using CrestApps.OrchardCore.ContactCenter; +using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.ContactCenter.Hubs; using Microsoft.AspNetCore.SignalR; diff --git a/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/CrestApps.OrchardCore.Reports.OpenXml.csproj b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/CrestApps.OrchardCore.Reports.OpenXml.csproj new file mode 100644 index 000000000..e1b895a5c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/CrestApps.OrchardCore.Reports.OpenXml.csproj @@ -0,0 +1,28 @@ + + + + $(MSBuildProjectName) + CrestApps OrchardCore Reports OpenXml Module + + $(CrestAppsDescription) + + Adds Open XML Excel workbook exports to the CrestApps OrchardCore Reports framework. + + $(PackageTags) Reports OpenXml + + + + + + + + + + + + + + + + + diff --git a/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Manifest.cs new file mode 100644 index 000000000..1069084f1 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Manifest.cs @@ -0,0 +1,23 @@ +using CrestApps.OrchardCore; +using CrestApps.OrchardCore.Reports; +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Reports (OpenXml)", + Author = CrestAppsManifestConstants.Author, + Website = CrestAppsManifestConstants.Website, + Version = CrestAppsManifestConstants.Version, + Description = "Adds Excel (.xlsx) exports to the Reports framework using Open XML.", + Category = "Reporting", + Dependencies = + [ + ReportsConstants.Feature, + ] +)] + +[assembly: Feature( + Id = ReportsConstants.OpenXmlFeature, + Name = "Reports (OpenXml)", + Description = "Adds Excel (.xlsx) workbook exports to the Reports area using Open XML.", + Category = "Reporting" +)] diff --git a/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/README.md b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/README.md new file mode 100644 index 000000000..df3f982c7 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/README.md @@ -0,0 +1,31 @@ +# CrestApps.OrchardCore.Reports.OpenXml + +Adds Excel workbook (`.xlsx`) exports to the CrestApps OrchardCore Reports framework by registering an `IReportExportFormat` implementation backed by `DocumentFormat.OpenXml`. + +## Features + +- Registers an Excel (`.xlsx`) export format for the shared Reports area +- Reuses the Open XML SDK instead of introducing a separate spreadsheet stack +- Keeps the base `CrestApps.OrchardCore.Reports` module provider-agnostic and lightweight + +## Dependencies + +- `CrestApps.OrchardCore.Reports` + +## Usage + +Enable both features when you want CSV and Excel exports in the Reports UI: + +```json +{ + "steps": [ + { + "name": "Feature", + "enable": [ + "CrestApps.OrchardCore.Reports", + "CrestApps.OrchardCore.Reports.OpenXml" + ] + } + ] +} +``` diff --git a/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Services/ExcelReportExportFormat.cs b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Services/ExcelReportExportFormat.cs new file mode 100644 index 000000000..412ba740f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Services/ExcelReportExportFormat.cs @@ -0,0 +1,233 @@ +using System.Globalization; +using System.Text; +using CrestApps.OrchardCore.Reports.Models; +using DocumentFormat.OpenXml; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.Reports.OpenXml.Services; + +/// +/// Serializes a report document to an Excel workbook where each report section is written to its own +/// worksheet using the Open XML SDK. +/// +public sealed class ExcelReportExportFormat : IReportExportFormat +{ + private const string DefaultSheetName = "Report"; + private static readonly char[] _invalidSheetNameCharacters = ['\\', '/', '*', '[', ']', ':', '?']; + + private readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The string localizer. + public ExcelReportExportFormat(IStringLocalizer stringLocalizer) + { + S = stringLocalizer; + } + + /// + public string Name => ReportsConstants.XlsxExportFormat; + + /// + public LocalizedString DisplayName => S["Excel (.xlsx)"]; + + /// + public string ContentType => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + + /// + public string FileExtension => "xlsx"; + + /// + public byte[] Serialize(ReportDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + using var stream = new MemoryStream(); + + using (var spreadsheetDocument = SpreadsheetDocument.Create(stream, SpreadsheetDocumentType.Workbook)) + { + var workbookPart = spreadsheetDocument.AddWorkbookPart(); + workbookPart.Workbook = new Workbook(); + + var sheets = workbookPart.Workbook.AppendChild(new Sheets()); + var usedSheetNames = new HashSet(StringComparer.OrdinalIgnoreCase); + uint sheetId = 1; + + if (document.Sections.Count == 0) + { + AddWorksheet(workbookPart, sheets, usedSheetNames, DefaultSheetName, sheetId++, 0); + } + else + { + for (var i = 0; i < document.Sections.Count; i++) + { + var section = document.Sections[i]; + var sheetData = AddWorksheet( + workbookPart, + sheets, + usedSheetNames, + section.Title, + sheetId, + i + 1); + + WriteSection(sheetData, section); + sheetId++; + } + } + + workbookPart.Workbook.Save(); + } + + return stream.ToArray(); + } + + private static SheetData AddWorksheet( + WorkbookPart workbookPart, + Sheets sheets, + ISet usedSheetNames, + string title, + uint sheetId, + int sectionIndex) + { + var worksheetPart = workbookPart.AddNewPart(); + var sheetData = new SheetData(); + worksheetPart.Worksheet = new Worksheet(sheetData); + + sheets.Append(new Sheet + { + Id = workbookPart.GetIdOfPart(worksheetPart), + SheetId = sheetId, + Name = CreateUniqueSheetName(title, sectionIndex, usedSheetNames), + }); + + return sheetData; + } + + private static string CreateUniqueSheetName(string title, int index, ISet usedSheetNames) + { + var baseName = SanitizeSheetName(string.IsNullOrWhiteSpace(title) ? $"{DefaultSheetName} {index}" : title); + var candidate = baseName; + var suffix = 2; + + while (!usedSheetNames.Add(candidate)) + { + var suffixText = $" ({suffix})"; + var maxLength = 31 - suffixText.Length; + var trimmedBaseName = baseName.Length > maxLength ? baseName.Substring(0, maxLength) : baseName; + candidate = trimmedBaseName + suffixText; + suffix++; + } + + return candidate; + } + + private static string SanitizeSheetName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return DefaultSheetName; + } + + var builder = new StringBuilder(value.Length); + + foreach (var character in value.Trim()) + { + builder.Append(_invalidSheetNameCharacters.Contains(character) ? ' ' : character); + } + + var sanitized = builder.ToString().Trim().Trim('\''); + + if (string.IsNullOrWhiteSpace(sanitized)) + { + sanitized = DefaultSheetName; + } + + if (sanitized.Length > 31) + { + sanitized = sanitized.Substring(0, 31); + } + + return sanitized; + } + + private static void WriteSection(SheetData sheetData, ReportSection section) + { + if (!string.IsNullOrWhiteSpace(section.Description)) + { + AppendTextRow(sheetData, section.Description); + AppendEmptyRow(sheetData); + } + + switch (section.Kind) + { + case ReportSectionKind.Metrics: + WriteMetricsSection(sheetData, section); + break; + case ReportSectionKind.Table: + WriteTableSection(sheetData, section); + break; + case ReportSectionKind.Bars: + WriteBarsSection(sheetData, section); + break; + } + } + + private static void WriteMetricsSection(SheetData sheetData, ReportSection section) + { + var hasHints = section.Metrics.Any(metric => !string.IsNullOrWhiteSpace(metric.Hint)); + + AppendTextRow(sheetData, hasHints ? ["Metric", "Value", "Hint"] : ["Metric", "Value"]); + + foreach (var metric in section.Metrics) + { + AppendTextRow(sheetData, hasHints ? [metric.Label, metric.Value, metric.Hint] : [metric.Label, metric.Value]); + } + } + + private static void WriteTableSection(SheetData sheetData, ReportSection section) + { + if (section.Columns.Count > 0) + { + AppendTextRow(sheetData, [.. section.Columns.Select(column => column.Label)]); + } + + foreach (var row in section.Rows) + { + AppendTextRow(sheetData, [.. row.Cells]); + } + } + + private static void WriteBarsSection(SheetData sheetData, ReportSection section) + { + AppendTextRow(sheetData, "Label", "Value", "Ratio"); + + foreach (var bar in section.Bars) + { + AppendTextRow(sheetData, bar.Label, bar.Value, bar.Ratio.ToString(CultureInfo.InvariantCulture)); + } + } + + private static void AppendEmptyRow(SheetData sheetData) + { + sheetData.Append(new Row()); + } + + private static void AppendTextRow(SheetData sheetData, params string[] values) + { + var row = new Row(); + + foreach (var value in values) + { + row.Append(new Cell + { + DataType = CellValues.InlineString, + InlineString = new InlineString(new Text(value ?? string.Empty)), + }); + } + + sheetData.Append(row); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Startup.cs b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Startup.cs new file mode 100644 index 000000000..a8430e929 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Reports.OpenXml/Startup.cs @@ -0,0 +1,17 @@ +using CrestApps.OrchardCore.Reports.OpenXml.Services; +using Microsoft.Extensions.DependencyInjection; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Reports.OpenXml; + +/// +/// Registers the Open XML Excel export format for the Reports framework. +/// +public sealed class Startup : StartupBase +{ + /// + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs b/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs index 8f2bf4f53..e9ebdf745 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs +++ b/src/Modules/CrestApps.OrchardCore.Reports/Controllers/ReportsController.cs @@ -100,6 +100,7 @@ public async Task Display(string id) return View(new ReportDisplayViewModel { Report = report, + ExportFormats = _exportManager.ListFormats(), FilterShape = filterShape, Document = document, FromUtc = filter.FromUtc.GetValueOrDefault(), diff --git a/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj b/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj index 883409cb8..4d47d663c 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj +++ b/src/Modules/CrestApps.OrchardCore.Reports/CrestApps.OrchardCore.Reports.csproj @@ -31,6 +31,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs index cea05095d..ac94c27e4 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Reports/ViewModels/ReportDisplayViewModel.cs @@ -14,6 +14,11 @@ public sealed class ReportDisplayViewModel /// public IReport Report { get; set; } + /// + /// Gets or sets the available export formats for the report. + /// + public IReadOnlyList ExportFormats { get; set; } = []; + /// /// Gets or sets the rendered filter editor shape. /// diff --git a/src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Views/NavigationItemText-reports.Id.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/NavigationItemText-reports.Id.cshtml similarity index 100% rename from src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Views/NavigationItemText-reports.Id.cshtml rename to src/Modules/CrestApps.OrchardCore.Reports/Views/NavigationItemText-reports.Id.cshtml diff --git a/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml index 619265d20..2fcaa3bc1 100644 --- a/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Reports/Views/Reports/Display.cshtml @@ -18,9 +18,12 @@
    - + @foreach (var exportFormat in Model.ExportFormats) + { + + }
    diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss index 633731fec..e186320b2 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/scss/soft-phone.scss @@ -53,6 +53,7 @@ &__title { font-weight: 600; + margin-right: auto; } &__header-actions { @@ -63,8 +64,8 @@ } &__status { - font-size: 0.78rem; - opacity: 0.9; + font-size: 0.72rem; + color: var(--bs-secondary-color, rgba(255, 255, 255, 0.85)); } &__icon-btn { @@ -85,19 +86,21 @@ } &__unavailable { - padding: 1.25rem 1rem; - text-align: center; - color: var(--bs-secondary-color, #6c757d); + display: flex; + align-items: flex-start; + gap: 0.6rem; + margin: 0.85rem; + padding: 0.75rem 0.85rem; + font-size: 0.8rem; i { - font-size: 1.5rem; - margin-bottom: 0.5rem; - color: var(--bs-tertiary-color, #adb5bd); + flex: 0 0 auto; + margin-top: 0.1rem; } p { margin: 0; - font-size: 0.85rem; + text-align: left; } } @@ -222,6 +225,13 @@ padding: 0.85rem 0.85rem 0.35rem; } + &__display-meta { + display: flex; + justify-content: flex-end; + min-height: 1rem; + margin-bottom: 0.35rem; + } + &__number { width: 100%; border: 1px solid var(--bs-border-color, #ced4da); diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml index ed8e80ba9..6fab94efe 100644 --- a/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Telephony/Views/SoftPhoneWidget.cshtml @@ -40,7 +40,7 @@ ["failed"] = T["Call failed"].Value, ["disconnectedHub"] = T["Disconnected"].Value, ["notReady"] = T["Not Ready"].Value, - ["notConfigured"] = T["No telephony provider is configured."].Value, + ["notConfigured"] = T["No provider is configured. To use the soft phone enable at least one provider and update the site settings by setting the default phone provider."].Value, ["notConnected"] = T["Not connected"].Value, ["invalidNumber"] = T["Enter a phone number to call."].Value, ["transferPrompt"] = T["Transfer to number"].Value, @@ -82,17 +82,11 @@ { @await DisplayAsync(Model.HeaderActions) } - @T["Not Ready"]
    - -
    + +
    +
    + @T["Not Ready"] +
    + diff --git a/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj b/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj index 574aa30c7..2cdd0b849 100644 --- a/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj +++ b/tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj @@ -53,6 +53,7 @@ + diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/Reports/OpenXml/Services/ExcelReportExportFormatTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/Reports/OpenXml/Services/ExcelReportExportFormatTests.cs new file mode 100644 index 000000000..db3922e35 --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/Reports/OpenXml/Services/ExcelReportExportFormatTests.cs @@ -0,0 +1,92 @@ +using System.IO; +using CrestApps.OrchardCore.Reports.Models; +using CrestApps.OrchardCore.Reports.OpenXml.Services; +using DocumentFormat.OpenXml.Packaging; +using DocumentFormat.OpenXml.Spreadsheet; +using Microsoft.Extensions.Localization; +using Moq; + +namespace CrestApps.OrchardCore.Tests.Modules.Reports.OpenXml.Services; + +public sealed class ExcelReportExportFormatTests +{ + [Fact] + public void Serialize_WhenDocumentHasMultipleSections_ShouldCreateWorkbookWithExpectedSheetsAndCells() + { + // Arrange + var document = new ReportDocument() + .Add(ReportSection.ForMetrics("Summary", [new ReportMetric("Open conversations", "42", "Current period")])) + .Add(ReportSection.ForTable("Queues", [new ReportColumn("Queue"), new ReportColumn("Count")], [new ReportRow(["Support", "18"])])) + .Add(ReportSection.ForBars("Channel mix", [new ReportBar("Voice", "12", 0.6)])); + + var exportFormat = new ExcelReportExportFormat(Mock.Of>()); + + // Act + var content = exportFormat.Serialize(document); + + // Assert + using var stream = new MemoryStream(content); + using var spreadsheetDocument = SpreadsheetDocument.Open(stream, false); + var workbookPart = spreadsheetDocument.WorkbookPart; + + Assert.NotNull(workbookPart); + + var sheets = workbookPart.Workbook.Sheets.Elements().ToArray(); + Assert.Equal(3, sheets.Length); + Assert.Equal("Summary", sheets[0].Name); + Assert.Equal("Queues", sheets[1].Name); + Assert.Equal("Channel mix", sheets[2].Name); + + var summaryRows = GetSheetRows(workbookPart, sheets[0]); + Assert.Equal(["Metric", "Value", "Hint"], GetCellValues(summaryRows[0])); + Assert.Equal(["Open conversations", "42", "Current period"], GetCellValues(summaryRows[1])); + + var queueRows = GetSheetRows(workbookPart, sheets[1]); + Assert.Equal(["Queue", "Count"], GetCellValues(queueRows[0])); + Assert.Equal(["Support", "18"], GetCellValues(queueRows[1])); + + var barRows = GetSheetRows(workbookPart, sheets[2]); + Assert.Equal(["Label", "Value", "Ratio"], GetCellValues(barRows[0])); + Assert.Equal(["Voice", "12", "0.6"], GetCellValues(barRows[1])); + } + + [Fact] + public void Serialize_WhenSectionNamesCollideOrContainInvalidCharacters_ShouldProduceValidUniqueSheetNames() + { + // Arrange + var document = new ReportDocument() + .Add(ReportSection.ForMetrics("Queue/Overview", [new ReportMetric("Calls", "8")])) + .Add(ReportSection.ForMetrics("Queue:Overview", [new ReportMetric("Calls", "9")])); + + var exportFormat = new ExcelReportExportFormat(Mock.Of>()); + + // Act + var content = exportFormat.Serialize(document); + + // Assert + using var stream = new MemoryStream(content); + using var spreadsheetDocument = SpreadsheetDocument.Open(stream, false); + var workbookPart = spreadsheetDocument.WorkbookPart; + + Assert.NotNull(workbookPart); + + var sheetNames = workbookPart.Workbook.Sheets.Elements().Select(sheet => sheet.Name.Value).ToArray(); + Assert.Equal(["Queue Overview", "Queue Overview (2)"], sheetNames); + } + + private static Row[] GetSheetRows(WorkbookPart workbookPart, Sheet sheet) + { + var worksheetPart = (WorksheetPart)workbookPart.GetPartById(sheet.Id); + return worksheetPart.Worksheet.GetFirstChild().Elements().ToArray(); + } + + private static string[] GetCellValues(Row row) + { + return [.. row.Elements().Select(GetCellValue)]; + } + + private static string GetCellValue(Cell cell) + { + return cell.InlineString?.Text?.Text ?? cell.CellValue?.Text ?? cell.InnerText; + } +} From f9c2c5f134be5570831afd2cad40db7ad0a5354b Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 6 Jul 2026 17:03:03 -0700 Subject: [PATCH 39/56] Add more function to activities bulk management --- .../TelephonyServiceCollectionExtensions.cs | 3 +- .../Models/BulkActivityAction.cs | 15 ++ .../Models/BulkManageActivityFilter.cs | 25 +++ .../Services/OmnichannelActivityStore.cs | 7 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 4 +- .../docs/omnichannel/management.md | 12 +- .../CrestApps.OrchardCore.DialPad/Startup.cs | 1 + .../Controllers/ActivitiesController.cs | 183 +++++++++++++++++- ...OrchardCore.Omnichannel.Managements.csproj | 1 + .../BulkManageActivityActionsDisplayDriver.cs | 7 + .../BulkManageActivityFilterDisplayDriver.cs | 62 +++++- .../BulkManageActivityFilterHandler.cs | 35 ++++ .../BulkActivityAdminFormOptionsProvider.cs | 150 ++++++++++++++ .../Startup.cs | 2 + .../BulkActivityActionsViewModel.cs | 15 ++ .../BulkManageActivitiesViewModel.cs | 20 ++ .../BulkManageActivityFilterViewModel.cs | 55 ++++++ .../Views/BulkActivityActions.cshtml | 63 ++++++ ...BulkManageActivityFilterFields.Edit.cshtml | 20 ++ ...kManageOmnichannelActivityContainer.cshtml | 15 ++ 20 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs index 9d24ec78a..a97e294e5 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Extensions/TelephonyServiceCollectionExtensions.cs @@ -1,7 +1,8 @@ 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/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkActivityAction.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkActivityAction.cs index a4140d9dd..5e3b697bf 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkActivityAction.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkActivityAction.cs @@ -39,4 +39,19 @@ public enum BulkActivityAction /// Change the subject content type for all selected activities. /// ChangeSubject, + + /// + /// Clear the assigned user and reservation state so the activity can be routed again. + /// + ClearAssignment, + + /// + /// Change the activity source and optionally its interaction type. + /// + ChangeSource, + + /// + /// Apply a dialer profile to the selected activities. + /// + ChangeDialerProfile, } diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs index f62fc533b..fea6777a7 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/BulkManageActivityFilter.cs @@ -33,6 +33,31 @@ public sealed class BulkManageActivityFilter : Entity /// public string Channel { get; set; } + /// + /// Gets or sets the activity source to filter by. + /// + public string Source { get; set; } + + /// + /// Gets or sets the interaction type to filter by. + /// + public ActivityInteractionType? InteractionType { get; set; } + + /// + /// Gets or sets the activity status to filter by. + /// + public ActivityStatus? Status { get; set; } + + /// + /// Gets or sets the assignment lifecycle status to filter by. + /// + public ActivityAssignmentStatus? AssignmentStatus { get; set; } + + /// + /// Gets or sets the campaign identifier to filter by. + /// + public string CampaignId { get; set; } + /// /// Gets or sets the user IDs to filter activities assigned to. /// diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/OmnichannelActivityStore.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/OmnichannelActivityStore.cs index a88d0de14..d847dfabd 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/OmnichannelActivityStore.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Services/OmnichannelActivityStore.cs @@ -202,12 +202,9 @@ private async Task BuildBulkManageableContextAs sqlBuilder.Selector($"{dialect.QuoteForAliasName(ActivityAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.DocumentId))}"); sqlBuilder.Table(activityTableName, ActivityAlias, schema); - // Base conditions: only not-started manual activities. + // Base conditions: only editable inventory, excluding completed, purged, and in-flight work. var statusCol = $"{dialect.QuoteForAliasName(ActivityAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.Status))}"; - var interactionCol = $"{dialect.QuoteForAliasName(ActivityAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.InteractionType))}"; - - sqlBuilder.WhereAnd($"{statusCol} = {(int)ActivityStatus.NotStated}"); - sqlBuilder.WhereAnd($"{interactionCol} = {(int)ActivityInteractionType.Manual}"); + sqlBuilder.WhereAnd($"{statusCol} IN ({(int)ActivityStatus.NotStated}, {(int)ActivityStatus.Scheduled}, {(int)ActivityStatus.Pending}, {(int)ActivityStatus.AwaitingAgentResponse}, {(int)ActivityStatus.Failed}, {(int)ActivityStatus.Cancelled})"); // Default ordering. var scheduledCol = $"{dialect.QuoteForAliasName(ActivityAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.ScheduledUtc))}"; diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index adced63d4..fc1b6034a 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -417,7 +417,9 @@ At a high level, the platform changes are: - Activity batch sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. - Activity batch 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 batch to the `New` state so it can be retried. - 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. +- A new **Manage Activities** page (`Admin/omnichannel/manage-activities`) supports page-size selection, separate Contact and Activity filter groups, urgency icons, and bulk actions against selected or filtered activities. +- 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. - 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 Activity Batch 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. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index ce87825e5..e4e034b2e 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -246,7 +246,7 @@ The scheduled activities list now includes a **Time zone** filter alongside the ## Bulk Activity Management -The **Manage Activities** page provides a centralized interface for performing bulk operations on `NotStarted` manual activities. +The **Manage Activities** page provides a centralized interface for managing active omnichannel inventory across manual, automated, and dialer-oriented activities. It targets editable work states such as `NotStarted`, `Scheduled`, `Pending`, `AwaitingAgentResponse`, `Failed`, and `Cancelled` so managers can clean up, re-route, or reclassify queued work without opening each activity one by one. ### Accessing the page @@ -271,6 +271,11 @@ The filter panel groups fields into **Contact filters** and **Activity filters** | Attempts | Select | Filter by the current attempt number. Values `0` and `1` both mean no attempt, and `2` means the second attempt. | | Subject | Select | Filter by subject content type | | Channel | Select | Filter by communication channel (Phone, SMS, Email) | +| Source | Select | Filter by activity source such as Manual, Automatic, Dialer, Preview dial, Power dial, or Progressive dial | +| Interaction type | Select | Filter by manual versus automated activities | +| Status | Select | Filter by active editable statuses | +| Assignment status | Select | Filter by unassigned, available, reserved, assigned, in-progress, or released work | +| Campaign | Select | Filter by campaign | | Assigned to users | User picker | Filter by one or more assigned users | | Urgency level | Select | Filter by urgency level (Normal, Low, Medium, High, etc.) | | Scheduled from | Date | Filter activities scheduled on or after this date | @@ -299,3 +304,8 @@ The page also includes a **Page size** selector so managers can review more than | **Set Instructions** | Set instruction text for all selected activities. Instructions are notes the agent reads before completing the task. | | **Set Urgency Level** | Update the urgency level for all selected activities. | | **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 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. diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Startup.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Startup.cs index 5130b8f2f..91f90b767 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Startup.cs @@ -1,5 +1,6 @@ using CrestApps.OrchardCore.DialPad.Drivers; using CrestApps.OrchardCore.DialPad.Services; +using CrestApps.OrchardCore.Telephony.Extensions; using Microsoft.Extensions.DependencyInjection; using OrchardCore.DisplayManagement.Handlers; using OrchardCore.Modules; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index 59b5d758c..8a2e57c39 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -1,6 +1,9 @@ using System.Globalization; using System.Security.Claims; using CrestApps.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Models; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; @@ -11,6 +14,7 @@ using Microsoft.AspNetCore.Mvc.Localization; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Routing; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Options; using OrchardCore.Admin; @@ -731,7 +735,7 @@ public async Task ManageActivitiesBulkActionPost( { var activity = await _omnichannelActivityManager.FindByIdAsync(itemId); - if (activity is not null && activity.Status == ActivityStatus.NotStated && activity.InteractionType == ActivityInteractionType.Manual) + if (IsBulkManageableActivity(activity)) { activities.Add(activity); } @@ -772,6 +776,18 @@ public async Task ManageActivitiesBulkActionPost( case BulkActivityAction.ChangeSubject: processedCount = await BulkChangeSubjectAsync(activities, viewModel.NewSubjectContentType); break; + + case BulkActivityAction.ClearAssignment: + processedCount = await BulkClearAssignmentAsync(activities); + break; + + case BulkActivityAction.ChangeSource: + processedCount = await BulkChangeSourceAsync(activities, viewModel.NewSource, viewModel.NewInteractionType, viewModel.ClearCurrentAssignment); + break; + + case BulkActivityAction.ChangeDialerProfile: + processedCount = await BulkChangeDialerProfileAsync(activities, viewModel.NewDialerProfileId, viewModel.ClearCurrentAssignment); + break; } if (processedCount > 0) @@ -822,6 +838,9 @@ private async Task BulkAssignAsync(List activities, st activity.AssignedToId = user.UserId; activity.AssignedToUsername = user.UserName; activity.AssignedToUtc = now; + activity.AssignmentStatus = ActivityAssignmentStatus.Assigned; + ClearReservationState(activity); + activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: true); await _omnichannelActivityManager.UpdateAsync(activity); processedCount++; @@ -864,6 +883,7 @@ private async Task BulkPurgeAsync(List activities) foreach (var activity in activities) { activity.Status = ActivityStatus.Purged; + ClearReservationState(activity); await _omnichannelActivityManager.UpdateAsync(activity); processedCount++; } @@ -940,6 +960,7 @@ private async Task BulkChangeSubjectAsync(List activit activity.InteractionType = flowSettings.InteractionType; activity.ChannelEndpointId = flowSettings.ChannelEndpointId; activity.Subject = null; + activity.Source = ResolveSourceForChangedSubject(activity.Source, flowSettings.InteractionType); var contact = await _contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest); @@ -948,10 +969,170 @@ private async Task BulkChangeSubjectAsync(List activit activity.PreferredDestination = OmnichannelHelper.GetPreferredDestenation(contact, flowSettings.Channel); } + activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus( + activity.InteractionType, + hasAssignedUser: !string.IsNullOrEmpty(activity.AssignedToId)); + + await _omnichannelActivityManager.UpdateAsync(activity); + processedCount++; + } + + return processedCount; + } + + private async Task BulkClearAssignmentAsync(List activities) + { + var processedCount = 0; + + foreach (var activity in activities) + { + ResetAssignment(activity); + await _omnichannelActivityManager.UpdateAsync(activity); + processedCount++; + } + + return processedCount; + } + + private async Task BulkChangeSourceAsync( + List activities, + string newSource, + ActivityInteractionType? newInteractionType, + bool clearCurrentAssignment) + { + if (string.IsNullOrWhiteSpace(newSource)) + { + return 0; + } + + var processedCount = 0; + + foreach (var activity in activities) + { + activity.Source = newSource.Trim(); + + if (newInteractionType.HasValue) + { + activity.InteractionType = newInteractionType.Value; + + if (activity.InteractionType == ActivityInteractionType.Manual) + { + activity.AISessionId = null; + } + } + + if (clearCurrentAssignment) + { + ResetAssignment(activity); + } + else if (string.IsNullOrEmpty(activity.AssignedToId)) + { + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: false); + } + await _omnichannelActivityManager.UpdateAsync(activity); processedCount++; } return processedCount; } + + private async Task BulkChangeDialerProfileAsync( + List activities, + string dialerProfileId, + bool clearCurrentAssignment) + { + if (string.IsNullOrWhiteSpace(dialerProfileId)) + { + return 0; + } + + var dialerProfileManager = HttpContext.RequestServices.GetService(); + + if (dialerProfileManager is null) + { + return 0; + } + + var profile = await dialerProfileManager.FindByIdAsync(dialerProfileId); + + if (profile is null) + { + return 0; + } + + var processedCount = 0; + + foreach (var activity in activities) + { + activity.CampaignId = profile.CampaignId; + activity.Source = MapDialerModeToActivitySource(profile.Mode); + activity.InteractionType = ActivityInteractionType.Manual; + activity.AISessionId = null; + + if (clearCurrentAssignment) + { + ResetAssignment(activity); + } + else if (string.IsNullOrEmpty(activity.AssignedToId)) + { + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: false); + } + + await _omnichannelActivityManager.UpdateAsync(activity); + processedCount++; + } + + return processedCount; + } + + private static bool IsBulkManageableActivity(OmnichannelActivity activity) + { + return activity is not null && + activity.Status is ActivityStatus.NotStated or ActivityStatus.Scheduled or ActivityStatus.Pending or ActivityStatus.AwaitingAgentResponse or ActivityStatus.Failed or ActivityStatus.Cancelled; + } + + private static void ResetAssignment(OmnichannelActivity activity) + { + activity.AssignedToId = null; + activity.AssignedToUsername = null; + activity.AssignedToUtc = null; + activity.AssignmentStatus = ActivityAssignmentStatus.Available; + activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: false); + ClearReservationState(activity); + } + + private static void ClearReservationState(OmnichannelActivity activity) + { + activity.ReservationId = null; + activity.ReservedById = null; + activity.ReservedByUsername = null; + activity.ReservedUtc = null; + activity.ReservationExpiresUtc = null; + } + + private static string ResolveSourceForChangedSubject(string currentSource, ActivityInteractionType interactionType) + { + if (interactionType == ActivityInteractionType.Automated) + { + return ActivitySources.Automatic; + } + + return string.Equals(currentSource, ActivitySources.Automatic, StringComparison.Ordinal) + ? ActivitySources.Manual + : currentSource; + } + + private static string MapDialerModeToActivitySource(DialerMode mode) + { + return mode switch + { + DialerMode.Power => ActivitySources.PowerDial, + DialerMode.Progressive => ActivitySources.ProgressiveDial, + DialerMode.Predictive => ActivitySources.PredictiveDial, + _ => ActivitySources.PreviewDial, + }; + } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj index 30986bc50..ab35b3c85 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/CrestApps.OrchardCore.Omnichannel.Managements.csproj @@ -27,6 +27,7 @@ + diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs index 965d66479..5a3f5b18a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs @@ -15,6 +15,7 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers; internal sealed class BulkManageActivityActionsDisplayDriver : DisplayDriver { private readonly LinkGenerator _linkGenerator; + private readonly BulkActivityAdminFormOptionsProvider _optionsProvider; private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; internal readonly IStringLocalizer S; @@ -23,14 +24,17 @@ internal sealed class BulkManageActivityActionsDisplayDriver : DisplayDriver class. /// /// The link generator. + /// The bulk activity form options provider. /// The subject flow settings service. /// The string localizer. public BulkManageActivityActionsDisplayDriver( LinkGenerator linkGenerator, + BulkActivityAdminFormOptionsProvider optionsProvider, ISubjectFlowSettingsService subjectFlowSettingsService, IStringLocalizer stringLocalizer) { _linkGenerator = linkGenerator; + _optionsProvider = optionsProvider; _subjectFlowSettingsService = subjectFlowSettingsService; S = stringLocalizer; } @@ -57,6 +61,9 @@ public override IDisplayResult Display(BulkManageOmnichannelActivityContainer mo } vm.SubjectContentTypes = subjectContentTypes.OrderBy(x => x.Text); + vm.SourceOptions = _optionsProvider.GetSourceOptions(string.Empty, "Select a source"); + vm.InteractionTypeOptions = _optionsProvider.GetInteractionTypeOptions(string.Empty, "Keep current interaction type"); + vm.DialerProfileOptions = await _optionsProvider.GetDialerProfileOptionsAsync(string.Empty, "Select a dialer profile"); vm.UserSearchEndpoint = _linkGenerator.GetPathByName("CrestApps.Users.Search", new { valueType = "userId" }); vm.TotalCount = model.TotalCount; }).Location("Content:5"); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs index b8ed629db..461088d6a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs @@ -24,6 +24,7 @@ internal sealed class BulkManageActivityFilterDisplayDriver : DisplayDriver /// The link generator. /// The YesSql session. - /// The clock. + /// The bulk activity form options provider. + /// The time zone select list provider. /// The subject flow settings service. /// The string localizer. public BulkManageActivityFilterDisplayDriver( LinkGenerator linkGenerator, ISession session, + BulkActivityAdminFormOptionsProvider optionsProvider, ITimeZoneSelectListProvider timeZoneSelectListProvider, ISubjectFlowSettingsService subjectFlowSettingsService, IStringLocalizer stringLocalizer) { _linkGenerator = linkGenerator; _session = session; + _optionsProvider = optionsProvider; _timeZoneSelectListProvider = timeZoneSelectListProvider; _subjectFlowSettingsService = subjectFlowSettingsService; S = stringLocalizer; @@ -64,6 +68,11 @@ public override IDisplayResult Edit(BulkManageActivityFilter filter, BuildEditor model.AttemptFilter = filter.AttemptFilter; model.SubjectContentType = filter.SubjectContentType; model.Channel = filter.Channel; + model.Source = filter.Source; + model.InteractionType = filter.InteractionType?.ToString(); + model.Status = filter.Status?.ToString(); + model.AssignmentStatus = filter.AssignmentStatus?.ToString(); + model.CampaignId = filter.CampaignId; model.AssignedToUserIds = filter.AssignedToUserIds ?? []; model.UrgencyLevel = filter.UrgencyLevel?.ToString(); model.ScheduledFrom = filter.ScheduledFrom?.ToString("yyyy-MM-dd"); @@ -121,6 +130,12 @@ public override IDisplayResult Edit(BulkManageActivityFilter filter, BuildEditor new(S["5- attempts"], "5-"), ]; + model.Sources = _optionsProvider.GetSourceOptions(filter.Source, "Any source"); + model.InteractionTypes = _optionsProvider.GetInteractionTypeOptions(model.InteractionType, "Any interaction type"); + model.Statuses = _optionsProvider.GetStatusOptions(model.Status, "Any active status"); + model.AssignmentStatuses = _optionsProvider.GetAssignmentStatusOptions(model.AssignmentStatus, "Any assignment status"); + model.Campaigns = await _optionsProvider.GetCampaignOptionsAsync(filter.CampaignId, "Any campaign"); + model.PhoneNumberMatchTypes = [ new(S["Exact match"], nameof(PhoneNumberMatchType.Exact)), @@ -167,9 +182,14 @@ public override async Task UpdateAsync(BulkManageActivityFilter filter.SubjectContentType = model.SubjectContentType; filter.Channel = model.Channel; + filter.Source = model.Source; filter.AttemptFilter = model.AttemptFilter; + filter.CampaignId = model.CampaignId; filter.AssignedToUserIds = model.AssignedToUserIds; filter.ContactIsPublished = null; + filter.InteractionType = null; + filter.Status = null; + filter.AssignmentStatus = null; filter.UrgencyLevel = null; filter.ScheduledFrom = null; filter.ScheduledTo = null; @@ -197,6 +217,21 @@ public override async Task UpdateAsync(BulkManageActivityFilter filter.UrgencyLevel = urgencyLevel; } + if (!string.IsNullOrEmpty(model.InteractionType) && Enum.TryParse(model.InteractionType, out var interactionType)) + { + filter.InteractionType = interactionType; + } + + if (!string.IsNullOrEmpty(model.Status) && Enum.TryParse(model.Status, out var status)) + { + filter.Status = status; + } + + if (!string.IsNullOrEmpty(model.AssignmentStatus) && Enum.TryParse(model.AssignmentStatus, out var assignmentStatus)) + { + filter.AssignmentStatus = assignmentStatus; + } + if (!string.IsNullOrEmpty(model.ScheduledFrom) && DateTime.TryParseExact(model.ScheduledFrom, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var scheduledFrom)) { filter.ScheduledFrom = scheduledFrom; @@ -244,11 +279,36 @@ public override async Task UpdateAsync(BulkManageActivityFilter filter.RouteValues.TryAdd(Prefix + ".Channel", filter.Channel); } + if (!string.IsNullOrEmpty(filter.Source)) + { + filter.RouteValues.TryAdd(Prefix + ".Source", filter.Source); + } + if (!string.IsNullOrEmpty(filter.AttemptFilter)) { filter.RouteValues.TryAdd(Prefix + ".AttemptFilter", filter.AttemptFilter); } + if (filter.InteractionType.HasValue) + { + filter.RouteValues.TryAdd(Prefix + ".InteractionType", filter.InteractionType.Value.ToString()); + } + + if (filter.Status.HasValue) + { + filter.RouteValues.TryAdd(Prefix + ".Status", filter.Status.Value.ToString()); + } + + if (filter.AssignmentStatus.HasValue) + { + filter.RouteValues.TryAdd(Prefix + ".AssignmentStatus", filter.AssignmentStatus.Value.ToString()); + } + + if (!string.IsNullOrEmpty(filter.CampaignId)) + { + filter.RouteValues.TryAdd(Prefix + ".CampaignId", filter.CampaignId); + } + if (filter.UrgencyLevel.HasValue) { filter.RouteValues.TryAdd(Prefix + ".UrgencyLevel", filter.UrgencyLevel.Value.ToString()); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs index 322ba5fd8..56ed674cf 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Handlers/BulkManageActivityFilterHandler.cs @@ -44,6 +44,41 @@ public Task FilteringAsync(BulkManageActivityFilterContext context, Cancellation builder.WhereAnd($"{col} = @Channel"); } + if (!string.IsNullOrEmpty(filter.Source)) + { + var col = $"{dialect.QuoteForAliasName(actAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.Source))}"; + builder.Parameters["@Source"] = filter.Source; + builder.WhereAnd($"{col} = @Source"); + } + + if (filter.InteractionType.HasValue) + { + var col = $"{dialect.QuoteForAliasName(actAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.InteractionType))}"; + builder.Parameters["@InteractionType"] = (int)filter.InteractionType.Value; + builder.WhereAnd($"{col} = @InteractionType"); + } + + if (filter.Status.HasValue) + { + var col = $"{dialect.QuoteForAliasName(actAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.Status))}"; + builder.Parameters["@Status"] = (int)filter.Status.Value; + builder.WhereAnd($"{col} = @Status"); + } + + if (filter.AssignmentStatus.HasValue) + { + var col = $"{dialect.QuoteForAliasName(actAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.AssignmentStatus))}"; + builder.Parameters["@AssignmentStatus"] = (int)filter.AssignmentStatus.Value; + builder.WhereAnd($"{col} = @AssignmentStatus"); + } + + if (!string.IsNullOrEmpty(filter.CampaignId)) + { + var col = $"{dialect.QuoteForAliasName(actAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.CampaignId))}"; + builder.Parameters["@CampaignId"] = filter.CampaignId; + builder.WhereAnd($"{col} = @CampaignId"); + } + if (filter.AssignedToUserIds is { Length: > 0 }) { var col = $"{dialect.QuoteForAliasName(actAlias)}.{dialect.QuoteForColumnName(nameof(OmnichannelActivityIndex.AssignedToId))}"; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs new file mode 100644 index 000000000..c602e6074 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs @@ -0,0 +1,150 @@ +using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; +using CrestApps.OrchardCore.Omnichannel.Core.Models; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Localization; + +namespace CrestApps.OrchardCore.Omnichannel.Managements.Services; + +/// +/// Provides select-list options used by the bulk activity management filters and actions. +/// +public sealed class BulkActivityAdminFormOptionsProvider +{ + private readonly ICatalogManager _campaignManager; + private readonly IServiceProvider _serviceProvider; + + internal readonly IStringLocalizer S; + + /// + /// Initializes a new instance of the class. + /// + /// The omnichannel campaign manager. + /// The service provider used for optional dialer services. + /// The string localizer. + public BulkActivityAdminFormOptionsProvider( + ICatalogManager campaignManager, + IServiceProvider serviceProvider, + IStringLocalizer stringLocalizer) + { + _campaignManager = campaignManager; + _serviceProvider = serviceProvider; + S = stringLocalizer; + } + + internal IList GetSourceOptions( + string selectedSource, + string emptyText) + { + return + [ + new SelectListItem(S[emptyText], string.Empty, string.IsNullOrEmpty(selectedSource)), + new SelectListItem(S["Manual"], ActivitySources.Manual, string.Equals(selectedSource, ActivitySources.Manual, StringComparison.Ordinal)), + new SelectListItem(S["Automatic"], ActivitySources.Automatic, string.Equals(selectedSource, ActivitySources.Automatic, StringComparison.Ordinal)), + new SelectListItem(S["Dialer"], ActivitySources.Dialer, string.Equals(selectedSource, ActivitySources.Dialer, StringComparison.Ordinal)), + new SelectListItem(S["Preview dial"], ActivitySources.PreviewDial, string.Equals(selectedSource, ActivitySources.PreviewDial, StringComparison.Ordinal)), + new SelectListItem(S["Power dial"], ActivitySources.PowerDial, string.Equals(selectedSource, ActivitySources.PowerDial, StringComparison.Ordinal)), + new SelectListItem(S["Progressive dial"], ActivitySources.ProgressiveDial, string.Equals(selectedSource, ActivitySources.ProgressiveDial, StringComparison.Ordinal)), + new SelectListItem(S["Predictive dial"], ActivitySources.PredictiveDial, string.Equals(selectedSource, ActivitySources.PredictiveDial, StringComparison.Ordinal)), + new SelectListItem(S["Callback"], ActivitySources.Callback, string.Equals(selectedSource, ActivitySources.Callback, StringComparison.Ordinal)), + new SelectListItem(S["Inbound"], ActivitySources.Inbound, string.Equals(selectedSource, ActivitySources.Inbound, StringComparison.Ordinal)), + new SelectListItem(S["Workflow"], ActivitySources.Workflow, string.Equals(selectedSource, ActivitySources.Workflow, StringComparison.Ordinal)), + new SelectListItem(S["API"], ActivitySources.Api, string.Equals(selectedSource, ActivitySources.Api, StringComparison.Ordinal)), + ]; + } + + internal IList GetInteractionTypeOptions(string selectedInteractionType, string emptyText) + { + return + [ + new SelectListItem(S[emptyText], string.Empty, string.IsNullOrEmpty(selectedInteractionType)), + new SelectListItem(S["Manual"], nameof(ActivityInteractionType.Manual), string.Equals(selectedInteractionType, nameof(ActivityInteractionType.Manual), StringComparison.Ordinal)), + new SelectListItem(S["Automated"], nameof(ActivityInteractionType.Automated), string.Equals(selectedInteractionType, nameof(ActivityInteractionType.Automated), StringComparison.Ordinal)), + ]; + } + + internal IList GetStatusOptions(string selectedStatus, string emptyText) + { + return + [ + new SelectListItem(S[emptyText], string.Empty, string.IsNullOrEmpty(selectedStatus)), + new SelectListItem(S["Not started"], nameof(ActivityStatus.NotStated), string.Equals(selectedStatus, nameof(ActivityStatus.NotStated), StringComparison.Ordinal)), + new SelectListItem(S["Scheduled"], nameof(ActivityStatus.Scheduled), string.Equals(selectedStatus, nameof(ActivityStatus.Scheduled), StringComparison.Ordinal)), + new SelectListItem(S["Pending"], nameof(ActivityStatus.Pending), string.Equals(selectedStatus, nameof(ActivityStatus.Pending), StringComparison.Ordinal)), + new SelectListItem(S["Awaiting agent response"], nameof(ActivityStatus.AwaitingAgentResponse), string.Equals(selectedStatus, nameof(ActivityStatus.AwaitingAgentResponse), StringComparison.Ordinal)), + new SelectListItem(S["Failed"], nameof(ActivityStatus.Failed), string.Equals(selectedStatus, nameof(ActivityStatus.Failed), StringComparison.Ordinal)), + new SelectListItem(S["Cancelled"], nameof(ActivityStatus.Cancelled), string.Equals(selectedStatus, nameof(ActivityStatus.Cancelled), StringComparison.Ordinal)), + ]; + } + + internal IList GetAssignmentStatusOptions(string selectedAssignmentStatus, string emptyText) + { + return + [ + new SelectListItem(S[emptyText], string.Empty, string.IsNullOrEmpty(selectedAssignmentStatus)), + new SelectListItem(S["Unassigned"], nameof(ActivityAssignmentStatus.Unassigned), string.Equals(selectedAssignmentStatus, nameof(ActivityAssignmentStatus.Unassigned), StringComparison.Ordinal)), + new SelectListItem(S["Available"], nameof(ActivityAssignmentStatus.Available), string.Equals(selectedAssignmentStatus, nameof(ActivityAssignmentStatus.Available), StringComparison.Ordinal)), + new SelectListItem(S["Reserved"], nameof(ActivityAssignmentStatus.Reserved), string.Equals(selectedAssignmentStatus, nameof(ActivityAssignmentStatus.Reserved), StringComparison.Ordinal)), + new SelectListItem(S["Assigned"], nameof(ActivityAssignmentStatus.Assigned), string.Equals(selectedAssignmentStatus, nameof(ActivityAssignmentStatus.Assigned), StringComparison.Ordinal)), + new SelectListItem(S["In progress"], nameof(ActivityAssignmentStatus.InProgress), string.Equals(selectedAssignmentStatus, nameof(ActivityAssignmentStatus.InProgress), StringComparison.Ordinal)), + new SelectListItem(S["Released"], nameof(ActivityAssignmentStatus.Released), string.Equals(selectedAssignmentStatus, nameof(ActivityAssignmentStatus.Released), StringComparison.Ordinal)), + ]; + } + + internal async Task> GetCampaignOptionsAsync(string selectedCampaignId, string emptyText) + { + var campaigns = await _campaignManager.GetAllAsync(); + var options = campaigns + .OrderBy(campaign => campaign.DisplayText, StringComparer.CurrentCultureIgnoreCase) + .Select(campaign => new SelectListItem(GetCampaignText(campaign), campaign.ItemId, string.Equals(selectedCampaignId, campaign.ItemId, StringComparison.Ordinal))) + .ToList(); + + options.Insert(0, new SelectListItem(S[emptyText], string.Empty, string.IsNullOrEmpty(selectedCampaignId))); + + if (!string.IsNullOrWhiteSpace(selectedCampaignId) && + options.All(option => !string.Equals(option.Value, selectedCampaignId, StringComparison.Ordinal))) + { + options.Add(new SelectListItem(selectedCampaignId, selectedCampaignId, selected: true)); + } + + return options; + } + + internal async Task> GetDialerProfileOptionsAsync(string selectedDialerProfileId, string emptyText) + { + var dialerProfileManager = _serviceProvider.GetService(); + + if (dialerProfileManager is null) + { + return + [ + new SelectListItem(S[emptyText], string.Empty, selected: true), + ]; + } + + var profiles = await dialerProfileManager.GetAllAsync(); + var options = profiles + .OrderBy(profile => profile.Name, StringComparer.CurrentCultureIgnoreCase) + .Select(profile => new SelectListItem(profile.Name ?? profile.ItemId, profile.ItemId, string.Equals(selectedDialerProfileId, profile.ItemId, StringComparison.Ordinal))) + .ToList(); + + options.Insert(0, new SelectListItem(S[emptyText], string.Empty, string.IsNullOrEmpty(selectedDialerProfileId))); + + if (!string.IsNullOrWhiteSpace(selectedDialerProfileId) && + options.All(option => !string.Equals(option.Value, selectedDialerProfileId, StringComparison.Ordinal))) + { + options.Add(new SelectListItem(selectedDialerProfileId, selectedDialerProfileId, selected: true)); + } + + return options; + } + + private static string GetCampaignText(OmnichannelCampaign campaign) + { + return string.IsNullOrWhiteSpace(campaign.DisplayText) + ? campaign.ItemId + : campaign.DisplayText; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index d88ba3fe5..7cbc6d438 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -92,6 +92,8 @@ public override void ConfigureServices(IServiceCollection services) .AddDisplayDriver() .AddScoped(); + services.AddScoped(); + services .AddDisplayDriver(); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkActivityActionsViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkActivityActionsViewModel.cs index 83628a635..1d2c38744 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkActivityActionsViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkActivityActionsViewModel.cs @@ -17,6 +17,21 @@ public class BulkActivityActionsViewModel /// public IEnumerable SubjectContentTypes { get; set; } = []; + /// + /// Gets or sets the available activity sources for the bulk action. + /// + public IEnumerable SourceOptions { get; set; } = []; + + /// + /// Gets or sets the available interaction types for the bulk action. + /// + public IEnumerable InteractionTypeOptions { get; set; } = []; + + /// + /// Gets or sets the available dialer profiles for the bulk action. + /// + public IEnumerable DialerProfileOptions { get; set; } = []; + /// /// Gets or sets the user search endpoint URL. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivitiesViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivitiesViewModel.cs index 74a4574e1..75a50a11e 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivitiesViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivitiesViewModel.cs @@ -46,4 +46,24 @@ public sealed class BulkManageActivitiesViewModel /// Gets or sets the new subject content type (for ChangeSubject action). /// public string NewSubjectContentType { get; set; } + + /// + /// Gets or sets the new activity source (for ChangeSource action). + /// + public string NewSource { get; set; } + + /// + /// Gets or sets the new interaction type (for ChangeSource action). + /// + public ActivityInteractionType? NewInteractionType { get; set; } + + /// + /// Gets or sets the selected dialer profile identifier (for ChangeDialerProfile action). + /// + public string NewDialerProfileId { get; set; } + + /// + /// Gets or sets a value indicating whether current assignment and reservation state should be cleared. + /// + public bool ClearCurrentAssignment { get; set; } } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs index d37a0b8fc..84931f9f6 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/BulkManageActivityFilterViewModel.cs @@ -29,6 +29,31 @@ public class BulkManageActivityFilterViewModel /// public string Channel { get; set; } + /// + /// Gets or sets the source filter. + /// + public string Source { get; set; } + + /// + /// Gets or sets the interaction type filter. + /// + public string InteractionType { get; set; } + + /// + /// Gets or sets the activity status filter. + /// + public string Status { get; set; } + + /// + /// Gets or sets the assignment status filter. + /// + public string AssignmentStatus { get; set; } + + /// + /// Gets or sets the campaign identifier filter. + /// + public string CampaignId { get; set; } + /// /// Gets or sets the assigned to user IDs filter. /// @@ -113,6 +138,36 @@ public class BulkManageActivityFilterViewModel [BindNever] public IEnumerable Channels { get; set; } = []; + /// + /// Gets or sets the available activity sources. + /// + [BindNever] + public IEnumerable Sources { get; set; } = []; + + /// + /// Gets or sets the available interaction types. + /// + [BindNever] + public IEnumerable InteractionTypes { get; set; } = []; + + /// + /// Gets or sets the available activity statuses. + /// + [BindNever] + public IEnumerable Statuses { get; set; } = []; + + /// + /// Gets or sets the available assignment statuses. + /// + [BindNever] + public IEnumerable AssignmentStatuses { get; set; } = []; + + /// + /// Gets or sets the available campaigns. + /// + [BindNever] + public IEnumerable Campaigns { get; set; } = []; + /// /// Gets or sets the available urgency levels. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkActivityActions.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkActivityActions.cshtml index 396e04e77..be19c877b 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkActivityActions.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkActivityActions.cshtml @@ -37,6 +37,12 @@
  • @T["Set Instructions"]
  • @T["Set Urgency Level"]
  • @T["Change Subject"]
  • +
  • @T["Clear Assignment"]
  • +
  • @T["Change Source"]
  • + @if (Model.DialerProfileOptions.Count() > 1) + { +
  • @T["Change Dialer Profile"]
  • + }
    @@ -117,6 +123,63 @@
    + @* Clear Assignment Parameters *@ +
    +
    + @T["This removes the current assignee and any reservation state so the selected activities can be routed or dialed again."] +
    +
    + + @* Change Source Parameters *@ +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + @* Change Dialer Profile Parameters *@ +
    +
    + + +
    + @T["Applying a dialer profile updates the activity campaign and dialer source to match that profile."] +
    +
    +
    + + +
    +
    +
    +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    + +
    +
    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageOmnichannelActivityContainer.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageOmnichannelActivityContainer.cshtml index 56c9cab9b..434a3b131 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageOmnichannelActivityContainer.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/BulkManageOmnichannelActivityContainer.cshtml @@ -95,6 +95,17 @@ var bulkActionInput = document.getElementById('BulkAction'); var bulkActionTitle = document.getElementById('bulk-action-title'); var dropdownItems = document.querySelectorAll('.bulk-action-item'); + var togglePanelInputs = function (activePanel) { + document.querySelectorAll('.bulk-action-param').forEach(function (panel) { + var isActive = panel === activePanel; + + panel.querySelectorAll('input, select, textarea, button').forEach(function (input) { + input.disabled = !isActive; + }); + }); + }; + + togglePanelInputs(null); dropdownItems.forEach(function (item) { item.addEventListener('click', function () { @@ -110,11 +121,15 @@ if (paramPanel) { paramPanel.classList.remove('d-none'); bulkActionParams.classList.remove('d-none'); + togglePanelInputs(paramPanel); } else if (action === 'Purge') { + togglePanelInputs(null); bulkActionInput.value = action; document.querySelector('[name="submit.BulkAction"]').click(); return; + } else { + togglePanelInputs(null); } bulkActionInput.value = action; From f7fbf86a9556b10b01bf556254abec70ff333a6c Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Mon, 6 Jul 2026 21:12:11 -0700 Subject: [PATCH 40/56] fix activity batch --- .github/contact-center/PLAN.md | 1 + CrestApps.OrchardCore.slnx | 8 +- .../DialerActivitySourceHelper.cs | 26 ++++++ .../Models/ActivityBatchSourceOptions.cs | 5 ++ .../Models/OmnichannelActivityBatch.cs | 6 ++ src/CrestApps.Docs/docs/changelog/v2.0.0.md | 2 + .../docs/contact-center/index.md | 2 +- .../docs/omnichannel/management.md | 12 ++- .../Startup.cs | 9 +++ .../Controllers/ActivitiesController.cs | 11 +-- .../Controllers/ActivityBatchesController.cs | 1 + .../OmnichannelActivityBatchDisplayDriver.cs | 28 +++++++ .../BulkActivityAdminFormOptionsProvider.cs | 17 ++++ .../DefaultContactActivityBatchLoader.cs | 81 +++++++++++++++++-- .../Startup.cs | 8 +- .../OmnichannelActivityBatchViewModel.cs | 11 +++ ...OmnichannelActivityBatchFields.Edit.cshtml | 13 +++ .../DialerActivitySourceHelperTests.cs | 25 ++++++ 18 files changed, 230 insertions(+), 36 deletions(-) create mode 100644 src/Core/CrestApps.OrchardCore.ContactCenter.Core/DialerActivitySourceHelper.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerActivitySourceHelperTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 7551ea31d..0033cb8d1 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1544,3 +1544,4 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **PR review security hardening.** Addressed the CodeQL logging findings on provider voice webhooks by logging the matched adapter's registered provider technical name instead of the webhook-supplied provider route value when signature validation or idempotency-key validation fails. - 2026-07-02: **Phase 12 Reports UI shipped — the "Reports" tab (Analytics feature).** Added the new `CrestApps.OrchardCore.ContactCenter.Analytics` feature (depends on `Queues`) that surfaces an **Interaction Center → Reports** admin area (`ContactCenterReportsAdminMenu`, nested Reports group) with six pages served by `ReportsController` (`[Feature(Analytics)]`, `[Admin]`): **Overview**, **Call insights**, **Agent productivity**, **Queue usage**, **Campaign summary**, and **Subject inventory**. Each page shares a `yyyy-MM-dd` date-range filter (default last 30 days) and a CSV export (`ReportCsvBuilder`, UTF-8 BOM). **Core:** added `IContactCenterReportingService`/`ContactCenterReportingService` (Core) that aggregates the durable interaction history and the CRM `OmnichannelActivityIndex` inventory into strongly-typed report models under `Models/Reports` (`CallInsightsReport` with per-day trend + channel/status breakdowns + answered/abandoned/failed + AHT/ASA/talk time; `AgentProductivityReport` per-agent handled/talk-time/completed-activities; `QueueUsageReport` per-queue handled/answered/abandoned/AHT/ASA + live waiting depth + SLA threshold; `CampaignSummaryReport` and `SubjectInventoryReport` bucketing each group into Completed/Pending/InProgress/Failed/Cancelled + attempts + completion rate — i.e. "what is completed vs pending" per campaign and per subject). The heavy aggregation lives in `internal static` builders so it is unit-testable without a live session. Added the `ViewContactCenterReports` permission (granted to Administrator + the built-in Supervisor role). **Tests:** +6 reporting unit tests (call insights totals/handle-time, daily grouping, agent productivity aggregation, queue usage + waiting, campaign completed-vs-pending bucketing, subject grouping) — **all ContactCenter tests pass**; clean `-warnaserror` Release build of `ContactCenter.Core` and the module. Docs (`contact-center/index.md` new "Reports and analytics" section, `agents-queues-dialer.md` feature table + recipe, `v2.0.0` changelog "Reports & Analytics UI") updated. Also verified DialPad completeness as the phone provider: outbound dial + inbound signed webhook routing + `CallTransfer` are shipped (`AgentDeviceNative` delivery); provider media execution of recording/monitoring remains a Phase 9 roadmap item, not a dialing-path gap. **Remaining Phase 12:** SLA/adherence trend snapshots and operational alerts. - 2026-07-02: **Reports generalized into a reusable framework + industry-standard CRM reports (maintainer direction).** Per the maintainer's request for a reusable reports structure, a top-level Reports tab, extensible (display-driver) filters with a from/to range, exports, and CRM reports, the Contact Center-specific reports UI was replaced by a shared framework. **New `CrestApps.OrchardCore.Reports` module** (+ `CrestApps.OrchardCore.Reports.Abstractions`): `IReport` (name/category/permission/`RunAsync`→`ReportDocument`), a display-driver-extensible `ReportFilter` with a built-in from/to date-range driver, a uniform `ReportDocument` (metric-card / table / bar sections, with emphasized totals rows for aggregated reports), a generic `ReportsController` (Index landing + `Display(id)` + `Export(id, format)`), a top-level **Reports** admin menu (`ReportsAdminMenu`) that groups registered reports by category and gates each by its own permission, an `IReportManager` registry, and a pluggable `IReportExportFormat` with a built-in `CsvReportExportFormat`. Registered in `.slnx` and the `Cms.Core.Targets` bundle. **Contact Center migration:** deleted the CC-specific `ReportsController`, `ContactCenterReportsAdminMenu`, `ReportCsvBuilder`, `ReportFormat`, report `ViewModels`, and `Views/Reports`; kept `IContactCenterReportingService` + its DTOs (and the 6 tests) and added five thin `IReport` adapters (`*ReportProvider`) that map the DTOs to `ReportDocument`. The Analytics feature now depends on the Reports feature and registers the adapters. **Omnichannel CRM reports:** added an **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) in the Managements module with `OmnichannelReportAggregator` (pure, tested) + `OmnichannelReportQuery` and three `IReport`s — **Activity summary**, **Campaign performance**, and **Disposition breakdown** — plus a `ViewOmnichannelReports` permission (implied by `ManageActivities`). **Validation:** clean full-solution `-warnaserror` build; 226 ContactCenter/Omnichannel/Report tests pass (+3 new Omnichannel aggregator tests); and a live end-to-end smoke test confirmed the top-level **Reports** menu, all eight report pages (5 CC + 3 CRM) rendering with the from/to filter, and CSV export, on a fresh tenant. Docs: new `modules/reports.md`, updated `modules/index.md`, `contact-center/index.md`, `omnichannel/index.md`, and the `v2.0.0` changelog. **Remaining:** per-agent self-service report scoping, additional export formats (Excel/PDF), and SLA/adherence trend snapshots. +- 2026-07-06: Fixed the default Debug solution build by re-enabling `CrestApps.OrchardCore.Reports.Abstractions` and `CrestApps.OrchardCore.Reports` in `CrestApps.OrchardCore.slnx`, which removed the 45 reporting-related errors from plain `dotnet build`. Activity batch creation was also tightened back to the Contact Center design: the picker now exposes only **Manual** and **Dialer** sources, dialer batches require a dialer profile, and the default batch loader applies the selected profile's dialing mode and campaign to the created activities while leaving them unassigned for later dialer reservation. Legacy hidden source registrations remain available so older batches do not lose their source metadata. diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index fad15ee25..c8cba85ac 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -21,9 +21,7 @@ - - - + @@ -85,9 +83,7 @@ - - - + 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..98fdece66 --- /dev/null +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/DialerActivitySourceHelper.cs @@ -0,0 +1,26 @@ +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, + }; + } +} diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs index 6302ad56c..303c0ecbd 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/ActivityBatchSourceOptions.cs @@ -73,4 +73,9 @@ public ActivityBatchSourceEntry(string source) /// Gets or sets a value indicating whether batches from this source require user assignment while loading. /// public bool RequiresUserAssignment { get; set; } = true; + + /// + /// Gets or sets a value indicating whether the source should appear in the batch creation picker. + /// + public bool ShowInCreationPicker { get; set; } = true; } diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs index f2ef7890e..c4a76d4db 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivityBatch.cs @@ -39,6 +39,11 @@ public sealed class OmnichannelActivityBatch : CatalogItem, IDisplayTextAwareMod /// public string AIProfileId { get; set; } + /// + /// Gets or sets the dialer profile identifier assigned to dialer activities loaded from this batch. + /// + public string DialerProfileId { get; set; } + /// /// Gets or sets the user ids. /// @@ -170,6 +175,7 @@ public OmnichannelActivityBatch Clone() SubjectContentType = SubjectContentType, ContactContentType = ContactContentType, AIProfileId = AIProfileId, + DialerProfileId = DialerProfileId, UserIds = UserIds?.ToArray(), IncludeDoNoCalls = IncludeDoNoCalls, IncludeDoNoSms = IncludeDoNoSms, diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index fc1b6034a..8cb96fca0 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -416,6 +416,8 @@ At a high level, the platform changes are: - Automatic activity batches can now 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. - Activity batch sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. - Activity batch 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 batch 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. +- Activity batch creation now shows only **Manual** and **Dialer** sources. Dialer batches require a dialer profile, and the loader applies that profile's dialing mode and campaign to the created activities while keeping the activities unassigned for later reservation. - 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 bulk actions against selected or filtered activities. - 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. diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index faa414d94..fdcea7302 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -42,7 +42,7 @@ Contact Center extends `OmnichannelActivity` with metadata needed by queues and - Nullable ownership so preview, power, progressive, and predictive dialing can create activities before an agent is selected. - Activity kind and extensible source metadata so the same Activity model can represent calls, SMS, email, meetings, tasks, callbacks, inbound work, workflow-created work, API-created work, and dialer inventory. - Assignment and reservation metadata so multiple dialer or routing instances do not claim the same record concurrently. -- Activity batches can load either user-assigned manual work or unassigned dialer work. The batch creation dialog selects a source first, then display drivers render source-specific UI. +- Activity batches can load either user-assigned manual work or unassigned dialer work. The batch creation dialog selects a source first, and dialer batches require a dialer profile so the loaded activities inherit the correct dialing mode and campaign. Dispositions are applied to Activities, not Interactions. Agent, provider, AI, workflow, and system outcomes converge through the activity disposition service before Subject Actions or workflow automation runs. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index e4e034b2e..2420a54d9 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -202,24 +202,22 @@ Subjects without any actions show a **Missing flow** badge in the Subject Flows 1. Go to `Interaction Center` → `Activity Batches`. 2. Click **Add Activity Batch** and choose a source: - **Manual** loads activities assigned to the selected users immediately. - - **Automatic** loads unassigned automated activities for AI processing. - - **Dialer** loads unassigned activities that a dialer reserves and assigns later. + - **Dialer** loads unassigned activities for outbound dialing and requires a dialer profile when the batch is created. 3. Create the new batch: - Select contact type - Select subject type - - For **Automatic** batches, optionally select an AI profile. The list only includes chat profiles - with **Add initial prompt** enabled; leaving it empty uses the subject flow's AI profile. - - Assign users when the selected source requires assignment + - For **Dialer** batches, select the required dialer profile that controls the dialing mode, queue, and campaign assignment. + - Assign users when the selected source requires assignment. - Optionally set lead created range, phone number, time zone, and last activity filters 4. Click `Load`. -The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the campaign, interaction type, channel, and channel endpoint. For Automatic batches, the loader stores the selected batch AI profile on each activity; if no batch profile is selected, the activity uses the subject flow's AI profile. The automated activity processor then uses that profile's initial prompt to send the first outbound SMS and to continue the AI conversation when the contact replies. Manual batches assign each created activity to a selected user. Automatic batches leave activities unassigned but immediately eligible for the automated activity processor when their schedule is due. Dialer batches leave activities unassigned with assignment status `Available` so dialers can reserve and assign them safely later. +The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the channel and channel endpoint. Manual batches assign each created activity to a selected user. Dialer batches require a phone subject flow, leave activities unassigned with assignment status `Available`, and apply the selected dialer profile so the created activities inherit the profile's dialing mode and campaign before dialers reserve them later. ### Extending activity batch sources Activity batch loading is extensible. Each batch has a **source**, and the source controls how the batch resolves and loads activities. There are two layers of extensibility: -1. **Registering a source** — register sources through `ActivityBatchSourceOptions` in a feature `Startup`. Each `ActivityBatchSourceEntry` provides the display name, description, and whether the source requires user assignment. Registered sources appear as creation cards, and display drivers can add source-specific editor sections. +1. **Registering a source** — register sources through `ActivityBatchSourceOptions` in a feature `Startup`. Each `ActivityBatchSourceEntry` provides the display name, description, whether the source requires user assignment, and whether it should appear in the creation picker. Display drivers can add source-specific editor sections. 2. **Controlling the load** — implement `IActivityBatchLoader` (from `CrestApps.OrchardCore.Omnichannel.Core.Services`) to fully own how a source queries leads, applies filters, and creates activities. The loader's `Source` property must match the registered source. Register the loader as a scoped service: diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs index e9bbec524..03b8b1a28 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Startup.cs @@ -241,11 +241,19 @@ public override void ConfigureServices(IServiceCollection services) services.Configure(options => { + options.AddSource(ActivitySources.Dialer, entry => + { + entry.DisplayName = S["Dialer"]; + entry.Description = S["Loads unassigned activities and applies the selected dialer profile when the batch is loaded."]; + entry.RequiresUserAssignment = false; + }); + options.AddSource(ActivitySources.PreviewDial, entry => { entry.DisplayName = S["Preview dial batch"]; entry.Description = S["Loads unassigned activities the dialer offers to agents one at a time for review before dialing."]; entry.RequiresUserAssignment = false; + entry.ShowInCreationPicker = false; }); options.AddSource(ActivitySources.PowerDial, entry => @@ -253,6 +261,7 @@ public override void ConfigureServices(IServiceCollection services) entry.DisplayName = S["Power dial batch"]; entry.Description = S["Loads unassigned activities the dialer dials automatically for available agents."]; entry.RequiresUserAssignment = false; + entry.ShowInCreationPicker = false; }); }); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs index 8a2e57c39..bb6dd9d1d 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Security.Claims; using CrestApps.Core; +using CrestApps.OrchardCore.ContactCenter.Core; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; @@ -1126,13 +1127,5 @@ private static string ResolveSourceForChangedSubject(string currentSource, Activ } private static string MapDialerModeToActivitySource(DialerMode mode) - { - return mode switch - { - DialerMode.Power => ActivitySources.PowerDial, - DialerMode.Progressive => ActivitySources.ProgressiveDial, - DialerMode.Predictive => ActivitySources.PredictiveDial, - _ => ActivitySources.PreviewDial, - }; - } + => DialerActivitySourceHelper.GetActivitySource(mode); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs index 69715d070..d10e9226d 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs @@ -113,6 +113,7 @@ public async Task Index( Options = options, Pager = await shapeFactory.PagerAsync(pager, result.Count, routeData), Sources = _activityBatchSourceOptions.Sources.Values + .Where(source => source.ShowInCreationPicker) .OrderBy(source => source.DisplayName.Value), }; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs index 3532464b5..6ecdf724f 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs @@ -32,6 +32,7 @@ internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriver _dispositionsCatalog; private readonly ISubjectFlowSettingsService _subjectFlowSettingsService; + private readonly BulkActivityAdminFormOptionsProvider _optionsProvider; private readonly ActivityBatchSourceOptions _activityBatchSourceOptions; internal readonly IStringLocalizer S; @@ -47,6 +48,7 @@ internal sealed class OmnichannelActivityBatchDisplayDriver : DisplayDriverThe YesSql session. /// The dispositions catalog. /// The subject flow settings service. + /// The bulk activity options provider. /// The configured activity batch sources. /// The string localizer. public OmnichannelActivityBatchDisplayDriver( @@ -58,6 +60,7 @@ public OmnichannelActivityBatchDisplayDriver( ISession session, INamedCatalog dispositionsCatalog, ISubjectFlowSettingsService subjectFlowSettingsService, + BulkActivityAdminFormOptionsProvider optionsProvider, IOptions activityBatchSourceOptions, IStringLocalizer stringLocalizer) { @@ -69,6 +72,7 @@ public OmnichannelActivityBatchDisplayDriver( _session = session; _dispositionsCatalog = dispositionsCatalog; _subjectFlowSettingsService = subjectFlowSettingsService; + _optionsProvider = optionsProvider; _activityBatchSourceOptions = activityBatchSourceOptions.Value; S = stringLocalizer; } @@ -102,6 +106,7 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC model.SubjectContentType = batch.SubjectContentType; model.ContactContentType = batch.ContactContentType; model.AIProfileId = batch.AIProfileId; + model.DialerProfileId = batch.DialerProfileId; model.UserIds = batch.UserIds; model.IncludeDoNoCalls = batch.IncludeDoNoCalls; model.IncludeDoNoSms = batch.IncludeDoNoSms; @@ -145,6 +150,7 @@ public override IDisplayResult Edit(OmnichannelActivityBatch batch, BuildEditorC } model.AIProfiles = await GetAIProfileOptionsAsync(selectedAIProfileId); + model.DialerProfiles = await _optionsProvider.GetDialerProfileOptionsAsync(model.DialerProfileId, "Select a dialer profile"); if (model.RequiresUserAssignment && batch.UserIds is { Length: > 0 }) { @@ -252,6 +258,18 @@ public override async Task UpdateAsync(OmnichannelActivityBatch context.Updater.ModelState.AddModelError(Prefix, nameof(model.UserIds), S["At least one user is required."]); } + if (string.Equals(model.Source, ActivitySources.Dialer, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(model.DialerProfileId)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DialerProfileId), S["Dialer profile is required for dialer activity batches."]); + } + else if (!await _optionsProvider.DialerProfileExistsAsync(model.DialerProfileId)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DialerProfileId), S["The selected dialer profile is invalid."]); + } + } + if (string.Equals(model.Source, ActivitySources.Automatic, StringComparison.OrdinalIgnoreCase) && flowSettings?.InteractionType != ActivityInteractionType.Automated) { @@ -264,6 +282,13 @@ public override async Task UpdateAsync(OmnichannelActivityBatch context.Updater.ModelState.AddModelError(Prefix, nameof(model.Source), S["Automated subject flows must be loaded with the Automatic source."]); } + if (string.Equals(model.Source, ActivitySources.Dialer, StringComparison.OrdinalIgnoreCase) && + flowSettings?.Channel is not null && + !string.Equals(flowSettings.Channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubjectContentType), S["Dialer activity batches require a subject flow that uses the Phone channel."]); + } + var selectedAIProfileId = string.IsNullOrWhiteSpace(model.AIProfileId) ? flowSettings?.ProfileId : model.AIProfileId.Trim(); @@ -306,6 +331,9 @@ public override async Task UpdateAsync(OmnichannelActivityBatch batch.AIProfileId = string.Equals(model.Source, ActivitySources.Automatic, StringComparison.OrdinalIgnoreCase) ? model.AIProfileId?.Trim() : null; + batch.DialerProfileId = string.Equals(model.Source, ActivitySources.Dialer, StringComparison.OrdinalIgnoreCase) + ? model.DialerProfileId?.Trim() + : null; batch.Instructions = model.Instructions?.Trim(); batch.UrgencyLevel = model.UrgencyLevel; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs index c602e6074..aa5ef86aa 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/BulkActivityAdminFormOptionsProvider.cs @@ -141,6 +141,23 @@ internal async Task> GetDialerProfileOptionsAsync(string s return options; } + internal async Task DialerProfileExistsAsync(string dialerProfileId) + { + if (string.IsNullOrWhiteSpace(dialerProfileId)) + { + return false; + } + + var dialerProfileManager = _serviceProvider.GetService(); + + if (dialerProfileManager is null) + { + return false; + } + + return await dialerProfileManager.FindByIdAsync(dialerProfileId.Trim()) is not null; + } + private static string GetCampaignText(OmnichannelCampaign campaign) { return string.IsNullOrWhiteSpace(campaign.DisplayText) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs index 60df7dd76..d7758c528 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/DefaultContactActivityBatchLoader.cs @@ -1,9 +1,13 @@ using CrestApps.Core.Services; +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Core.Models; +using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.Omnichannel.Core.Indexes; using CrestApps.OrchardCore.Omnichannel.Core.Models; using CrestApps.OrchardCore.Omnichannel.Core.Services; using Dapper; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OrchardCore.ContentManagement; @@ -36,6 +40,7 @@ public class DefaultContactActivityBatchLoader : IActivityBatchLoader private readonly IOmnichannelActivityManager _activityManager; private readonly IStore _store; private readonly IDbConnectionAccessor _dbConnectionAccessor; + private readonly IServiceProvider _serviceProvider; private readonly ActivityBatchSourceOptions _sourceOptions; private readonly ILogger _logger; @@ -50,6 +55,7 @@ public class DefaultContactActivityBatchLoader : IActivityBatchLoader /// The activity manager. /// The store. /// The database connection accessor. + /// The service provider used to resolve optional dialer services. /// The configured activity batch sources. /// The logger. public DefaultContactActivityBatchLoader( @@ -61,6 +67,7 @@ public DefaultContactActivityBatchLoader( IOmnichannelActivityManager activityManager, IStore store, IDbConnectionAccessor dbConnectionAccessor, + IServiceProvider serviceProvider, IOptions sourceOptions, ILogger logger) { @@ -72,6 +79,7 @@ public DefaultContactActivityBatchLoader( _activityManager = activityManager; _store = store; _dbConnectionAccessor = dbConnectionAccessor; + _serviceProvider = serviceProvider; _sourceOptions = sourceOptions.Value; _logger = logger; } @@ -126,6 +134,55 @@ public virtual async Task LoadAsync(ActivityBatchLoadContext context, Cancellati return; } + DialerProfile dialerProfile = null; + + if (string.Equals(sourceEntry.Source, ActivitySources.Dialer, StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(batch.DialerProfileId)) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("A dialer profile is required before loading the dialer batch with ID '{BatchId}'.", batch.ItemId); + return; + } + + var dialerProfileManager = _serviceProvider.GetService(); + + if (dialerProfileManager is null) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("The Contact Center dialer services are not available for the dialer batch with ID '{BatchId}'.", batch.ItemId); + return; + } + + dialerProfile = await dialerProfileManager.FindByIdAsync(batch.DialerProfileId.Trim(), cancellationToken); + + if (dialerProfile is null) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("Unable to find the dialer profile '{DialerProfileId}' for the dialer batch with ID '{BatchId}'.", batch.DialerProfileId, batch.ItemId); + return; + } + + if (!string.Equals(flowSettings.Channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase)) + { + batch.Status = OmnichannelActivityBatchStatus.New; + + await _catalog.UpdateAsync(batch, cancellationToken); + + _logger.LogError("Dialer batches require a phone subject flow. Batch '{BatchId}' uses channel '{Channel}'.", batch.ItemId, flowSettings.Channel); + return; + } + } + long documentId = 0; DateTime? leadCreatedFrom = batch.LeadCreatedFrom.HasValue @@ -355,14 +412,26 @@ SELECT MAX(a2.{completedCol}) : null; var activity = await _activityManager.NewAsync(cancellationToken: cancellationToken); + var activitySource = sourceEntry.Source; + var campaignId = flowSettings.CampaignId; + var interactionType = flowSettings.InteractionType; + var aiProfileId = string.IsNullOrWhiteSpace(batch.AIProfileId) + ? flowSettings.ProfileId + : batch.AIProfileId; + + if (dialerProfile is not null) + { + activitySource = DialerActivitySourceHelper.GetActivitySource(dialerProfile.Mode); + campaignId = dialerProfile.CampaignId; + interactionType = ActivityInteractionType.Manual; + aiProfileId = null; + } activity.Kind = GetActivityKind(flowSettings.Channel); - activity.Source = sourceEntry.Source; - activity.InteractionType = flowSettings.InteractionType; + activity.Source = activitySource; + activity.InteractionType = interactionType; activity.Channel = flowSettings.Channel; - activity.AIProfileId = string.IsNullOrWhiteSpace(batch.AIProfileId) - ? flowSettings.ProfileId - : batch.AIProfileId; + activity.AIProfileId = aiProfileId; activity.ContactContentItemId = contact.ContentItemId; activity.ContactContentType = batch.ContactContentType; activity.SubjectContentType = batch.SubjectContentType; @@ -375,7 +444,7 @@ SELECT MAX(a2.{completedCol}) } activity.ChannelEndpointId = flowSettings.ChannelEndpointId; - activity.CampaignId = flowSettings.CampaignId; + activity.CampaignId = campaignId; activity.ScheduledUtc = scheduledUtc; if (user is not null) { diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index 7cbc6d438..d06e1e93d 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -160,13 +160,7 @@ public override void ConfigureServices(IServiceCollection services) entry.DisplayName = S["Automatic"]; entry.Description = S["Loads unassigned activities that AI automation processes through the configured subject flow."]; entry.RequiresUserAssignment = false; - }); - - options.AddSource(ActivitySources.Dialer, entry => - { - entry.DisplayName = S["Dialer"]; - entry.Description = S["Loads unassigned activities that dialers reserve and assign later."]; - entry.RequiresUserAssignment = false; + entry.ShowInCreationPicker = false; }); }); diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs index 652a58708..ecbfc0cd0 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/ViewModels/OmnichannelActivityBatchViewModel.cs @@ -53,6 +53,11 @@ public class OmnichannelActivityBatchViewModel /// public string AIProfileId { get; set; } + /// + /// Gets or sets the dialer profile identifier used by dialer activities. + /// + public string DialerProfileId { get; set; } + /// /// Gets or sets the instructions. /// @@ -159,6 +164,12 @@ public class OmnichannelActivityBatchViewModel [BindNever] public IEnumerable AIProfiles { get; set; } + /// + /// Gets or sets the available dialer profiles. + /// + [BindNever] + public IEnumerable DialerProfiles { get; set; } + /// /// Gets or sets the selected users. /// diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml index 09135b52c..0a87e61a2 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelActivityBatchFields.Edit.cshtml @@ -85,6 +85,19 @@
    } + @if (string.Equals(Model.Source, ActivitySources.Dialer, StringComparison.OrdinalIgnoreCase)) + { +
    + +
    + + + @T["Required. The selected dialer profile controls the outbound dialing mode, queue, and campaign assignment for the loaded activities."] +
    +
    + } +
    diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerActivitySourceHelperTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerActivitySourceHelperTests.cs new file mode 100644 index 000000000..5e8ee6acc --- /dev/null +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/DialerActivitySourceHelperTests.cs @@ -0,0 +1,25 @@ +using CrestApps.OrchardCore.ContactCenter.Core; +using CrestApps.OrchardCore.ContactCenter.Models; +using CrestApps.OrchardCore.Omnichannel.Core.Models; + +namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; + +public sealed class DialerActivitySourceHelperTests +{ + [Theory] + [InlineData(DialerMode.Manual, ActivitySources.PreviewDial)] + [InlineData(DialerMode.Preview, ActivitySources.PreviewDial)] + [InlineData(DialerMode.Power, ActivitySources.PowerDial)] + [InlineData(DialerMode.Progressive, ActivitySources.ProgressiveDial)] + [InlineData(DialerMode.Predictive, ActivitySources.PredictiveDial)] + public void GetActivitySource_WhenModeProvided_ReturnsExpectedSource( + DialerMode mode, + string expectedSource) + { + // Act + var actualSource = DialerActivitySourceHelper.GetActivitySource(mode); + + // Assert + Assert.Equal(expectedSource, actualSource); + } +} From 3a5d740a6a443c9cb9f56e6c8c3b9cbea25f33f0 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Tue, 7 Jul 2026 12:01:32 -0700 Subject: [PATCH 41/56] Add Asterisk --- .github/contact-center/PLAN.md | 22 +- CrestApps.OrchardCore.slnx | 1 + .../ReportsConstants.cs | 4 +- .../OmnichannelConstants.cs | 6 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 43 +- .../docs/contact-center/agent-desktop.md | 2 +- .../contact-center/agents-queues-dialer.md | 4 +- .../docs/contact-center/index.md | 2 +- .../docs/modules/content-transfer.md | 6 +- .../modules/phone-number-verifications.md | 8 +- src/CrestApps.Docs/docs/modules/reports.md | 8 +- src/CrestApps.Docs/docs/omnichannel/index.md | 2 +- .../docs/omnichannel/management.md | 28 +- src/CrestApps.Docs/docs/omnichannel/sms.md | 2 +- src/CrestApps.Docs/docs/telephony/asterisk.md | 118 +++++ src/CrestApps.Docs/docs/telephony/index.md | 4 +- .../AssemblyInfo.cs | 3 + .../AsteriskConstants.cs | 53 +++ .../CrestApps.OrchardCore.Asterisk.csproj | 40 ++ .../Drivers/AsteriskSettingsDisplayDriver.cs | 192 ++++++++ .../Manifest.cs | 24 + .../Models/AsteriskConnectionSettings.cs | 38 ++ .../Models/AsteriskSettings.cs | 17 + .../Models/DefaultAsteriskOptions.cs | 17 + .../AsteriskProviderOptionsConfigurations.cs | 61 +++ .../Services/AsteriskResolvedSettings.cs | 22 + .../Services/AsteriskSettingsUtilities.cs | 74 +++ .../Services/AsteriskTelephonyProvider.cs | 133 ++++++ .../Services/AsteriskTelephonyProviderBase.cs | 436 ++++++++++++++++++ .../DefaultAsteriskOptionsConfiguration.cs | 46 ++ .../DefaultAsteriskTelephonyProvider.cs | 54 +++ .../CrestApps.OrchardCore.Asterisk/Startup.cs | 42 ++ .../ViewModels/AsteriskSettingsViewModel.cs | 55 +++ .../Views/AsteriskSettings.Edit.cshtml | 101 ++++ .../Views/_ViewImports.cshtml | 10 + .../AdminMenu.cs | 4 +- .../Drivers/DialPadSettingsDisplayDriver.cs | 2 +- .../Controllers/ActivityBatchesController.cs | 18 +- .../OmnichannelActivityBatchDisplayDriver.cs | 8 +- .../Manifest.cs | 13 - .../Reports/OmnichannelReportBase.cs | 4 +- .../Services/AdminMenu.cs | 2 +- .../Startup.cs | 4 +- .../Views/ActivityBatches/Create.cshtml | 2 +- .../Views/ActivityBatches/Edit.cshtml | 2 +- .../Views/ActivityBatches/Index.cshtml | 10 +- ...OmnichannelActivityBatchFields.Edit.cshtml | 4 +- .../Controllers/ReportsController.cs | 109 ----- ...hardCore.PhoneNumbers.Verifications.csproj | 1 + .../Manifest.cs | 2 +- .../PhoneNumberVerificationReportProvider.cs | 145 ++++++ .../PhoneNumberVerificationsAdminMenu.cs | 15 +- .../Startup.cs | 14 + .../PhoneNumberVerificationReportViewModel.cs | 67 --- .../Views/Reports/Index.cshtml | 127 ----- .../Views/Reports/Display.cshtml | 24 +- .../Views/Reports/Index.cshtml | 6 +- .../Asterisk/ari.conf | 9 + .../Asterisk/extensions.conf | 7 + .../Asterisk/http.conf | 4 + .../CrestApps.Aspire.AppHost/Program.cs | 15 + ...stApps.OrchardCore.Cms.Core.Targets.csproj | 1 + .../CrestApps.OrchardCore.Tests.csproj | 1 + .../PhoneNumberVerificationsStartupTests.cs | 18 + ...eriskProviderOptionsConfigurationsTests.cs | 55 +++ .../AsteriskTelephonyProviderTests.cs | 102 ++++ 66 files changed, 2046 insertions(+), 427 deletions(-) create mode 100644 src/CrestApps.Docs/docs/telephony/asterisk.md create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/AssemblyInfo.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Drivers/AsteriskSettingsDisplayDriver.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Manifest.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskConnectionSettings.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskSettings.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Models/DefaultAsteriskOptions.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskProviderOptionsConfigurations.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskResolvedSettings.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskOptionsConfiguration.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskTelephonyProvider.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/ViewModels/AsteriskSettingsViewModel.cs create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Views/AsteriskSettings.Edit.cshtml create mode 100644 src/Modules/CrestApps.OrchardCore.Asterisk/Views/_ViewImports.cshtml delete mode 100644 src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Controllers/ReportsController.cs create mode 100644 src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Reports/PhoneNumberVerificationReportProvider.cs delete mode 100644 src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/ViewModels/PhoneNumberVerificationReportViewModel.cs delete mode 100644 src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Views/Reports/Index.cshtml create mode 100644 src/Startup/CrestApps.Aspire.AppHost/Asterisk/ari.conf create mode 100644 src/Startup/CrestApps.Aspire.AppHost/Asterisk/extensions.conf create mode 100644 src/Startup/CrestApps.Aspire.AppHost/Asterisk/http.conf create mode 100644 tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskProviderOptionsConfigurationsTests.cs create mode 100644 tests/CrestApps.OrchardCore.Tests/Telephony/AsteriskTelephonyProviderTests.cs diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 0033cb8d1..7fcc45515 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -35,7 +35,7 @@ Design implication: Telephony must remain the media and provider execution layer - `src\Core\CrestApps.OrchardCore.Omnichannel.Core` owns shared CRM communication models: - contacts via `OmnichannelContactPart` - activities via `OmnichannelActivity` - - activity batches via `OmnichannelActivityBatch` + - inventory loading via `OmnichannelActivityBatch` - campaigns via `OmnichannelCampaign` - dispositions via `OmnichannelDisposition` - channel endpoints via `OmnichannelChannelEndpoint` @@ -58,8 +58,9 @@ Required CRM alignment: - Activities are the only business work items. They may be created with `AssignedToId = null` so dialers and routing can dynamically reserve and assign ownership later. - Activities need Contact Center assignment metadata (for example assignment status, reservation id, reserved-by actor, reservation timestamps, and reservation expiry) so multiple dialer or routing instances cannot claim the same work. - Activities need classification metadata such as kind (call, email, SMS, meeting, task) and source (manual, preview dial, power dial, progressive dial, predictive dial, callback, inbound, workflow, API). Workflows must ignore source and react only to the activity and final disposition. -- Activity batches select an activity source before showing the editor. Manual batches require user assignment while loading; dialer batches hide user selection and load unassigned activities with an available assignment status so the dialer can reserve and assign them later. +- Load Inventory selects an activity source before showing the editor. Manual inventory loads require user assignment while loading; dialer inventory loads hide user selection and load unassigned activities with an available assignment status so the dialer can reserve and assign them later. - Dispositions belong to activities, not interactions. Provider, agent, AI, and workflow outcomes must converge through a single activity disposition service before subject actions/workflows run. +- Dialer and voice services classify technical attempt outcomes first (for example no answer, busy, disconnected number, rejected, failed, voicemail). Subject-owned workflow then decides the business meaning by mapping those system outcomes to dispositions or retry/callback behavior. Dialer profiles never own business workflow. - Contact Center must provide CRM-integrated agent surfaces where agents receive work. Queue/campaign sign-in, sign-out, and presence belong in the Telephony soft phone through display-driver tabs; broader desktop surfaces handle offers, active activity context, interaction history, wrap-up, required disposition, and supervisor/AI assistance from the CRM UI. - PBX/voice providers that can do more than soft-phone call control may implement Contact Center voice-provider abstractions for dialer dialing, call assignment, provider-side queues, queue events, and PBX presence synchronization. @@ -90,7 +91,7 @@ Design implication: Contact Center should add its own real-time event stream for 1. Activity first for work: `OmnichannelActivity` is the universal CRM work item for queues, dialers, wrap-up, dispositions, and workflows. 2. Interaction first for communication history: every voice call, chat, SMS, email, or future channel attempt is an Interaction linked to an Activity; an Interaction never owns workflow or disposition. -3. Source-driven activity loading: Activity batches are the common activity-loading surface and support source-specific UI/behavior for manual, dialer, callback, inbound, API, and future sources through Orchard display drivers and source options. +3. Source-driven activity loading: Load Inventory is the common activity-loading surface and supports source-specific UI/behavior for manual, dialer, callback, inbound, API, and future sources through Orchard display drivers and source options. 4. Contact Center owns orchestration: routing, queues, reservations, presence, dialer pacing, wrap-up, lifecycle, and operational metrics. 5. Telephony owns media execution: providers dial, answer, transfer, hang up, hold, resume, conference, and report provider call state. 6. Domain events connect components: components publish and subscribe to events instead of directly invoking each other’s internal workflows. @@ -800,7 +801,7 @@ WorkCompleted -> CallbackScheduled -> Queued ## Outbound dialing sequence ```text -1. CRM campaign/activity batch produces eligible phone activities. +1. CRM campaign/inventory load produces eligible phone activities. 2. Campaign Dialer selects eligible activities using schedule, timezone, DNC/compliance, retry, and priority rules. 3. Dialer mode determines whether an agent previews first or the system reserves an agent before dialing. 4. Routing Engine reserves an eligible agent and capacity. @@ -810,8 +811,8 @@ WorkCompleted -> CallbackScheduled -> Queued 8. Call Session Management maps provider call id to Interaction. 9. Agent desktop receives real-time dial/call state updates. 10. Outcome is classified: connected, no answer, busy, failed, canceled, voicemail, callback, or completed. -11. Wrap-Up enforces disposition and notes when agent-handled. -12. CRM Activity, retry policy, callback schedule, and campaign metrics update. +11. Agent-handled calls use normal wrap-up/disposition selection; unattended/system-ended calls use subject-level system outcome mappings to choose the disposition or retry/callback path. +12. CRM Activity, retry policy, callback schedule, subject actions, and campaign metrics update. 13. Agent capacity is released or the next dialer reservation begins. ``` @@ -1020,7 +1021,7 @@ Goals: Deliverables: -- Dialer profiles as execution policies over CRM campaign/activity inventory, not as a replacement for campaigns, subjects, activity batches, or activity configuration. +- Dialer profiles as execution policies over CRM campaign/activity inventory, not as a replacement for campaigns, subjects, inventory loads, or activity configuration. - Dialer run and attempt projections. - Preview dialing agent UX. - Power dialing service. @@ -1467,7 +1468,7 @@ Keep this section current. Use the checklist below to track phase-level progress - [x] **Phase 1 — Domain foundation** (CRM activity extension, interaction history, event log, base module) - [x] `CrestApps.OrchardCore.ContactCenter.Abstractions` (constants, channel/direction/status/priority/role enums, event vocabulary) - [x] `OmnichannelActivity` extended with activity kind/source, assignment status, and reservation metadata so CRM activities remain the universal work item - - [x] Activity Batch UI changed to source-first creation with Manual and Dialer sources; Dialer batches load unassigned activities for later reservation + - [x] Load Inventory UI changed to source-first creation with Manual and Dialer sources; Dialer inventory loads load unassigned activities for later reservation - [x] `IActivityDispositionService` contract added as the source-neutral path for activity dispositions - [x] `CrestApps.OrchardCore.ContactCenter.Core` (Interaction + InteractionParticipant + InteractionEvent models, indexes, stores, `IInteractionManager`, event publisher, permissions) - [x] `CrestApps.OrchardCore.ContactCenter` base module (Startup, index providers, migrations, permission provider) @@ -1511,7 +1512,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - Plan reviewed and expanded to add inbound entry points/IVR, call recording, live monitoring (silent monitor/whisper/barge/take-over), outbound compliance hardening, quality management, a standard terminology/metrics glossary, scale-out/high-availability, data retention/privacy, a testing strategy, and a migration strategy. Phases renumbered to 0-14. - Phase 0 started: promoted the session plan to this durable repo-tracked document, added the copilot-instructions pointer, and created the public Contact Center docs landing page. - Phase 0 completed and Phase 1 (Domain foundation) implemented: added the `ContactCenter.Abstractions`, `ContactCenter.Core`, and `ContactCenter` base module projects; extended `OmnichannelActivity` with kind/source/assignment/reservation metadata so CRM activities remain the universal work item; made `Interaction` an Orchard `Entity` communication-history record linked to activities; added the durable `InteractionEvent` log with idempotency and the `DefaultContactCenterEventPublisher`; added the `IActivityDispositionService` contract; registered everything in `.slnx` and the `Cms.Core.Targets` bundle; added 13 unit tests; and documented the feature on the docs landing page and the `v2.0.0` changelog. The base feature is headless by design — all future CRUD/agent/supervisor UI must use Display Management, display drivers, shapes, placement, and AI Profile-style catalog screens. Next: Phase 2 (agents, presence, activity queues, reservations). -- Activity Batch source selection implemented: **Add Activity Batch** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual batches keep the selected-user assignment flow. Dialer batches hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. +- Load Inventory source selection implemented: **Add Inventory Load** now opens a source modal like AI Provider Connections, sources are registered through `ActivityBatchSourceOptions`, and source cards use shape alternates. Manual inventory loads keep the selected-user assignment flow. Dialer inventory loads hide the user selector and load activities as unassigned `Available` dialer inventory for later reservation by dialer/routing services. - Phase 2 implemented and Phases 3/5 started: added `Agents`, `Queues`, and `Dialer` features in the ContactCenter module. Core now has AgentProfile/ActivityQueue/QueueItem/ActivityReservation/DialerProfile models, indexes, stores, and managers, plus presence/queue/reservation/assignment/dialer orchestration services. Assignment pairs the highest-priority waiting item with the longest-idle available agent (Phase 3 core); reservations expire via background task. Outbound dialing initially used `IDialerProvider`/`IDialerProviderResolver`, power/progressive pacing, dialer batch sources, and a `DialPad.Dialer` provider; this was later corrected so outbound voice calls route through `IVoiceContactCenterCallRouter` and `IContactCenterVoiceProvider`. Added admin CRUD for queues/dialer profiles, agent/queue/dialer permissions, and 7 new unit tests (20 total pass). Docs + `v2.0.0` changelog updated. Next: skills/sticky/business-hours routing (Phase 3), voice integration (Phase 4), retry/callback/suppression (Phase 5), wrap-up (Phase 6). - 2026-06-29: Phase 3 routing advanced with an extensible `IActivityRoutingStrategy` pipeline, required-skills eligibility, longest-idle scoring, and `RoutingDecisionMade` audit events that capture candidate scores and reasons. Contact Center voice-provider resolution was added through `IContactCenterVoiceProviderResolver`; outbound dialer failure paths now cancel reservations and enforce max-attempt boundaries; inbound offer failures release reservations immediately; agent sign-in clears stale reservations and serializes profile creation. Queue and dialer admin UI now uses Orchard `ocat-*` layout and exposes routing skills, inbound endpoint mapping, and retry/do-not-call settings. Added routing/dialer/reservation tests and updated docs/changelog. Next: sticky-agent and business-hours routing, then wrap-up timers and required-disposition policies before Phase 7 real-time desktop work. - Phase 4 inbound voice MVP + Phase 6 disposition unification implemented. **Telephony:** added an incoming-call extensibility contract (`IncomingCallCard`/`IncomingCallContext`/`IncomingCallContributionContext`, `IIncomingCallContextProvider`, `IIncomingCallDispatcher`), implemented `DefaultIncomingCallDispatcher` over `IHubContext` (`Clients.User`), added a capability-gated voicemail operation (`ITelephonyProvider.SendToVoicemailAsync` + `TelephonyCapabilities.Voicemail`, implemented in DialPad), a `Voicemail` hub method, and a soft-phone incoming-call modal (Answer/Voicemail/Ignore + contributed contact cards with an Answer-and-open shortcut and accept/decline lifecycle posts). **Contact Center:** added the `Voice` feature with `InboundVoiceEvent` and the `VoiceContactCenterCallRouter`/`IInboundVoiceService` compatibility seam (resolves endpoint→subject flow→queue, looks up the contact by caller number, creates the inbound `OmnichannelActivity` + Subject + voice `Interaction`, enqueues, reserves the longest-idle available agent, and offers the call via the dispatcher; re-offers on decline), `ContactCenterIncomingCallContextProvider` (lists matched customers + wires accept/decline URLs), `IInboundContactLookup`, a provider-agnostic ingress endpoint (`POST /api/contact-center/voice/inbound`), an offer-lifecycle `VoiceController`, and an optional `ActivityQueue.InboundChannelEndpointId` mapping. **Disposition unification:** implemented `DefaultActivityDispositionService` (the previously contract-only `IActivityDispositionService`) and routed `ActivitiesController.Complete` through it so inbound and outbound activities disposition through the same subject workflow. Added 5 unit tests (25 ContactCenter tests pass); full solution builds clean with `-warnaserror`. Docs (`telephony/index.md`, `contact-center/index.md`) and the `v2.0.0` changelog updated. Next: transfer/conference taxonomy + standalone call-session aggregate (Phase 4), wrap-up timers + required-disposition policies (Phase 6), agent desktop/supervisor real-time UX (Phase 7). @@ -1544,4 +1545,5 @@ Ordered by the "Design review" execution order. Each item is a hard requirement - 2026-07-01: **PR review security hardening.** Addressed the CodeQL logging findings on provider voice webhooks by logging the matched adapter's registered provider technical name instead of the webhook-supplied provider route value when signature validation or idempotency-key validation fails. - 2026-07-02: **Phase 12 Reports UI shipped — the "Reports" tab (Analytics feature).** Added the new `CrestApps.OrchardCore.ContactCenter.Analytics` feature (depends on `Queues`) that surfaces an **Interaction Center → Reports** admin area (`ContactCenterReportsAdminMenu`, nested Reports group) with six pages served by `ReportsController` (`[Feature(Analytics)]`, `[Admin]`): **Overview**, **Call insights**, **Agent productivity**, **Queue usage**, **Campaign summary**, and **Subject inventory**. Each page shares a `yyyy-MM-dd` date-range filter (default last 30 days) and a CSV export (`ReportCsvBuilder`, UTF-8 BOM). **Core:** added `IContactCenterReportingService`/`ContactCenterReportingService` (Core) that aggregates the durable interaction history and the CRM `OmnichannelActivityIndex` inventory into strongly-typed report models under `Models/Reports` (`CallInsightsReport` with per-day trend + channel/status breakdowns + answered/abandoned/failed + AHT/ASA/talk time; `AgentProductivityReport` per-agent handled/talk-time/completed-activities; `QueueUsageReport` per-queue handled/answered/abandoned/AHT/ASA + live waiting depth + SLA threshold; `CampaignSummaryReport` and `SubjectInventoryReport` bucketing each group into Completed/Pending/InProgress/Failed/Cancelled + attempts + completion rate — i.e. "what is completed vs pending" per campaign and per subject). The heavy aggregation lives in `internal static` builders so it is unit-testable without a live session. Added the `ViewContactCenterReports` permission (granted to Administrator + the built-in Supervisor role). **Tests:** +6 reporting unit tests (call insights totals/handle-time, daily grouping, agent productivity aggregation, queue usage + waiting, campaign completed-vs-pending bucketing, subject grouping) — **all ContactCenter tests pass**; clean `-warnaserror` Release build of `ContactCenter.Core` and the module. Docs (`contact-center/index.md` new "Reports and analytics" section, `agents-queues-dialer.md` feature table + recipe, `v2.0.0` changelog "Reports & Analytics UI") updated. Also verified DialPad completeness as the phone provider: outbound dial + inbound signed webhook routing + `CallTransfer` are shipped (`AgentDeviceNative` delivery); provider media execution of recording/monitoring remains a Phase 9 roadmap item, not a dialing-path gap. **Remaining Phase 12:** SLA/adherence trend snapshots and operational alerts. - 2026-07-02: **Reports generalized into a reusable framework + industry-standard CRM reports (maintainer direction).** Per the maintainer's request for a reusable reports structure, a top-level Reports tab, extensible (display-driver) filters with a from/to range, exports, and CRM reports, the Contact Center-specific reports UI was replaced by a shared framework. **New `CrestApps.OrchardCore.Reports` module** (+ `CrestApps.OrchardCore.Reports.Abstractions`): `IReport` (name/category/permission/`RunAsync`→`ReportDocument`), a display-driver-extensible `ReportFilter` with a built-in from/to date-range driver, a uniform `ReportDocument` (metric-card / table / bar sections, with emphasized totals rows for aggregated reports), a generic `ReportsController` (Index landing + `Display(id)` + `Export(id, format)`), a top-level **Reports** admin menu (`ReportsAdminMenu`) that groups registered reports by category and gates each by its own permission, an `IReportManager` registry, and a pluggable `IReportExportFormat` with a built-in `CsvReportExportFormat`. Registered in `.slnx` and the `Cms.Core.Targets` bundle. **Contact Center migration:** deleted the CC-specific `ReportsController`, `ContactCenterReportsAdminMenu`, `ReportCsvBuilder`, `ReportFormat`, report `ViewModels`, and `Views/Reports`; kept `IContactCenterReportingService` + its DTOs (and the 6 tests) and added five thin `IReport` adapters (`*ReportProvider`) that map the DTOs to `ReportDocument`. The Analytics feature now depends on the Reports feature and registers the adapters. **Omnichannel CRM reports:** added an **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) in the Managements module with `OmnichannelReportAggregator` (pure, tested) + `OmnichannelReportQuery` and three `IReport`s — **Activity summary**, **Campaign performance**, and **Disposition breakdown** — plus a `ViewOmnichannelReports` permission (implied by `ManageActivities`). **Validation:** clean full-solution `-warnaserror` build; 226 ContactCenter/Omnichannel/Report tests pass (+3 new Omnichannel aggregator tests); and a live end-to-end smoke test confirmed the top-level **Reports** menu, all eight report pages (5 CC + 3 CRM) rendering with the from/to filter, and CSV export, on a fresh tenant. Docs: new `modules/reports.md`, updated `modules/index.md`, `contact-center/index.md`, `omnichannel/index.md`, and the `v2.0.0` changelog. **Remaining:** per-agent self-service report scoping, additional export formats (Excel/PDF), and SLA/adherence trend snapshots. -- 2026-07-06: Fixed the default Debug solution build by re-enabling `CrestApps.OrchardCore.Reports.Abstractions` and `CrestApps.OrchardCore.Reports` in `CrestApps.OrchardCore.slnx`, which removed the 45 reporting-related errors from plain `dotnet build`. Activity batch creation was also tightened back to the Contact Center design: the picker now exposes only **Manual** and **Dialer** sources, dialer batches require a dialer profile, and the default batch loader applies the selected profile's dialing mode and campaign to the created activities while leaving them unassigned for later dialer reservation. Legacy hidden source registrations remain available so older batches do not lose their source metadata. +- 2026-07-06: Fixed the default Debug solution build by re-enabling `CrestApps.OrchardCore.Reports.Abstractions` and `CrestApps.OrchardCore.Reports` in `CrestApps.OrchardCore.slnx`, which removed the 45 reporting-related errors from plain `dotnet build`. Load Inventory was also tightened back to the Contact Center design: the picker now exposes only **Manual** and **Dialer** sources, dialer inventory loads require a dialer profile, and the default batch loader applies the selected profile's dialing mode and campaign to the created activities while leaving them unassigned for later dialer reservation. Legacy hidden source registrations remain available so older inventory loads do not lose their source metadata. +- 2026-07-06: Terminology was aligned again by renaming the Omnichannel Management UI/docs label from **Contact Lists** to **Load Inventory** while keeping the internal `OmnichannelActivityBatch` model/API stable for compatibility. The Contact Center plan also records the dialer workflow decision: technical call outcomes (no answer, busy, disconnected, rejected, failed, voicemail, and similar provider states) must be classified by the dialer/voice layer and then mapped at the Subject level into business dispositions or retry/callback actions; the dialer profile remains an execution policy, not a workflow owner. diff --git a/CrestApps.OrchardCore.slnx b/CrestApps.OrchardCore.slnx index c8cba85ac..ab749312c 100644 --- a/CrestApps.OrchardCore.slnx +++ b/CrestApps.OrchardCore.slnx @@ -64,6 +64,7 @@ + diff --git a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs index 622394457..6b1357522 100644 --- a/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs +++ b/src/Abstractions/CrestApps.OrchardCore.Reports.Abstractions/ReportsConstants.cs @@ -31,9 +31,9 @@ public static class ReportsConstants public static class Categories { /// - /// The customer relationship management report category. + /// The Omnichannel report category. /// - public const string Crm = "CRM"; + public const string Omnichannel = "Omnichannel"; /// /// The contact center report category. diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs index 51252eb65..1db1a7535 100644 --- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs +++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/OmnichannelConstants.cs @@ -99,10 +99,6 @@ public static class Features public const string Managements = "CrestApps.OrchardCore.Omnichannel.Managements"; - /// - /// The identifier of the optional Omnichannel CRM reports feature. - /// - public const string Reports = "CrestApps.OrchardCore.Omnichannel.Reports"; } /// @@ -166,7 +162,7 @@ public static class Permissions public readonly static Permission ManageSubjectFlows = new("ManageSubjectFlows", "Manage subject flows"); /// - /// Gets the permission to view the Omnichannel CRM reports. + /// Gets the permission to view the Omnichannel reports. /// public readonly static Permission ViewReports = new("ViewOmnichannelReports", "View Omnichannel reports", [ManageActivities]); } diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index 8cb96fca0..3779947aa 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` @@ -186,8 +186,15 @@ At a high level, the platform changes are: #### 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 + +- 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 `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 activity batches. 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.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.Dialer` 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. @@ -288,7 +295,7 @@ At a high level, the platform changes are: - 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`). -- Adds an **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) that contributes CRM reports to the same area: **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**. +- **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) @@ -409,20 +416,20 @@ At a high level, the platform changes are: - **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 **Configure** 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 only show subjects whose flows are fully configured, and activities and activity batches now resolve `CampaignId`, channel, interaction type, and channel endpoint from the selected subject flow. -- 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 batches now use a source-selection modal before creation. Manual batches keep the existing selected-user assignment flow, while dialer batches hide the user selector and load activities as unassigned, available dialer work for later reservation. -- Automatic activity batches now load unassigned AI-ready activities, and automated subject flows require a chat AI profile with **Add initial prompt** enabled so the first outbound message can start the conversation reliably. -- Automatic activity batches can now 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. -- Activity batch sources are registered through `ActivityBatchSourceOptions`, allowing additional modules to add source-specific creation cards and display-driver-driven editor sections. -- Activity batch 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 batch to the `New` state so it can be retried. +- Subject pickers only show subjects whose flows are fully configured, and activities and inventory loads now resolve `CampaignId`, channel, interaction type, and channel endpoint from the selected subject flow. +- Omnichannel dispositions now use unique immutable names after creation, the delete action is wired correctly, inventory loads no longer ask for a campaign, and user pickers now use the named user-search route instead of hard-coded admin API paths. +- Load Inventory now uses a source-selection modal before creation. Manual inventory loads keep the existing selected-user assignment flow, while dialer inventory loads hide the user selector and load activities as unassigned, available dialer work for later reservation. +- Automatic inventory loads now load unassigned AI-ready activities, and automated subject flows require a chat AI profile with **Add initial prompt** enabled so the first outbound message can start the conversation reliably. +- Automatic inventory loads can now 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. +- 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. -- Activity batch creation now shows only **Manual** and **Dialer** sources. Dialer batches require a dialer profile, and the loader applies that profile's dialing mode and campaign to the created activities while keeping the activities unassigned for later reservation. +- Load Inventory now shows only **Manual** and **Dialer** sources. Dialer inventory loads require a dialer profile, and the loader applies that profile's dialing mode and campaign to the created activities while keeping the activities unassigned for later reservation. - 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 bulk actions against selected or filtered activities. - 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. -- 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 Activity Batch time-zone multiselects now load the registered `crestapps-bootstrap-select` script/style pair so only one selector is visible. +- 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. #### SMS @@ -452,7 +459,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 @@ -561,6 +568,8 @@ At a high level, the platform changes are: - The shared Reports contracts now live in `CrestApps.OrchardCore.Reports.Abstractions`, including `IReportManager` and `IReportExportManager`, so consumers can depend on the reporting service contracts without referencing the Orchard module assembly. - The default non-Orchard-specific Reports implementations now live in `CrestApps.OrchardCore.Reports.Core`, while Orchard-specific pieces such as the admin menu, controller, views, and display-driver filter UI remain in the `CrestApps.OrchardCore.Reports` module. - Added `CrestApps.OrchardCore.Reports.OpenXml`, an optional add-on module that registers an Excel (`.xlsx`) `IReportExportFormat` using `DocumentFormat.OpenXml` so report pages can export the current filter to CSV or Excel. +- Report pages now collapse multiple enabled exporters into a single **Export** dropdown instead of rendering one button per format, while keeping the single-button experience when only one exporter is available. +- Phone Number Verifications now contributes its report directly under **Reports** without the old CRM grouping, and the Omnichannel report group is now labeled **Omnichannel**. #### DialPad provider @@ -572,6 +581,14 @@ At a high level, the platform changes are: - The DialPad provider now resolves a named HTTP client configured by startup with standard ASP.NET Core HTTP resiliency policies for DialPad REST API and OAuth token requests. - The **DialPad Contact Center Voice** feature now handles inbound calls end to end. A new `DialPadWebhookController` at `POST /api/dialpad/webhook/call` receives DialPad call-event webhooks, validated by a configurable **Webhook signing secret** (DialPad signs the payload as an HS256 JWT; validated without any external JWT dependency, with unsigned JSON accepted only when no secret is set). New inbound DialPad calls are normalized into an `InboundVoiceEvent` and routed through the Voice Contact Center Call Router — creating a CRM activity and voice interaction, resolving the entry point/queue, and offering the call to an available agent — while subsequent state events (ringing, connected, held, ended) are normalized into `ProviderVoiceEvent`s that update the interaction and call session. Together with the existing outbound dialing, DialPad can now serve as the complete phone provider for the Contact Center. The DialPad voice provider also advertises the `CallTransfer` capability. +#### Asterisk provider + +- `v2.0.0` adds the `CrestApps.OrchardCore.Asterisk` module, which integrates Asterisk through the Asterisk REST Interface (ARI) and contributes an **Asterisk** tab to the telephony settings. +- The module now registers two selectable telephony providers: **Asterisk**, which is enabled and configured per tenant from site settings, and **Default Asterisk**, which is exposed automatically when the host supplies `OrchardCore:CrestApps:Asterisk:Default` configuration through appsettings or environment variables. +- Tenant-configured Asterisk settings store the ARI password encrypted through the data protection provider and capture the ARI base URL, ARI user name, Stasis application name, optional endpoint template, optional outbound caller ID, and dial timeout. Enabling or disabling the tenant provider updates the default telephony provider the same way the DialPad integration does. +- The Asterisk provider currently supports outbound dialing, hang up, hold/resume, mute/unmute, call merge, and DTMF through ARI, using server-side HTTP basic authentication so the browser never receives ARI credentials. +- The Aspire AppHost now provisions a local `andrius/asterisk` container with development ARI and HTTP configuration and injects the **Default Asterisk** environment variables into the Orchard Core web project automatically, making the provider available for local tenant testing as soon as the feature is enabled. + ### Time Zones #### Time zone maps diff --git a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md index e565baa28..315595ba0 100644 --- a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md +++ b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md @@ -68,7 +68,7 @@ Use this checklist before publishing a new inbound line: Use CRM campaigns and activities as the source of outbound work; the dialer profile only controls execution. 1. Create the campaign and Subject Flow in Omnichannel. Configure dispositions and subject actions first so every outcome has a business result. -2. Load activities through an Activity Batch. Choose a dialer source for dialer inventory so activities are loaded unassigned and available for reservation. +2. Load activities through **Load Inventory**. Choose a dialer source for dialer inventory so activities are loaded unassigned and available for reservation. 3. Create a dialer profile that points to the campaign, queue, voice provider, dialing mode, pacing, and compliance settings. 4. Confirm do-not-call, retry delay, calling window, and national registry settings before enabling an automated mode. 5. For callbacks, schedule a callback request with the destination, due time, queue, and notes. The callback dispatcher promotes due callbacks into outbound callback activities and enqueues them when a queue is set. diff --git a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md index 5fc4ad49b..61f14e1f6 100644 --- a/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md +++ b/src/CrestApps.Docs/docs/contact-center/agents-queues-dialer.md @@ -13,7 +13,7 @@ This phase adds the operational core of the Contact Center: agent presence, work | --- | --- | --- | | Contact Center Agents | `CrestApps.OrchardCore.ContactCenter.Agents` | Agent profiles, presence, capacity, skills, and queue/campaign sign-in. | | Contact Center Queues | `CrestApps.OrchardCore.ContactCenter.Queues` | Managed skills, business-hours calendars, work queues, queue items, reservations, policy-based routing, and availability-based assignment. | -| Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Outbound profiles, pacing, and dialer activity batches routed through Contact Center Voice. | +| Contact Center Dialer | `CrestApps.OrchardCore.ContactCenter.Dialer` | Outbound profiles, pacing, and dialer inventory loads routed through Contact Center Voice. | | Contact Center Real-Time | `CrestApps.OrchardCore.ContactCenter.RealTime` | SignalR hub, live agent sessions with heartbeat and stale-session cleanup, and real-time presence, offer, and queue broadcasts. | | Contact Center Reports & Analytics | `CrestApps.OrchardCore.ContactCenter.Analytics` | Reports area under Interaction Center with call insights, agent productivity, queue usage, and campaign/subject progress reports plus CSV exports. | | DialPad Contact Center Voice | `CrestApps.OrchardCore.DialPad.Dialer` | DialPad implementation of the Contact Center voice provider boundary. | @@ -74,7 +74,7 @@ Assignment is concurrency-safe. Each queue's assignment runs under a per-queue d ## Dialer -A **dialer profile** is an execution policy, not the source of CRM work. Activities, campaigns, subjects, batches, dispositions, and contact context still come from Omnichannel. The profile tells the Contact Center how a specific outbound campaign should be dialed: which queue supplies agents, which dialing mode is used, which Contact Center voice provider places calls, how pacing works, and how attempts/retries and compliance are bounded. Power and progressive profiles run automatically each minute: the Contact Center reserves an available agent, evaluates the compliance gate, creates an outbound interaction, and asks the Voice Contact Center Call Router to place the call. Manual and preview profiles wait for agent action. Dialer activity batches load **unassigned** inventory the dialer reserves later. +A **dialer profile** is an execution policy, not the source of CRM work. Activities, campaigns, subjects, inventory definitions, dispositions, and contact context still come from Omnichannel. The profile tells the Contact Center how a specific outbound campaign should be dialed: which queue supplies agents, which dialing mode is used, which Contact Center voice provider places calls, how pacing works, and how attempts/retries and compliance are bounded. Power and progressive profiles run automatically each minute: the Contact Center reserves an available agent, evaluates the compliance gate, creates an outbound interaction, and asks the Voice Contact Center Call Router to place the call. Manual and preview profiles wait for agent action. Dialer inventory loads load **unassigned** inventory the dialer reserves later. ### Dialing modes and safety diff --git a/src/CrestApps.Docs/docs/contact-center/index.md b/src/CrestApps.Docs/docs/contact-center/index.md index fdcea7302..25b4a11dc 100644 --- a/src/CrestApps.Docs/docs/contact-center/index.md +++ b/src/CrestApps.Docs/docs/contact-center/index.md @@ -42,7 +42,7 @@ Contact Center extends `OmnichannelActivity` with metadata needed by queues and - Nullable ownership so preview, power, progressive, and predictive dialing can create activities before an agent is selected. - Activity kind and extensible source metadata so the same Activity model can represent calls, SMS, email, meetings, tasks, callbacks, inbound work, workflow-created work, API-created work, and dialer inventory. - Assignment and reservation metadata so multiple dialer or routing instances do not claim the same record concurrently. -- Activity batches can load either user-assigned manual work or unassigned dialer work. The batch creation dialog selects a source first, and dialer batches require a dialer profile so the loaded activities inherit the correct dialing mode and campaign. +- Load Inventory can load either user-assigned manual work or unassigned dialer work. The creation dialog selects a source first, and dialer inventory loads require a dialer profile so the loaded activities inherit the correct dialing mode and campaign. Dispositions are applied to Activities, not Interactions. Agent, provider, AI, workflow, and system outcomes converge through the activity disposition service before Subject Actions or workflow automation runs. diff --git a/src/CrestApps.Docs/docs/modules/content-transfer.md b/src/CrestApps.Docs/docs/modules/content-transfer.md index 9307abde5..affdf9a74 100644 --- a/src/CrestApps.Docs/docs/modules/content-transfer.md +++ b/src/CrestApps.Docs/docs/modules/content-transfer.md @@ -18,7 +18,7 @@ Bulk import and export Orchard Core content by using the enabled transfer file f 1. Enable **Content Transfer** under **Tools** -> **Features**. 2. Enable **Content Transfer (OpenXml)** when you also want Excel workbook (`.xlsx`) support. 3. Edit the content type only if you want to opt-out of transfer for that type. **Allow Bulk Import** and **Allow Bulk Export** are enabled by default. -4. Open **Content** -> **Bulk Import** or **Content** -> **Bulk Export**. +4. Open **Content** -> **Import** or **Content** -> **Export**. By default, content types appear in the import and export screens automatically. Set **Allow Bulk Import** or **Allow Bulk Export** to `false` on a content type when that type should opt out. @@ -35,7 +35,7 @@ Enable **Content Transfer (OpenXml)** feature to add Excel workbook support (`.x ## Bulk import -Use **Content** -> **Bulk Import** to upload a transfer file for a content type. +Use **Content** -> **Import** to upload a transfer file for a content type. 1. Select a content type. 2. Download the template if you need the expected column layout. @@ -59,7 +59,7 @@ The Omnichannel contact columns `DoNotCall`, `DoNotSms`, `DoNotEmail`, and `DoNo ## Bulk export -Use **Content** -> **Bulk Export** to export content items by using one of the enabled transfer formats. +Use **Content** -> **Export** to export content items by using one of the enabled transfer formats. Export supports: diff --git a/src/CrestApps.Docs/docs/modules/phone-number-verifications.md b/src/CrestApps.Docs/docs/modules/phone-number-verifications.md index d05248f54..47da2a27e 100644 --- a/src/CrestApps.Docs/docs/modules/phone-number-verifications.md +++ b/src/CrestApps.Docs/docs/modules/phone-number-verifications.md @@ -2,7 +2,7 @@ sidebar_label: Phone Number Verifications sidebar_position: 8 title: Phone Number Verifications -description: A provider-agnostic framework for verifying contact phone numbers, with pluggable providers, content-part storage, SQL indexing, background revalidation, and reporting. +description: A provider-agnostic framework for verifying contact phone numbers, with pluggable providers, content-part storage, SQL indexing, background revalidation, and shared Reports-module integration. --- | | | @@ -10,7 +10,7 @@ description: A provider-agnostic framework for verifying contact phone numbers, | **Feature Name** | Phone Number Verifications | | **Feature ID** | `CrestApps.OrchardCore.PhoneNumbers.Verifications` | -The **Phone Number Verifications** module provides a provider-agnostic framework for verifying phone numbers and storing the results directly on content items through a content part. It manages verification providers, content-part storage helpers, SQL indexing, reporting, and a background revalidation process. +The **Phone Number Verifications** module provides a provider-agnostic framework for verifying phone numbers and storing the results directly on content items through a content part. It manages verification providers, content-part storage helpers, SQL indexing, and a background revalidation process. When the shared [Reports](reports) module is enabled, this module also contributes its report automatically. The core feature does not depend on any external verification provider. Providers ship as separate features (for example, **AbstractAPI Phone Number Verification**, **Veriphone Phone Number Verification**, and **Twilio Phone Number Verification**) and are discovered dynamically, so adding a provider never requires changes to the core feature. @@ -36,7 +36,7 @@ The module ships with the following features: | Feature | Feature ID | Description | | --- | --- | --- | -| Phone Number Verifications | `CrestApps.OrchardCore.PhoneNumbers.Verifications` | The core framework, settings, content part, SQL index, automatic contact verification, reporting, and background revalidation. | +| Phone Number Verifications | `CrestApps.OrchardCore.PhoneNumbers.Verifications` | The core framework, settings, content part, SQL index, automatic contact verification, background revalidation, and automatic integration with the shared Reports area when `CrestApps.OrchardCore.Reports` is enabled. | | AbstractAPI Phone Number Verification | `CrestApps.OrchardCore.PhoneNumbers.Verifications.AbstractApi` | Verifies phone numbers using the [AbstractAPI Phone Validation](https://www.abstractapi.com/api/phone-validation-api) service. | | Veriphone Phone Number Verification | `CrestApps.OrchardCore.PhoneNumbers.Verifications.Veriphone` | Verifies phone numbers using the [Veriphone phone number validation API](https://veriphone.io/docs). | | Twilio Phone Number Verification | `CrestApps.OrchardCore.PhoneNumbers.Verifications.Twilio` | Verifies phone numbers using the [Twilio Lookup API](https://www.twilio.com/docs/lookup/v2-api). | @@ -160,7 +160,7 @@ External verification APIs are paid services, so the framework minimizes provide ## Reporting -A report dashboard is available under **Reports** -> **Phone Number Verifications** for users who have the `RunPhoneNumberVerificationsReport` permission. It surfaces operational metrics such as total contacts, verified and unverified numbers, invalid numbers, mobile/landline/VoIP counts, numbers pending verification, numbers requiring revalidation, verification success rate, verification failures, and provider usage counts. The reporting infrastructure is built on the SQL index and is extensible for future dashboard widgets. +Enable **Reports** (`CrestApps.OrchardCore.Reports`) alongside **Phone Number Verifications** to surface the report directly under **Reports** for users who have the `RunPhoneNumberVerificationsReport` permission. The report uses the shared Reports module renderer and export pipeline, and it surfaces operational metrics such as total contacts, verified and unverified numbers, invalid numbers, mobile/landline/VoIP counts, numbers pending verification, numbers requiring revalidation, verification success rate, verification failures, and provider usage counts. The reporting infrastructure is built on the SQL index and is extensible for future dashboard widgets. ![Phone number verifications report dashboard](/img/docs/phone-number-verifications-report.png) diff --git a/src/CrestApps.Docs/docs/modules/reports.md b/src/CrestApps.Docs/docs/modules/reports.md index 15cae56af..93c584ccc 100644 --- a/src/CrestApps.Docs/docs/modules/reports.md +++ b/src/CrestApps.Docs/docs/modules/reports.md @@ -10,14 +10,14 @@ description: A reusable reporting framework for OrchardCore with a shared admin | **Feature Name** | Reports | | **Feature ID** | `CrestApps.OrchardCore.Reports` | -The **Reports** module is a reusable reporting framework. It provides a single admin **Reports** area and a small contract that any module can implement to surface an industry-standard report — with a shared from/to date-range filter, extensible filters, a uniform renderer (metric cards, tables, and bars), and pluggable exports (CSV built in). Modules such as the [Contact Center](../contact-center/index.md) and [Omnichannel](../omnichannel/index.md) CRM contribute their reports through this framework so every report looks and behaves the same. +The **Reports** module is a reusable reporting framework. It provides a single admin **Reports** area and a small contract that any module can implement to surface an industry-standard report — with a shared from/to date-range filter, extensible filters, a uniform renderer (metric cards, tables, and bars), and pluggable exports (CSV built in). Modules such as the [Contact Center](../contact-center/index.md), [Omnichannel](../omnichannel/index.md), and [Phone Number Verifications](phone-number-verifications) contribute their reports through this framework so every report looks and behaves the same. | | | | --- | --- | | **Add-on Feature Name** | Reports (OpenXml) | | **Add-on Feature ID** | `CrestApps.OrchardCore.Reports.OpenXml` | -The optional **Reports (OpenXml)** add-on extends the Reports area with Excel workbook (`.xlsx`) exports using the `DocumentFormat.OpenXml` library. When the add-on is enabled, report pages show an additional export button alongside CSV. +The optional **Reports (OpenXml)** add-on extends the Reports area with Excel workbook (`.xlsx`) exports using the `DocumentFormat.OpenXml` library. When the add-on is enabled alongside other exporters, report pages collapse those formats into a single **Export** dropdown so operators can choose the file type they want. The implementation is split into three layers: @@ -34,7 +34,7 @@ The implementation is split into three layers: ## Reports area -Enabling the feature adds a top-level **Reports** item to the admin menu. Reports are grouped by their category, and each entry is gated by the report's own permission, so a user only sees the reports they are allowed to run. Selecting a report opens a page with the filter form, the rendered document, and one export button per enabled format so the current filter can be downloaded as CSV and, when the add-on is enabled, Excel (`.xlsx`). +Enabling the feature adds a top-level **Reports** item to the admin menu. Reports with a category are grouped under that heading, while uncategorized reports appear directly under **Reports**. Each entry is gated by the report's own permission, so a user only sees the reports they are allowed to run. Selecting a report opens a page with the filter form, the rendered document, and export actions for the current filter. A single enabled exporter renders as a normal button, while multiple enabled exporters render as an **Export** dropdown that can download CSV and, when the add-on is enabled, Excel (`.xlsx`). ## Extensible filters @@ -98,4 +98,4 @@ services.AddScoped(); } ``` -Enable `CrestApps.OrchardCore.Reports.OpenXml` only when you want Excel workbook exports. The base Reports feature is enabled automatically when you enable a feature that contributes reports, such as **Contact Center Reports & Analytics** or **Omnichannel Reports**. +Enable `CrestApps.OrchardCore.Reports.OpenXml` only when you want Excel workbook exports. The base Reports feature is enabled automatically when you enable **Contact Center Reports & Analytics**. Enabled modules such as **Omnichannel Management** and **Phone Number Verifications** also contribute their reports automatically once `CrestApps.OrchardCore.Reports` is enabled. diff --git a/src/CrestApps.Docs/docs/omnichannel/index.md b/src/CrestApps.Docs/docs/omnichannel/index.md index 1c5dad9f9..70568ee96 100644 --- a/src/CrestApps.Docs/docs/omnichannel/index.md +++ b/src/CrestApps.Docs/docs/omnichannel/index.md @@ -49,7 +49,7 @@ Provider-specific integrations can forward inbound events into this endpoint. ## Reports -When the **Omnichannel Reports** feature (`CrestApps.OrchardCore.Omnichannel.Reports`) is enabled, CRM +When **Omnichannel Management** and the shared **Reports** feature (`CrestApps.OrchardCore.Reports`) are enabled, CRM reports are contributed to the reusable [Reports](../modules/reports.md) framework and appear under the top-level admin **Reports** menu (grouped under **CRM**). Each report shares the standard from/to date-range filter and a CSV export. diff --git a/src/CrestApps.Docs/docs/omnichannel/management.md b/src/CrestApps.Docs/docs/omnichannel/management.md index 2420a54d9..25f3f4084 100644 --- a/src/CrestApps.Docs/docs/omnichannel/management.md +++ b/src/CrestApps.Docs/docs/omnichannel/management.md @@ -83,10 +83,10 @@ When an activity is completed, the user selects a disposition and is shown a pre Editing an already completed activity does **not** re-run workflow logic. Administrators can correct the saved disposition or notes without creating retry or follow-up activities. -### Activity Batch -An **Activity Batch** defines filters to find contacts and then **loads activities in the background**. +### Load Inventory +A **Load Inventory** definition stores filters to find contacts and then **loads activities in the background**. -The batch loader runs as a background process to avoid overloading the system and to allow loading large contact lists safely. +The loader runs as a background process to avoid overloading the system and to allow loading large inventory sets safely. ## Getting started (recommended order) @@ -175,7 +175,7 @@ After creating your subject content types and campaigns, go to `Interaction Cent 6. If the AI feature is enabled, automated subject flows also expose the AI profile, subject goal, update permissions, no-response timeout, response delay, and opt-out keyword fields. 7. Save the subject flow. -Subjects are only considered **configured** after the flow has the required campaign, channel, and interaction settings (plus a channel endpoint and AI profile for automated flows). Activity creation, batch loading, and subject-selection UIs only allow configured subjects because the subject flow now supplies the campaign and runtime channel settings used by each activity. +Subjects are only considered **configured** after the flow has the required campaign, channel, and interaction settings (plus a channel endpoint and AI profile for automated flows). Activity creation, inventory loading, and subject-selection UIs only allow configured subjects because the subject flow now supplies the campaign and runtime channel settings used by each activity. ### 7) Manage Flow @@ -197,25 +197,25 @@ After saving the subject flow, click **Manage Flow** from the `Subject Flows` li Subjects without any actions show a **Missing flow** badge in the Subject Flows list so you can find incomplete setups quickly. -### 8) Create and Load an Activity Batch +### 8) Create and Load Inventory -1. Go to `Interaction Center` → `Activity Batches`. -2. Click **Add Activity Batch** and choose a source: +1. Go to `Interaction Center` → `Load Inventory`. +2. Click **Add Inventory Load** and choose a source: - **Manual** loads activities assigned to the selected users immediately. - - **Dialer** loads unassigned activities for outbound dialing and requires a dialer profile when the batch is created. -3. Create the new batch: + - **Dialer** loads unassigned activities for outbound dialing and requires a dialer profile when the inventory load is created. +3. Create the inventory load: - Select contact type - Select subject type - - For **Dialer** batches, select the required dialer profile that controls the dialing mode, queue, and campaign assignment. + - For **Dialer** inventory loads, select the required dialer profile that controls the dialing mode, queue, and campaign assignment. - Assign users when the selected source requires assignment. - Optionally set lead created range, phone number, time zone, and last activity filters 4. Click `Load`. -The batch runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the channel and channel endpoint. Manual batches assign each created activity to a selected user. Dialer batches require a phone subject flow, leave activities unassigned with assignment status `Available`, and apply the selected dialer profile so the created activities inherit the profile's dialing mode and campaign before dialers reserve them later. +The inventory load runs in the background and loads activities incrementally. Loaded activities use the selected subject's flow configuration to resolve the channel and channel endpoint. Manual inventory loads assign each created activity to a selected user. Dialer inventory loads require a phone subject flow, leave activities unassigned with assignment status `Available`, and apply the selected dialer profile so the created activities inherit the profile's dialing mode and campaign before dialers reserve them later. -### Extending activity batch sources +### Extending inventory load sources -Activity batch loading is extensible. Each batch has a **source**, and the source controls how the batch resolves and loads activities. There are two layers of extensibility: +Inventory loading is extensible. Each inventory load has a **source**, and the source controls how it resolves and loads activities. There are two layers of extensibility: 1. **Registering a source** — register sources through `ActivityBatchSourceOptions` in a feature `Startup`. Each `ActivityBatchSourceEntry` provides the display name, description, whether the source requires user assignment, and whether it should appear in the creation picker. Display drivers can add source-specific editor sections. @@ -225,7 +225,7 @@ Activity batch loading is extensible. Each batch has a **source**, and the sourc services.AddScoped(); ``` -When a batch is loaded, the `IActivityBatchLoadCoordinator` transitions the batch to the loading state, resolves the loader whose `Source` matches the batch source, and delegates to it. Sources **without** a dedicated loader fall back to the built-in `DefaultContactActivityBatchLoader`, which pages over contacts of the batch contact content type, applies the standard lead filters (created range, phone number, time zone, last completed activity), and creates activities from the subject flow settings. The default loader is not sealed, so a custom loader can inherit from it to reuse the contact-paging pipeline while overriding individual stages. If a loader throws, the coordinator logs the error and returns the batch to the `New` state so it can be retried. +When an inventory load is started, the `IActivityBatchLoadCoordinator` transitions it to the loading state, resolves the loader whose `Source` matches the selected source, and delegates to it. Sources **without** a dedicated loader fall back to the built-in `DefaultContactActivityBatchLoader`, which pages over contacts of the inventory load's contact content type, applies the standard lead filters (created range, phone number, time zone, last completed activity), and creates activities from the subject flow settings. The default loader is not sealed, so a custom loader can inherit from it to reuse the contact-paging pipeline while overriding individual stages. If a loader throws, the coordinator logs the error and returns the inventory load to the `New` state so it can be retried. ### 9) Complete Activities diff --git a/src/CrestApps.Docs/docs/omnichannel/sms.md b/src/CrestApps.Docs/docs/omnichannel/sms.md index 70ff0f922..3fcbc0ff9 100644 --- a/src/CrestApps.Docs/docs/omnichannel/sms.md +++ b/src/CrestApps.Docs/docs/omnichannel/sms.md @@ -38,7 +38,7 @@ You describe what you want the AI to do (tone, rules, goals), and the AI carries 3. Create a chat AI profile with **Add initial prompt** enabled. The profile's initial prompt is sent as the first outbound SMS message that starts the conversation. 4. If the AI feature is enabled, select that initial-prompt chat profile on the subject flow, then configure the subject goal, update permissions, no-response timeout, response delay, and opt-out keywords. 5. Configure your SMS provider webhook to deliver inbound SMS messages to Orchard Core. -6. Load activities via Activity Batches using the **Automatic** source. +6. Load activities via **Load Inventory** using the **Automatic** source. 7. The Automated Activities Processor will run in the background and let AI handle the assigned SMS interactions. ## Automated SMS behavior diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md new file mode 100644 index 000000000..356fab943 --- /dev/null +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -0,0 +1,118 @@ +--- +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. + +## 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** +- **Hold / resume** +- **Mute / unmute** +- **Merge** +- **Send DTMF digits** + +It does not currently advertise inbound-call, transfer, or soft-phone voicemail capabilities. + +## 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** | The ARI application name that receives originated channels. | +| **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. | + +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": "crestapps", + "Password": "crestapps-dev", + "ApplicationName": "crestapps-telephony", + "EndpointTemplate": "Local/{number}@default", + "TimeoutSeconds": 30 + } + } + } + } +} +``` + +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=crestapps +OrchardCore__CrestApps__Asterisk__Default__Password=crestapps-dev +OrchardCore__CrestApps__Asterisk__Default__ApplicationName=crestapps-telephony +OrchardCore__CrestApps__Asterisk__Default__EndpointTemplate=Local/{number}@default +OrchardCore__CrestApps__Asterisk__Default__TimeoutSeconds=30 +``` + +The provider becomes available only when `BaseUrl`, `UserName`, `Password`, and `ApplicationName` are all configured. + +## 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` / `DELETE /channels/{id}/hold` to hold and resume +- `POST` / `DELETE /channels/{id}/mute?direction=both` to mute and unmute +- `POST /channels/{id}/dtmf` to send digits +- `POST /bridges` plus `POST /bridges/{id}/addChannel` to merge two calls + +Because all requests are issued server-side, the ARI password never reaches the browser. + +## 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. + +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 bundled local configuration is intended for development and connectivity testing. Production deployments should supply their own ARI credentials, dialplan, endpoints, and media/network configuration. diff --git a/src/CrestApps.Docs/docs/telephony/index.md b/src/CrestApps.Docs/docs/telephony/index.md index 766fe0c89..c0d53d1c2 100644 --- a/src/CrestApps.Docs/docs/telephony/index.md +++ b/src/CrestApps.Docs/docs/telephony/index.md @@ -36,7 +36,7 @@ TelephonyHub ──► ITelephonyService ──► ITelephonyProviderResolve 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. ## The provider contract @@ -295,4 +295,4 @@ To add a new provider: `ITelephonyAuthenticationService`. Providers that only use a shared account key do not implement this interface. -See the [DialPad](dialpad) provider for a complete example. +See the [DialPad](dialpad) and [Asterisk](asterisk) providers for complete examples. diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/AssemblyInfo.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/AssemblyInfo.cs new file mode 100644 index 000000000..2a0d8854c --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("CrestApps.OrchardCore.Tests")] diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs new file mode 100644 index 000000000..ae022f767 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/AsteriskConstants.cs @@ -0,0 +1,53 @@ +namespace CrestApps.OrchardCore.Asterisk; + +/// +/// Contains constant values used by the Asterisk telephony provider. +/// +public static class AsteriskConstants +{ + /// + /// The technical name used to register and resolve the tenant-configured Asterisk provider. + /// + public const string ProviderTechnicalName = "Asterisk"; + + /// + /// The technical name used to register and resolve the configuration-backed default Asterisk provider. + /// + public const string DefaultProviderTechnicalName = "Default Asterisk"; + + /// + /// The name of the HTTP client used for Asterisk ARI calls. + /// + public const string HttpClientName = "Asterisk"; + + /// + /// The name of the data protector used to protect the tenant-configured Asterisk password. + /// + public const string ProtectorName = "Asterisk"; + + /// + /// The default ARI application name used when one is not supplied explicitly. + /// + public const string DefaultApplicationName = "crestapps-telephony"; + + /// + /// The default outbound call timeout, in seconds. + /// + public const int DefaultTimeoutSeconds = 30; + + /// + /// The shell configuration section used for the configuration-backed default provider. + /// + public const string DefaultConfigurationSectionPath = "CrestApps:Asterisk:Default"; + + /// + /// Contains the feature identifiers exposed by the Asterisk module. + /// + public static class Feature + { + /// + /// The identifier of the Asterisk provider feature. + /// + public const string Area = "CrestApps.OrchardCore.Asterisk"; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj b/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj new file mode 100644 index 000000000..18b5b6390 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/CrestApps.OrchardCore.Asterisk.csproj @@ -0,0 +1,40 @@ + + + + $(MSBuildProjectName) + true + CrestApps OrchardCore Asterisk Module + + $(CrestAppsDescription) + + Integrates the Asterisk telephony platform as a provider for the CrestApps Telephony soft phone. + + $(PackageTags) OrchardCoreCMS Telephony Asterisk + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Drivers/AsteriskSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Drivers/AsteriskSettingsDisplayDriver.cs new file mode 100644 index 000000000..ec12fe3f2 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Drivers/AsteriskSettingsDisplayDriver.cs @@ -0,0 +1,192 @@ +using CrestApps.OrchardCore.Asterisk.Models; +using CrestApps.OrchardCore.Asterisk.Services; +using CrestApps.OrchardCore.Asterisk.ViewModels; +using CrestApps.OrchardCore.Telephony; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc.Localization; +using Microsoft.Extensions.Localization; +using OrchardCore.DisplayManagement.Entities; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.DisplayManagement.Notify; +using OrchardCore.DisplayManagement.Views; +using OrchardCore.Entities; +using OrchardCore.Environment.Shell; +using OrchardCore.Mvc.ModelBinding; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.Asterisk.Drivers; + +/// +/// Display driver that renders the tenant-configured Asterisk provider settings tab on the telephony settings screen. +/// +public sealed class AsteriskSettingsDisplayDriver : SiteDisplayDriver +{ + private readonly IShellReleaseManager _shellReleaseManager; + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly IAuthorizationService _authorizationService; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly INotifier _notifier; + + internal readonly IHtmlLocalizer H; + internal readonly IStringLocalizer S; + + protected override string SettingsGroupId + => TelephonyConstants.SettingsGroupId; + + /// + /// Initializes a new instance of the class. + /// + /// The shell release manager. + /// The HTTP context accessor. + /// The authorization service. + /// The data protection provider. + /// The notifier. + /// The HTML localizer. + /// The string localizer. + public AsteriskSettingsDisplayDriver( + IShellReleaseManager shellReleaseManager, + IHttpContextAccessor httpContextAccessor, + IAuthorizationService authorizationService, + IDataProtectionProvider dataProtectionProvider, + INotifier notifier, + IHtmlLocalizer htmlLocalizer, + IStringLocalizer stringLocalizer) + { + _shellReleaseManager = shellReleaseManager; + _httpContextAccessor = httpContextAccessor; + _authorizationService = authorizationService; + _dataProtectionProvider = dataProtectionProvider; + _notifier = notifier; + H = htmlLocalizer; + S = stringLocalizer; + } + + public override IDisplayResult Edit(ISite site, AsteriskSettings settings, BuildEditorContext context) + { + return Initialize("AsteriskSettings_Edit", model => + { + model.IsEnabled = settings.IsEnabled; + model.BaseUrl = settings.BaseUrl; + model.UserName = settings.UserName; + model.ApplicationName = settings.ApplicationName; + model.EndpointTemplate = settings.EndpointTemplate; + model.OutboundCallerId = settings.OutboundCallerId; + model.TimeoutSeconds = settings.TimeoutSeconds > 0 + ? settings.TimeoutSeconds + : AsteriskConstants.DefaultTimeoutSeconds; + model.HasPassword = !string.IsNullOrEmpty(settings.Password); + }).Location("Content:10#Asterisk") + .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, TelephonyPermissions.ManageTelephonySettings)) + .OnGroup(SettingsGroupId); + } + + public override async Task UpdateAsync(ISite site, AsteriskSettings settings, UpdateEditorContext context) + { + var user = _httpContextAccessor.HttpContext?.User; + + if (!await _authorizationService.AuthorizeAsync(user, TelephonyPermissions.ManageTelephonySettings)) + { + return null; + } + + var model = new AsteriskSettingsViewModel(); + + await context.Updater.TryUpdateModelAsync(model, Prefix); + + var hasChanges = settings.IsEnabled != model.IsEnabled; + var telephonySettings = site.GetOrCreate(); + + if (!model.IsEnabled) + { + if (hasChanges && telephonySettings.DefaultProviderName == AsteriskConstants.ProviderTechnicalName) + { + await _notifier.WarningAsync(H["You have disabled the default telephony provider. The soft phone is now disabled until you designate a new default provider."]); + + telephonySettings.DefaultProviderName = null; + + site.Put(telephonySettings); + } + + settings.IsEnabled = false; + } + else + { + settings.IsEnabled = true; + + var normalizedBaseUrl = AsteriskSettingsUtilities.NormalizeBaseUrl(model.BaseUrl); + var applicationName = string.IsNullOrWhiteSpace(model.ApplicationName) + ? AsteriskConstants.DefaultApplicationName + : model.ApplicationName.Trim(); + var timeoutSeconds = model.TimeoutSeconds > 0 + ? model.TimeoutSeconds + : AsteriskConstants.DefaultTimeoutSeconds; + + hasChanges |= settings.BaseUrl != normalizedBaseUrl; + hasChanges |= settings.UserName != model.UserName?.Trim(); + hasChanges |= settings.ApplicationName != applicationName; + hasChanges |= settings.EndpointTemplate != model.EndpointTemplate?.Trim(); + hasChanges |= settings.OutboundCallerId != model.OutboundCallerId?.Trim(); + hasChanges |= settings.TimeoutSeconds != timeoutSeconds; + + settings.BaseUrl = normalizedBaseUrl; + settings.UserName = model.UserName?.Trim(); + settings.ApplicationName = applicationName; + settings.EndpointTemplate = model.EndpointTemplate?.Trim(); + settings.OutboundCallerId = model.OutboundCallerId?.Trim(); + settings.TimeoutSeconds = timeoutSeconds; + + if (string.IsNullOrWhiteSpace(settings.BaseUrl) || !Uri.TryCreate(settings.BaseUrl, UriKind.Absolute, out _)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.BaseUrl), S["Enter a valid absolute Asterisk ARI URL."]); + } + + if (string.IsNullOrWhiteSpace(settings.UserName)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.UserName), S["Enter the Asterisk ARI user name."]); + } + + if (string.IsNullOrWhiteSpace(settings.ApplicationName)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.ApplicationName), S["Enter the Asterisk Stasis application name."]); + } + + if (settings.TimeoutSeconds <= 0) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.TimeoutSeconds), S["Enter a timeout greater than zero."]); + } + + if (string.IsNullOrEmpty(settings.Password) && string.IsNullOrWhiteSpace(model.Password)) + { + context.Updater.ModelState.AddModelError(Prefix, nameof(model.Password), S["Enter the Asterisk ARI password."]); + } + + if (!string.IsNullOrWhiteSpace(model.Password)) + { + var protector = _dataProtectionProvider.CreateProtector(AsteriskConstants.ProtectorName); + var protectedPassword = protector.Protect(model.Password); + + hasChanges |= settings.Password != protectedPassword; + + settings.Password = protectedPassword; + } + } + + if (context.Updater.ModelState.IsValid && settings.IsEnabled && string.IsNullOrEmpty(telephonySettings.DefaultProviderName)) + { + telephonySettings.DefaultProviderName = AsteriskConstants.ProviderTechnicalName; + + site.Put(telephonySettings); + + hasChanges = true; + } + + if (hasChanges) + { + _shellReleaseManager.RequestRelease(); + } + + return Edit(site, settings, context); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Manifest.cs new file mode 100644 index 000000000..13c7e3591 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Manifest.cs @@ -0,0 +1,24 @@ +using CrestApps.OrchardCore.Asterisk; +using CrestApps.OrchardCore; +using CrestApps.OrchardCore.Telephony; +using OrchardCore.Modules.Manifest; + +[assembly: Module( + Name = "Asterisk", + Author = CrestAppsManifestConstants.Author, + Website = CrestAppsManifestConstants.Website, + Version = CrestAppsManifestConstants.Version, + Description = "Integrates the Asterisk telephony platform with the Telephony soft phone.", + Category = "Communication" +)] + +[assembly: Feature( + Id = AsteriskConstants.Feature.Area, + Name = "Asterisk", + Description = "Provides the Asterisk telephony provider and its settings.", + Category = "Communication", + Dependencies = + [ + TelephonyConstants.Feature.Area, + ] +)] diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskConnectionSettings.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskConnectionSettings.cs new file mode 100644 index 000000000..5c0778c61 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskConnectionSettings.cs @@ -0,0 +1,38 @@ +namespace CrestApps.OrchardCore.Asterisk.Models; + +/// +/// Represents the common Asterisk connection settings shared by the tenant and configuration-backed providers. +/// +public class AsteriskConnectionSettings +{ + /// + /// Gets or sets the base URL of the Asterisk ARI endpoint. + /// + public virtual string BaseUrl { get; set; } + + /// + /// Gets or sets the ARI user name. + /// + public virtual string UserName { get; set; } + + /// + /// Gets or sets the Stasis application name the provider uses for originated channels. + /// + public virtual string ApplicationName { get; set; } + + /// + /// Gets or sets an optional endpoint template used to turn the dialed destination into an ARI endpoint. + /// Use the {number} token to inject the dialed destination. + /// + public virtual string EndpointTemplate { get; set; } + + /// + /// Gets or sets the caller identifier presented on outbound calls. + /// + public virtual string OutboundCallerId { get; set; } + + /// + /// Gets or sets the outbound dial timeout, in seconds. + /// + public virtual int TimeoutSeconds { get; set; } = AsteriskConstants.DefaultTimeoutSeconds; +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskSettings.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskSettings.cs new file mode 100644 index 000000000..50be47ff0 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Models/AsteriskSettings.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Asterisk.Models; + +/// +/// Represents the tenant-configured Asterisk provider site settings. +/// +public sealed class AsteriskSettings : AsteriskConnectionSettings +{ + /// + /// Gets or sets a value indicating whether the tenant-configured Asterisk provider is enabled. + /// + public bool IsEnabled { get; set; } + + /// + /// Gets or sets the protected ARI password. The value is stored encrypted using the data protection provider. + /// + public string Password { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Models/DefaultAsteriskOptions.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Models/DefaultAsteriskOptions.cs new file mode 100644 index 000000000..50a2d90f0 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Models/DefaultAsteriskOptions.cs @@ -0,0 +1,17 @@ +namespace CrestApps.OrchardCore.Asterisk.Models; + +/// +/// Represents the configuration-backed default Asterisk provider options loaded from shell configuration. +/// +public sealed class DefaultAsteriskOptions : AsteriskConnectionSettings +{ + /// + /// Gets or sets the ARI password from shell configuration. + /// + public string Password { get; set; } + + /// + /// Gets or sets a value indicating whether the configuration contains enough information to expose the provider. + /// + public bool IsEnabled { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskProviderOptionsConfigurations.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskProviderOptionsConfigurations.cs new file mode 100644 index 000000000..839da844f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskProviderOptionsConfigurations.cs @@ -0,0 +1,61 @@ +using CrestApps.OrchardCore.Asterisk.Models; +using CrestApps.OrchardCore.Telephony; +using Microsoft.Extensions.Options; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +/// +/// Registers the tenant-configured and configuration-backed Asterisk providers with the telephony provider options. +/// +public sealed class AsteriskProviderOptionsConfigurations : IConfigureOptions +{ + private readonly ISiteService _siteService; + private readonly DefaultAsteriskOptions _defaultOptions; + + /// + /// Initializes a new instance of the class. + /// + /// The site service used to read tenant-configured Asterisk settings. + /// The configuration-backed default Asterisk options. + public AsteriskProviderOptionsConfigurations( + ISiteService siteService, + IOptions defaultOptions) + { + _siteService = siteService; + _defaultOptions = defaultOptions.Value; + } + + /// + public void Configure(TelephonyProviderOptions options) + { + ConfigureTenantProvider(options); + + if (_defaultOptions.IsEnabled) + { + ConfigureDefaultProvider(options); + } + } + + private void ConfigureTenantProvider(TelephonyProviderOptions options) + { + var settings = _siteService.GetSettings(); + + var typeOptions = new TelephonyProviderTypeOptions(typeof(AsteriskTelephonyProvider)) + { + IsEnabled = settings.IsEnabled, + }; + + options.TryAddProvider(AsteriskConstants.ProviderTechnicalName, typeOptions); + } + + private static void ConfigureDefaultProvider(TelephonyProviderOptions options) + { + var typeOptions = new TelephonyProviderTypeOptions(typeof(DefaultAsteriskTelephonyProvider)) + { + IsEnabled = true, + }; + + options.TryAddProvider(AsteriskConstants.DefaultProviderTechnicalName, typeOptions); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskResolvedSettings.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskResolvedSettings.cs new file mode 100644 index 000000000..de61929d5 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskResolvedSettings.cs @@ -0,0 +1,22 @@ +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal sealed class AsteriskResolvedSettings +{ + public bool IsEnabled { get; set; } + + public string ProviderName { get; set; } + + public string BaseUrl { get; set; } + + public string UserName { get; set; } + + public string Password { get; set; } + + public string ApplicationName { get; set; } + + public string EndpointTemplate { get; set; } + + public string OutboundCallerId { get; set; } + + public int TimeoutSeconds { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs new file mode 100644 index 000000000..943312403 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskSettingsUtilities.cs @@ -0,0 +1,74 @@ +using System.Globalization; +using CrestApps.OrchardCore.Asterisk.Models; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal static class AsteriskSettingsUtilities +{ + public static void ApplyDefaults(AsteriskConnectionSettings settings) + { + settings.ApplicationName = string.IsNullOrWhiteSpace(settings.ApplicationName) + ? AsteriskConstants.DefaultApplicationName + : settings.ApplicationName.Trim(); + + settings.TimeoutSeconds = settings.TimeoutSeconds > 0 + ? settings.TimeoutSeconds + : AsteriskConstants.DefaultTimeoutSeconds; + + settings.BaseUrl = NormalizeBaseUrl(settings.BaseUrl); + settings.UserName = settings.UserName?.Trim(); + settings.EndpointTemplate = settings.EndpointTemplate?.Trim(); + settings.OutboundCallerId = settings.OutboundCallerId?.Trim(); + } + + public static bool HasRequiredConfiguration(AsteriskConnectionSettings settings, string password) + => !string.IsNullOrWhiteSpace(settings.BaseUrl) && + !string.IsNullOrWhiteSpace(settings.UserName) && + !string.IsNullOrWhiteSpace(password) && + !string.IsNullOrWhiteSpace(settings.ApplicationName); + + public static string ResolveEndpoint(string endpointTemplate, string destination) + { + if (string.IsNullOrWhiteSpace(destination)) + { + return null; + } + + if (string.IsNullOrWhiteSpace(endpointTemplate)) + { + return destination.Trim(); + } + + return endpointTemplate.Replace("{number}", destination.Trim(), StringComparison.OrdinalIgnoreCase); + } + + public static string NormalizeBaseUrl(string baseUrl) + { + if (string.IsNullOrWhiteSpace(baseUrl)) + { + return null; + } + + if (!Uri.TryCreate(baseUrl.Trim(), UriKind.Absolute, out var uri)) + { + return baseUrl.Trim(); + } + + var builder = new UriBuilder(uri); + var path = string.IsNullOrWhiteSpace(builder.Path) || builder.Path == "/" + ? "/ari/" + : builder.Path; + + if (path[^1] != '/') + { + path += "/"; + } + + builder.Path = path; + + return builder.Uri.ToString(); + } + + public static string ToInvariantString(int value) + => value.ToString(CultureInfo.InvariantCulture); +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProvider.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProvider.cs new file mode 100644 index 000000000..24d140043 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProvider.cs @@ -0,0 +1,133 @@ +using CrestApps.OrchardCore.Asterisk.Models; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; +using OrchardCore.Settings; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +/// +/// A telephony provider that controls calls through a tenant-configured Asterisk ARI endpoint. +/// +internal sealed class AsteriskTelephonyProvider : AsteriskTelephonyProviderBase +{ + private readonly ISiteService _siteService; + private readonly IDataProtectionProvider _dataProtectionProvider; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The site service used to read the tenant-configured Asterisk settings. + /// The data protection provider used to unprotect the stored password. + /// The HTTP client factory. + /// The clock. + /// The logger. + /// The string localizer. + public AsteriskTelephonyProvider( + ISiteService siteService, + IDataProtectionProvider dataProtectionProvider, + IHttpClientFactory httpClientFactory, + IClock clock, + ILogger logger, + IStringLocalizer stringLocalizer) + : base(httpClientFactory, clock, logger, stringLocalizer) + { + _siteService = siteService; + _dataProtectionProvider = dataProtectionProvider; + _logger = logger; + } + + /// + public override LocalizedString Name => S["Asterisk"]; + + protected override string ProviderName + => AsteriskConstants.ProviderTechnicalName; + + protected override ValueTask GetResolvedSettingsAsync(CancellationToken cancellationToken) + { + var settings = _siteService.GetSettings(); + var resolved = new AsteriskResolvedSettings + { + IsEnabled = settings.IsEnabled, + ProviderName = ProviderName, + BaseUrl = settings.BaseUrl, + UserName = settings.UserName, + Password = UnprotectPassword(settings.Password), + ApplicationName = settings.ApplicationName, + EndpointTemplate = settings.EndpointTemplate, + OutboundCallerId = settings.OutboundCallerId, + TimeoutSeconds = settings.TimeoutSeconds, + }; + + AsteriskSettingsUtilities.ApplyDefaults(new AsteriskConnectionSettingsAdapter(resolved)); + + return ValueTask.FromResult(resolved); + } + + private string UnprotectPassword(string protectedPassword) + { + if (string.IsNullOrWhiteSpace(protectedPassword)) + { + return null; + } + + try + { + return _dataProtectionProvider.CreateProtector(AsteriskConstants.ProtectorName).Unprotect(protectedPassword); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to unprotect the tenant-configured Asterisk password."); + + return null; + } + } + + private sealed class AsteriskConnectionSettingsAdapter : AsteriskConnectionSettings + { + private readonly AsteriskResolvedSettings _settings; + + public AsteriskConnectionSettingsAdapter(AsteriskResolvedSettings settings) + { + _settings = settings; + } + + public override string BaseUrl + { + get => _settings.BaseUrl; + set => _settings.BaseUrl = value; + } + + public override string UserName + { + get => _settings.UserName; + set => _settings.UserName = value; + } + + public override string ApplicationName + { + get => _settings.ApplicationName; + set => _settings.ApplicationName = value; + } + + public override string EndpointTemplate + { + get => _settings.EndpointTemplate; + set => _settings.EndpointTemplate = value; + } + + public override string OutboundCallerId + { + get => _settings.OutboundCallerId; + set => _settings.OutboundCallerId = value; + } + + public override int TimeoutSeconds + { + get => _settings.TimeoutSeconds; + set => _settings.TimeoutSeconds = value; + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs new file mode 100644 index 000000000..9abccf36a --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/AsteriskTelephonyProviderBase.cs @@ -0,0 +1,436 @@ +using System.Globalization; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using CrestApps.OrchardCore.Telephony; +using CrestApps.OrchardCore.Telephony.Models; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +internal abstract class AsteriskTelephonyProviderBase : ITelephonyProvider +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IClock _clock; + private readonly ILogger _logger; + + protected AsteriskTelephonyProviderBase( + IHttpClientFactory httpClientFactory, + IClock clock, + ILogger logger, + IStringLocalizer stringLocalizer) + { + _httpClientFactory = httpClientFactory; + _clock = clock; + _logger = logger; + S = stringLocalizer; + } + + protected IStringLocalizer S { get; } + + protected abstract string ProviderName { get; } + + public abstract LocalizedString Name { get; } + + public TelephonyCapabilities Capabilities + { + get + { + return TelephonyCapabilities.Dial | + TelephonyCapabilities.Hangup | + TelephonyCapabilities.Hold | + TelephonyCapabilities.Resume | + TelephonyCapabilities.Mute | + TelephonyCapabilities.Merge | + TelephonyCapabilities.SendDigits; + } + } + + public async Task DialAsync(DialRequest request, CancellationToken cancellationToken = default) + { + if (request is null || string.IsNullOrWhiteSpace(request.To)) + { + return TelephonyResult.Failed(S["A destination phone number or endpoint is required to place a call."].Value); + } + + var settings = await GetResolvedSettingsAsync(cancellationToken); + + if (!IsConfigured(settings)) + { + return NotConfigured(); + } + + var endpoint = AsteriskSettingsUtilities.ResolveEndpoint(settings.EndpointTemplate, request.To); + + if (string.IsNullOrWhiteSpace(endpoint)) + { + return TelephonyResult.Failed(S["The configured endpoint template did not produce a valid Asterisk endpoint."].Value); + } + + var callerId = string.IsNullOrWhiteSpace(request.From) + ? settings.OutboundCallerId + : request.From; + + var query = new Dictionary + { + ["endpoint"] = endpoint, + ["app"] = settings.ApplicationName, + ["timeout"] = AsteriskSettingsUtilities.ToInvariantString(settings.TimeoutSeconds), + }; + + if (!string.IsNullOrWhiteSpace(callerId)) + { + query["callerId"] = callerId; + } + + try + { + using var response = await SendAsync(settings, HttpMethod.Post, "channels", query, request.Metadata, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError("Asterisk rejected a dial request for provider {ProviderName} with status code {StatusCode}.", ProviderName, response.StatusCode); + + return TelephonyResult.Failed(S["Asterisk could not place the call."].Value); + } + + var callId = await ReadIdAsync(response, cancellationToken); + + var call = new TelephonyCall + { + CallId = callId ?? request.To, + From = callerId, + To = request.To, + State = CallState.Connecting, + Direction = CallDirection.Outbound, + ProviderName = ProviderName, + StartedUtc = _clock.UtcNow, + }; + + return TelephonyResult.Success(call); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while placing an Asterisk call for provider {ProviderName}.", ProviderName); + + return TelephonyResult.Failed(S["Asterisk could not place the call. Error: {0}", ex.Message].Value); + } + } + + public Task HangupAsync(CallReference call, CancellationToken cancellationToken = default) + => ExecuteCallActionAsync( + call?.CallId, + HttpMethod.Delete, + "channels/{callId}", + null, + () => BuildCall(call?.CallId, CallState.Disconnected), + S["Asterisk could not end the call."].Value, + S["A call id is required to end the call."].Value, + cancellationToken); + + public Task HoldAsync(CallReference call, CancellationToken cancellationToken = default) + => ExecuteCallActionAsync( + call?.CallId, + HttpMethod.Post, + "channels/{callId}/hold", + null, + () => BuildCall(call?.CallId, CallState.OnHold, isOnHold: true), + S["Asterisk could not place the call on hold."].Value, + S["A call id is required to hold the call."].Value, + cancellationToken); + + public Task ResumeAsync(CallReference call, CancellationToken cancellationToken = default) + => ExecuteCallActionAsync( + call?.CallId, + HttpMethod.Delete, + "channels/{callId}/hold", + null, + () => BuildCall(call?.CallId, CallState.Connected), + S["Asterisk could not resume the call."].Value, + S["A call id is required to resume the call."].Value, + cancellationToken); + + public Task MuteAsync(CallReference call, CancellationToken cancellationToken = default) + => ExecuteCallActionAsync( + call?.CallId, + HttpMethod.Post, + "channels/{callId}/mute", + new Dictionary { ["direction"] = "both" }, + () => BuildCall(call?.CallId, CallState.Connected, isMuted: true), + S["Asterisk could not mute the call."].Value, + S["A call id is required to mute the call."].Value, + cancellationToken); + + public Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default) + => ExecuteCallActionAsync( + call?.CallId, + HttpMethod.Delete, + "channels/{callId}/mute", + new Dictionary { ["direction"] = "both" }, + () => BuildCall(call?.CallId, CallState.Connected), + S["Asterisk could not unmute the call."].Value, + S["A call id is required to unmute the call."].Value, + cancellationToken); + + public Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Failed(S["The {0} provider does not support call transfers.", Name.Value].Value)); + + public async Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default) + { + if (request is null || string.IsNullOrWhiteSpace(request.PrimaryCallId) || string.IsNullOrWhiteSpace(request.SecondaryCallId)) + { + return TelephonyResult.Failed(S["Both call ids are required to merge calls."].Value); + } + + var settings = await GetResolvedSettingsAsync(cancellationToken); + + if (!IsConfigured(settings)) + { + return NotConfigured(); + } + + try + { + var createBridgeQuery = new Dictionary + { + ["type"] = "mixing", + }; + + if (!string.IsNullOrWhiteSpace(request.ConferenceName)) + { + createBridgeQuery["name"] = request.ConferenceName; + } + + using var createBridgeResponse = await SendAsync(settings, HttpMethod.Post, "bridges", createBridgeQuery, null, cancellationToken); + + if (!createBridgeResponse.IsSuccessStatusCode) + { + _logger.LogError("Asterisk rejected a bridge creation request for provider {ProviderName} with status code {StatusCode}.", ProviderName, createBridgeResponse.StatusCode); + + return TelephonyResult.Failed(S["Asterisk could not merge the calls."].Value); + } + + var bridgeId = await ReadIdAsync(createBridgeResponse, cancellationToken); + + if (string.IsNullOrWhiteSpace(bridgeId)) + { + _logger.LogError("Asterisk did not return a bridge id when merging calls for provider {ProviderName}.", ProviderName); + + return TelephonyResult.Failed(S["Asterisk could not merge the calls."].Value); + } + + var addChannelQuery = new Dictionary + { + ["channel"] = $"{request.PrimaryCallId},{request.SecondaryCallId}", + }; + + using var addChannelResponse = await SendAsync( + settings, + HttpMethod.Post, + $"bridges/{Uri.EscapeDataString(bridgeId)}/addChannel", + addChannelQuery, + null, + cancellationToken); + + if (!addChannelResponse.IsSuccessStatusCode) + { + _logger.LogError("Asterisk rejected a bridge add-channel request for provider {ProviderName} with status code {StatusCode}.", ProviderName, addChannelResponse.StatusCode); + + return TelephonyResult.Failed(S["Asterisk could not merge the calls."].Value); + } + + return TelephonyResult.Success(BuildCall(request.PrimaryCallId, CallState.Connected)); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while merging Asterisk calls for provider {ProviderName}.", ProviderName); + + return TelephonyResult.Failed(S["Asterisk could not merge the calls. Error: {0}", ex.Message].Value); + } + } + + public Task SendDigitsAsync(SendDigitsRequest request, CancellationToken cancellationToken = default) + { + if (request is null || string.IsNullOrWhiteSpace(request.Digits)) + { + return Task.FromResult(TelephonyResult.Failed(S["Digits are required."].Value)); + } + + return ExecuteCallActionAsync( + request.CallId, + HttpMethod.Post, + "channels/{callId}/dtmf", + new Dictionary { ["dtmf"] = request.Digits }, + () => null, + S["Asterisk could not send the digits."].Value, + S["A call id is required to send digits."].Value, + cancellationToken); + } + + public Task AnswerAsync(CallReference call, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Failed(S["The {0} provider does not support answering inbound calls in the soft phone.", Name.Value].Value)); + + public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Failed(S["The {0} provider does not support rejecting inbound calls in the soft phone.", Name.Value].Value)); + + public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default) + => Task.FromResult(TelephonyResult.Failed(S["The {0} provider does not support sending calls to voicemail from the soft phone.", Name.Value].Value)); + + public async Task GetClientCredentialsAsync(CancellationToken cancellationToken = default) + { + var settings = await GetResolvedSettingsAsync(cancellationToken); + + if (!IsConfigured(settings)) + { + return null; + } + + return new TelephonyClientCredentials + { + ProviderName = ProviderName, + Settings = new Dictionary(), + }; + } + + protected abstract ValueTask GetResolvedSettingsAsync(CancellationToken cancellationToken); + + private async Task ExecuteCallActionAsync( + string callId, + HttpMethod method, + string pathTemplate, + IDictionary query, + Func onSuccess, + string errorMessage, + string missingCallIdMessage, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(callId)) + { + return TelephonyResult.Failed(missingCallIdMessage); + } + + var settings = await GetResolvedSettingsAsync(cancellationToken); + + if (!IsConfigured(settings)) + { + return NotConfigured(); + } + + try + { + var path = pathTemplate.Replace("{callId}", Uri.EscapeDataString(callId), StringComparison.Ordinal); + + using var response = await SendAsync(settings, method, path, query, null, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogError( + "Asterisk rejected a call action request for provider {ProviderName}. Path: {Path}. Status code: {StatusCode}.", + ProviderName, + path, + response.StatusCode); + + return TelephonyResult.Failed(errorMessage); + } + + return TelephonyResult.Success(onSuccess?.Invoke()); + } + catch (Exception ex) + { + _logger.LogError(ex, "An error occurred while executing an Asterisk call action for provider {ProviderName}.", ProviderName); + + return TelephonyResult.Failed(S["{0} Error: {1}", errorMessage, ex.Message].Value); + } + } + + private HttpClient CreateClient(AsteriskResolvedSettings settings) + { + var client = _httpClientFactory.CreateClient(AsteriskConstants.HttpClientName); + client.BaseAddress = new Uri(settings.BaseUrl, UriKind.Absolute); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue( + "Basic", + Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.UserName}:{settings.Password}"))); + + return client; + } + + private TelephonyCall BuildCall( + string callId, + CallState state, + bool isOnHold = false, + bool isMuted = false) + => new() + { + CallId = callId, + State = state, + Direction = CallDirection.Outbound, + IsOnHold = isOnHold, + IsMuted = isMuted, + ProviderName = ProviderName, + }; + + private static bool IsConfigured(AsteriskResolvedSettings settings) + => settings is not null && + settings.IsEnabled && + !string.IsNullOrWhiteSpace(settings.BaseUrl) && + !string.IsNullOrWhiteSpace(settings.UserName) && + !string.IsNullOrWhiteSpace(settings.Password) && + !string.IsNullOrWhiteSpace(settings.ApplicationName); + + private TelephonyResult NotConfigured() + => TelephonyResult.Failed(S["The {0} provider is not configured.", Name.Value].Value); + + private async Task SendAsync( + AsteriskResolvedSettings settings, + HttpMethod method, + string relativePath, + IDictionary query, + IDictionary variables, + CancellationToken cancellationToken) + { + var client = CreateClient(settings); + var url = relativePath; + + if (query is { Count: > 0 }) + { + url = QueryHelpers.AddQueryString(url, query); + } + + using var request = new HttpRequestMessage(method, url); + + if (variables is { Count: > 0 }) + { + request.Content = JsonContent.Create(new { variables }); + } + + return await client.SendAsync(request, cancellationToken); + } + + private static async Task ReadIdAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(content)) + { + return null; + } + + using var document = JsonDocument.Parse(content); + + if (!document.RootElement.TryGetProperty("id", out var idElement)) + { + return null; + } + + return idElement.ValueKind switch + { + JsonValueKind.String => idElement.GetString(), + JsonValueKind.Number => idElement.GetRawText(), + _ => null, + }; + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskOptionsConfiguration.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskOptionsConfiguration.cs new file mode 100644 index 000000000..37ad9eb9f --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskOptionsConfiguration.cs @@ -0,0 +1,46 @@ +using CrestApps.OrchardCore.Asterisk.Models; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Environment.Shell.Configuration; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +/// +/// Configures the configuration-backed default Asterisk provider from shell configuration. +/// +public sealed class DefaultAsteriskOptionsConfiguration : IConfigureOptions +{ + private readonly IShellConfiguration _shellConfiguration; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The shell configuration. + /// The logger. + public DefaultAsteriskOptionsConfiguration( + IShellConfiguration shellConfiguration, + ILogger logger) + { + _shellConfiguration = shellConfiguration; + _logger = logger; + } + + /// + public void Configure(DefaultAsteriskOptions options) + { + var section = _shellConfiguration.GetSection(AsteriskConstants.DefaultConfigurationSectionPath); + + section.Bind(options); + AsteriskSettingsUtilities.ApplyDefaults(options); + options.IsEnabled = AsteriskSettingsUtilities.HasRequiredConfiguration(options, options.Password); + + if (section.Exists() && !options.IsEnabled) + { + _logger.LogWarning( + "The default Asterisk provider configuration section '{SectionPath}' is present but incomplete. BaseUrl, UserName, Password, and ApplicationName are required for the provider to be available.", + AsteriskConstants.DefaultConfigurationSectionPath); + } + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskTelephonyProvider.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskTelephonyProvider.cs new file mode 100644 index 000000000..228c2a5e9 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Services/DefaultAsteriskTelephonyProvider.cs @@ -0,0 +1,54 @@ +using CrestApps.OrchardCore.Asterisk.Models; +using Microsoft.Extensions.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using OrchardCore.Modules; + +namespace CrestApps.OrchardCore.Asterisk.Services; + +/// +/// A telephony provider that controls calls through a configuration-backed default Asterisk ARI endpoint. +/// +internal sealed class DefaultAsteriskTelephonyProvider : AsteriskTelephonyProviderBase +{ + private readonly DefaultAsteriskOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The configuration-backed default Asterisk options. + /// The HTTP client factory. + /// The clock. + /// The logger. + /// The string localizer. + public DefaultAsteriskTelephonyProvider( + IOptions options, + IHttpClientFactory httpClientFactory, + IClock clock, + ILogger logger, + IStringLocalizer stringLocalizer) + : base(httpClientFactory, clock, logger, stringLocalizer) + { + _options = options.Value; + } + + /// + public override LocalizedString Name => S["Default Asterisk"]; + + protected override string ProviderName + => AsteriskConstants.DefaultProviderTechnicalName; + + protected override ValueTask GetResolvedSettingsAsync(CancellationToken cancellationToken) + => ValueTask.FromResult(new AsteriskResolvedSettings + { + IsEnabled = _options.IsEnabled, + ProviderName = ProviderName, + BaseUrl = _options.BaseUrl, + UserName = _options.UserName, + Password = _options.Password, + ApplicationName = _options.ApplicationName, + EndpointTemplate = _options.EndpointTemplate, + OutboundCallerId = _options.OutboundCallerId, + TimeoutSeconds = _options.TimeoutSeconds, + }); +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs new file mode 100644 index 000000000..f1fca09aa --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Startup.cs @@ -0,0 +1,42 @@ +using CrestApps.OrchardCore.Asterisk.Drivers; +using CrestApps.OrchardCore.Asterisk.Models; +using CrestApps.OrchardCore.Asterisk.Services; +using CrestApps.OrchardCore.Telephony.Extensions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using OrchardCore.DisplayManagement.Handlers; +using OrchardCore.Modules; +using Polly; + +namespace CrestApps.OrchardCore.Asterisk; + +/// +/// Registers the Asterisk telephony providers and their settings driver. +/// +public sealed class Startup : StartupBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddHttpClient(AsteriskConstants.HttpClientName) + .AddStandardResilienceHandler(options => + { + options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(30); + options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(10); + + options.Retry.MaxRetryAttempts = 3; + options.Retry.Delay = TimeSpan.FromSeconds(2); + options.Retry.BackoffType = DelayBackoffType.Exponential; + options.Retry.UseJitter = true; + + options.CircuitBreaker.FailureRatio = 0.1; + options.CircuitBreaker.SamplingDuration = TimeSpan.FromSeconds(30); + options.CircuitBreaker.MinimumThroughput = 100; + options.CircuitBreaker.BreakDuration = TimeSpan.FromSeconds(5); + }); + + services + .AddTelephonyProviderOptionsConfiguration() + .AddSiteDisplayDriver() + .AddTransient, DefaultAsteriskOptionsConfiguration>(); + } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/ViewModels/AsteriskSettingsViewModel.cs b/src/Modules/CrestApps.OrchardCore.Asterisk/ViewModels/AsteriskSettingsViewModel.cs new file mode 100644 index 000000000..2b6d79192 --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/ViewModels/AsteriskSettingsViewModel.cs @@ -0,0 +1,55 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace CrestApps.OrchardCore.Asterisk.ViewModels; + +/// +/// View model for editing the tenant-configured Asterisk provider settings. +/// +public class AsteriskSettingsViewModel +{ + /// + /// Gets or sets a value indicating whether the tenant-configured Asterisk provider is enabled. + /// + public bool IsEnabled { get; set; } + + /// + /// Gets or sets the base URL of the Asterisk ARI endpoint. + /// + public string BaseUrl { get; set; } + + /// + /// Gets or sets the ARI user name. + /// + public string UserName { get; set; } + + /// + /// Gets or sets the ARI password. + /// + public string Password { get; set; } + + /// + /// Gets or sets the Stasis application name used for originated channels. + /// + public string ApplicationName { get; set; } + + /// + /// Gets or sets the optional endpoint template used to turn the dialed destination into an ARI endpoint. + /// + public string EndpointTemplate { get; set; } + + /// + /// Gets or sets the caller identifier presented on outbound calls. + /// + public string OutboundCallerId { get; set; } + + /// + /// Gets or sets the outbound dial timeout, in seconds. + /// + public int TimeoutSeconds { get; set; } = AsteriskConstants.DefaultTimeoutSeconds; + + /// + /// Gets or sets a value indicating whether a password has already been saved. + /// + [BindNever] + public bool HasPassword { get; set; } +} diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Views/AsteriskSettings.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Asterisk/Views/AsteriskSettings.Edit.cshtml new file mode 100644 index 000000000..d3c24e0be --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Views/AsteriskSettings.Edit.cshtml @@ -0,0 +1,101 @@ +@model AsteriskSettingsViewModel + +
    +
    +
    + + +
    + @T["When enabled, this provider becomes available for selection as the default telephony provider."] +
    +
    + +
    +
    + +
    + + + @T["Enter the base URL of the Asterisk ARI endpoint, for example http://localhost:8088/ari/. If you omit /ari, it is added automatically."] +
    +
    + +
    + +
    + + + @T["The Asterisk ARI user name used for HTTP basic authentication."] +
    +
    + +
    + +
    + + + @if (Model.HasPassword) + { + @T["An encrypted ARI password is already saved. Leave this empty to keep the existing value, or enter a new password to replace it."] + } + else + { + @T["Enter the Asterisk ARI password. This value is encrypted before it is stored."] + } +
    +
    + +
    + +
    + + + @T["The ARI Stasis application name that receives originated channels. Use the same application name that your Asterisk server is configured to allow."] +
    +
    + +
    + +
    + + + @T["Optional. Use {{number}} to insert the dialed destination into an Asterisk endpoint such as PJSIP/{{number}}@phones. When empty, the dialed destination is sent to Asterisk as-is."] +
    +
    + +
    + +
    + + + @T["Optional. The caller identifier presented on outbound calls when the dial request does not provide one."] +
    +
    + +
    + +
    + + + @T["How long Asterisk should keep trying to originate the outbound call before it times out."] +
    +
    +
    + + diff --git a/src/Modules/CrestApps.OrchardCore.Asterisk/Views/_ViewImports.cshtml b/src/Modules/CrestApps.OrchardCore.Asterisk/Views/_ViewImports.cshtml new file mode 100644 index 000000000..cc2edcdfa --- /dev/null +++ b/src/Modules/CrestApps.OrchardCore.Asterisk/Views/_ViewImports.cshtml @@ -0,0 +1,10 @@ +@inherits OrchardCore.DisplayManagement.Razor.RazorPage + +@using CrestApps.OrchardCore.Asterisk.ViewModels +@using OrchardCore.DisplayManagement +@using OrchardCore.DisplayManagement.Views + +@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers +@addTagHelper *, OrchardCore.DisplayManagement +@addTagHelper *, OrchardCore.ResourceManagement +@addTagHelper *, OrchardCore.Contents.TagHelpers diff --git a/src/Modules/CrestApps.OrchardCore.ContentTransfer/AdminMenu.cs b/src/Modules/CrestApps.OrchardCore.ContentTransfer/AdminMenu.cs index fdcba1e38..7d8b7eea9 100644 --- a/src/Modules/CrestApps.OrchardCore.ContentTransfer/AdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.ContentTransfer/AdminMenu.cs @@ -20,7 +20,7 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) builder .Add(S["Content"], content => content - .Add(S["Bulk Import"], S["Bulk Import"].PrefixPosition(), transfer => transfer + .Add(S["Import"], S["Import"].PrefixPosition(), transfer => transfer .Action(nameof(AdminController.List), adminControllerName, new { area = ContentTransferConstants.Feature.ModuleId, @@ -28,7 +28,7 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Permission(ContentTransferPermissions.ListContentTransferEntries) .LocalNav() ) - .Add(S["Bulk Export"], S["Bulk Export"].PrefixPosition(), transfer => transfer + .Add(S["Export"], S["Export"].PrefixPosition(), transfer => transfer .Action(nameof(AdminController.Export), adminControllerName, new { area = ContentTransferConstants.Feature.ModuleId, diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs index f72f5f41b..98f3136ea 100644 --- a/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs @@ -76,7 +76,7 @@ public override IDisplayResult Edit(ISite site, DialPadSettings settings, BuildE model.HasApiToken = !string.IsNullOrEmpty(settings.ApiToken); model.HasClientSecret = !string.IsNullOrEmpty(settings.ClientSecret); model.HasWebhookSigningSecret = !string.IsNullOrEmpty(settings.WebhookSigningSecret); - }).Location("Content:10#DialPad;15") + }).Location("Content:10#DialPad") .RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, TelephonyPermissions.ManageTelephonySettings)) .OnGroup(SettingsGroupId); } diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs index d10e9226d..06cc2da65 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivityBatchesController.cs @@ -165,7 +165,7 @@ public async Task Create(string source) if (!TryGetActivityBatchSource(source, out var sourceEntry)) { - await _notifier.ErrorAsync(H["Unable to find an activity batch source with the name '{0}'.", source]); + await _notifier.ErrorAsync(H["Unable to find an inventory load source with the name '{0}'.", source]); return RedirectToAction(nameof(Index)); } @@ -197,7 +197,7 @@ public async Task CreatePost(string source) if (!TryGetActivityBatchSource(source, out var sourceEntry)) { - await _notifier.ErrorAsync(H["Unable to find an activity batch source with the name '{0}'.", source]); + await _notifier.ErrorAsync(H["Unable to find an inventory load source with the name '{0}'.", source]); return RedirectToAction(nameof(Index)); } @@ -215,7 +215,7 @@ public async Task CreatePost(string source) { await _manager.CreateAsync(model); - await _notifier.SuccessAsync(H["A new activity batch has been created successfully."]); + await _notifier.SuccessAsync(H["A new inventory load has been created successfully."]); return RedirectToAction(nameof(Index)); } @@ -298,7 +298,7 @@ public async Task EditPost(string id) { await _manager.UpdateAsync(model); - await _notifier.SuccessAsync(H["The Activity Batch has been updated successfully."]); + await _notifier.SuccessAsync(H["The inventory load has been updated successfully."]); return RedirectToAction(nameof(Index)); } @@ -344,11 +344,11 @@ public async Task Delete(string id) if (await _manager.DeleteAsync(model)) { - await _notifier.SuccessAsync(H["The activity batch has been deleted successfully."]); + await _notifier.SuccessAsync(H["The inventory load has been deleted successfully."]); } else { - await _notifier.ErrorAsync(H["Unable to remove the activity batch."]); + await _notifier.ErrorAsync(H["Unable to remove the inventory load."]); } return RedirectToAction(nameof(Index)); @@ -407,7 +407,7 @@ await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync("load-activity-batch", loa }); }); - await _notifier.SuccessAsync(H["The Activity Batch started loaded in the background."]); + await _notifier.SuccessAsync(H["The inventory load has started loading in the background."]); return RedirectToAction(nameof(Index)); } @@ -452,11 +452,11 @@ public async Task IndexPost(CatalogEntryOptions options, IEnumerab } if (counter == 0) { - await _notifier.WarningAsync(H["No activity batch were removed."]); + await _notifier.WarningAsync(H["No inventory loads were removed."]); } else { - await _notifier.SuccessAsync(H.Plural(counter, "1 activity batch has been removed successfully.", "{0} activity batches have been removed successfully.")); + await _notifier.SuccessAsync(H.Plural(counter, "1 inventory load has been removed successfully.", "{0} inventory loads have been removed successfully.")); } break; default: diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs index 6ecdf724f..4f80a15af 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityBatchDisplayDriver.cs @@ -244,7 +244,7 @@ public override async Task UpdateAsync(OmnichannelActivityBatch if (flowSettings is null) { - context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubjectContentType), S["The selected subject must be configured under Subject Flows before activity batches can load activities."]); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubjectContentType), S["The selected subject must be configured under Subject Flows before inventory loads can load activities."]); } } @@ -262,7 +262,7 @@ public override async Task UpdateAsync(OmnichannelActivityBatch { if (string.IsNullOrWhiteSpace(model.DialerProfileId)) { - context.Updater.ModelState.AddModelError(Prefix, nameof(model.DialerProfileId), S["Dialer profile is required for dialer activity batches."]); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.DialerProfileId), S["Dialer profile is required for dialer inventory loads."]); } else if (!await _optionsProvider.DialerProfileExistsAsync(model.DialerProfileId)) { @@ -286,7 +286,7 @@ public override async Task UpdateAsync(OmnichannelActivityBatch flowSettings?.Channel is not null && !string.Equals(flowSettings.Channel, OmnichannelConstants.Channels.Phone, StringComparison.OrdinalIgnoreCase)) { - context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubjectContentType), S["Dialer activity batches require a subject flow that uses the Phone channel."]); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.SubjectContentType), S["Dialer inventory loads require a subject flow that uses the Phone channel."]); } var selectedAIProfileId = string.IsNullOrWhiteSpace(model.AIProfileId) @@ -297,7 +297,7 @@ public override async Task UpdateAsync(OmnichannelActivityBatch { if (string.IsNullOrWhiteSpace(selectedAIProfileId)) { - context.Updater.ModelState.AddModelError(Prefix, nameof(model.AIProfileId), S["AI profile is required for automatic activity batches."]); + context.Updater.ModelState.AddModelError(Prefix, nameof(model.AIProfileId), S["AI profile is required for automatic inventory loads."]); } else { diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs index 9ab757a99..500dc55b4 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Manifest.cs @@ -1,7 +1,6 @@ using CrestApps.OrchardCore; using CrestApps.OrchardCore.Omnichannel.Core; using CrestApps.OrchardCore.PhoneNumbers.Core; -using CrestApps.OrchardCore.Reports; using CrestApps.OrchardCore.TimeZones; using CrestApps.OrchardCore.Users.Core; using OrchardCore.Modules.Manifest; @@ -33,15 +32,3 @@ "CrestApps.OrchardCore.Users", ] )] - -[assembly: Feature( - Name = "Omnichannel Reports", - Id = OmnichannelConstants.Features.Reports, - Category = "Communication", - Description = "Adds Omnichannel CRM reports (activity summary, campaign performance, and disposition breakdown) to the admin Reports area.", - Dependencies = - [ - OmnichannelConstants.Features.Managements, - ReportsConstants.Feature, - ] -)] diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs index 41b2fa084..b814f7ef9 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Reports/OmnichannelReportBase.cs @@ -7,7 +7,7 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.Reports; /// -/// Provides the shared category and permission for the Omnichannel CRM reports contributed to the admin +/// Provides the shared category and permission for the Omnichannel reports contributed to the admin /// Reports area. /// public abstract class OmnichannelReportBase : IReport @@ -36,7 +36,7 @@ protected OmnichannelReportBase(IStringLocalizer stringLocalizer) public abstract LocalizedString Description { get; } /// - public string Category => ReportsConstants.Categories.Crm; + public string Category => ReportsConstants.Categories.Omnichannel; /// public Permission Permission => OmnichannelConstants.Permissions.ViewReports; diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/AdminMenu.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/AdminMenu.cs index b81f1f7f0..bb3674dbb 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/AdminMenu.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Services/AdminMenu.cs @@ -37,7 +37,7 @@ protected override ValueTask BuildAsync(NavigationBuilder builder) .Permission(OmnichannelConstants.Permissions.ManageActivities) .LocalNav() ) - .Add(S["Activity Batches"], S["Activity Batches"].PrefixPosition(), dispositions => dispositions + .Add(S["Load Inventory"], S["Load Inventory"].PrefixPosition(), dispositions => dispositions .AddClass("activity-batches") .Id("activityBatches") .Action("Index", "ActivityBatches", "CrestApps.OrchardCore.Omnichannel.Managements") diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs index d06e1e93d..4c74a22e8 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs @@ -220,9 +220,9 @@ public override void ConfigureServices(IServiceCollection services) } /// -/// Registers the Omnichannel CRM reports contributed to the admin Reports area. +/// Registers the Omnichannel reports contributed to the admin Reports area. /// -[Feature(OmnichannelConstants.Features.Reports)] +[RequireFeatures(ReportsConstants.Feature)] public sealed class ReportsStartup : StartupBase { public override void ConfigureServices(IServiceCollection services) diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Create.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Create.cshtml index 59b6424c7..40ed64a1a 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Create.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Create.cshtml @@ -4,7 +4,7 @@ @model EditCatalogEntryViewModel -

    @RenderTitleSegments(T["New '{0}' Activity Batch", Model.DisplayName])

    +

    @RenderTitleSegments(T["New '{0}' Inventory Load", Model.DisplayName])

    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Edit.cshtml index f45ad9f54..a55d564ad 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Edit.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Edit.cshtml @@ -3,7 +3,7 @@ @model EditCatalogEntryViewModel -

    @RenderTitleSegments(T["Edit '{0}' Activity Batch", Model.DisplayName])

    +

    @RenderTitleSegments(T["Edit '{0}' Inventory Load", Model.DisplayName])

    diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml index 78f553f45..6d0a0a470 100644 --- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml +++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/ActivityBatches/Index.cshtml @@ -7,7 +7,7 @@ @model ListOmnichannelActivityBatchViewModel -

    @RenderTitleSegments(T["Activity Batches"])

    +

    @RenderTitleSegments(T["Load Inventory"])

    @* The form is necessary to generate an antiforgery token for the delete and toggle actions. *@ @@ -25,7 +25,7 @@
    - +
    @@ -88,7 +88,7 @@ else {
  • - @T["Nothing here! There are no activity batches at the moment."] + @T["Nothing here! There are no inventory loads at the moment."]
  • } @@ -104,13 +104,13 @@
  • No active calls.
    ' + escapeHtml(call.callerName || call.callerNumber || call.key || call.primaryChannelId) + '
    ' + escapeHtml(call.primaryChannelId || '') + '
    ' + escapeHtml(call.direction || 'Unknown') + '
    ' + escapeHtml(call.state || 'Unknown') + '
    ' + renderStateBadges(call) + '
    ' + escapeHtml(call.connectedNumber || '-') + '' + escapeHtml(call.application || '-') + '' + escapeHtml(call.channelCount || 1) + '' + escapeHtml(call.partyCount || 1) + '' + escapeHtml(formatDuration(call.durationSeconds)) + '
    No active bridges.
    ' + escapeHtml(bridge.name || bridge.id) + '
    ' + escapeHtml(bridge.id || '') + '
    ' + escapeHtml(bridge.bridgeType || '-') + '' + escapeHtml(bridge.channelCount) + '' + escapeHtml(formatDuration(bridge.durationSeconds)) + '
    -
    @(result.Routed == true ? "Offered" : "Queued / rejected")
    +
    + @(result.Routed == true + ? "Offered" + : result.Queued == true + ? "Waiting in queue" + : "Not queued") +
    @result.Reason
    @if (!string.IsNullOrEmpty(result.AgentUserId)) { diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs index 0eb60ce91..d461123c9 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Pages/Simulator.cshtml.cs @@ -48,6 +48,11 @@ public SimulatorModel( /// public int RoutedCount => Results.Count(result => result.Routed == true); + /// + /// Gets how many requests are waiting in a Contact Center queue. + /// + public int QueuedCount => Results.Count(result => result.Queued == true); + /// /// Initializes the page with configuration-backed defaults. /// diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs index 0c3d9a47d..a07c2595e 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskInboundSimulationCoordinator.cs @@ -245,6 +245,11 @@ private static async Task ForwardToOrchardAsync( result.Routed = routed.GetBoolean(); } + if (root.TryGetProperty("queued", out var queued)) + { + result.Queued = queued.GetBoolean(); + } + if (root.TryGetProperty("interactionId", out var interactionId)) { result.InteractionId = interactionId.GetString(); diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/dashboard.js b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/dashboard.js index 9e2ca2a1a..fa23d886c 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/dashboard.js +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/dashboard.js @@ -80,6 +80,7 @@ var config = parseConfig(root); var notifications = []; var current = config.initialSnapshot || null; + var fallbackPollingTimer = null; var refs = { statusBadge: root.querySelector('[data-dashboard-status]'), @@ -289,6 +290,26 @@ .catch(function () { }); } + function startFallbackPolling() { + if (fallbackPollingTimer) { + return; + } + + fetchSnapshot(); + fallbackPollingTimer = window.setInterval( + fetchSnapshot, + Math.max((config.reconciliationSeconds || 15) * 1000, 5000)); + } + + function stopFallbackPolling() { + if (!fallbackPollingTimer) { + return; + } + + window.clearInterval(fallbackPollingTimer); + fallbackPollingTimer = null; + } + function disconnectChannel(channelId) { if (!channelId) { return Promise.resolve(); @@ -332,15 +353,34 @@ applySnapshot(snapshot); }); + connection.onreconnecting(function () { + pushNotification('SignalR disconnected; reconciliation polling is active until it reconnects.', 'warning'); + startFallbackPolling(); + }); + + connection.onreconnected(function () { + stopFallbackPolling(); + pushNotification('SignalR reconnected to the Asterisk event stream.', 'success'); + fetchSnapshot(); + }); + + connection.onclose(function () { + pushNotification('SignalR closed; reconciliation polling remains active.', 'warning'); + startFallbackPolling(); + }); + connection.start() - .then(fetchSnapshot) + .then(function () { + stopFallbackPolling(); + return fetchSnapshot(); + }) .catch(function () { pushNotification('SignalR could not connect; falling back to reconciliation polling.', 'warning'); - fetchSnapshot(); + startFallbackPolling(); }); } else { pushNotification('SignalR is unavailable; showing reconciliation polling only.', 'warning'); - fetchSnapshot(); + startFallbackPolling(); } if (current) { @@ -348,7 +388,6 @@ renderNotifications(); } - window.setInterval(fetchSnapshot, Math.max((config.reconciliationSeconds || 15) * 1000, 5000)); } function boot() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs index 5cad9936d..6519725e6 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityQueueServiceTests.cs @@ -1,4 +1,5 @@ using System.Text.Json.Nodes; +using CrestApps.OrchardCore.ContactCenter; using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; @@ -6,6 +7,7 @@ using CrestApps.OrchardCore.Omnichannel.Core.Services; using Moq; using OrchardCore.Modules; +using YesSql; namespace CrestApps.OrchardCore.Tests.Modules.ContactCenter; @@ -65,6 +67,47 @@ public async Task EnqueueAsync_WhenPriorityIsNotProvided_UsesQueueDefaultPriorit Assert.Equal(InteractionPriority.High, item.Priority); } + [Fact] + public async Task EnqueueAsync_FlushesQueuedWorkBeforePublishingTheQueueEvent() + { + // Arrange + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act-1", It.IsAny())).ReturnsAsync((QueueItem)null); + queueItemManager.Setup(m => m.NewAsync(It.IsAny(), It.IsAny())).ReturnsAsync(new QueueItem()); + + var queueManager = new Mock(); + queueManager.Setup(m => m.FindByIdAsync("q1", It.IsAny())).ReturnsAsync(new ActivityQueue { ItemId = "q1" }); + + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act-1", It.IsAny())).ReturnsAsync(new OmnichannelActivity { ItemId = "act-1" }); + + var session = new Mock(); + var publisher = new Mock(); + var sequence = new MockSequence(); + session.InSequence(sequence) + .Setup(s => s.SaveChangesAsync(It.IsAny())) + .Returns(Task.CompletedTask); + publisher.InSequence(sequence) + .Setup(p => p.PublishAsync( + It.Is(interactionEvent => interactionEvent.EventType == ContactCenterConstants.Events.QueueItemAdded), + It.IsAny())) + .Returns(Task.CompletedTask); + var service = CreateService( + queueItemManager, + queueManager, + activityManager, + new Mock(), + session, + publisher); + + // Act + await service.EnqueueAsync("act-1", "q1", null, TestContext.Current.CancellationToken); + + // Assert + session.Verify(s => s.SaveChangesAsync(It.IsAny()), Times.Once); + publisher.VerifyAll(); + } + [Fact] public async Task OverflowDueAsync_WhenNoOverflowTarget_ReturnsZero() { @@ -208,17 +251,22 @@ private static ActivityQueueService CreateService( Mock queueItemManager, Mock queueManager, Mock activityManager, - Mock businessHours) + Mock businessHours, + Mock session = null, + Mock publisher = null) { var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(_now); + session ??= new Mock(); + publisher ??= new Mock(); return new ActivityQueueService( queueItemManager.Object, queueManager.Object, activityManager.Object, businessHours.Object, - new Mock().Object, + publisher.Object, + session.Object, clock.Object); } } diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index e0c9dcb97..36f0b2c56 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -62,6 +62,49 @@ public async Task SignInAsync_PublishesAgentSignedInEvent_ForQueuedOfferHandlers Times.Once); } + [Fact] + public async Task UpdateMembershipsAsync_PreservesPresenceAndActiveReservation() + { + // Arrange + var existing = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.Reserved, + RequestedPresenceStatus = AgentPresenceStatus.Break, + ActiveReservationId = "r1", + QueueIds = ["q1", "q2"], + CampaignIds = ["c1"], + }; + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByUserIdAsync("u1", It.IsAny())).ReturnsAsync(existing); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService( + agentManager.Object, + [], + [], + new Mock().Object, + CreateDistributedLock().Object, + clock.Object, + new Mock>().Object); + + // Act + var profile = await service.UpdateMembershipsAsync( + "u1", + ["q2"], + ["c1"], + TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(AgentPresenceStatus.Reserved, profile.PresenceStatus); + Assert.Equal(AgentPresenceStatus.Break, profile.RequestedPresenceStatus); + Assert.Equal("r1", profile.ActiveReservationId); + Assert.Equal(["q2"], profile.QueueIds); + Assert.Equal(["c1"], profile.CampaignIds); + } + [Fact] public async Task SignOutAsync_ClearsMembershipAndSetsOffline() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs index a5d34387b..834284578 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentSoftPhoneControllerTests.cs @@ -74,11 +74,35 @@ public async Task CurrentIncomingOffer_WhenOfferMissing_ReturnsNotFound() Assert.IsType(result); } - private static AgentSoftPhoneController CreateController(bool isAuthorized) + [Fact] + public async Task SignIn_WhenNoQueueOrCampaignIsSelected_ReturnsBadRequest() + { + // Arrange + var presenceManager = new Mock(); + var controller = CreateController(true, presenceManager.Object); + + // Act + var result = await controller.SignIn([], [], "/Admin"); + + // Assert + var badRequest = Assert.IsType(result); + Assert.Equal("Select at least one queue or campaign before signing in.", badRequest.Value); + presenceManager.Verify( + manager => manager.SignInAsync( + It.IsAny(), + It.IsAny>(), + It.IsAny>(), + It.IsAny()), + Times.Never); + } + + private static AgentSoftPhoneController CreateController( + bool isAuthorized, + IAgentPresenceManager presenceManager = null) { var authorizationService = new TestAuthorizationService(isAuthorized); var controller = new AgentSoftPhoneController( - Mock.Of(), + presenceManager ?? Mock.Of(), authorizationService, NullLogger.Instance) { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs index ceeba3d5e..0c4f7c18c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/InboundVoiceServiceTests.cs @@ -90,6 +90,7 @@ public async Task HandleInboundAsync_WhenAgentAvailable_CreatesInboundActivityAn // Assert Assert.True(result.Routed); + Assert.False(result.Queued); Assert.Equal("u1", result.AgentUserId); Assert.Equal("act1", result.ActivityItemId); Assert.Equal("q1", result.QueueId); @@ -161,7 +162,9 @@ public async Task HandleInboundAsync_WhenNoAgentAvailable_QueuesWithoutOffering( // Assert Assert.False(result.Routed); + Assert.True(result.Queued); Assert.Equal("q1", result.QueueId); + Assert.Equal("The call is waiting in the queue for the next eligible agent.", result.Reason); harness.IncomingCallDispatcher.Verify(d => d.DispatchAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } @@ -177,6 +180,15 @@ public async Task HandleInboundAsync_WhenProviderCallIsAlreadyTracked_ReturnsExi ItemId = "interaction-1", ActivityItemId = "activity-1", ProviderInteractionId = "call-1", + QueueId = "queue-1", + }); + harness.QueueItemManager + .Setup(manager => manager.FindByActivityIdAsync("activity-1", It.IsAny())) + .ReturnsAsync(new QueueItem + { + ActivityItemId = "activity-1", + QueueId = "queue-1", + Status = QueueItemStatus.Removed, }); var service = harness.CreateService(); @@ -193,6 +205,7 @@ public async Task HandleInboundAsync_WhenProviderCallIsAlreadyTracked_ReturnsExi // Assert Assert.False(result.Routed); + Assert.False(result.Queued); Assert.Equal("activity-1", result.ActivityItemId); Assert.Equal("interaction-1", result.InteractionId); harness.ActivityManager.Verify( @@ -335,6 +348,8 @@ private sealed class Harness public Mock QueueManager { get; } = new(); + public Mock QueueItemManager { get; } = new(); + public Mock QueueService { get; } = new(); public Mock AssignmentService { get; } = new(); @@ -403,6 +418,7 @@ public VoiceContactCenterCallRouter CreateService() ContentManager.Object, InteractionManager.Object, QueueManager.Object, + QueueItemManager.Object, QueueService.Object, AssignmentService.Object, ReservationService.Object, From e1938d3fb8f37ef7b7da49ecd5a874ff6b56bee0 Mon Sep 17 00:00:00 2001 From: Mike Alhayek Date: Sat, 11 Jul 2026 17:40:18 -0700 Subject: [PATCH 56/56] Fix Contact Center real-time routing Repair stale provider call state, preserve agent capacity across pre-connect endings, improve routing diagnostics, correct soft-phone selectors, and restore Asterisk dashboard SignalR updates. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b264ef76-66ac-4c60-a65c-1ebc6a664121 --- .github/contact-center/PLAN.md | 1 + Directory.Build.props | 3 +- gulpfile.js | 2 +- .../Services/ActivityAssignmentService.cs | 88 +- .../Services/AgentPresenceManagerService.cs | 57 +- ...ProviderCallStateSynchronizationService.cs | 59 +- ...roviderVoiceOfferSynchronizationService.cs | 55 +- src/CrestApps.Docs/docs/changelog/v2.0.0.md | 8 +- .../docs/contact-center/agent-desktop.md | 5 + src/CrestApps.Docs/docs/telephony/asterisk.md | 4 +- .../ContactCenterSoftPhoneWork.View.cshtml | 8 +- .../CrestApps.Aspire.AppHost/Program.cs | 4 - .../Assets.json | 8 + .../CrestApps.OrchardCore.Asterisk.Web.csproj | 7 - .../Program.cs | 2 +- .../AsteriskDashboardBroadcastService.cs | 53 +- .../Services/AsteriskDiagnosticsService.cs | 23 +- .../AsteriskStasisEventForwarderService.cs | 5 +- .../wwwroot/js/signalr.js | 4776 +++++++++++++++++ .../wwwroot/js/signalr.min.js | 1 + .../ActivityAssignmentServiceTests.cs | 4 +- .../AgentPresenceManagerServiceTests.cs | 40 + ...derCallStateSynchronizationServiceTests.cs | 79 + ...erVoiceOfferSynchronizationServiceTests.cs | 100 +- 24 files changed, 5316 insertions(+), 76 deletions(-) create mode 100644 src/Startup/CrestApps.OrchardCore.Asterisk.Web/Assets.json create mode 100644 src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.js create mode 100644 src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.min.js diff --git a/.github/contact-center/PLAN.md b/.github/contact-center/PLAN.md index 1f057f37e..0e76b5252 100644 --- a/.github/contact-center/PLAN.md +++ b/.github/contact-center/PLAN.md @@ -1508,6 +1508,7 @@ Ordered by the "Design review" execution order. Each item is a hard requirement ### Change log +- 2026-07-11: Repaired the live stale-capacity failure found in the `blog1` tenant. Provider reconciliation now treats a terminal `CallSession` as the invariant when the paired interaction has drifted back to a nonterminal state, restores the interaction's terminal status/timestamps, and runs ended-offer cleanup so dead queue assignments, reservations, and agent capacity are released even when the original terminal provider event's idempotency key is already recorded. Answered-call cleanup also idempotently starts wrap-up, covering partial failures that persisted terminal provider state before the normal presence transition. Follow-up reproduction showed Asterisk can mark the inbound caller leg answered before an agent accepts; ended-offer cleanup now uses accepted reservation/assigned queue state instead of that provider timestamp, removes terminal waiting/reserved items, releases the temporarily reserved agent, and lets the bounded offer loop continue to the next live call. The dashboard delay was independently traced to `/js/signalr.min.js` returning `404`: the linked output asset resolved to a nonexistent source path, so the browser used its 15-second polling fallback even though server snapshots took only milliseconds. Asterisk Web now builds the SignalR client into its own physical `wwwroot`. Assignment, sign-in/membership, and Asterisk dashboard refresh paths now emit structured trace details; the Work tab omits false boolean option attributes and shows explicit empty-selection placeholders; and single-framework builds use `TargetFramework` while multi-target overrides retain `TargetFrameworks`, so Aspire project resources start successfully without breaking cross-targeting. - 2026-07-11: Hardened inbound queue routing and real-time operations after local Asterisk testing. New queue/activity writes are flushed before the immediate YesSql-backed assignment query, eliminating the read-after-write gap that reported no agent even when an eligible signed-in agent was available. Inbound results now distinguish durable `queued` work from immediately `routed` work, and the simulator labels waiting calls instead of presenting them as rejected. The soft-phone Work tab now validates empty sign-in attempts, displays signed-in queue/campaign names, and supports per-membership sign-out. Supervisor queue tiles now expose signed-in, available, busy/reserved/wrap-up, and other not-ready staffing counts. The Asterisk Web dashboard serves SignalR locally and polls only while the SignalR connection is unavailable. - 2026-07-11: Inbound contact phone lookup remains scoped to published contact versions after the shared omnichannel contact index became version-aware. - 2026-07-11: Closed the remaining "zombie inbound offer" gap where a queued call whose Asterisk channel no longer existed kept being offered and accepted, leaving the agent stuck (unable to disconnect, unable to receive new calls). Three provider-truth guards were added and unit-tested: (1) `VoiceContactCenterCallRouter.OfferNextAsync` now refreshes each queued interaction against provider truth in a bounded loop and, when the provider confirms the call is gone, removes it from the queue and releases the reservation/agent via `ReconcileEndedOfferAsync` before offering the next call — a dead call is never offered; (2) `ContactCenterCallCommandService` accept-media failures now re-check provider truth and reconcile a confirmed-gone call (removing the offer and releasing the agent) instead of leaving an accepted reservation stuck on a Cancel no-op; (3) `ProviderVoiceEventService` now seeds a freshly created call session with the interaction's pre-event state instead of the incoming terminal state, so a first-observed terminal event (e.g. a reconcile of an already-gone queued call) still records a non-terminal→terminal transition, publishes `CallEnded`, and runs ended-offer cleanup instead of silently leaving the interaction queued. +3 unit tests (1,279 total pass, clean `-warnaserror` build). Confirmed the Asterisk dashboard is already event-driven end to end (ARI `subscribeAll` forwarder → 50ms-coalesced SignalR snapshot → client `dashboardSnapshot` handler with reconciliation-poll fallback); the observed lag is provider emission/network, not a code defect. diff --git a/Directory.Build.props b/Directory.Build.props index 3c3d902fb..15eb060df 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -19,7 +19,8 @@ - $(CommonTargetFrameworks) + $(CommonTargetFrameworks) + $(CommonTargetFrameworks) enable Mike Alhayek CrestApps 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/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs index 3f6307e84..ffa65b825 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ActivityAssignmentService.cs @@ -1,4 +1,5 @@ using CrestApps.OrchardCore.ContactCenter.Core.Models; +using Microsoft.Extensions.Logging; using OrchardCore.Locking.Distributed; using OrchardCore.Modules; @@ -25,6 +26,7 @@ public sealed class ActivityAssignmentService : IActivityAssignmentService private readonly IContactCenterEventPublisher _publisher; private readonly IDistributedLock _distributedLock; private readonly IClock _clock; + private readonly ILogger _logger; /// /// Initializes a new instance of the class. @@ -38,6 +40,7 @@ public sealed class ActivityAssignmentService : IActivityAssignmentService /// The Contact Center event publisher. /// The distributed lock used to serialize assignment per queue. /// The clock used to evaluate SLA aging and business hours. + /// The logger. public ActivityAssignmentService( IQueueItemManager queueItemManager, IAgentProfileManager agentManager, @@ -47,7 +50,8 @@ public ActivityAssignmentService( IBusinessHoursService businessHours, IContactCenterEventPublisher publisher, IDistributedLock distributedLock, - IClock clock) + IClock clock, + ILogger logger) { _queueItemManager = queueItemManager; _agentManager = agentManager; @@ -58,6 +62,7 @@ public ActivityAssignmentService( _publisher = publisher; _distributedLock = distributedLock; _clock = clock; + _logger = logger; } /// @@ -72,6 +77,13 @@ public async Task AssignNextAsync(string queueId, Cancellat 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); + } + return null; } @@ -92,6 +104,13 @@ public async Task AssignQueueAsync(string queueId, CancellationToken cancel if (!locked) { + if (_logger.IsEnabled(LogLevel.Warning)) + { + _logger.LogWarning( + "Skipped assigning Contact Center queue '{QueueId}' because its assignment lock was not acquired.", + queueId); + } + return 0; } @@ -113,6 +132,14 @@ private async Task AssignNextCoreAsync(string queueId, Canc 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, + queue is null ? "missing" : "disabled"); + } + return null; } @@ -120,6 +147,13 @@ private async Task AssignNextCoreAsync(string queueId, Canc 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); + } + return null; } @@ -128,15 +162,46 @@ private async Task AssignNextCoreAsync(string queueId, Canc if (topItem is null) { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "No waiting Contact Center item is available for queue '{QueueId}'.", + queueId); + } + return null; } var agents = await _agentManager.ListAvailableForQueueAsync(queueId, cancellationToken); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Evaluating Contact Center queue item '{QueueItemId}' for queue '{QueueId}' against {AvailableAgentCount} available agents.", + topItem.ItemId, + queueId, + agents.Count); + } + 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}: eligible={candidate.IsEligible}, reasons={string.Join(", ", candidate.Reasons)}")); + + _logger.LogWarning( + "Contact Center routing did not assign queue item '{QueueItemId}' from queue '{QueueId}'. Reason: {Reason}. Candidates: {CandidateSummary}", + topItem.ItemId, + queueId, + decision.Reason, + candidateSummary); + } + await PublishRoutingDecisionAsync(decision, cancellationToken); return null; @@ -152,6 +217,27 @@ private async Task AssignNextCoreAsync(string queueId, Canc { 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, + decision.Agent.ItemId, + queueId); + } + } + 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, + reservation.ItemId, + decision.Agent.ItemId, + queueId); + } } await PublishRoutingDecisionAsync(decision, cancellationToken); diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs index e605e87ec..db51e7e6c 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/AgentPresenceManagerService.cs @@ -56,6 +56,20 @@ public async Task SignInAsync(string userId, IEnumerable q { ArgumentException.ThrowIfNullOrEmpty(userId); + var selectedQueueIds = queueIds?.Distinct().ToList() ?? []; + var selectedCampaignIds = campaignIds?.Distinct().ToList() ?? []; + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Signing Contact Center user '{UserId}' in to {QueueCount} queues and {CampaignCount} campaigns. Queue ids: {QueueIds}. Campaign ids: {CampaignIds}.", + userId, + selectedQueueIds.Count, + selectedCampaignIds.Count, + string.Join(", ", selectedQueueIds), + string.Join(", ", selectedCampaignIds)); + } + var profile = await _agentManager.FindByUserIdAsync(userId, cancellationToken); if (profile is not null && _agentWorkStateHealingService is not null) @@ -84,8 +98,8 @@ public async Task SignInAsync(string userId, IEnumerable q profile.Name = userId; } - profile.QueueIds = queueIds?.Distinct().ToList() ?? []; - profile.CampaignIds = campaignIds?.Distinct().ToList() ?? []; + profile.QueueIds = selectedQueueIds; + profile.CampaignIds = selectedCampaignIds; profile.PresenceStatus = AgentPresenceStatus.Available; profile.RequestedPresenceStatus = null; profile.PresenceChangedUtc = _clock.UtcNow; @@ -95,6 +109,15 @@ public async Task SignInAsync(string userId, IEnumerable q await SyncSessionMembershipAsync(userId, profile.QueueIds, profile.CampaignIds, cancellationToken); await PublishAsync(ContactCenterConstants.Events.AgentSignedIn, profile, cancellationToken); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Completed Contact Center sign-in for agent '{AgentId}' and user '{UserId}' with presence '{PresenceStatus}'.", + profile.ItemId, + userId, + profile.PresenceStatus); + } + return profile; } @@ -310,6 +333,12 @@ public async Task StartWrapUpAsync(string agentId, CancellationTok return null; } + if (profile.PresenceStatus == AgentPresenceStatus.WrapUp && + string.IsNullOrWhiteSpace(profile.ActiveReservationId)) + { + return profile; + } + profile.PresenceStatus = AgentPresenceStatus.WrapUp; profile.ActiveReservationId = null; profile.PresenceChangedUtc = _clock.UtcNow; @@ -390,6 +419,13 @@ private async Task SyncSessionMembershipAsync( { 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); + } + return; } @@ -397,6 +433,13 @@ private async Task SyncSessionMembershipAsync( 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); + } + return; } @@ -405,6 +448,16 @@ private async Task SyncSessionMembershipAsync( 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, + userId, + session.QueueIds.Count, + session.CampaignIds.Count); + } } private Task PublishAsync(string eventType, AgentProfile profile, CancellationToken cancellationToken) diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs index 7b8e5f081..ffc1794b4 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderCallStateSynchronizationService.cs @@ -21,6 +21,7 @@ public sealed class ProviderCallStateSynchronizationService : IProviderCallState 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; @@ -32,6 +33,7 @@ public sealed class ProviderCallStateSynchronizationService : IProviderCallState /// 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. @@ -40,6 +42,7 @@ public ProviderCallStateSynchronizationService( IInteractionManager interactionManager, ICallSessionManager callSessionManager, IProviderVoiceEventService providerVoiceEventService, + IProviderVoiceOfferSynchronizationService offerSynchronizationService, ITelephonyProviderResolver telephonyProviderResolver, IDistributedLock distributedLock, IClock clock, @@ -48,6 +51,7 @@ public ProviderCallStateSynchronizationService( _interactionManager = interactionManager; _callSessionManager = callSessionManager; _providerVoiceEventService = providerVoiceEventService; + _offerSynchronizationService = offerSynchronizationService; _telephonyProviderResolver = telephonyProviderResolver; _distributedLock = distributedLock; _clock = clock; @@ -64,6 +68,35 @@ public async Task RefreshInteractionAsync(Interaction interaction, 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.Status = 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, + previousStatus, + interaction.Status, + currentSession.ItemId, + currentSession.State); + } + + await _offerSynchronizationService.ReconcileEndedOfferAsync(interaction.ItemId, cancellationToken); + + return interaction; + } + var providerName = interaction.ProviderName; var provider = await _telephonyProviderResolver.GetAsync(providerName); @@ -105,8 +138,6 @@ public async Task RefreshInteractionAsync(Interaction interaction, return interaction; } - var currentSession = await _callSessionManager.FindByInteractionIdAsync(interaction.ItemId, cancellationToken); - if (lookup.Found && IsEquivalent(currentSession, lookup.Call)) { return interaction; @@ -241,4 +272,28 @@ private static bool IsEquivalent(CallSession session, TelephonyCall call) session.IsMuted == call.IsMuted && session.IsOnHold == (mappedState == ContactCenterCallState.OnHold); } + + private static bool TryMapTerminalInteractionStatus( + ContactCenterCallState? callState, + out InteractionStatus interactionStatus) + { + switch (callState) + { + case ContactCenterCallState.Ended: + interactionStatus = InteractionStatus.Ended; + + return true; + case ContactCenterCallState.Failed: + case ContactCenterCallState.NoAnswer: + case ContactCenterCallState.Rejected: + case ContactCenterCallState.Canceled: + interactionStatus = InteractionStatus.Failed; + + return true; + default: + interactionStatus = default; + + return false; + } + } } diff --git a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs index 838edd783..4ddcc85d5 100644 --- a/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs +++ b/src/Core/CrestApps.OrchardCore.ContactCenter.Core/Services/ProviderVoiceOfferSynchronizationService.cs @@ -2,6 +2,7 @@ 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; @@ -18,6 +19,7 @@ public sealed class ProviderVoiceOfferSynchronizationService : IProviderVoiceOff private readonly IActivityReservationManager _reservationManager; private readonly IAgentProfileManager _agentManager; private readonly IOmnichannelActivityManager _activityManager; + private readonly IServiceProvider _serviceProvider; private readonly IClock _clock; private readonly ILogger _logger; @@ -30,6 +32,7 @@ public sealed class ProviderVoiceOfferSynchronizationService : IProviderVoiceOff /// The reservation manager. /// The agent manager. /// The activity manager. + /// The service provider used to lazily resolve presence management without an event-publisher cycle. /// The clock. /// The logger. public ProviderVoiceOfferSynchronizationService( @@ -39,6 +42,7 @@ public ProviderVoiceOfferSynchronizationService( IActivityReservationManager reservationManager, IAgentProfileManager agentManager, IOmnichannelActivityManager activityManager, + IServiceProvider serviceProvider, IClock clock, ILogger logger) { @@ -48,6 +52,7 @@ public ProviderVoiceOfferSynchronizationService( _reservationManager = reservationManager; _agentManager = agentManager; _activityManager = activityManager; + _serviceProvider = serviceProvider; _clock = clock; _logger = logger; } @@ -72,13 +77,16 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok return; } - var wasAnswered = interaction.AnsweredUtc.HasValue || session?.AnsweredUtc.HasValue == true; var queueItem = await _queueItemManager.FindByActivityIdAsync(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; @@ -90,7 +98,7 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok canceledReservationIds.Add(reservation.ItemId); } - if (wasAnswered) + if (wasAnsweredByAgent) { if (queueItem?.Status == QueueItemStatus.Assigned) { @@ -99,12 +107,13 @@ public async Task ReconcileEndedOfferAsync(string interactionId, CancellationTok await _queueItemManager.UpdateAsync(queueItem, cancellationToken: cancellationToken); } - // The wrap-up cascade owns the agent's presence transition for answered calls, but still clear a - // dangling reservation pointer so the agent is not blocked from receiving the next offer. - await ClearStaleReservationPointerAsync( - session?.AgentId ?? interaction.AgentId ?? reservationAgentId, - canceledReservationIds, - cancellationToken); + var answeredAgentId = session?.AgentId ?? interaction.AgentId ?? reservationAgentId; + + if (!string.IsNullOrWhiteSpace(answeredAgentId)) + { + var presenceManager = _serviceProvider.GetRequiredService(); + await presenceManager.StartWrapUpAsync(answeredAgentId, cancellationToken); + } return; } @@ -117,7 +126,8 @@ await ClearStaleReservationPointerAsync( interaction.ActivityItemId); } - if (queueItem is not null && queueItem.Status is QueueItemStatus.Reserved or QueueItemStatus.Assigned) + if (queueItem is not null && + queueItem.Status is QueueItemStatus.Waiting or QueueItemStatus.Reserved or QueueItemStatus.Assigned) { queueItem.Status = QueueItemStatus.Removed; queueItem.DequeuedUtc = _clock.UtcNow; @@ -138,7 +148,9 @@ await ClearStaleReservationPointerAsync( agent.ActiveReservationId = null; } - if (agent.PresenceStatus is AgentPresenceStatus.Reserved or AgentPresenceStatus.Busy) + if (agent.PresenceStatus is AgentPresenceStatus.Reserved or + AgentPresenceStatus.Busy or + AgentPresenceStatus.WrapUp) { agent.PresenceStatus = AgentPresenceUtilities.ResolveDefaultReadyState(agent); } @@ -168,29 +180,6 @@ await ClearStaleReservationPointerAsync( await _activityManager.UpdateAsync(activity, cancellationToken: cancellationToken); } - private async Task ClearStaleReservationPointerAsync( - string agentId, - HashSet canceledReservationIds, - CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(agentId) || canceledReservationIds.Count == 0) - { - return; - } - - var agent = await _agentManager.FindByIdAsync(agentId, cancellationToken); - - if (agent is null || - string.IsNullOrWhiteSpace(agent.ActiveReservationId) || - !canceledReservationIds.Contains(agent.ActiveReservationId)) - { - return; - } - - agent.ActiveReservationId = null; - await _agentManager.UpdateAsync(agent, cancellationToken: cancellationToken); - } - private static bool IsTerminalState(ContactCenterCallState? state) { return state is ContactCenterCallState.Ended or diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md index d4d4021c1..8ae016992 100644 --- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md +++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md @@ -53,6 +53,12 @@ At a high level, the platform changes are: ### Contact Center +- 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 campaigns(s)** instead of appearing to choose the first membership. +- 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. +- 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. @@ -83,7 +89,7 @@ At a high level, the platform changes are: - 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. -- The Aspire AppHost now launches every multi-target project resource with the explicit `net10.0` framework so Orchard Core, Asterisk Web, and the sample clients do not exit at startup with a framework-selection error. +- 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. diff --git a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md index cc81330fe..04a7de21a 100644 --- a/src/CrestApps.Docs/docs/contact-center/agent-desktop.md +++ b/src/CrestApps.Docs/docs/contact-center/agent-desktop.md @@ -94,6 +94,7 @@ Open **Interaction Center → My workspace**. This is the screen an agent keeps ### 1. Sign in and set your presence - **Sign in to queues and campaigns** from the soft phone's **Work** tab. You can only choose queues and campaigns you are allowed to handle. +- Empty queue and campaign selectors show **Select queue(s)** and **Select campaigns(s)**. No membership is selected until you explicitly choose it. - Select at least one queue or campaign before signing in. The Work tab shows an inline error when nothing is selected. - After sign-in, the Work tab lists every queue and campaign you are signed in to. Use the individual **Sign out** action to leave one membership while remaining signed in to the others, or **Sign out of all** to leave every membership. - If inbound voice work is already waiting in one of those queues, signing in or switching back to **Available** immediately asks routing to offer the next queued call instead of waiting for another inbound event. @@ -139,6 +140,10 @@ When Contact Center owns the assigned voice interaction, server-side call-sessio Contact Center also runs a provider-truth reconciliation pass when the tenant activates and on a periodic safety cadence. If Orchard Core restarts during busy hours, persisted ringing or active interactions are revalidated against the telephony server before routing resumes, and a pre-connect offer that already ended on the provider side is removed from the queue instead of being re-offered as a ghost call. +If a prior terminal provider event was already recorded in the call session but another recovery path left the interaction nonterminal, reconciliation now repairs the interaction from the terminal call session before capacity is evaluated, then clears stale queue, reservation, and agent state. This prevents an ended call from consuming the agent's `MaxConcurrentInteractions` slot indefinitely. + +For inbound server-side calls, a provider may report the caller leg as connected before an agent accepts the Contact Center offer. Ended-offer cleanup therefore uses the accepted reservation or assigned queue item—not the provider leg's answered timestamp—to decide whether the work reached an agent. A terminal call that was only waiting or reserved is removed and releases the agent so routing can continue to the next live call. + ### 4. Complete the activity in the CRM When the conversation ends, click **Complete activity** in the active panel. This opens the shared Omnichannel completion page for the assigned activity, so contact-center work follows the same CRM experience as manual activities: diff --git a/src/CrestApps.Docs/docs/telephony/asterisk.md b/src/CrestApps.Docs/docs/telephony/asterisk.md index e50b4f529..0b25512b3 100644 --- a/src/CrestApps.Docs/docs/telephony/asterisk.md +++ b/src/CrestApps.Docs/docs/telephony/asterisk.md @@ -165,7 +165,7 @@ 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. The AppHost explicitly launches its multi-target project resources with `net10.0`, preventing `dotnet run` from exiting before the Orchard and Asterisk Web applications start. +`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: @@ -202,6 +202,8 @@ If the soft phone dials successfully but `channels` stays empty while the call i 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 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. diff --git a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml index 67acaacd0..e5db99994 100644 --- a/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml +++ b/src/Modules/CrestApps.OrchardCore.ContactCenter/Views/Items/ContactCenterSoftPhoneWork.View.cshtml @@ -37,20 +37,20 @@
    - @foreach (var queue in viewModel.AvailableQueues) { - + }
    - @foreach (var option in viewModel.CampaignOptions) { - + }
    diff --git a/src/Startup/CrestApps.Aspire.AppHost/Program.cs b/src/Startup/CrestApps.Aspire.AppHost/Program.cs index 1fe723042..d15d5d605 100644 --- a/src/Startup/CrestApps.Aspire.AppHost/Program.cs +++ b/src/Startup/CrestApps.Aspire.AppHost/Program.cs @@ -27,7 +27,6 @@ // .WithReference(redis) // .WithReference(ollama) // .WaitFor(redis) - .WithArgs("--framework", "net10.0") .WaitFor(asterisk) .WithHttpsEndpoint(5001, name: "HttpsOrchardCore") .WithEnvironment((options) => @@ -78,21 +77,18 @@ }); builder.AddProject("McpClientSample") - .WithArgs("--framework", "net10.0") .WithReference(orchardCore) .WaitFor(orchardCore) .WithHttpsEndpoint(5002, name: "HttpsMcpClient") .WithEnvironment("Mcp__Endpoint", "https://localhost:5001/mcp"); builder.AddProject("A2AClientSample") - .WithArgs("--framework", "net10.0") .WithReference(orchardCore) .WaitFor(orchardCore) .WithHttpsEndpoint(5003, name: "HttpsA2AClient") .WithEnvironment("A2A__Endpoint", "https://localhost:5001"); builder.AddProject("AsteriskWeb") - .WithArgs("--framework", "net10.0") .WithReference(orchardCore) .WaitFor(orchardCore) .WaitFor(asterisk) diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Assets.json b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Assets.json new file mode 100644 index 000000000..76aa87c6e --- /dev/null +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Assets.json @@ -0,0 +1,8 @@ +[ + { + "inputs": [ + "../../Modules/CrestApps.OrchardCore.SignalR/node_modules/@microsoft/signalr/dist/browser/signalr.js" + ], + "output": "wwwroot/js/signalr.js" + } +] diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj index d79899af4..b39cb74ec 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/CrestApps.OrchardCore.Asterisk.Web.csproj @@ -5,11 +5,4 @@ false - - - - diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Program.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Program.cs index 0843724fa..ae8f553d8 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Program.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Program.cs @@ -41,7 +41,7 @@ async Task>> ( } await diagnosticsService.DisconnectChannelAsync(channelId, cancellationToken); - dashboardBroadcastService.RequestRefresh(); + dashboardBroadcastService.RequestRefresh("dashboard disconnect action"); return TypedResults.NoContent(); }); diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs index 6c99606ec..dcf27b140 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDashboardBroadcastService.cs @@ -1,6 +1,7 @@ using System.Threading.Channels; using CrestApps.OrchardCore.Asterisk.Web.Hubs; using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; namespace CrestApps.OrchardCore.Asterisk.Web.Services; @@ -13,7 +14,9 @@ public sealed class AsteriskDashboardBroadcastService : BackgroundService private readonly AsteriskDiagnosticsService _asteriskDiagnosticsService; private readonly IHubContext _hubContext; - private readonly Channel _refreshSignals = Channel.CreateBounded(new BoundedChannelOptions(1) + private readonly ILogger _logger; + private readonly TimeProvider _timeProvider; + private readonly Channel _refreshSignals = Channel.CreateBounded(new BoundedChannelOptions(1) { SingleReader = true, FullMode = BoundedChannelFullMode.DropOldest, @@ -24,20 +27,37 @@ public sealed class AsteriskDashboardBroadcastService : BackgroundService ///
    /// The diagnostics snapshot provider. /// The SignalR hub context. + /// The time provider used to measure refresh latency. + /// The logger. public AsteriskDashboardBroadcastService( AsteriskDiagnosticsService asteriskDiagnosticsService, - IHubContext hubContext) + IHubContext hubContext, + TimeProvider timeProvider, + ILogger logger) { _asteriskDiagnosticsService = asteriskDiagnosticsService; _hubContext = hubContext; + _timeProvider = timeProvider; + _logger = logger; } /// /// Requests an immediate dashboard refresh and broadcast. /// - public void RequestRefresh() + /// The event or action that requested the refresh. + public void RequestRefresh(string source) { - _refreshSignals.Writer.TryWrite(true); + ArgumentException.ThrowIfNullOrEmpty(source); + + if (!_refreshSignals.Writer.TryWrite(source)) + { + if (_logger.IsEnabled(LogLevel.Debug)) + { + _logger.LogDebug( + "Coalesced Asterisk dashboard refresh request from '{RefreshSource}' because another refresh is already pending.", + source); + } + } } /// @@ -46,16 +66,18 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) var reconciliationInterval = TimeSpan.FromSeconds(_asteriskDiagnosticsService.RefreshSeconds); var refreshSignalTask = _refreshSignals.Reader.ReadAsync(stoppingToken).AsTask(); - await BroadcastSnapshotAsync(stoppingToken); + await BroadcastSnapshotAsync("startup", stoppingToken); while (!stoppingToken.IsCancellationRequested) { var timerTask = Task.Delay(reconciliationInterval, stoppingToken); var completedTask = await Task.WhenAny(refreshSignalTask, timerTask); + var refreshSource = "periodic reconciliation"; + if (completedTask == refreshSignalTask) { - await refreshSignalTask; + refreshSource = await refreshSignalTask; refreshSignalTask = _refreshSignals.Reader.ReadAsync(stoppingToken).AsTask(); await Task.Delay(EventCoalescingDelay, stoppingToken); @@ -64,14 +86,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) } } - await BroadcastSnapshotAsync(stoppingToken); + await BroadcastSnapshotAsync(refreshSource, stoppingToken); } } - private async Task BroadcastSnapshotAsync(CancellationToken cancellationToken) + private async Task BroadcastSnapshotAsync(string source, CancellationToken cancellationToken) { + var startedTimestamp = _timeProvider.GetTimestamp(); var snapshot = await _asteriskDiagnosticsService.RefreshAsync(cancellationToken); + var snapshotElapsed = _timeProvider.GetElapsedTime(startedTimestamp); await _hubContext.Clients.All.SendAsync("dashboardSnapshot", snapshot, cancellationToken); + var broadcastElapsed = _timeProvider.GetElapsedTime(startedTimestamp); + + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Broadcast Asterisk dashboard snapshot requested by '{RefreshSource}' in {BroadcastMilliseconds} ms; snapshot acquisition took {SnapshotMilliseconds} ms. Channels: {ChannelCount}. Bridges: {BridgeCount}. Logical calls: {CallCount}.", + source, + broadcastElapsed.TotalMilliseconds, + snapshotElapsed.TotalMilliseconds, + snapshot.ChannelCount, + snapshot.BridgeCount, + snapshot.ActiveCallCount); + } } } diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs index 8e011a025..af1b8da1e 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskDiagnosticsService.cs @@ -1,5 +1,6 @@ using System.Text.Json; using CrestApps.OrchardCore.Asterisk.Web.Models; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace CrestApps.OrchardCore.Asterisk.Web.Services; @@ -21,6 +22,7 @@ public sealed class AsteriskDiagnosticsService private readonly AsteriskWebOptions _options; private readonly SemaphoreSlim _refreshLock = new(1, 1); private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; private AsteriskDiagnosticsSnapshot _currentSnapshot = new(); @@ -30,14 +32,17 @@ public sealed class AsteriskDiagnosticsService /// The HTTP client factory. /// The configured sample app options. /// The time provider. + /// The logger. public AsteriskDiagnosticsService( IHttpClientFactory httpClientFactory, IOptions options, - TimeProvider timeProvider) + TimeProvider timeProvider, + ILogger logger) { _httpClientFactory = httpClientFactory; _options = options.Value; _timeProvider = timeProvider; + _logger = logger; } /// @@ -67,12 +72,28 @@ public async Task GetSnapshotAsync(CancellationToke /// The refreshed dashboard snapshot. public async Task RefreshAsync(CancellationToken cancellationToken) { + var waitStartedTimestamp = _timeProvider.GetTimestamp(); await _refreshLock.WaitAsync(cancellationToken); + var lockWaitElapsed = _timeProvider.GetElapsedTime(waitStartedTimestamp); + var refreshStartedTimestamp = _timeProvider.GetTimestamp(); try { _currentSnapshot = await LoadSnapshotAsync(cancellationToken); + if (_logger.IsEnabled(LogLevel.Information)) + { + _logger.LogInformation( + "Refreshed Asterisk diagnostics in {RefreshMilliseconds} ms after waiting {LockWaitMilliseconds} ms for the refresh lock. Reachable: {Reachable}. Channels: {ChannelCount}. Bridges: {BridgeCount}. Calls: {CallCount}. Error: {ErrorMessage}", + _timeProvider.GetElapsedTime(refreshStartedTimestamp).TotalMilliseconds, + lockWaitElapsed.TotalMilliseconds, + _currentSnapshot.Reachable, + _currentSnapshot.ChannelCount, + _currentSnapshot.BridgeCount, + _currentSnapshot.ActiveCallCount, + _currentSnapshot.ErrorMessage); + } + return _currentSnapshot; } finally diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs index 73b385ba4..6baeb0984 100644 --- a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/Services/AsteriskStasisEventForwarderService.cs @@ -184,9 +184,10 @@ private async Task HandleEventAsync(string payload, CancellationToken cancellati return; } - _dashboardBroadcastService.RequestRefresh(); + var eventType = typeElement.GetString(); + _dashboardBroadcastService.RequestRefresh(eventType ?? "unknown Asterisk event"); - if (!string.Equals(typeElement.GetString(), "StasisStart", StringComparison.OrdinalIgnoreCase)) + if (!string.Equals(eventType, "StasisStart", StringComparison.OrdinalIgnoreCase)) { return; } diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.js b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.js new file mode 100644 index 000000000..31a91674c --- /dev/null +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.js @@ -0,0 +1,4776 @@ +/* +** NOTE: This file is generated by Gulp and should not be edited directly! +** Any changes made directly to this file will be overwritten next time its asset group is processed by Gulp. +*/ + +function _regeneratorValues(e) { if (null != e) { var t = e["function" == typeof Symbol && Symbol.iterator || "@@iterator"], r = 0; if (t) return t.call(e); if ("function" == typeof e.next) return e; if (!isNaN(e.length)) return { next: function next() { return e && r >= e.length && (e = void 0), { value: e && e[r++], done: !e }; } }; } throw new TypeError(_typeof(e) + " is not iterable"); } +function _toConsumableArray(r) { return _arrayWithoutHoles(r) || _iterableToArray(r) || _unsupportedIterableToArray(r) || _nonIterableSpread(); } +function _nonIterableSpread() { throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } +function _iterableToArray(r) { if ("undefined" != typeof Symbol && null != r[Symbol.iterator] || null != r["@@iterator"]) return Array.from(r); } +function _arrayWithoutHoles(r) { if (Array.isArray(r)) return _arrayLikeToArray(r); } +function _slicedToArray(r, e) { return _arrayWithHoles(r) || _iterableToArrayLimit(r, e) || _unsupportedIterableToArray(r, e) || _nonIterableRest(); } +function _nonIterableRest() { throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } +function _iterableToArrayLimit(r, l) { var t = null == r ? null : "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (null != t) { var e, n, i, u, a = [], f = !0, o = !1; try { if (i = (t = t.call(r)).next, 0 === l) { if (Object(t) !== t) return; f = !1; } else for (; !(f = (e = i.call(t)).done) && (a.push(e.value), a.length !== l); f = !0); } catch (r) { o = !0, n = r; } finally { try { if (!f && null != t["return"] && (u = t["return"](), Object(u) !== u)) return; } finally { if (o) throw n; } } return a; } } +function _arrayWithHoles(r) { if (Array.isArray(r)) return r; } +function _createForOfIteratorHelper(r, e) { var t = "undefined" != typeof Symbol && r[Symbol.iterator] || r["@@iterator"]; if (!t) { if (Array.isArray(r) || (t = _unsupportedIterableToArray(r)) || e && r && "number" == typeof r.length) { t && (r = t); var _n = 0, F = function F() {}; return { s: F, n: function n() { return _n >= r.length ? { done: !0 } : { done: !1, value: r[_n++] }; }, e: function e(r) { throw r; }, f: F }; } throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method."); } var o, a = !0, u = !1; return { s: function s() { t = t.call(r); }, n: function n() { var r = t.next(); return a = r.done, r; }, e: function e(r) { u = !0, o = r; }, f: function f() { try { a || null == t["return"] || t["return"](); } finally { if (u) throw o; } } }; } +function _unsupportedIterableToArray(r, a) { if (r) { if ("string" == typeof r) return _arrayLikeToArray(r, a); var t = {}.toString.call(r).slice(8, -1); return "Object" === t && r.constructor && (t = r.constructor.name), "Map" === t || "Set" === t ? Array.from(r) : "Arguments" === t || /^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(t) ? _arrayLikeToArray(r, a) : void 0; } } +function _arrayLikeToArray(r, a) { (null == a || a > r.length) && (a = r.length); for (var e = 0, n = Array(a); e < a; e++) n[e] = r[e]; return n; } +function _regenerator() { /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/babel/babel/blob/main/packages/babel-helpers/LICENSE */ var e, t, r = "function" == typeof Symbol ? Symbol : {}, n = r.iterator || "@@iterator", o = r.toStringTag || "@@toStringTag"; function i(r, n, o, i) { var c = n && n.prototype instanceof Generator ? n : Generator, u = Object.create(c.prototype); return _regeneratorDefine2(u, "_invoke", function (r, n, o) { var i, c, u, f = 0, p = o || [], y = !1, G = { p: 0, n: 0, v: e, a: d, f: d.bind(e, 4), d: function d(t, r) { return i = t, c = 0, u = e, G.n = r, a; } }; function d(r, n) { for (c = r, u = n, t = 0; !y && f && !o && t < p.length; t++) { var o, i = p[t], d = G.p, l = i[2]; r > 3 ? (o = l === n) && (u = i[(c = i[4]) ? 5 : (c = 3, 3)], i[4] = i[5] = e) : i[0] <= d && ((o = r < 2 && d < i[1]) ? (c = 0, G.v = n, G.n = i[1]) : d < l && (o = r < 3 || i[0] > n || n > l) && (i[4] = r, i[5] = n, G.n = l, c = 0)); } if (o || r > 1) return a; throw y = !0, n; } return function (o, p, l) { if (f > 1) throw TypeError("Generator is already running"); for (y && 1 === p && d(p, l), c = p, u = l; (t = c < 2 ? e : u) || !y;) { i || (c ? c < 3 ? (c > 1 && (G.n = -1), d(c, u)) : G.n = u : G.v = u); try { if (f = 2, i) { if (c || (o = "next"), t = i[o]) { if (!(t = t.call(i, u))) throw TypeError("iterator result is not an object"); if (!t.done) return t; u = t.value, c < 2 && (c = 0); } else 1 === c && (t = i["return"]) && t.call(i), c < 2 && (u = TypeError("The iterator does not provide a '" + o + "' method"), c = 1); i = e; } else if ((t = (y = G.n < 0) ? u : r.call(n, G)) !== a) break; } catch (t) { i = e, c = 1, u = t; } finally { f = 1; } } return { value: t, done: y }; }; }(r, o, i), !0), u; } var a = {}; function Generator() {} function GeneratorFunction() {} function GeneratorFunctionPrototype() {} t = Object.getPrototypeOf; var c = [][n] ? t(t([][n]())) : (_regeneratorDefine2(t = {}, n, function () { return this; }), t), u = GeneratorFunctionPrototype.prototype = Generator.prototype = Object.create(c); function f(e) { return Object.setPrototypeOf ? Object.setPrototypeOf(e, GeneratorFunctionPrototype) : (e.__proto__ = GeneratorFunctionPrototype, _regeneratorDefine2(e, o, "GeneratorFunction")), e.prototype = Object.create(u), e; } return GeneratorFunction.prototype = GeneratorFunctionPrototype, _regeneratorDefine2(u, "constructor", GeneratorFunctionPrototype), _regeneratorDefine2(GeneratorFunctionPrototype, "constructor", GeneratorFunction), GeneratorFunction.displayName = "GeneratorFunction", _regeneratorDefine2(GeneratorFunctionPrototype, o, "GeneratorFunction"), _regeneratorDefine2(u), _regeneratorDefine2(u, o, "Generator"), _regeneratorDefine2(u, n, function () { return this; }), _regeneratorDefine2(u, "toString", function () { return "[object Generator]"; }), (_regenerator = function _regenerator() { return { w: i, m: f }; })(); } +function _regeneratorDefine2(e, r, n, t) { var i = Object.defineProperty; try { i({}, "", {}); } catch (e) { i = 0; } _regeneratorDefine2 = function _regeneratorDefine(e, r, n, t) { function o(r, n) { _regeneratorDefine2(e, r, function (e) { return this._invoke(r, n, e); }); } r ? i ? i(e, r, { value: n, enumerable: !t, configurable: !t, writable: !t }) : e[r] = n : (o("next", 0), o("throw", 1), o("return", 2)); }, _regeneratorDefine2(e, r, n, t); } +function asyncGeneratorStep(n, t, e, r, o, a, c) { try { var i = n[a](c), u = i.value; } catch (n) { return void e(n); } i.done ? t(u) : Promise.resolve(u).then(r, o); } +function _asyncToGenerator(n) { return function () { var t = this, e = arguments; return new Promise(function (r, o) { var a = n.apply(t, e); function _next(n) { asyncGeneratorStep(a, r, o, _next, _throw, "next", n); } function _throw(n) { asyncGeneratorStep(a, r, o, _next, _throw, "throw", n); } _next(void 0); }); }; } +function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; } +function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; } +function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } +function _defineProperties(e, r) { for (var t = 0; t < r.length; t++) { var o = r[t]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(e, _toPropertyKey(o.key), o); } } +function _createClass(e, r, t) { return r && _defineProperties(e.prototype, r), t && _defineProperties(e, t), Object.defineProperty(e, "prototype", { writable: !1 }), e; } +function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == _typeof(i) ? i : i + ""; } +function _toPrimitive(t, r) { if ("object" != _typeof(t) || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != _typeof(i)) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } +function _classCallCheck(a, n) { if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function"); } +function _callSuper(t, o, e) { return o = _getPrototypeOf(o), _possibleConstructorReturn(t, _isNativeReflectConstruct() ? Reflect.construct(o, e || [], _getPrototypeOf(t).constructor) : o.apply(t, e)); } +function _possibleConstructorReturn(t, e) { if (e && ("object" == _typeof(e) || "function" == typeof e)) return e; if (void 0 !== e) throw new TypeError("Derived constructors may only return object or undefined"); return _assertThisInitialized(t); } +function _assertThisInitialized(e) { if (void 0 === e) throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); return e; } +function _inherits(t, e) { if ("function" != typeof e && null !== e) throw new TypeError("Super expression must either be null or a function"); t.prototype = Object.create(e && e.prototype, { constructor: { value: t, writable: !0, configurable: !0 } }), Object.defineProperty(t, "prototype", { writable: !1 }), e && _setPrototypeOf(t, e); } +function _wrapNativeSuper(t) { var r = "function" == typeof Map ? new Map() : void 0; return _wrapNativeSuper = function _wrapNativeSuper(t) { if (null === t || !_isNativeFunction(t)) return t; if ("function" != typeof t) throw new TypeError("Super expression must either be null or a function"); if (void 0 !== r) { if (r.has(t)) return r.get(t); r.set(t, Wrapper); } function Wrapper() { return _construct(t, arguments, _getPrototypeOf(this).constructor); } return Wrapper.prototype = Object.create(t.prototype, { constructor: { value: Wrapper, enumerable: !1, writable: !0, configurable: !0 } }), _setPrototypeOf(Wrapper, t); }, _wrapNativeSuper(t); } +function _construct(t, e, r) { if (_isNativeReflectConstruct()) return Reflect.construct.apply(null, arguments); var o = [null]; o.push.apply(o, e); var p = new (t.bind.apply(t, o))(); return r && _setPrototypeOf(p, r.prototype), p; } +function _isNativeReflectConstruct() { try { var t = !Boolean.prototype.valueOf.call(Reflect.construct(Boolean, [], function () {})); } catch (t) {} return (_isNativeReflectConstruct = function _isNativeReflectConstruct() { return !!t; })(); } +function _isNativeFunction(t) { try { return -1 !== Function.toString.call(t).indexOf("[native code]"); } catch (n) { return "function" == typeof t; } } +function _setPrototypeOf(t, e) { return _setPrototypeOf = Object.setPrototypeOf ? Object.setPrototypeOf.bind() : function (t, e) { return t.__proto__ = e, t; }, _setPrototypeOf(t, e); } +function _getPrototypeOf(t) { return _getPrototypeOf = Object.setPrototypeOf ? Object.getPrototypeOf.bind() : function (t) { return t.__proto__ || Object.getPrototypeOf(t); }, _getPrototypeOf(t); } +function _typeof(o) { "@babel/helpers - typeof"; return _typeof = "function" == typeof Symbol && "symbol" == typeof Symbol.iterator ? function (o) { return typeof o; } : function (o) { return o && "function" == typeof Symbol && o.constructor === Symbol && o !== Symbol.prototype ? "symbol" : typeof o; }, _typeof(o); } +(function webpackUniversalModuleDefinition(root, factory) { + if ((typeof exports === "undefined" ? "undefined" : _typeof(exports)) === 'object' && (typeof module === "undefined" ? "undefined" : _typeof(module)) === 'object') module.exports = factory();else if (typeof define === 'function' && define.amd) define([], factory);else if ((typeof exports === "undefined" ? "undefined" : _typeof(exports)) === 'object') exports["signalR"] = factory();else root["signalR"] = factory(); +})(self, function () { + return /******/function () { + // webpackBootstrap + /******/ + "use strict"; + + /******/ // The require scope + /******/ + var __webpack_require__ = {}; + /******/ + /************************************************************************/ + /******/ /* webpack/runtime/define property getters */ + /******/ + (function () { + /******/ // define getter functions for harmony exports + /******/__webpack_require__.d = function (exports, definition) { + /******/for (var key in definition) { + /******/if (__webpack_require__.o(definition, key) && !__webpack_require__.o(exports, key)) { + /******/Object.defineProperty(exports, key, { + enumerable: true, + get: definition[key] + }); + /******/ + } + /******/ + } + /******/ + }; + /******/ + })(); + /******/ + /******/ /* webpack/runtime/global */ + /******/ + (function () { + /******/__webpack_require__.g = function () { + /******/if ((typeof globalThis === "undefined" ? "undefined" : _typeof(globalThis)) === 'object') return globalThis; + /******/ + try { + /******/return this || new Function('return this')(); + /******/ + } catch (e) { + /******/if ((typeof window === "undefined" ? "undefined" : _typeof(window)) === 'object') return window; + /******/ + } + /******/ + }(); + /******/ + })(); + /******/ + /******/ /* webpack/runtime/hasOwnProperty shorthand */ + /******/ + (function () { + /******/__webpack_require__.o = function (obj, prop) { + return Object.prototype.hasOwnProperty.call(obj, prop); + }; + /******/ + })(); + /******/ + /******/ /* webpack/runtime/make namespace object */ + /******/ + (function () { + /******/ // define __esModule on exports + /******/__webpack_require__.r = function (exports) { + /******/if (typeof Symbol !== 'undefined' && Symbol.toStringTag) { + /******/Object.defineProperty(exports, Symbol.toStringTag, { + value: 'Module' + }); + /******/ + } + /******/ + Object.defineProperty(exports, '__esModule', { + value: true + }); + /******/ + }; + /******/ + })(); + /******/ + /************************************************************************/ + var __webpack_exports__ = {}; + // ESM COMPAT FLAG + __webpack_require__.r(__webpack_exports__); + + // EXPORTS + __webpack_require__.d(__webpack_exports__, { + AbortError: function AbortError() { + return /* reexport */_AbortError; + }, + DefaultHttpClient: function DefaultHttpClient() { + return /* reexport */_DefaultHttpClient; + }, + HttpClient: function HttpClient() { + return /* reexport */_HttpClient; + }, + HttpError: function HttpError() { + return /* reexport */_HttpError; + }, + HttpResponse: function HttpResponse() { + return /* reexport */_HttpResponse; + }, + HttpTransportType: function HttpTransportType() { + return /* reexport */_HttpTransportType; + }, + HubConnection: function HubConnection() { + return /* reexport */_HubConnection; + }, + HubConnectionBuilder: function HubConnectionBuilder() { + return /* reexport */_HubConnectionBuilder; + }, + HubConnectionState: function HubConnectionState() { + return /* reexport */_HubConnectionState; + }, + JsonHubProtocol: function JsonHubProtocol() { + return /* reexport */_JsonHubProtocol; + }, + LogLevel: function LogLevel() { + return /* reexport */_LogLevel; + }, + MessageType: function MessageType() { + return /* reexport */_MessageType; + }, + NullLogger: function NullLogger() { + return /* reexport */_NullLogger; + }, + Subject: function Subject() { + return /* reexport */_Subject; + }, + TimeoutError: function TimeoutError() { + return /* reexport */_TimeoutError; + }, + TransferFormat: function TransferFormat() { + return /* reexport */_TransferFormat; + }, + VERSION: function VERSION() { + return /* reexport */_VERSION; + } + }); + ; // ./src/Errors.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + /** Error thrown when an HTTP request fails. */ + var _HttpError = /*#__PURE__*/function (_Error) { + /** Constructs a new instance of {@link @microsoft/signalr.HttpError}. + * + * @param {string} errorMessage A descriptive error message. + * @param {number} statusCode The HTTP status code represented by this error. + */ + function _HttpError(errorMessage, statusCode) { + var _this; + _classCallCheck(this, _HttpError); + var trueProto = (this instanceof _HttpError ? this.constructor : void 0).prototype; + _this = _callSuper(this, _HttpError, ["".concat(errorMessage, ": Status code '").concat(statusCode, "'")]); + _this.statusCode = statusCode; + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this.__proto__ = trueProto; + return _this; + } + _inherits(_HttpError, _Error); + return _createClass(_HttpError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when a timeout elapses. */ + var _TimeoutError = /*#__PURE__*/function (_Error2) { + /** Constructs a new instance of {@link @microsoft/signalr.TimeoutError}. + * + * @param {string} errorMessage A descriptive error message. + */ + function _TimeoutError() { + var _this2; + var errorMessage = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "A timeout occurred."; + _classCallCheck(this, _TimeoutError); + var trueProto = (this instanceof _TimeoutError ? this.constructor : void 0).prototype; + _this2 = _callSuper(this, _TimeoutError, [errorMessage]); + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this2.__proto__ = trueProto; + return _this2; + } + _inherits(_TimeoutError, _Error2); + return _createClass(_TimeoutError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when an action is aborted. */ + var _AbortError = /*#__PURE__*/function (_Error3) { + /** Constructs a new instance of {@link AbortError}. + * + * @param {string} errorMessage A descriptive error message. + */ + function _AbortError() { + var _this3; + var errorMessage = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : "An abort occurred."; + _classCallCheck(this, _AbortError); + var trueProto = (this instanceof _AbortError ? this.constructor : void 0).prototype; + _this3 = _callSuper(this, _AbortError, [errorMessage]); + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this3.__proto__ = trueProto; + return _this3; + } + _inherits(_AbortError, _Error3); + return _createClass(_AbortError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when the selected transport is unsupported by the browser. */ + /** @private */ + var UnsupportedTransportError = /*#__PURE__*/function (_Error4) { + /** Constructs a new instance of {@link @microsoft/signalr.UnsupportedTransportError}. + * + * @param {string} message A descriptive error message. + * @param {HttpTransportType} transport The {@link @microsoft/signalr.HttpTransportType} this error occurred on. + */ + function UnsupportedTransportError(message, transport) { + var _this4; + _classCallCheck(this, UnsupportedTransportError); + var trueProto = (this instanceof UnsupportedTransportError ? this.constructor : void 0).prototype; + _this4 = _callSuper(this, UnsupportedTransportError, [message]); + _this4.transport = transport; + _this4.errorType = 'UnsupportedTransportError'; + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this4.__proto__ = trueProto; + return _this4; + } + _inherits(UnsupportedTransportError, _Error4); + return _createClass(UnsupportedTransportError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when the selected transport is disabled by the browser. */ + /** @private */ + var DisabledTransportError = /*#__PURE__*/function (_Error5) { + /** Constructs a new instance of {@link @microsoft/signalr.DisabledTransportError}. + * + * @param {string} message A descriptive error message. + * @param {HttpTransportType} transport The {@link @microsoft/signalr.HttpTransportType} this error occurred on. + */ + function DisabledTransportError(message, transport) { + var _this5; + _classCallCheck(this, DisabledTransportError); + var trueProto = (this instanceof DisabledTransportError ? this.constructor : void 0).prototype; + _this5 = _callSuper(this, DisabledTransportError, [message]); + _this5.transport = transport; + _this5.errorType = 'DisabledTransportError'; + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this5.__proto__ = trueProto; + return _this5; + } + _inherits(DisabledTransportError, _Error5); + return _createClass(DisabledTransportError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when the selected transport cannot be started. */ + /** @private */ + var FailedToStartTransportError = /*#__PURE__*/function (_Error6) { + /** Constructs a new instance of {@link @microsoft/signalr.FailedToStartTransportError}. + * + * @param {string} message A descriptive error message. + * @param {HttpTransportType} transport The {@link @microsoft/signalr.HttpTransportType} this error occurred on. + */ + function FailedToStartTransportError(message, transport) { + var _this6; + _classCallCheck(this, FailedToStartTransportError); + var trueProto = (this instanceof FailedToStartTransportError ? this.constructor : void 0).prototype; + _this6 = _callSuper(this, FailedToStartTransportError, [message]); + _this6.transport = transport; + _this6.errorType = 'FailedToStartTransportError'; + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this6.__proto__ = trueProto; + return _this6; + } + _inherits(FailedToStartTransportError, _Error6); + return _createClass(FailedToStartTransportError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when the negotiation with the server failed to complete. */ + /** @private */ + var FailedToNegotiateWithServerError = /*#__PURE__*/function (_Error7) { + /** Constructs a new instance of {@link @microsoft/signalr.FailedToNegotiateWithServerError}. + * + * @param {string} message A descriptive error message. + */ + function FailedToNegotiateWithServerError(message) { + var _this7; + _classCallCheck(this, FailedToNegotiateWithServerError); + var trueProto = (this instanceof FailedToNegotiateWithServerError ? this.constructor : void 0).prototype; + _this7 = _callSuper(this, FailedToNegotiateWithServerError, [message]); + _this7.errorType = 'FailedToNegotiateWithServerError'; + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this7.__proto__ = trueProto; + return _this7; + } + _inherits(FailedToNegotiateWithServerError, _Error7); + return _createClass(FailedToNegotiateWithServerError); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + /** Error thrown when multiple errors have occurred. */ + /** @private */ + var AggregateErrors = /*#__PURE__*/function (_Error8) { + /** Constructs a new instance of {@link @microsoft/signalr.AggregateErrors}. + * + * @param {string} message A descriptive error message. + * @param {Error[]} innerErrors The collection of errors this error is aggregating. + */ + function AggregateErrors(message, innerErrors) { + var _this8; + _classCallCheck(this, AggregateErrors); + var trueProto = (this instanceof AggregateErrors ? this.constructor : void 0).prototype; + _this8 = _callSuper(this, AggregateErrors, [message]); + _this8.innerErrors = innerErrors; + // Workaround issue in Typescript compiler + // https://github.com/Microsoft/TypeScript/issues/13965#issuecomment-278570200 + _this8.__proto__ = trueProto; + return _this8; + } + _inherits(AggregateErrors, _Error8); + return _createClass(AggregateErrors); + }(/*#__PURE__*/_wrapNativeSuper(Error)); + ; // ./src/HttpClient.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + /** Represents an HTTP response. */ + var _HttpResponse = /*#__PURE__*/_createClass(function _HttpResponse(statusCode, statusText, content) { + _classCallCheck(this, _HttpResponse); + this.statusCode = statusCode; + this.statusText = statusText; + this.content = content; + }); + /** Abstraction over an HTTP client. + * + * This class provides an abstraction over an HTTP client so that a different implementation can be provided on different platforms. + */ + var _HttpClient = /*#__PURE__*/function () { + function _HttpClient() { + _classCallCheck(this, _HttpClient); + } + return _createClass(_HttpClient, [{ + key: "get", + value: function get(url, options) { + return this.send(_objectSpread(_objectSpread({}, options), {}, { + method: "GET", + url: url + })); + } + }, { + key: "post", + value: function post(url, options) { + return this.send(_objectSpread(_objectSpread({}, options), {}, { + method: "POST", + url: url + })); + } + }, { + key: "delete", + value: function _delete(url, options) { + return this.send(_objectSpread(_objectSpread({}, options), {}, { + method: "DELETE", + url: url + })); + } + /** Gets all cookies that apply to the specified URL. + * + * @param url The URL that the cookies are valid for. + * @returns {string} A string containing all the key-value cookie pairs for the specified URL. + */ + // @ts-ignore + }, { + key: "getCookieString", + value: function getCookieString(url) { + return ""; + } + }]); + }(); + ; // ./src/ILogger.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // These values are designed to match the ASP.NET Log Levels since that's the pattern we're emulating here. + /** Indicates the severity of a log message. + * + * Log Levels are ordered in increasing severity. So `Debug` is more severe than `Trace`, etc. + */ + var _LogLevel; + (function (LogLevel) { + /** Log level for very low severity diagnostic messages. */ + LogLevel[LogLevel["Trace"] = 0] = "Trace"; + /** Log level for low severity diagnostic messages. */ + LogLevel[LogLevel["Debug"] = 1] = "Debug"; + /** Log level for informational diagnostic messages. */ + LogLevel[LogLevel["Information"] = 2] = "Information"; + /** Log level for diagnostic messages that indicate a non-fatal problem. */ + LogLevel[LogLevel["Warning"] = 3] = "Warning"; + /** Log level for diagnostic messages that indicate a failure in the current operation. */ + LogLevel[LogLevel["Error"] = 4] = "Error"; + /** Log level for diagnostic messages that indicate a failure that will terminate the entire application. */ + LogLevel[LogLevel["Critical"] = 5] = "Critical"; + /** The highest possible log level. Used when configuring logging to indicate that no log messages should be emitted. */ + LogLevel[LogLevel["None"] = 6] = "None"; + })(_LogLevel || (_LogLevel = {})); + ; // ./src/Loggers.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + /** A logger that does nothing when log messages are sent to it. */ + var _NullLogger = /*#__PURE__*/function () { + function _NullLogger() { + _classCallCheck(this, _NullLogger); + } + /** @inheritDoc */ + // eslint-disable-next-line + return _createClass(_NullLogger, [{ + key: "log", + value: function log(_logLevel, _message) {} + }]); + }(); + /** The singleton instance of the {@link @microsoft/signalr.NullLogger}. */ + _NullLogger.instance = new _NullLogger(); + ; // ./src/pkg-version.ts + var _VERSION = '10.0.0'; + ; // ./src/Utils.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + // Version token that will be replaced by the prepack command + /** The version of the SignalR client. */ + + /** @private */ + var Arg = /*#__PURE__*/function () { + function Arg() { + _classCallCheck(this, Arg); + } + return _createClass(Arg, null, [{ + key: "isRequired", + value: function isRequired(val, name) { + if (val === null || val === undefined) { + throw new Error("The '".concat(name, "' argument is required.")); + } + } + }, { + key: "isNotEmpty", + value: function isNotEmpty(val, name) { + if (!val || val.match(/^\s*$/)) { + throw new Error("The '".concat(name, "' argument should not be empty.")); + } + } + }, { + key: "isIn", + value: function isIn(val, values, name) { + // TypeScript enums have keys for **both** the name and the value of each enum member on the type itself. + if (!(val in values)) { + throw new Error("Unknown ".concat(name, " value: ").concat(val, ".")); + } + } + }]); + }(); + /** @private */ + var Platform = /*#__PURE__*/function () { + function Platform() { + _classCallCheck(this, Platform); + } + return _createClass(Platform, null, [{ + key: "isBrowser", + get: + // react-native has a window but no document so we should check both + function get() { + return !Platform.isNode && (typeof window === "undefined" ? "undefined" : _typeof(window)) === "object" && _typeof(window.document) === "object"; + } + // WebWorkers don't have a window object so the isBrowser check would fail + }, { + key: "isWebWorker", + get: function get() { + return !Platform.isNode && (typeof self === "undefined" ? "undefined" : _typeof(self)) === "object" && "importScripts" in self; + } + // react-native has a window but no document + }, { + key: "isReactNative", + get: function get() { + return !Platform.isNode && (typeof window === "undefined" ? "undefined" : _typeof(window)) === "object" && typeof window.document === "undefined"; + } + // Node apps shouldn't have a window object, but WebWorkers don't either + // so we need to check for both WebWorker and window + }, { + key: "isNode", + get: function get() { + return typeof process !== "undefined" && process.release && process.release.name === "node"; + } + }]); + }(); + /** @private */ + function getDataDetail(data, includeContent) { + var detail = ""; + if (isArrayBuffer(data)) { + detail = "Binary data of length ".concat(data.byteLength); + if (includeContent) { + detail += ". Content: '".concat(formatArrayBuffer(data), "'"); + } + } else if (typeof data === "string") { + detail = "String data of length ".concat(data.length); + if (includeContent) { + detail += ". Content: '".concat(data, "'"); + } + } + return detail; + } + /** @private */ + function formatArrayBuffer(data) { + var view = new Uint8Array(data); + // Uint8Array.map only supports returning another Uint8Array? + var str = ""; + view.forEach(function (num) { + var pad = num < 16 ? "0" : ""; + str += "0x".concat(pad).concat(num.toString(16), " "); + }); + // Trim of trailing space. + return str.substring(0, str.length - 1); + } + // Also in signalr-protocol-msgpack/Utils.ts + /** @private */ + function isArrayBuffer(val) { + return val && typeof ArrayBuffer !== "undefined" && (val instanceof ArrayBuffer || + // Sometimes we get an ArrayBuffer that doesn't satisfy instanceof + val.constructor && val.constructor.name === "ArrayBuffer"); + } + /** @private */ + function sendMessage(_x, _x2, _x3, _x4, _x5, _x6) { + return _sendMessage.apply(this, arguments); + } + /** @private */ + function _sendMessage() { + _sendMessage = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee27(logger, transportName, httpClient, url, content, options) { + var headers, _getUserAgentHeader1, _getUserAgentHeader10, name, value, responseType, response; + return _regenerator().w(function (_context28) { + while (1) switch (_context28.n) { + case 0: + headers = {}; + _getUserAgentHeader1 = getUserAgentHeader(), _getUserAgentHeader10 = _slicedToArray(_getUserAgentHeader1, 2), name = _getUserAgentHeader10[0], value = _getUserAgentHeader10[1]; + headers[name] = value; + logger.log(_LogLevel.Trace, "(".concat(transportName, " transport) sending data. ").concat(getDataDetail(content, options.logMessageContent), ".")); + responseType = isArrayBuffer(content) ? "arraybuffer" : "text"; + _context28.n = 1; + return httpClient.post(url, { + content: content, + headers: _objectSpread(_objectSpread({}, headers), options.headers), + responseType: responseType, + timeout: options.timeout, + withCredentials: options.withCredentials + }); + case 1: + response = _context28.v; + logger.log(_LogLevel.Trace, "(".concat(transportName, " transport) request complete. Response status: ").concat(response.statusCode, ".")); + case 2: + return _context28.a(2); + } + }, _callee27); + })); + return _sendMessage.apply(this, arguments); + } + function createLogger(logger) { + if (logger === undefined) { + return new ConsoleLogger(_LogLevel.Information); + } + if (logger === null) { + return _NullLogger.instance; + } + if (logger.log !== undefined) { + return logger; + } + return new ConsoleLogger(logger); + } + /** @private */ + var SubjectSubscription = /*#__PURE__*/function () { + function SubjectSubscription(subject, observer) { + _classCallCheck(this, SubjectSubscription); + this._subject = subject; + this._observer = observer; + } + return _createClass(SubjectSubscription, [{ + key: "dispose", + value: function dispose() { + var index = this._subject.observers.indexOf(this._observer); + if (index > -1) { + this._subject.observers.splice(index, 1); + } + if (this._subject.observers.length === 0 && this._subject.cancelCallback) { + this._subject.cancelCallback()["catch"](function (_) {}); + } + } + }]); + }(); + /** @private */ + var ConsoleLogger = /*#__PURE__*/function () { + function ConsoleLogger(minimumLogLevel) { + _classCallCheck(this, ConsoleLogger); + this._minLevel = minimumLogLevel; + this.out = console; + } + return _createClass(ConsoleLogger, [{ + key: "log", + value: function log(logLevel, message) { + if (logLevel >= this._minLevel) { + var msg = "[".concat(new Date().toISOString(), "] ").concat(_LogLevel[logLevel], ": ").concat(message); + switch (logLevel) { + case _LogLevel.Critical: + case _LogLevel.Error: + this.out.error(msg); + break; + case _LogLevel.Warning: + this.out.warn(msg); + break; + case _LogLevel.Information: + this.out.info(msg); + break; + default: + // console.debug only goes to attached debuggers in Node, so we use console.log for Trace and Debug + this.out.log(msg); + break; + } + } + } + }]); + }(); + /** @private */ + function getUserAgentHeader() { + var userAgentHeaderName = "X-SignalR-User-Agent"; + if (Platform.isNode) { + userAgentHeaderName = "User-Agent"; + } + return [userAgentHeaderName, constructUserAgent(_VERSION, getOsName(), getRuntime(), getRuntimeVersion())]; + } + /** @private */ + function constructUserAgent(version, os, runtime, runtimeVersion) { + // Microsoft SignalR/[Version] ([Detailed Version]; [Operating System]; [Runtime]; [Runtime Version]) + var userAgent = "Microsoft SignalR/"; + var majorAndMinor = version.split("."); + userAgent += "".concat(majorAndMinor[0], ".").concat(majorAndMinor[1]); + userAgent += " (".concat(version, "; "); + if (os && os !== "") { + userAgent += "".concat(os, "; "); + } else { + userAgent += "Unknown OS; "; + } + userAgent += "".concat(runtime); + if (runtimeVersion) { + userAgent += "; ".concat(runtimeVersion); + } else { + userAgent += "; Unknown Runtime Version"; + } + userAgent += ")"; + return userAgent; + } + // eslint-disable-next-line spaced-comment + /*#__PURE__*/ + function getOsName() { + if (Platform.isNode) { + switch (process.platform) { + case "win32": + return "Windows NT"; + case "darwin": + return "macOS"; + case "linux": + return "Linux"; + default: + return process.platform; + } + } else { + return ""; + } + } + // eslint-disable-next-line spaced-comment + /*#__PURE__*/ + function getRuntimeVersion() { + if (Platform.isNode) { + return process.versions.node; + } + return undefined; + } + function getRuntime() { + if (Platform.isNode) { + return "NodeJS"; + } else { + return "Browser"; + } + } + /** @private */ + function getErrorString(e) { + if (e.stack) { + return e.stack; + } else if (e.message) { + return e.message; + } + return "".concat(e); + } + /** @private */ + function getGlobalThis() { + // globalThis is semi-new and not available in Node until v12 + if (typeof globalThis !== "undefined") { + return globalThis; + } + if (typeof self !== "undefined") { + return self; + } + if (typeof window !== "undefined") { + return window; + } + if (typeof __webpack_require__.g !== "undefined") { + return __webpack_require__.g; + } + throw new Error("could not find global"); + } + ; // ./src/FetchHttpClient.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + var FetchHttpClient = /*#__PURE__*/function (_HttpClient2) { + function FetchHttpClient(logger) { + var _this9; + _classCallCheck(this, FetchHttpClient); + _this9 = _callSuper(this, FetchHttpClient); + _this9._logger = logger; + // Node added a fetch implementation to the global scope starting in v18. + // We need to add a cookie jar in node to be able to share cookies with WebSocket + if (typeof fetch === "undefined" || Platform.isNode) { + // In order to ignore the dynamic require in webpack builds we need to do this magic + // @ts-ignore: TS doesn't know about these names + var requireFunc = true ? require : 0; + // Cookies aren't automatically handled in Node so we need to add a CookieJar to preserve cookies across requests + _this9._jar = new (requireFunc("tough-cookie").CookieJar)(); + if (typeof fetch === "undefined") { + _this9._fetchType = requireFunc("node-fetch"); + } else { + // Use fetch from Node if available + _this9._fetchType = fetch; + } + // node-fetch doesn't have a nice API for getting and setting cookies + // fetch-cookie will wrap a fetch implementation with a default CookieJar or a provided one + _this9._fetchType = requireFunc("fetch-cookie")(_this9._fetchType, _this9._jar); + } else { + _this9._fetchType = fetch.bind(getGlobalThis()); + } + if (typeof AbortController === "undefined") { + // In order to ignore the dynamic require in webpack builds we need to do this magic + // @ts-ignore: TS doesn't know about these names + var _requireFunc = true ? require : 0; + // Node needs EventListener methods on AbortController which our custom polyfill doesn't provide + _this9._abortControllerType = _requireFunc("abort-controller"); + } else { + _this9._abortControllerType = AbortController; + } + return _this9; + } + /** @inheritDoc */ + _inherits(FetchHttpClient, _HttpClient2); + return _createClass(FetchHttpClient, [{ + key: "send", + value: (function () { + var _send = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee(request) { + var _this0 = this; + var abortController, error, timeoutId, msTimeout, response, errorMessage, content, payload, _t; + return _regenerator().w(function (_context) { + while (1) switch (_context.p = _context.n) { + case 0: + if (!(request.abortSignal && request.abortSignal.aborted)) { + _context.n = 1; + break; + } + throw new _AbortError(); + case 1: + if (request.method) { + _context.n = 2; + break; + } + throw new Error("No method defined."); + case 2: + if (request.url) { + _context.n = 3; + break; + } + throw new Error("No url defined."); + case 3: + abortController = new this._abortControllerType(); + // Hook our abortSignal into the abort controller + if (request.abortSignal) { + request.abortSignal.onabort = function () { + abortController.abort(); + error = new _AbortError(); + }; + } + // If a timeout has been passed in, setup a timeout to call abort + // Type needs to be any to fit window.setTimeout and NodeJS.setTimeout + timeoutId = null; + if (request.timeout) { + msTimeout = request.timeout; + timeoutId = setTimeout(function () { + abortController.abort(); + _this0._logger.log(_LogLevel.Warning, "Timeout from HTTP request."); + error = new _TimeoutError(); + }, msTimeout); + } + if (request.content === "") { + request.content = undefined; + } + if (request.content) { + // Explicitly setting the Content-Type header for React Native on Android platform. + request.headers = request.headers || {}; + if (isArrayBuffer(request.content)) { + request.headers["Content-Type"] = "application/octet-stream"; + } else { + request.headers["Content-Type"] = "text/plain;charset=UTF-8"; + } + } + _context.p = 4; + _context.n = 5; + return this._fetchType(request.url, { + body: request.content, + cache: "no-cache", + credentials: request.withCredentials === true ? "include" : "same-origin", + headers: _objectSpread({ + "X-Requested-With": "XMLHttpRequest" + }, request.headers), + method: request.method, + mode: "cors", + redirect: "follow", + signal: abortController.signal + }); + case 5: + response = _context.v; + _context.n = 8; + break; + case 6: + _context.p = 6; + _t = _context.v; + if (!error) { + _context.n = 7; + break; + } + throw error; + case 7: + this._logger.log(_LogLevel.Warning, "Error from HTTP request. ".concat(_t, ".")); + throw _t; + case 8: + _context.p = 8; + if (timeoutId) { + clearTimeout(timeoutId); + } + if (request.abortSignal) { + request.abortSignal.onabort = null; + } + return _context.f(8); + case 9: + if (response.ok) { + _context.n = 11; + break; + } + _context.n = 10; + return deserializeContent(response, "text"); + case 10: + errorMessage = _context.v; + throw new _HttpError(errorMessage || response.statusText, response.status); + case 11: + content = deserializeContent(response, request.responseType); + _context.n = 12; + return content; + case 12: + payload = _context.v; + return _context.a(2, new _HttpResponse(response.status, response.statusText, payload)); + } + }, _callee, this, [[4, 6, 8, 9]]); + })); + function send(_x7) { + return _send.apply(this, arguments); + } + return send; + }()) + }, { + key: "getCookieString", + value: function getCookieString(url) { + var cookies = ""; + if (Platform.isNode && this._jar) { + // @ts-ignore: unused variable + this._jar.getCookies(url, function (e, c) { + return cookies = c.join("; "); + }); + } + return cookies; + } + }]); + }(_HttpClient); + function deserializeContent(response, responseType) { + var content; + switch (responseType) { + case "arraybuffer": + content = response.arrayBuffer(); + break; + case "text": + content = response.text(); + break; + case "blob": + case "document": + case "json": + throw new Error("".concat(responseType, " is not supported.")); + default: + content = response.text(); + break; + } + return content; + } + ; // ./src/XhrHttpClient.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + var XhrHttpClient = /*#__PURE__*/function (_HttpClient3) { + function XhrHttpClient(logger) { + var _this1; + _classCallCheck(this, XhrHttpClient); + _this1 = _callSuper(this, XhrHttpClient); + _this1._logger = logger; + return _this1; + } + /** @inheritDoc */ + _inherits(XhrHttpClient, _HttpClient3); + return _createClass(XhrHttpClient, [{ + key: "send", + value: function send(request) { + var _this10 = this; + // Check that abort was not signaled before calling send + if (request.abortSignal && request.abortSignal.aborted) { + return Promise.reject(new _AbortError()); + } + if (!request.method) { + return Promise.reject(new Error("No method defined.")); + } + if (!request.url) { + return Promise.reject(new Error("No url defined.")); + } + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + xhr.open(request.method, request.url, true); + xhr.withCredentials = request.withCredentials === undefined ? true : request.withCredentials; + xhr.setRequestHeader("X-Requested-With", "XMLHttpRequest"); + if (request.content === "") { + request.content = undefined; + } + if (request.content) { + // Explicitly setting the Content-Type header for React Native on Android platform. + if (isArrayBuffer(request.content)) { + xhr.setRequestHeader("Content-Type", "application/octet-stream"); + } else { + xhr.setRequestHeader("Content-Type", "text/plain;charset=UTF-8"); + } + } + var headers = request.headers; + if (headers) { + Object.keys(headers).forEach(function (header) { + xhr.setRequestHeader(header, headers[header]); + }); + } + if (request.responseType) { + xhr.responseType = request.responseType; + } + if (request.abortSignal) { + request.abortSignal.onabort = function () { + xhr.abort(); + reject(new _AbortError()); + }; + } + if (request.timeout) { + xhr.timeout = request.timeout; + } + xhr.onload = function () { + if (request.abortSignal) { + request.abortSignal.onabort = null; + } + if (xhr.status >= 200 && xhr.status < 300) { + resolve(new _HttpResponse(xhr.status, xhr.statusText, xhr.response || xhr.responseText)); + } else { + reject(new _HttpError(xhr.response || xhr.responseText || xhr.statusText, xhr.status)); + } + }; + xhr.onerror = function () { + _this10._logger.log(_LogLevel.Warning, "Error from HTTP request. ".concat(xhr.status, ": ").concat(xhr.statusText, ".")); + reject(new _HttpError(xhr.statusText, xhr.status)); + }; + xhr.ontimeout = function () { + _this10._logger.log(_LogLevel.Warning, "Timeout from HTTP request."); + reject(new _TimeoutError()); + }; + xhr.send(request.content); + }); + } + }]); + }(_HttpClient); + ; // ./src/DefaultHttpClient.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** Default implementation of {@link @microsoft/signalr.HttpClient}. */ + var _DefaultHttpClient = /*#__PURE__*/function (_HttpClient4) { + /** Creates a new instance of the {@link @microsoft/signalr.DefaultHttpClient}, using the provided {@link @microsoft/signalr.ILogger} to log messages. */ + function _DefaultHttpClient(logger) { + var _this11; + _classCallCheck(this, _DefaultHttpClient); + _this11 = _callSuper(this, _DefaultHttpClient); + if (typeof fetch !== "undefined" || Platform.isNode) { + _this11._httpClient = new FetchHttpClient(logger); + } else if (typeof XMLHttpRequest !== "undefined") { + _this11._httpClient = new XhrHttpClient(logger); + } else { + throw new Error("No usable HttpClient found."); + } + return _this11; + } + /** @inheritDoc */ + _inherits(_DefaultHttpClient, _HttpClient4); + return _createClass(_DefaultHttpClient, [{ + key: "send", + value: function send(request) { + // Check that abort was not signaled before calling send + if (request.abortSignal && request.abortSignal.aborted) { + return Promise.reject(new _AbortError()); + } + if (!request.method) { + return Promise.reject(new Error("No method defined.")); + } + if (!request.url) { + return Promise.reject(new Error("No url defined.")); + } + return this._httpClient.send(request); + } + }, { + key: "getCookieString", + value: function getCookieString(url) { + return this._httpClient.getCookieString(url); + } + }]); + }(_HttpClient); + ; // ./src/TextMessageFormat.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // Not exported from index + /** @private */ + var TextMessageFormat = /*#__PURE__*/function () { + function TextMessageFormat() { + _classCallCheck(this, TextMessageFormat); + } + return _createClass(TextMessageFormat, null, [{ + key: "write", + value: function write(output) { + return "".concat(output).concat(TextMessageFormat.RecordSeparator); + } + }, { + key: "parse", + value: function parse(input) { + if (input[input.length - 1] !== TextMessageFormat.RecordSeparator) { + throw new Error("Message is incomplete."); + } + var messages = input.split(TextMessageFormat.RecordSeparator); + messages.pop(); + return messages; + } + }]); + }(); + TextMessageFormat.RecordSeparatorCode = 0x1e; + TextMessageFormat.RecordSeparator = String.fromCharCode(TextMessageFormat.RecordSeparatorCode); + ; // ./src/HandshakeProtocol.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** @private */ + var HandshakeProtocol = /*#__PURE__*/function () { + function HandshakeProtocol() { + _classCallCheck(this, HandshakeProtocol); + } + return _createClass(HandshakeProtocol, [{ + key: "writeHandshakeRequest", + value: + // Handshake request is always JSON + function writeHandshakeRequest(handshakeRequest) { + return TextMessageFormat.write(JSON.stringify(handshakeRequest)); + } + }, { + key: "parseHandshakeResponse", + value: function parseHandshakeResponse(data) { + var messageData; + var remainingData; + if (isArrayBuffer(data)) { + // Format is binary but still need to read JSON text from handshake response + var binaryData = new Uint8Array(data); + var separatorIndex = binaryData.indexOf(TextMessageFormat.RecordSeparatorCode); + if (separatorIndex === -1) { + throw new Error("Message is incomplete."); + } + // content before separator is handshake response + // optional content after is additional messages + var responseLength = separatorIndex + 1; + messageData = String.fromCharCode.apply(null, Array.prototype.slice.call(binaryData.slice(0, responseLength))); + remainingData = binaryData.byteLength > responseLength ? binaryData.slice(responseLength).buffer : null; + } else { + var textData = data; + var _separatorIndex = textData.indexOf(TextMessageFormat.RecordSeparator); + if (_separatorIndex === -1) { + throw new Error("Message is incomplete."); + } + // content before separator is handshake response + // optional content after is additional messages + var _responseLength = _separatorIndex + 1; + messageData = textData.substring(0, _responseLength); + remainingData = textData.length > _responseLength ? textData.substring(_responseLength) : null; + } + // At this point we should have just the single handshake message + var messages = TextMessageFormat.parse(messageData); + var response = JSON.parse(messages[0]); + if (response.type) { + throw new Error("Expected a handshake response from the server."); + } + var responseMessage = response; + // multiple messages could have arrived with handshake + // return additional data to be parsed as usual, or null if all parsed + return [remainingData, responseMessage]; + } + }]); + }(); + ; // ./src/IHubProtocol.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + /** Defines the type of a Hub Message. */ + var _MessageType; + (function (MessageType) { + /** Indicates the message is an Invocation message and implements the {@link @microsoft/signalr.InvocationMessage} interface. */ + MessageType[MessageType["Invocation"] = 1] = "Invocation"; + /** Indicates the message is a StreamItem message and implements the {@link @microsoft/signalr.StreamItemMessage} interface. */ + MessageType[MessageType["StreamItem"] = 2] = "StreamItem"; + /** Indicates the message is a Completion message and implements the {@link @microsoft/signalr.CompletionMessage} interface. */ + MessageType[MessageType["Completion"] = 3] = "Completion"; + /** Indicates the message is a Stream Invocation message and implements the {@link @microsoft/signalr.StreamInvocationMessage} interface. */ + MessageType[MessageType["StreamInvocation"] = 4] = "StreamInvocation"; + /** Indicates the message is a Cancel Invocation message and implements the {@link @microsoft/signalr.CancelInvocationMessage} interface. */ + MessageType[MessageType["CancelInvocation"] = 5] = "CancelInvocation"; + /** Indicates the message is a Ping message and implements the {@link @microsoft/signalr.PingMessage} interface. */ + MessageType[MessageType["Ping"] = 6] = "Ping"; + /** Indicates the message is a Close message and implements the {@link @microsoft/signalr.CloseMessage} interface. */ + MessageType[MessageType["Close"] = 7] = "Close"; + MessageType[MessageType["Ack"] = 8] = "Ack"; + MessageType[MessageType["Sequence"] = 9] = "Sequence"; + })(_MessageType || (_MessageType = {})); + ; // ./src/Subject.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** Stream implementation to stream items to the server. */ + var _Subject = /*#__PURE__*/function () { + function _Subject() { + _classCallCheck(this, _Subject); + this.observers = []; + } + return _createClass(_Subject, [{ + key: "next", + value: function next(item) { + var _iterator = _createForOfIteratorHelper(this.observers), + _step; + try { + for (_iterator.s(); !(_step = _iterator.n()).done;) { + var observer = _step.value; + observer.next(item); + } + } catch (err) { + _iterator.e(err); + } finally { + _iterator.f(); + } + } + }, { + key: "error", + value: function error(err) { + var _iterator2 = _createForOfIteratorHelper(this.observers), + _step2; + try { + for (_iterator2.s(); !(_step2 = _iterator2.n()).done;) { + var observer = _step2.value; + if (observer.error) { + observer.error(err); + } + } + } catch (err) { + _iterator2.e(err); + } finally { + _iterator2.f(); + } + } + }, { + key: "complete", + value: function complete() { + var _iterator3 = _createForOfIteratorHelper(this.observers), + _step3; + try { + for (_iterator3.s(); !(_step3 = _iterator3.n()).done;) { + var observer = _step3.value; + if (observer.complete) { + observer.complete(); + } + } + } catch (err) { + _iterator3.e(err); + } finally { + _iterator3.f(); + } + } + }, { + key: "subscribe", + value: function subscribe(observer) { + this.observers.push(observer); + return new SubjectSubscription(this, observer); + } + }]); + }(); + ; // ./src/MessageBuffer.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** @private */ + var MessageBuffer = /*#__PURE__*/function () { + function MessageBuffer(protocol, connection, bufferSize) { + _classCallCheck(this, MessageBuffer); + this._bufferSize = 100000; + this._messages = []; + this._totalMessageCount = 0; + this._waitForSequenceMessage = false; + // Message IDs start at 1 and always increment by 1 + this._nextReceivingSequenceId = 1; + this._latestReceivedSequenceId = 0; + this._bufferedByteCount = 0; + this._reconnectInProgress = false; + this._protocol = protocol; + this._connection = connection; + this._bufferSize = bufferSize; + } + return _createClass(MessageBuffer, [{ + key: "_send", + value: function () { + var _send2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee2(message) { + var serializedMessage, backpressurePromise, backpressurePromiseResolver, backpressurePromiseRejector, _t2; + return _regenerator().w(function (_context2) { + while (1) switch (_context2.p = _context2.n) { + case 0: + serializedMessage = this._protocol.writeMessage(message); + backpressurePromise = Promise.resolve(); // Only count invocation messages. Acks, pings, etc. don't need to be resent on reconnect + if (this._isInvocationMessage(message)) { + this._totalMessageCount++; + backpressurePromiseResolver = function backpressurePromiseResolver() {}; + backpressurePromiseRejector = function backpressurePromiseRejector() {}; + if (isArrayBuffer(serializedMessage)) { + this._bufferedByteCount += serializedMessage.byteLength; + } else { + this._bufferedByteCount += serializedMessage.length; + } + if (this._bufferedByteCount >= this._bufferSize) { + backpressurePromise = new Promise(function (resolve, reject) { + backpressurePromiseResolver = resolve; + backpressurePromiseRejector = reject; + }); + } + this._messages.push(new BufferedItem(serializedMessage, this._totalMessageCount, backpressurePromiseResolver, backpressurePromiseRejector)); + } + _context2.p = 1; + if (this._reconnectInProgress) { + _context2.n = 2; + break; + } + _context2.n = 2; + return this._connection.send(serializedMessage); + case 2: + _context2.n = 4; + break; + case 3: + _context2.p = 3; + _t2 = _context2.v; + this._disconnected(); + case 4: + _context2.n = 5; + return backpressurePromise; + case 5: + return _context2.a(2); + } + }, _callee2, this, [[1, 3]]); + })); + function _send(_x8) { + return _send2.apply(this, arguments); + } + return _send; + }() + }, { + key: "_ack", + value: function _ack(ackMessage) { + var newestAckedMessage = -1; + // Find index of newest message being acked + for (var index = 0; index < this._messages.length; index++) { + var element = this._messages[index]; + if (element._id <= ackMessage.sequenceId) { + newestAckedMessage = index; + if (isArrayBuffer(element._message)) { + this._bufferedByteCount -= element._message.byteLength; + } else { + this._bufferedByteCount -= element._message.length; + } + // resolve items that have already been sent and acked + element._resolver(); + } else if (this._bufferedByteCount < this._bufferSize) { + // resolve items that now fall under the buffer limit but haven't been acked + element._resolver(); + } else { + break; + } + } + if (newestAckedMessage !== -1) { + // We're removing everything including the message pointed to, so add 1 + this._messages = this._messages.slice(newestAckedMessage + 1); + } + } + }, { + key: "_shouldProcessMessage", + value: function _shouldProcessMessage(message) { + if (this._waitForSequenceMessage) { + if (message.type !== _MessageType.Sequence) { + return false; + } else { + this._waitForSequenceMessage = false; + return true; + } + } + // No special processing for acks, pings, etc. + if (!this._isInvocationMessage(message)) { + return true; + } + var currentId = this._nextReceivingSequenceId; + this._nextReceivingSequenceId++; + if (currentId <= this._latestReceivedSequenceId) { + if (currentId === this._latestReceivedSequenceId) { + // Should only hit this if we just reconnected and the server is sending + // Messages it has buffered, which would mean it hasn't seen an Ack for these messages + this._ackTimer(); + } + // Ignore, this is a duplicate message + return false; + } + this._latestReceivedSequenceId = currentId; + // Only start the timer for sending an Ack message when we have a message to ack. This also conveniently solves + // timer throttling by not having a recursive timer, and by starting the timer via a network call (recv) + this._ackTimer(); + return true; + } + }, { + key: "_resetSequence", + value: function _resetSequence(message) { + if (message.sequenceId > this._nextReceivingSequenceId) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._connection.stop(new Error("Sequence ID greater than amount of messages we've received.")); + return; + } + this._nextReceivingSequenceId = message.sequenceId; + } + }, { + key: "_disconnected", + value: function _disconnected() { + this._reconnectInProgress = true; + this._waitForSequenceMessage = true; + } + }, { + key: "_resend", + value: function () { + var _resend2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee3() { + var sequenceId, messages, _iterator4, _step4, element, _t3; + return _regenerator().w(function (_context3) { + while (1) switch (_context3.p = _context3.n) { + case 0: + sequenceId = this._messages.length !== 0 ? this._messages[0]._id : this._totalMessageCount + 1; + _context3.n = 1; + return this._connection.send(this._protocol.writeMessage({ + type: _MessageType.Sequence, + sequenceId: sequenceId + })); + case 1: + // Get a local variable to the _messages, just in case messages are acked while resending + // Which would slice the _messages array (which creates a new copy) + messages = this._messages; + _iterator4 = _createForOfIteratorHelper(messages); + _context3.p = 2; + _iterator4.s(); + case 3: + if ((_step4 = _iterator4.n()).done) { + _context3.n = 5; + break; + } + element = _step4.value; + _context3.n = 4; + return this._connection.send(element._message); + case 4: + _context3.n = 3; + break; + case 5: + _context3.n = 7; + break; + case 6: + _context3.p = 6; + _t3 = _context3.v; + _iterator4.e(_t3); + case 7: + _context3.p = 7; + _iterator4.f(); + return _context3.f(7); + case 8: + this._reconnectInProgress = false; + case 9: + return _context3.a(2); + } + }, _callee3, this, [[2, 6, 7, 8]]); + })); + function _resend() { + return _resend2.apply(this, arguments); + } + return _resend; + }() + }, { + key: "_dispose", + value: function _dispose(error) { + error !== null && error !== void 0 ? error : error = new Error("Unable to reconnect to server."); + // Unblock backpressure if any + var _iterator5 = _createForOfIteratorHelper(this._messages), + _step5; + try { + for (_iterator5.s(); !(_step5 = _iterator5.n()).done;) { + var element = _step5.value; + element._rejector(error); + } + } catch (err) { + _iterator5.e(err); + } finally { + _iterator5.f(); + } + } + }, { + key: "_isInvocationMessage", + value: function _isInvocationMessage(message) { + // There is no way to check if something implements an interface. + // So we individually check the messages in a switch statement. + // To make sure we don't miss any message types we rely on the compiler + // seeing the function returns a value and it will do the + // exhaustive check for us on the switch statement, since we don't use 'case default' + switch (message.type) { + case _MessageType.Invocation: + case _MessageType.StreamItem: + case _MessageType.Completion: + case _MessageType.StreamInvocation: + case _MessageType.CancelInvocation: + return true; + case _MessageType.Close: + case _MessageType.Sequence: + case _MessageType.Ping: + case _MessageType.Ack: + return false; + } + } + }, { + key: "_ackTimer", + value: function _ackTimer() { + var _this12 = this; + if (this._ackTimerHandle === undefined) { + this._ackTimerHandle = setTimeout(/*#__PURE__*/_asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee4() { + var _t4; + return _regenerator().w(function (_context4) { + while (1) switch (_context4.p = _context4.n) { + case 0: + _context4.p = 0; + if (_this12._reconnectInProgress) { + _context4.n = 1; + break; + } + _context4.n = 1; + return _this12._connection.send(_this12._protocol.writeMessage({ + type: _MessageType.Ack, + sequenceId: _this12._latestReceivedSequenceId + })); + case 1: + _context4.n = 3; + break; + case 2: + _context4.p = 2; + _t4 = _context4.v; + case 3: + clearTimeout(_this12._ackTimerHandle); + _this12._ackTimerHandle = undefined; + // 1 second delay so we don't spam Ack messages if there are many messages being received at once. + case 4: + return _context4.a(2); + } + }, _callee4, null, [[0, 2]]); + })), 1000); + } + } + }]); + }(); + var BufferedItem = /*#__PURE__*/_createClass(function BufferedItem(message, id, resolver, rejector) { + _classCallCheck(this, BufferedItem); + this._message = message; + this._id = id; + this._resolver = resolver; + this._rejector = rejector; + }); + ; // ./src/HubConnection.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + var DEFAULT_TIMEOUT_IN_MS = 30 * 1000; + var DEFAULT_PING_INTERVAL_IN_MS = 15 * 1000; + var DEFAULT_STATEFUL_RECONNECT_BUFFER_SIZE = 100000; + /** Describes the current state of the {@link HubConnection} to the server. */ + var _HubConnectionState; + (function (HubConnectionState) { + /** The hub connection is disconnected. */ + HubConnectionState["Disconnected"] = "Disconnected"; + /** The hub connection is connecting. */ + HubConnectionState["Connecting"] = "Connecting"; + /** The hub connection is connected. */ + HubConnectionState["Connected"] = "Connected"; + /** The hub connection is disconnecting. */ + HubConnectionState["Disconnecting"] = "Disconnecting"; + /** The hub connection is reconnecting. */ + HubConnectionState["Reconnecting"] = "Reconnecting"; + })(_HubConnectionState || (_HubConnectionState = {})); + /** Represents a connection to a SignalR Hub. */ + var _HubConnection = /*#__PURE__*/function () { + function _HubConnection(connection, logger, protocol, reconnectPolicy, serverTimeoutInMilliseconds, keepAliveIntervalInMilliseconds, statefulReconnectBufferSize) { + var _this13 = this; + _classCallCheck(this, _HubConnection); + this._nextKeepAlive = 0; + this._freezeEventListener = function () { + _this13._logger.log(_LogLevel.Warning, "The page is being frozen, this will likely lead to the connection being closed and messages being lost. For more information see the docs at https://learn.microsoft.com/aspnet/core/signalr/javascript-client#bsleep"); + }; + Arg.isRequired(connection, "connection"); + Arg.isRequired(logger, "logger"); + Arg.isRequired(protocol, "protocol"); + this.serverTimeoutInMilliseconds = serverTimeoutInMilliseconds !== null && serverTimeoutInMilliseconds !== void 0 ? serverTimeoutInMilliseconds : DEFAULT_TIMEOUT_IN_MS; + this.keepAliveIntervalInMilliseconds = keepAliveIntervalInMilliseconds !== null && keepAliveIntervalInMilliseconds !== void 0 ? keepAliveIntervalInMilliseconds : DEFAULT_PING_INTERVAL_IN_MS; + this._statefulReconnectBufferSize = statefulReconnectBufferSize !== null && statefulReconnectBufferSize !== void 0 ? statefulReconnectBufferSize : DEFAULT_STATEFUL_RECONNECT_BUFFER_SIZE; + this._logger = logger; + this._protocol = protocol; + this.connection = connection; + this._reconnectPolicy = reconnectPolicy; + this._handshakeProtocol = new HandshakeProtocol(); + this.connection.onreceive = function (data) { + return _this13._processIncomingData(data); + }; + this.connection.onclose = function (error) { + return _this13._connectionClosed(error); + }; + this._callbacks = {}; + this._methods = {}; + this._closedCallbacks = []; + this._reconnectingCallbacks = []; + this._reconnectedCallbacks = []; + this._invocationId = 0; + this._receivedHandshakeResponse = false; + this._connectionState = _HubConnectionState.Disconnected; + this._connectionStarted = false; + this._cachedPingMessage = this._protocol.writeMessage({ + type: _MessageType.Ping + }); + } + /** Indicates the state of the {@link HubConnection} to the server. */ + return _createClass(_HubConnection, [{ + key: "state", + get: function get() { + return this._connectionState; + } + /** Represents the connection id of the {@link HubConnection} on the server. The connection id will be null when the connection is either + * in the disconnected state or if the negotiation step was skipped. + */ + }, { + key: "connectionId", + get: function get() { + return this.connection ? this.connection.connectionId || null : null; + } + /** Indicates the url of the {@link HubConnection} to the server. */ + }, { + key: "baseUrl", + get: function get() { + return this.connection.baseUrl || ""; + } + /** + * Sets a new url for the HubConnection. Note that the url can only be changed when the connection is in either the Disconnected or + * Reconnecting states. + * @param {string} url The url to connect to. + */, + set: function set(url) { + if (this._connectionState !== _HubConnectionState.Disconnected && this._connectionState !== _HubConnectionState.Reconnecting) { + throw new Error("The HubConnection must be in the Disconnected or Reconnecting state to change the url."); + } + if (!url) { + throw new Error("The HubConnection url must be a valid url."); + } + this.connection.baseUrl = url; + } + /** Starts the connection. + * + * @returns {Promise} A Promise that resolves when the connection has been successfully established, or rejects with an error. + */ + }, { + key: "start", + value: function start() { + this._startPromise = this._startWithStateTransitions(); + return this._startPromise; + } + }, { + key: "_startWithStateTransitions", + value: function () { + var _startWithStateTransitions2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee5() { + var _t5; + return _regenerator().w(function (_context5) { + while (1) switch (_context5.p = _context5.n) { + case 0: + if (!(this._connectionState !== _HubConnectionState.Disconnected)) { + _context5.n = 1; + break; + } + return _context5.a(2, Promise.reject(new Error("Cannot start a HubConnection that is not in the 'Disconnected' state."))); + case 1: + this._connectionState = _HubConnectionState.Connecting; + this._logger.log(_LogLevel.Debug, "Starting HubConnection."); + _context5.p = 2; + _context5.n = 3; + return this._startInternal(); + case 3: + if (Platform.isBrowser) { + // Log when the browser freezes the tab so users know why their connection unexpectedly stopped working + window.document.addEventListener("freeze", this._freezeEventListener); + } + this._connectionState = _HubConnectionState.Connected; + this._connectionStarted = true; + this._logger.log(_LogLevel.Debug, "HubConnection connected successfully."); + _context5.n = 5; + break; + case 4: + _context5.p = 4; + _t5 = _context5.v; + this._connectionState = _HubConnectionState.Disconnected; + this._logger.log(_LogLevel.Debug, "HubConnection failed to start successfully because of error '".concat(_t5, "'.")); + return _context5.a(2, Promise.reject(_t5)); + case 5: + return _context5.a(2); + } + }, _callee5, this, [[2, 4]]); + })); + function _startWithStateTransitions() { + return _startWithStateTransitions2.apply(this, arguments); + } + return _startWithStateTransitions; + }() + }, { + key: "_startInternal", + value: function () { + var _startInternal2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee6() { + var _this14 = this; + var handshakePromise, version, handshakeRequest, useStatefulReconnect, _t6; + return _regenerator().w(function (_context6) { + while (1) switch (_context6.p = _context6.n) { + case 0: + this._stopDuringStartError = undefined; + this._receivedHandshakeResponse = false; + // Set up the promise before any connection is (re)started otherwise it could race with received messages + handshakePromise = new Promise(function (resolve, reject) { + _this14._handshakeResolver = resolve; + _this14._handshakeRejecter = reject; + }); + _context6.n = 1; + return this.connection.start(this._protocol.transferFormat); + case 1: + _context6.p = 1; + version = this._protocol.version; + if (!this.connection.features.reconnect) { + // Stateful Reconnect starts with HubProtocol version 2, newer clients connecting to older servers will fail to connect due to + // the handshake only supporting version 1, so we will try to send version 1 during the handshake to keep old servers working. + version = 1; + } + handshakeRequest = { + protocol: this._protocol.name, + version: version + }; + this._logger.log(_LogLevel.Debug, "Sending handshake request."); + _context6.n = 2; + return this._sendMessage(this._handshakeProtocol.writeHandshakeRequest(handshakeRequest)); + case 2: + this._logger.log(_LogLevel.Information, "Using HubProtocol '".concat(this._protocol.name, "'.")); + // defensively cleanup timeout in case we receive a message from the server before we finish start + this._cleanupTimeout(); + this._resetTimeoutPeriod(); + this._resetKeepAliveInterval(); + _context6.n = 3; + return handshakePromise; + case 3: + if (!this._stopDuringStartError) { + _context6.n = 4; + break; + } + throw this._stopDuringStartError; + case 4: + useStatefulReconnect = this.connection.features.reconnect || false; + if (useStatefulReconnect) { + this._messageBuffer = new MessageBuffer(this._protocol, this.connection, this._statefulReconnectBufferSize); + this.connection.features.disconnected = this._messageBuffer._disconnected.bind(this._messageBuffer); + this.connection.features.resend = function () { + if (_this14._messageBuffer) { + return _this14._messageBuffer._resend(); + } + }; + } + if (this.connection.features.inherentKeepAlive) { + _context6.n = 5; + break; + } + _context6.n = 5; + return this._sendMessage(this._cachedPingMessage); + case 5: + _context6.n = 8; + break; + case 6: + _context6.p = 6; + _t6 = _context6.v; + this._logger.log(_LogLevel.Debug, "Hub handshake failed with error '".concat(_t6, "' during start(). Stopping HubConnection.")); + this._cleanupTimeout(); + this._cleanupPingTimer(); + // HttpConnection.stop() should not complete until after the onclose callback is invoked. + // This will transition the HubConnection to the disconnected state before HttpConnection.stop() completes. + _context6.n = 7; + return this.connection.stop(_t6); + case 7: + throw _t6; + case 8: + return _context6.a(2); + } + }, _callee6, this, [[1, 6]]); + })); + function _startInternal() { + return _startInternal2.apply(this, arguments); + } + return _startInternal; + }() + /** Stops the connection. + * + * @returns {Promise} A Promise that resolves when the connection has been successfully terminated, or rejects with an error. + */ + }, { + key: "stop", + value: (function () { + var _stop = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee7() { + var startPromise, _t7; + return _regenerator().w(function (_context7) { + while (1) switch (_context7.p = _context7.n) { + case 0: + // Capture the start promise before the connection might be restarted in an onclose callback. + startPromise = this._startPromise; + this.connection.features.reconnect = false; + this._stopPromise = this._stopInternal(); + _context7.n = 1; + return this._stopPromise; + case 1: + _context7.p = 1; + _context7.n = 2; + return startPromise; + case 2: + _context7.n = 4; + break; + case 3: + _context7.p = 3; + _t7 = _context7.v; + case 4: + return _context7.a(2); + } + }, _callee7, this, [[1, 3]]); + })); + function stop() { + return _stop.apply(this, arguments); + } + return stop; + }()) + }, { + key: "_stopInternal", + value: function _stopInternal(error) { + if (this._connectionState === _HubConnectionState.Disconnected) { + this._logger.log(_LogLevel.Debug, "Call to HubConnection.stop(".concat(error, ") ignored because it is already in the disconnected state.")); + return Promise.resolve(); + } + if (this._connectionState === _HubConnectionState.Disconnecting) { + this._logger.log(_LogLevel.Debug, "Call to HttpConnection.stop(".concat(error, ") ignored because the connection is already in the disconnecting state.")); + return this._stopPromise; + } + var state = this._connectionState; + this._connectionState = _HubConnectionState.Disconnecting; + this._logger.log(_LogLevel.Debug, "Stopping HubConnection."); + if (this._reconnectDelayHandle) { + // We're in a reconnect delay which means the underlying connection is currently already stopped. + // Just clear the handle to stop the reconnect loop (which no one is waiting on thankfully) and + // fire the onclose callbacks. + this._logger.log(_LogLevel.Debug, "Connection stopped during reconnect delay. Done reconnecting."); + clearTimeout(this._reconnectDelayHandle); + this._reconnectDelayHandle = undefined; + this._completeClose(); + return Promise.resolve(); + } + if (state === _HubConnectionState.Connected) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._sendCloseMessage(); + } + this._cleanupTimeout(); + this._cleanupPingTimer(); + this._stopDuringStartError = error || new _AbortError("The connection was stopped before the hub handshake could complete."); + // HttpConnection.stop() should not complete until after either HttpConnection.start() fails + // or the onclose callback is invoked. The onclose callback will transition the HubConnection + // to the disconnected state if need be before HttpConnection.stop() completes. + return this.connection.stop(error); + } + }, { + key: "_sendCloseMessage", + value: function () { + var _sendCloseMessage2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee8() { + var _t8; + return _regenerator().w(function (_context8) { + while (1) switch (_context8.p = _context8.n) { + case 0: + _context8.p = 0; + _context8.n = 1; + return this._sendWithProtocol(this._createCloseMessage()); + case 1: + _context8.n = 3; + break; + case 2: + _context8.p = 2; + _t8 = _context8.v; + case 3: + return _context8.a(2); + } + }, _callee8, this, [[0, 2]]); + })); + function _sendCloseMessage() { + return _sendCloseMessage2.apply(this, arguments); + } + return _sendCloseMessage; + }() + /** Invokes a streaming hub method on the server using the specified name and arguments. + * + * @typeparam T The type of the items returned by the server. + * @param {string} methodName The name of the server method to invoke. + * @param {any[]} args The arguments used to invoke the server method. + * @returns {IStreamResult} An object that yields results from the server as they are received. + */ + }, { + key: "stream", + value: function stream(methodName) { + var _this15 = this; + for (var _len = arguments.length, args = new Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + var _this$_replaceStreami = this._replaceStreamingParams(args), + _this$_replaceStreami2 = _slicedToArray(_this$_replaceStreami, 2), + streams = _this$_replaceStreami2[0], + streamIds = _this$_replaceStreami2[1]; + var invocationDescriptor = this._createStreamInvocation(methodName, args, streamIds); + // eslint-disable-next-line prefer-const + var promiseQueue; + var subject = new _Subject(); + subject.cancelCallback = function () { + var cancelInvocation = _this15._createCancelInvocation(invocationDescriptor.invocationId); + delete _this15._callbacks[invocationDescriptor.invocationId]; + return promiseQueue.then(function () { + return _this15._sendWithProtocol(cancelInvocation); + }); + }; + this._callbacks[invocationDescriptor.invocationId] = function (invocationEvent, error) { + if (error) { + subject.error(error); + return; + } else if (invocationEvent) { + // invocationEvent will not be null when an error is not passed to the callback + if (invocationEvent.type === _MessageType.Completion) { + if (invocationEvent.error) { + subject.error(new Error(invocationEvent.error)); + } else { + subject.complete(); + } + } else { + subject.next(invocationEvent.item); + } + } + }; + promiseQueue = this._sendWithProtocol(invocationDescriptor)["catch"](function (e) { + subject.error(e); + delete _this15._callbacks[invocationDescriptor.invocationId]; + }); + this._launchStreams(streams, promiseQueue); + return subject; + } + }, { + key: "_sendMessage", + value: function _sendMessage(message) { + this._resetKeepAliveInterval(); + return this.connection.send(message); + } + /** + * Sends a js object to the server. + * @param message The js object to serialize and send. + */ + }, { + key: "_sendWithProtocol", + value: function _sendWithProtocol(message) { + if (this._messageBuffer) { + return this._messageBuffer._send(message); + } else { + return this._sendMessage(this._protocol.writeMessage(message)); + } + } + /** Invokes a hub method on the server using the specified name and arguments. Does not wait for a response from the receiver. + * + * The Promise returned by this method resolves when the client has sent the invocation to the server. The server may still + * be processing the invocation. + * + * @param {string} methodName The name of the server method to invoke. + * @param {any[]} args The arguments used to invoke the server method. + * @returns {Promise} A Promise that resolves when the invocation has been successfully sent, or rejects with an error. + */ + }, { + key: "send", + value: function send(methodName) { + for (var _len2 = arguments.length, args = new Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + args[_key2 - 1] = arguments[_key2]; + } + var _this$_replaceStreami3 = this._replaceStreamingParams(args), + _this$_replaceStreami4 = _slicedToArray(_this$_replaceStreami3, 2), + streams = _this$_replaceStreami4[0], + streamIds = _this$_replaceStreami4[1]; + var sendPromise = this._sendWithProtocol(this._createInvocation(methodName, args, true, streamIds)); + this._launchStreams(streams, sendPromise); + return sendPromise; + } + /** Invokes a hub method on the server using the specified name and arguments. + * + * The Promise returned by this method resolves when the server indicates it has finished invoking the method. When the promise + * resolves, the server has finished invoking the method. If the server method returns a result, it is produced as the result of + * resolving the Promise. + * + * @typeparam T The expected return type. + * @param {string} methodName The name of the server method to invoke. + * @param {any[]} args The arguments used to invoke the server method. + * @returns {Promise} A Promise that resolves with the result of the server method (if any), or rejects with an error. + */ + }, { + key: "invoke", + value: function invoke(methodName) { + var _this16 = this; + for (var _len3 = arguments.length, args = new Array(_len3 > 1 ? _len3 - 1 : 0), _key3 = 1; _key3 < _len3; _key3++) { + args[_key3 - 1] = arguments[_key3]; + } + var _this$_replaceStreami5 = this._replaceStreamingParams(args), + _this$_replaceStreami6 = _slicedToArray(_this$_replaceStreami5, 2), + streams = _this$_replaceStreami6[0], + streamIds = _this$_replaceStreami6[1]; + var invocationDescriptor = this._createInvocation(methodName, args, false, streamIds); + var p = new Promise(function (resolve, reject) { + // invocationId will always have a value for a non-blocking invocation + _this16._callbacks[invocationDescriptor.invocationId] = function (invocationEvent, error) { + if (error) { + reject(error); + return; + } else if (invocationEvent) { + // invocationEvent will not be null when an error is not passed to the callback + if (invocationEvent.type === _MessageType.Completion) { + if (invocationEvent.error) { + reject(new Error(invocationEvent.error)); + } else { + resolve(invocationEvent.result); + } + } else { + reject(new Error("Unexpected message type: ".concat(invocationEvent.type))); + } + } + }; + var promiseQueue = _this16._sendWithProtocol(invocationDescriptor)["catch"](function (e) { + reject(e); + // invocationId will always have a value for a non-blocking invocation + delete _this16._callbacks[invocationDescriptor.invocationId]; + }); + _this16._launchStreams(streams, promiseQueue); + }); + return p; + } + }, { + key: "on", + value: function on(methodName, newMethod) { + if (!methodName || !newMethod) { + return; + } + methodName = methodName.toLowerCase(); + if (!this._methods[methodName]) { + this._methods[methodName] = []; + } + // Preventing adding the same handler multiple times. + if (this._methods[methodName].indexOf(newMethod) !== -1) { + return; + } + this._methods[methodName].push(newMethod); + } + }, { + key: "off", + value: function off(methodName, method) { + if (!methodName) { + return; + } + methodName = methodName.toLowerCase(); + var handlers = this._methods[methodName]; + if (!handlers) { + return; + } + if (method) { + var removeIdx = handlers.indexOf(method); + if (removeIdx !== -1) { + handlers.splice(removeIdx, 1); + if (handlers.length === 0) { + delete this._methods[methodName]; + } + } + } else { + delete this._methods[methodName]; + } + } + /** Registers a handler that will be invoked when the connection is closed. + * + * @param {Function} callback The handler that will be invoked when the connection is closed. Optionally receives a single argument containing the error that caused the connection to close (if any). + */ + }, { + key: "onclose", + value: function onclose(callback) { + if (callback) { + this._closedCallbacks.push(callback); + } + } + /** Registers a handler that will be invoked when the connection starts reconnecting. + * + * @param {Function} callback The handler that will be invoked when the connection starts reconnecting. Optionally receives a single argument containing the error that caused the connection to start reconnecting (if any). + */ + }, { + key: "onreconnecting", + value: function onreconnecting(callback) { + if (callback) { + this._reconnectingCallbacks.push(callback); + } + } + /** Registers a handler that will be invoked when the connection successfully reconnects. + * + * @param {Function} callback The handler that will be invoked when the connection successfully reconnects. + */ + }, { + key: "onreconnected", + value: function onreconnected(callback) { + if (callback) { + this._reconnectedCallbacks.push(callback); + } + } + }, { + key: "_processIncomingData", + value: function _processIncomingData(data) { + var _this17 = this; + this._cleanupTimeout(); + if (!this._receivedHandshakeResponse) { + data = this._processHandshakeResponse(data); + this._receivedHandshakeResponse = true; + } + // Data may have all been read when processing handshake response + if (data) { + // Parse the messages + var messages = this._protocol.parseMessages(data, this._logger); + var _iterator6 = _createForOfIteratorHelper(messages), + _step6; + try { + for (_iterator6.s(); !(_step6 = _iterator6.n()).done;) { + var message = _step6.value; + if (this._messageBuffer && !this._messageBuffer._shouldProcessMessage(message)) { + // Don't process the message, we are either waiting for a SequenceMessage or received a duplicate message + continue; + } + switch (message.type) { + case _MessageType.Invocation: + this._invokeClientMethod(message)["catch"](function (e) { + _this17._logger.log(_LogLevel.Error, "Invoke client method threw error: ".concat(getErrorString(e))); + }); + break; + case _MessageType.StreamItem: + case _MessageType.Completion: + { + var callback = this._callbacks[message.invocationId]; + if (callback) { + if (message.type === _MessageType.Completion) { + delete this._callbacks[message.invocationId]; + } + try { + callback(message); + } catch (e) { + this._logger.log(_LogLevel.Error, "Stream callback threw error: ".concat(getErrorString(e))); + } + } + break; + } + case _MessageType.Ping: + // Don't care about pings + break; + case _MessageType.Close: + { + this._logger.log(_LogLevel.Information, "Close message received from server."); + var error = message.error ? new Error("Server returned an error on close: " + message.error) : undefined; + if (message.allowReconnect === true) { + // It feels wrong not to await connection.stop() here, but processIncomingData is called as part of an onreceive callback which is not async, + // this is already the behavior for serverTimeout(), and HttpConnection.Stop() should catch and log all possible exceptions. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.connection.stop(error); + } else { + // We cannot await stopInternal() here, but subsequent calls to stop() will await this if stopInternal() is still ongoing. + this._stopPromise = this._stopInternal(error); + } + break; + } + case _MessageType.Ack: + if (this._messageBuffer) { + this._messageBuffer._ack(message); + } + break; + case _MessageType.Sequence: + if (this._messageBuffer) { + this._messageBuffer._resetSequence(message); + } + break; + default: + this._logger.log(_LogLevel.Warning, "Invalid message type: ".concat(message.type, ".")); + break; + } + } + } catch (err) { + _iterator6.e(err); + } finally { + _iterator6.f(); + } + } + this._resetTimeoutPeriod(); + } + }, { + key: "_processHandshakeResponse", + value: function _processHandshakeResponse(data) { + var responseMessage; + var remainingData; + try { + var _this$_handshakeProto = this._handshakeProtocol.parseHandshakeResponse(data); + var _this$_handshakeProto2 = _slicedToArray(_this$_handshakeProto, 2); + remainingData = _this$_handshakeProto2[0]; + responseMessage = _this$_handshakeProto2[1]; + } catch (e) { + var message = "Error parsing handshake response: " + e; + this._logger.log(_LogLevel.Error, message); + var error = new Error(message); + this._handshakeRejecter(error); + throw error; + } + if (responseMessage.error) { + var _message2 = "Server returned handshake error: " + responseMessage.error; + this._logger.log(_LogLevel.Error, _message2); + var _error = new Error(_message2); + this._handshakeRejecter(_error); + throw _error; + } else { + this._logger.log(_LogLevel.Debug, "Server handshake complete."); + } + this._handshakeResolver(); + return remainingData; + } + }, { + key: "_resetKeepAliveInterval", + value: function _resetKeepAliveInterval() { + if (this.connection.features.inherentKeepAlive) { + return; + } + // Set the time we want the next keep alive to be sent + // Timer will be setup on next message receive + this._nextKeepAlive = new Date().getTime() + this.keepAliveIntervalInMilliseconds; + this._cleanupPingTimer(); + } + }, { + key: "_resetTimeoutPeriod", + value: function _resetTimeoutPeriod() { + var _this18 = this; + if (!this.connection.features || !this.connection.features.inherentKeepAlive) { + // Set the timeout timer + this._timeoutHandle = setTimeout(function () { + return _this18.serverTimeout(); + }, this.serverTimeoutInMilliseconds); + // Immediately fire Keep-Alive ping if nextPing is overdue to avoid dependency on JS timers + var nextPing = this._nextKeepAlive - new Date().getTime(); + if (nextPing < 0) { + if (this._connectionState === _HubConnectionState.Connected) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._trySendPingMessage(); + } + return; + } + // Set keepAlive timer if there isn't one + if (this._pingServerHandle === undefined) { + if (nextPing < 0) { + nextPing = 0; + } + // The timer needs to be set from a networking callback to avoid Chrome timer throttling from causing timers to run once a minute + this._pingServerHandle = setTimeout(/*#__PURE__*/_asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee9() { + return _regenerator().w(function (_context9) { + while (1) switch (_context9.n) { + case 0: + if (!(_this18._connectionState === _HubConnectionState.Connected)) { + _context9.n = 1; + break; + } + _context9.n = 1; + return _this18._trySendPingMessage(); + case 1: + return _context9.a(2); + } + }, _callee9); + })), nextPing); + } + } + } + // eslint-disable-next-line @typescript-eslint/naming-convention + }, { + key: "serverTimeout", + value: function serverTimeout() { + // The server hasn't talked to us in a while. It doesn't like us anymore ... :( + // Terminate the connection, but we don't need to wait on the promise. This could trigger reconnecting. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.connection.stop(new Error("Server timeout elapsed without receiving a message from the server.")); + } + }, { + key: "_invokeClientMethod", + value: function () { + var _invokeClientMethod2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee0(invocationMessage) { + var methodName, methods, methodsCopy, expectsResponse, res, exception, completionMessage, _iterator7, _step7, m, prevRes, _t9, _t0; + return _regenerator().w(function (_context0) { + while (1) switch (_context0.p = _context0.n) { + case 0: + methodName = invocationMessage.target.toLowerCase(); + methods = this._methods[methodName]; + if (methods) { + _context0.n = 2; + break; + } + this._logger.log(_LogLevel.Warning, "No client method with the name '".concat(methodName, "' found.")); + // No handlers provided by client but the server is expecting a response still, so we send an error + if (!invocationMessage.invocationId) { + _context0.n = 1; + break; + } + this._logger.log(_LogLevel.Warning, "No result given for '".concat(methodName, "' method and invocation ID '").concat(invocationMessage.invocationId, "'.")); + _context0.n = 1; + return this._sendWithProtocol(this._createCompletionMessage(invocationMessage.invocationId, "Client didn't provide a result.", null)); + case 1: + return _context0.a(2); + case 2: + // Avoid issues with handlers removing themselves thus modifying the list while iterating through it + methodsCopy = methods.slice(); // Server expects a response + expectsResponse = invocationMessage.invocationId ? true : false; // We preserve the last result or exception but still call all handlers + _iterator7 = _createForOfIteratorHelper(methodsCopy); + _context0.p = 3; + _iterator7.s(); + case 4: + if ((_step7 = _iterator7.n()).done) { + _context0.n = 9; + break; + } + m = _step7.value; + _context0.p = 5; + prevRes = res; + _context0.n = 6; + return m.apply(this, invocationMessage.arguments); + case 6: + res = _context0.v; + if (expectsResponse && res && prevRes) { + this._logger.log(_LogLevel.Error, "Multiple results provided for '".concat(methodName, "'. Sending error to server.")); + completionMessage = this._createCompletionMessage(invocationMessage.invocationId, "Client provided multiple results.", null); + } + // Ignore exception if we got a result after, the exception will be logged + exception = undefined; + _context0.n = 8; + break; + case 7: + _context0.p = 7; + _t9 = _context0.v; + exception = _t9; + this._logger.log(_LogLevel.Error, "A callback for the method '".concat(methodName, "' threw error '").concat(_t9, "'.")); + case 8: + _context0.n = 4; + break; + case 9: + _context0.n = 11; + break; + case 10: + _context0.p = 10; + _t0 = _context0.v; + _iterator7.e(_t0); + case 11: + _context0.p = 11; + _iterator7.f(); + return _context0.f(11); + case 12: + if (!completionMessage) { + _context0.n = 14; + break; + } + _context0.n = 13; + return this._sendWithProtocol(completionMessage); + case 13: + _context0.n = 17; + break; + case 14: + if (!expectsResponse) { + _context0.n = 16; + break; + } + // If there is an exception that means either no result was given or a handler after a result threw + if (exception) { + completionMessage = this._createCompletionMessage(invocationMessage.invocationId, "".concat(exception), null); + } else if (res !== undefined) { + completionMessage = this._createCompletionMessage(invocationMessage.invocationId, null, res); + } else { + this._logger.log(_LogLevel.Warning, "No result given for '".concat(methodName, "' method and invocation ID '").concat(invocationMessage.invocationId, "'.")); + // Client didn't provide a result or throw from a handler, server expects a response so we send an error + completionMessage = this._createCompletionMessage(invocationMessage.invocationId, "Client didn't provide a result.", null); + } + _context0.n = 15; + return this._sendWithProtocol(completionMessage); + case 15: + _context0.n = 17; + break; + case 16: + if (res) { + this._logger.log(_LogLevel.Error, "Result given for '".concat(methodName, "' method but server is not expecting a result.")); + } + case 17: + return _context0.a(2); + } + }, _callee0, this, [[5, 7], [3, 10, 11, 12]]); + })); + function _invokeClientMethod(_x9) { + return _invokeClientMethod2.apply(this, arguments); + } + return _invokeClientMethod; + }() + }, { + key: "_connectionClosed", + value: function _connectionClosed(error) { + this._logger.log(_LogLevel.Debug, "HubConnection.connectionClosed(".concat(error, ") called while in state ").concat(this._connectionState, ".")); + // Triggering this.handshakeRejecter is insufficient because it could already be resolved without the continuation having run yet. + this._stopDuringStartError = this._stopDuringStartError || error || new _AbortError("The underlying connection was closed before the hub handshake could complete."); + // If the handshake is in progress, start will be waiting for the handshake promise, so we complete it. + // If it has already completed, this should just noop. + if (this._handshakeResolver) { + this._handshakeResolver(); + } + this._cancelCallbacksWithError(error || new Error("Invocation canceled due to the underlying connection being closed.")); + this._cleanupTimeout(); + this._cleanupPingTimer(); + if (this._connectionState === _HubConnectionState.Disconnecting) { + this._completeClose(error); + } else if (this._connectionState === _HubConnectionState.Connected && this._reconnectPolicy) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this._reconnect(error); + } else if (this._connectionState === _HubConnectionState.Connected) { + this._completeClose(error); + } + // If none of the above if conditions were true were called the HubConnection must be in either: + // 1. The Connecting state in which case the handshakeResolver will complete it and stopDuringStartError will fail it. + // 2. The Reconnecting state in which case the handshakeResolver will complete it and stopDuringStartError will fail the current reconnect attempt + // and potentially continue the reconnect() loop. + // 3. The Disconnected state in which case we're already done. + } + }, { + key: "_completeClose", + value: function _completeClose(error) { + var _this19 = this; + if (this._connectionStarted) { + this._connectionState = _HubConnectionState.Disconnected; + this._connectionStarted = false; + if (this._messageBuffer) { + this._messageBuffer._dispose(error !== null && error !== void 0 ? error : new Error("Connection closed.")); + this._messageBuffer = undefined; + } + if (Platform.isBrowser) { + window.document.removeEventListener("freeze", this._freezeEventListener); + } + try { + this._closedCallbacks.forEach(function (c) { + return c.apply(_this19, [error]); + }); + } catch (e) { + this._logger.log(_LogLevel.Error, "An onclose callback called with error '".concat(error, "' threw error '").concat(e, "'.")); + } + } + } + }, { + key: "_reconnect", + value: function () { + var _reconnect2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee1(error) { + var _this20 = this; + var reconnectStartTime, previousReconnectAttempts, retryError, nextRetryDelay, _t1; + return _regenerator().w(function (_context1) { + while (1) switch (_context1.p = _context1.n) { + case 0: + reconnectStartTime = Date.now(); + previousReconnectAttempts = 0; + retryError = error !== undefined ? error : new Error("Attempting to reconnect due to a unknown error."); + nextRetryDelay = this._getNextRetryDelay(previousReconnectAttempts, 0, retryError); + if (!(nextRetryDelay === null)) { + _context1.n = 1; + break; + } + this._logger.log(_LogLevel.Debug, "Connection not reconnecting because the IRetryPolicy returned null on the first reconnect attempt."); + this._completeClose(error); + return _context1.a(2); + case 1: + this._connectionState = _HubConnectionState.Reconnecting; + if (error) { + this._logger.log(_LogLevel.Information, "Connection reconnecting because of error '".concat(error, "'.")); + } else { + this._logger.log(_LogLevel.Information, "Connection reconnecting."); + } + if (!(this._reconnectingCallbacks.length !== 0)) { + _context1.n = 2; + break; + } + try { + this._reconnectingCallbacks.forEach(function (c) { + return c.apply(_this20, [error]); + }); + } catch (e) { + this._logger.log(_LogLevel.Error, "An onreconnecting callback called with error '".concat(error, "' threw error '").concat(e, "'.")); + } + // Exit early if an onreconnecting callback called connection.stop(). + if (!(this._connectionState !== _HubConnectionState.Reconnecting)) { + _context1.n = 2; + break; + } + this._logger.log(_LogLevel.Debug, "Connection left the reconnecting state in onreconnecting callback. Done reconnecting."); + return _context1.a(2); + case 2: + if (!(nextRetryDelay !== null)) { + _context1.n = 8; + break; + } + this._logger.log(_LogLevel.Information, "Reconnect attempt number ".concat(previousReconnectAttempts + 1, " will start in ").concat(nextRetryDelay, " ms.")); + _context1.n = 3; + return new Promise(function (resolve) { + _this20._reconnectDelayHandle = setTimeout(resolve, nextRetryDelay); + }); + case 3: + this._reconnectDelayHandle = undefined; + if (!(this._connectionState !== _HubConnectionState.Reconnecting)) { + _context1.n = 4; + break; + } + this._logger.log(_LogLevel.Debug, "Connection left the reconnecting state during reconnect delay. Done reconnecting."); + return _context1.a(2); + case 4: + _context1.p = 4; + _context1.n = 5; + return this._startInternal(); + case 5: + this._connectionState = _HubConnectionState.Connected; + this._logger.log(_LogLevel.Information, "HubConnection reconnected successfully."); + if (this._reconnectedCallbacks.length !== 0) { + try { + this._reconnectedCallbacks.forEach(function (c) { + return c.apply(_this20, [_this20.connection.connectionId]); + }); + } catch (e) { + this._logger.log(_LogLevel.Error, "An onreconnected callback called with connectionId '".concat(this.connection.connectionId, "; threw error '").concat(e, "'.")); + } + } + return _context1.a(2); + case 6: + _context1.p = 6; + _t1 = _context1.v; + this._logger.log(_LogLevel.Information, "Reconnect attempt failed because of error '".concat(_t1, "'.")); + if (!(this._connectionState !== _HubConnectionState.Reconnecting)) { + _context1.n = 7; + break; + } + this._logger.log(_LogLevel.Debug, "Connection moved to the '".concat(this._connectionState, "' from the reconnecting state during reconnect attempt. Done reconnecting.")); + // The TypeScript compiler thinks that connectionState must be Connected here. The TypeScript compiler is wrong. + if (this._connectionState === _HubConnectionState.Disconnecting) { + this._completeClose(); + } + return _context1.a(2); + case 7: + previousReconnectAttempts++; + retryError = _t1 instanceof Error ? _t1 : new Error(_t1.toString()); + nextRetryDelay = this._getNextRetryDelay(previousReconnectAttempts, Date.now() - reconnectStartTime, retryError); + _context1.n = 2; + break; + case 8: + this._logger.log(_LogLevel.Information, "Reconnect retries have been exhausted after ".concat(Date.now() - reconnectStartTime, " ms and ").concat(previousReconnectAttempts, " failed attempts. Connection disconnecting.")); + this._completeClose(); + case 9: + return _context1.a(2); + } + }, _callee1, this, [[4, 6]]); + })); + function _reconnect(_x0) { + return _reconnect2.apply(this, arguments); + } + return _reconnect; + }() + }, { + key: "_getNextRetryDelay", + value: function _getNextRetryDelay(previousRetryCount, elapsedMilliseconds, retryReason) { + try { + return this._reconnectPolicy.nextRetryDelayInMilliseconds({ + elapsedMilliseconds: elapsedMilliseconds, + previousRetryCount: previousRetryCount, + retryReason: retryReason + }); + } catch (e) { + this._logger.log(_LogLevel.Error, "IRetryPolicy.nextRetryDelayInMilliseconds(".concat(previousRetryCount, ", ").concat(elapsedMilliseconds, ") threw error '").concat(e, "'.")); + return null; + } + } + }, { + key: "_cancelCallbacksWithError", + value: function _cancelCallbacksWithError(error) { + var _this21 = this; + var callbacks = this._callbacks; + this._callbacks = {}; + Object.keys(callbacks).forEach(function (key) { + var callback = callbacks[key]; + try { + callback(null, error); + } catch (e) { + _this21._logger.log(_LogLevel.Error, "Stream 'error' callback called with '".concat(error, "' threw error: ").concat(getErrorString(e))); + } + }); + } + }, { + key: "_cleanupPingTimer", + value: function _cleanupPingTimer() { + if (this._pingServerHandle) { + clearTimeout(this._pingServerHandle); + this._pingServerHandle = undefined; + } + } + }, { + key: "_cleanupTimeout", + value: function _cleanupTimeout() { + if (this._timeoutHandle) { + clearTimeout(this._timeoutHandle); + } + } + }, { + key: "_createInvocation", + value: function _createInvocation(methodName, args, nonblocking, streamIds) { + if (nonblocking) { + if (streamIds.length !== 0) { + return { + target: methodName, + arguments: args, + streamIds: streamIds, + type: _MessageType.Invocation + }; + } else { + return { + target: methodName, + arguments: args, + type: _MessageType.Invocation + }; + } + } else { + var invocationId = this._invocationId; + this._invocationId++; + if (streamIds.length !== 0) { + return { + target: methodName, + arguments: args, + invocationId: invocationId.toString(), + streamIds: streamIds, + type: _MessageType.Invocation + }; + } else { + return { + target: methodName, + arguments: args, + invocationId: invocationId.toString(), + type: _MessageType.Invocation + }; + } + } + } + }, { + key: "_launchStreams", + value: function _launchStreams(streams, promiseQueue) { + var _this22 = this; + if (streams.length === 0) { + return; + } + // Synchronize stream data so they arrive in-order on the server + if (!promiseQueue) { + promiseQueue = Promise.resolve(); + } + // We want to iterate over the keys, since the keys are the stream ids + // eslint-disable-next-line guard-for-in + var _loop = function _loop(streamId) { + streams[streamId].subscribe({ + complete: function complete() { + promiseQueue = promiseQueue.then(function () { + return _this22._sendWithProtocol(_this22._createCompletionMessage(streamId)); + }); + }, + error: function error(err) { + var message; + if (err instanceof Error) { + message = err.message; + } else if (err && err.toString) { + message = err.toString(); + } else { + message = "Unknown error"; + } + promiseQueue = promiseQueue.then(function () { + return _this22._sendWithProtocol(_this22._createCompletionMessage(streamId, message)); + }); + }, + next: function next(item) { + promiseQueue = promiseQueue.then(function () { + return _this22._sendWithProtocol(_this22._createStreamItemMessage(streamId, item)); + }); + } + }); + }; + for (var streamId in streams) { + _loop(streamId); + } + } + }, { + key: "_replaceStreamingParams", + value: function _replaceStreamingParams(args) { + var streams = []; + var streamIds = []; + for (var i = 0; i < args.length; i++) { + var argument = args[i]; + if (this._isObservable(argument)) { + var streamId = this._invocationId; + this._invocationId++; + // Store the stream for later use + streams[streamId] = argument; + streamIds.push(streamId.toString()); + // remove stream from args + args.splice(i, 1); + } + } + return [streams, streamIds]; + } + }, { + key: "_isObservable", + value: function _isObservable(arg) { + // This allows other stream implementations to just work (like rxjs) + return arg && arg.subscribe && typeof arg.subscribe === "function"; + } + }, { + key: "_createStreamInvocation", + value: function _createStreamInvocation(methodName, args, streamIds) { + var invocationId = this._invocationId; + this._invocationId++; + if (streamIds.length !== 0) { + return { + target: methodName, + arguments: args, + invocationId: invocationId.toString(), + streamIds: streamIds, + type: _MessageType.StreamInvocation + }; + } else { + return { + target: methodName, + arguments: args, + invocationId: invocationId.toString(), + type: _MessageType.StreamInvocation + }; + } + } + }, { + key: "_createCancelInvocation", + value: function _createCancelInvocation(id) { + return { + invocationId: id, + type: _MessageType.CancelInvocation + }; + } + }, { + key: "_createStreamItemMessage", + value: function _createStreamItemMessage(id, item) { + return { + invocationId: id, + item: item, + type: _MessageType.StreamItem + }; + } + }, { + key: "_createCompletionMessage", + value: function _createCompletionMessage(id, error, result) { + if (error) { + return { + error: error, + invocationId: id, + type: _MessageType.Completion + }; + } + return { + invocationId: id, + result: result, + type: _MessageType.Completion + }; + } + }, { + key: "_createCloseMessage", + value: function _createCloseMessage() { + return { + type: _MessageType.Close + }; + } + }, { + key: "_trySendPingMessage", + value: function () { + var _trySendPingMessage2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee10() { + var _t10; + return _regenerator().w(function (_context10) { + while (1) switch (_context10.p = _context10.n) { + case 0: + _context10.p = 0; + _context10.n = 1; + return this._sendMessage(this._cachedPingMessage); + case 1: + _context10.n = 3; + break; + case 2: + _context10.p = 2; + _t10 = _context10.v; + // We don't care about the error. It should be seen elsewhere in the client. + // The connection is probably in a bad or closed state now, cleanup the timer so it stops triggering + this._cleanupPingTimer(); + case 3: + return _context10.a(2); + } + }, _callee10, this, [[0, 2]]); + })); + function _trySendPingMessage() { + return _trySendPingMessage2.apply(this, arguments); + } + return _trySendPingMessage; + }() + }], [{ + key: "create", + value: /** @internal */ + // Using a public static factory method means we can have a private constructor and an _internal_ + // create method that can be used by HubConnectionBuilder. An "internal" constructor would just + // be stripped away and the '.d.ts' file would have no constructor, which is interpreted as a + // public parameter-less constructor. + function create(connection, logger, protocol, reconnectPolicy, serverTimeoutInMilliseconds, keepAliveIntervalInMilliseconds, statefulReconnectBufferSize) { + return new _HubConnection(connection, logger, protocol, reconnectPolicy, serverTimeoutInMilliseconds, keepAliveIntervalInMilliseconds, statefulReconnectBufferSize); + } + }]); + }(); + ; // ./src/DefaultReconnectPolicy.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // 0, 2, 10, 30 second delays before reconnect attempts. + var DEFAULT_RETRY_DELAYS_IN_MILLISECONDS = [0, 2000, 10000, 30000, null]; + /** @private */ + var DefaultReconnectPolicy = /*#__PURE__*/function () { + function DefaultReconnectPolicy(retryDelays) { + _classCallCheck(this, DefaultReconnectPolicy); + this._retryDelays = retryDelays !== undefined ? [].concat(_toConsumableArray(retryDelays), [null]) : DEFAULT_RETRY_DELAYS_IN_MILLISECONDS; + } + return _createClass(DefaultReconnectPolicy, [{ + key: "nextRetryDelayInMilliseconds", + value: function nextRetryDelayInMilliseconds(retryContext) { + return this._retryDelays[retryContext.previousRetryCount]; + } + }]); + }(); + ; // ./src/HeaderNames.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + var HeaderNames = /*#__PURE__*/_createClass(function HeaderNames() { + _classCallCheck(this, HeaderNames); + }); + HeaderNames.Authorization = "Authorization"; + HeaderNames.Cookie = "Cookie"; + ; // ./src/AccessTokenHttpClient.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** @private */ + var AccessTokenHttpClient = /*#__PURE__*/function (_HttpClient5) { + function AccessTokenHttpClient(innerClient, accessTokenFactory) { + var _this23; + _classCallCheck(this, AccessTokenHttpClient); + _this23 = _callSuper(this, AccessTokenHttpClient); + _this23._innerClient = innerClient; + _this23._accessTokenFactory = accessTokenFactory; + return _this23; + } + _inherits(AccessTokenHttpClient, _HttpClient5); + return _createClass(AccessTokenHttpClient, [{ + key: "send", + value: function () { + var _send3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee11(request) { + var allowRetry, response; + return _regenerator().w(function (_context11) { + while (1) switch (_context11.n) { + case 0: + allowRetry = true; + if (!(this._accessTokenFactory && (!this._accessToken || request.url && request.url.indexOf("/negotiate?") > 0))) { + _context11.n = 2; + break; + } + // don't retry if the request is a negotiate or if we just got a potentially new token from the access token factory + allowRetry = false; + _context11.n = 1; + return this._accessTokenFactory(); + case 1: + this._accessToken = _context11.v; + case 2: + this._setAuthorizationHeader(request); + _context11.n = 3; + return this._innerClient.send(request); + case 3: + response = _context11.v; + if (!(allowRetry && response.statusCode === 401 && this._accessTokenFactory)) { + _context11.n = 6; + break; + } + _context11.n = 4; + return this._accessTokenFactory(); + case 4: + this._accessToken = _context11.v; + this._setAuthorizationHeader(request); + _context11.n = 5; + return this._innerClient.send(request); + case 5: + return _context11.a(2, _context11.v); + case 6: + return _context11.a(2, response); + } + }, _callee11, this); + })); + function send(_x1) { + return _send3.apply(this, arguments); + } + return send; + }() + }, { + key: "_setAuthorizationHeader", + value: function _setAuthorizationHeader(request) { + if (!request.headers) { + request.headers = {}; + } + if (this._accessToken) { + request.headers[HeaderNames.Authorization] = "Bearer ".concat(this._accessToken); + } + // don't remove the header if there isn't an access token factory, the user manually added the header in this case + else if (this._accessTokenFactory) { + if (request.headers[HeaderNames.Authorization]) { + delete request.headers[HeaderNames.Authorization]; + } + } + } + }, { + key: "getCookieString", + value: function getCookieString(url) { + return this._innerClient.getCookieString(url); + } + }]); + }(_HttpClient); + ; // ./src/ITransport.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // This will be treated as a bit flag in the future, so we keep it using power-of-two values. + /** Specifies a specific HTTP transport type. */ + var _HttpTransportType; + (function (HttpTransportType) { + /** Specifies no transport preference. */ + HttpTransportType[HttpTransportType["None"] = 0] = "None"; + /** Specifies the WebSockets transport. */ + HttpTransportType[HttpTransportType["WebSockets"] = 1] = "WebSockets"; + /** Specifies the Server-Sent Events transport. */ + HttpTransportType[HttpTransportType["ServerSentEvents"] = 2] = "ServerSentEvents"; + /** Specifies the Long Polling transport. */ + HttpTransportType[HttpTransportType["LongPolling"] = 4] = "LongPolling"; + })(_HttpTransportType || (_HttpTransportType = {})); + /** Specifies the transfer format for a connection. */ + var _TransferFormat; + (function (TransferFormat) { + /** Specifies that only text data will be transmitted over the connection. */ + TransferFormat[TransferFormat["Text"] = 1] = "Text"; + /** Specifies that binary data will be transmitted over the connection. */ + TransferFormat[TransferFormat["Binary"] = 2] = "Binary"; + })(_TransferFormat || (_TransferFormat = {})); + ; // ./src/AbortController.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // Rough polyfill of https://developer.mozilla.org/en-US/docs/Web/API/AbortController + // We don't actually ever use the API being polyfilled, we always use the polyfill because + // it's a very new API right now. + // Not exported from index. + /** @private */ + var AbortController_AbortController = /*#__PURE__*/function () { + function AbortController_AbortController() { + _classCallCheck(this, AbortController_AbortController); + this._isAborted = false; + this.onabort = null; + } + return _createClass(AbortController_AbortController, [{ + key: "abort", + value: function abort() { + if (!this._isAborted) { + this._isAborted = true; + if (this.onabort) { + this.onabort(); + } + } + } + }, { + key: "signal", + get: function get() { + return this; + } + }, { + key: "aborted", + get: function get() { + return this._isAborted; + } + }]); + }(); + ; // ./src/LongPollingTransport.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + // Not exported from 'index', this type is internal. + /** @private */ + var LongPollingTransport = /*#__PURE__*/function () { + function LongPollingTransport(httpClient, logger, options) { + _classCallCheck(this, LongPollingTransport); + this._httpClient = httpClient; + this._logger = logger; + this._pollAbort = new AbortController_AbortController(); + this._options = options; + this._running = false; + this.onreceive = null; + this.onclose = null; + } + return _createClass(LongPollingTransport, [{ + key: "pollAborted", + get: + // This is an internal type, not exported from 'index' so this is really just internal. + function get() { + return this._pollAbort.aborted; + } + }, { + key: "connect", + value: function () { + var _connect = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee12(url, transferFormat) { + var _getUserAgentHeader, _getUserAgentHeader2, name, value, headers, pollOptions, pollUrl, response; + return _regenerator().w(function (_context12) { + while (1) switch (_context12.n) { + case 0: + Arg.isRequired(url, "url"); + Arg.isRequired(transferFormat, "transferFormat"); + Arg.isIn(transferFormat, _TransferFormat, "transferFormat"); + this._url = url; + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Connecting."); + // Allow binary format on Node and Browsers that support binary content (indicated by the presence of responseType property) + if (!(transferFormat === _TransferFormat.Binary && typeof XMLHttpRequest !== "undefined" && typeof new XMLHttpRequest().responseType !== "string")) { + _context12.n = 1; + break; + } + throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported."); + case 1: + _getUserAgentHeader = getUserAgentHeader(), _getUserAgentHeader2 = _slicedToArray(_getUserAgentHeader, 2), name = _getUserAgentHeader2[0], value = _getUserAgentHeader2[1]; + headers = _objectSpread(_defineProperty({}, name, value), this._options.headers); + pollOptions = { + abortSignal: this._pollAbort.signal, + headers: headers, + timeout: 100000, + withCredentials: this._options.withCredentials + }; + if (transferFormat === _TransferFormat.Binary) { + pollOptions.responseType = "arraybuffer"; + } + // Make initial long polling request + // Server uses first long polling request to finish initializing connection and it returns without data + pollUrl = "".concat(url, "&_=").concat(Date.now()); + this._logger.log(_LogLevel.Trace, "(LongPolling transport) polling: ".concat(pollUrl, ".")); + _context12.n = 2; + return this._httpClient.get(pollUrl, pollOptions); + case 2: + response = _context12.v; + if (response.statusCode !== 200) { + this._logger.log(_LogLevel.Error, "(LongPolling transport) Unexpected response code: ".concat(response.statusCode, ".")); + // Mark running as false so that the poll immediately ends and runs the close logic + this._closeError = new _HttpError(response.statusText || "", response.statusCode); + this._running = false; + } else { + this._running = true; + } + this._receiving = this._poll(this._url, pollOptions); + case 3: + return _context12.a(2); + } + }, _callee12, this); + })); + function connect(_x10, _x11) { + return _connect.apply(this, arguments); + } + return connect; + }() + }, { + key: "_poll", + value: function () { + var _poll2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee13(url, pollOptions) { + var pollUrl, response, _t11; + return _regenerator().w(function (_context13) { + while (1) switch (_context13.p = _context13.n) { + case 0: + _context13.p = 0; + case 1: + if (!this._running) { + _context13.n = 6; + break; + } + _context13.p = 2; + pollUrl = "".concat(url, "&_=").concat(Date.now()); + this._logger.log(_LogLevel.Trace, "(LongPolling transport) polling: ".concat(pollUrl, ".")); + _context13.n = 3; + return this._httpClient.get(pollUrl, pollOptions); + case 3: + response = _context13.v; + if (response.statusCode === 204) { + this._logger.log(_LogLevel.Information, "(LongPolling transport) Poll terminated by server."); + this._running = false; + } else if (response.statusCode !== 200) { + this._logger.log(_LogLevel.Error, "(LongPolling transport) Unexpected response code: ".concat(response.statusCode, ".")); + // Unexpected status code + this._closeError = new _HttpError(response.statusText || "", response.statusCode); + this._running = false; + } else { + // Process the response + if (response.content) { + this._logger.log(_LogLevel.Trace, "(LongPolling transport) data received. ".concat(getDataDetail(response.content, this._options.logMessageContent), ".")); + if (this.onreceive) { + this.onreceive(response.content); + } + } else { + // This is another way timeout manifest. + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Poll timed out, reissuing."); + } + } + _context13.n = 5; + break; + case 4: + _context13.p = 4; + _t11 = _context13.v; + if (!this._running) { + // Log but disregard errors that occur after stopping + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Poll errored after shutdown: ".concat(_t11.message)); + } else { + if (_t11 instanceof _TimeoutError) { + // Ignore timeouts and reissue the poll. + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Poll timed out, reissuing."); + } else { + // Close the connection with the error as the result. + this._closeError = _t11; + this._running = false; + } + } + case 5: + _context13.n = 1; + break; + case 6: + _context13.p = 6; + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Polling complete."); + // We will reach here with pollAborted==false when the server returned a response causing the transport to stop. + // If pollAborted==true then client initiated the stop and the stop method will raise the close event after DELETE is sent. + if (!this.pollAborted) { + this._raiseOnClose(); + } + return _context13.f(6); + case 7: + return _context13.a(2); + } + }, _callee13, this, [[2, 4], [0,, 6, 7]]); + })); + function _poll(_x12, _x13) { + return _poll2.apply(this, arguments); + } + return _poll; + }() + }, { + key: "send", + value: function () { + var _send4 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee14(data) { + return _regenerator().w(function (_context14) { + while (1) switch (_context14.n) { + case 0: + if (this._running) { + _context14.n = 1; + break; + } + return _context14.a(2, Promise.reject(new Error("Cannot send until the transport is connected"))); + case 1: + return _context14.a(2, sendMessage(this._logger, "LongPolling", this._httpClient, this._url, data, this._options)); + } + }, _callee14, this); + })); + function send(_x14) { + return _send4.apply(this, arguments); + } + return send; + }() + }, { + key: "stop", + value: function () { + var _stop2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee15() { + var headers, _getUserAgentHeader3, _getUserAgentHeader4, name, value, deleteOptions, error, _t12; + return _regenerator().w(function (_context15) { + while (1) switch (_context15.p = _context15.n) { + case 0: + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Stopping polling."); + // Tell receiving loop to stop, abort any current request, and then wait for it to finish + this._running = false; + this._pollAbort.abort(); + _context15.p = 1; + _context15.n = 2; + return this._receiving; + case 2: + // Send DELETE to clean up long polling on the server + this._logger.log(_LogLevel.Trace, "(LongPolling transport) sending DELETE request to ".concat(this._url, ".")); + headers = {}; + _getUserAgentHeader3 = getUserAgentHeader(), _getUserAgentHeader4 = _slicedToArray(_getUserAgentHeader3, 2), name = _getUserAgentHeader4[0], value = _getUserAgentHeader4[1]; + headers[name] = value; + deleteOptions = { + headers: _objectSpread(_objectSpread({}, headers), this._options.headers), + timeout: this._options.timeout, + withCredentials: this._options.withCredentials + }; + _context15.p = 3; + _context15.n = 4; + return this._httpClient["delete"](this._url, deleteOptions); + case 4: + _context15.n = 6; + break; + case 5: + _context15.p = 5; + _t12 = _context15.v; + error = _t12; + case 6: + if (error) { + if (error instanceof _HttpError) { + if (error.statusCode === 404) { + this._logger.log(_LogLevel.Trace, "(LongPolling transport) A 404 response was returned from sending a DELETE request."); + } else { + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Error sending a DELETE request: ".concat(error)); + } + } + } else { + this._logger.log(_LogLevel.Trace, "(LongPolling transport) DELETE request accepted."); + } + case 7: + _context15.p = 7; + this._logger.log(_LogLevel.Trace, "(LongPolling transport) Stop finished."); + // Raise close event here instead of in polling + // It needs to happen after the DELETE request is sent + this._raiseOnClose(); + return _context15.f(7); + case 8: + return _context15.a(2); + } + }, _callee15, this, [[3, 5], [1,, 7, 8]]); + })); + function stop() { + return _stop2.apply(this, arguments); + } + return stop; + }() + }, { + key: "_raiseOnClose", + value: function _raiseOnClose() { + if (this.onclose) { + var logMessage = "(LongPolling transport) Firing onclose event."; + if (this._closeError) { + logMessage += " Error: " + this._closeError; + } + this._logger.log(_LogLevel.Trace, logMessage); + this.onclose(this._closeError); + } + } + }]); + }(); + ; // ./src/ServerSentEventsTransport.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** @private */ + var ServerSentEventsTransport = /*#__PURE__*/function () { + function ServerSentEventsTransport(httpClient, accessToken, logger, options) { + _classCallCheck(this, ServerSentEventsTransport); + this._httpClient = httpClient; + this._accessToken = accessToken; + this._logger = logger; + this._options = options; + this.onreceive = null; + this.onclose = null; + } + return _createClass(ServerSentEventsTransport, [{ + key: "connect", + value: function () { + var _connect2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee16(url, transferFormat) { + var _this24 = this; + return _regenerator().w(function (_context16) { + while (1) switch (_context16.n) { + case 0: + Arg.isRequired(url, "url"); + Arg.isRequired(transferFormat, "transferFormat"); + Arg.isIn(transferFormat, _TransferFormat, "transferFormat"); + this._logger.log(_LogLevel.Trace, "(SSE transport) Connecting."); + // set url before accessTokenFactory because this._url is only for send and we set the auth header instead of the query string for send + this._url = url; + if (this._accessToken) { + url += (url.indexOf("?") < 0 ? "?" : "&") + "access_token=".concat(encodeURIComponent(this._accessToken)); + } + return _context16.a(2, new Promise(function (resolve, reject) { + var opened = false; + if (transferFormat !== _TransferFormat.Text) { + reject(new Error("The Server-Sent Events transport only supports the 'Text' transfer format")); + return; + } + var eventSource; + if (Platform.isBrowser || Platform.isWebWorker) { + eventSource = new _this24._options.EventSource(url, { + withCredentials: _this24._options.withCredentials + }); + } else { + // Non-browser passes cookies via the dictionary + var cookies = _this24._httpClient.getCookieString(url); + var headers = {}; + headers.Cookie = cookies; + var _getUserAgentHeader5 = getUserAgentHeader(), + _getUserAgentHeader6 = _slicedToArray(_getUserAgentHeader5, 2), + name = _getUserAgentHeader6[0], + value = _getUserAgentHeader6[1]; + headers[name] = value; + eventSource = new _this24._options.EventSource(url, { + withCredentials: _this24._options.withCredentials, + headers: _objectSpread(_objectSpread({}, headers), _this24._options.headers) + }); + } + try { + eventSource.onmessage = function (e) { + if (_this24.onreceive) { + try { + _this24._logger.log(_LogLevel.Trace, "(SSE transport) data received. ".concat(getDataDetail(e.data, _this24._options.logMessageContent), ".")); + _this24.onreceive(e.data); + } catch (error) { + _this24._close(error); + return; + } + } + }; + // @ts-ignore: not using event on purpose + eventSource.onerror = function (e) { + // EventSource doesn't give any useful information about server side closes. + if (opened) { + _this24._close(); + } else { + reject(new Error("EventSource failed to connect. The connection could not be found on the server," + " either the connection ID is not present on the server, or a proxy is refusing/buffering the connection." + " If you have multiple servers check that sticky sessions are enabled.")); + } + }; + eventSource.onopen = function () { + _this24._logger.log(_LogLevel.Information, "SSE connected to ".concat(_this24._url)); + _this24._eventSource = eventSource; + opened = true; + resolve(); + }; + } catch (e) { + reject(e); + return; + } + })); + } + }, _callee16, this); + })); + function connect(_x15, _x16) { + return _connect2.apply(this, arguments); + } + return connect; + }() + }, { + key: "send", + value: function () { + var _send5 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee17(data) { + return _regenerator().w(function (_context17) { + while (1) switch (_context17.n) { + case 0: + if (this._eventSource) { + _context17.n = 1; + break; + } + return _context17.a(2, Promise.reject(new Error("Cannot send until the transport is connected"))); + case 1: + return _context17.a(2, sendMessage(this._logger, "SSE", this._httpClient, this._url, data, this._options)); + } + }, _callee17, this); + })); + function send(_x17) { + return _send5.apply(this, arguments); + } + return send; + }() + }, { + key: "stop", + value: function stop() { + this._close(); + return Promise.resolve(); + } + }, { + key: "_close", + value: function _close(e) { + if (this._eventSource) { + this._eventSource.close(); + this._eventSource = undefined; + if (this.onclose) { + this.onclose(e); + } + } + } + }]); + }(); + ; // ./src/WebSocketTransport.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + /** @private */ + var WebSocketTransport = /*#__PURE__*/function () { + function WebSocketTransport(httpClient, accessTokenFactory, logger, logMessageContent, webSocketConstructor, headers) { + _classCallCheck(this, WebSocketTransport); + this._logger = logger; + this._accessTokenFactory = accessTokenFactory; + this._logMessageContent = logMessageContent; + this._webSocketConstructor = webSocketConstructor; + this._httpClient = httpClient; + this.onreceive = null; + this.onclose = null; + this._headers = headers; + } + return _createClass(WebSocketTransport, [{ + key: "connect", + value: function () { + var _connect3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee18(url, transferFormat) { + var _this25 = this; + var token; + return _regenerator().w(function (_context18) { + while (1) switch (_context18.n) { + case 0: + Arg.isRequired(url, "url"); + Arg.isRequired(transferFormat, "transferFormat"); + Arg.isIn(transferFormat, _TransferFormat, "transferFormat"); + this._logger.log(_LogLevel.Trace, "(WebSockets transport) Connecting."); + if (!this._accessTokenFactory) { + _context18.n = 2; + break; + } + _context18.n = 1; + return this._accessTokenFactory(); + case 1: + token = _context18.v; + case 2: + return _context18.a(2, new Promise(function (resolve, reject) { + url = url.replace(/^http/, "ws"); + var webSocket; + var cookies = _this25._httpClient.getCookieString(url); + var opened = false; + if (Platform.isNode || Platform.isReactNative) { + var headers = {}; + var _getUserAgentHeader7 = getUserAgentHeader(), + _getUserAgentHeader8 = _slicedToArray(_getUserAgentHeader7, 2), + name = _getUserAgentHeader8[0], + value = _getUserAgentHeader8[1]; + headers[name] = value; + if (token) { + headers[HeaderNames.Authorization] = "Bearer ".concat(token); + } + if (cookies) { + headers[HeaderNames.Cookie] = cookies; + } + // Only pass headers when in non-browser environments + webSocket = new _this25._webSocketConstructor(url, undefined, { + headers: _objectSpread(_objectSpread({}, headers), _this25._headers) + }); + } else { + if (token) { + url += (url.indexOf("?") < 0 ? "?" : "&") + "access_token=".concat(encodeURIComponent(token)); + } + } + if (!webSocket) { + // Chrome is not happy with passing 'undefined' as protocol + webSocket = new _this25._webSocketConstructor(url); + } + if (transferFormat === _TransferFormat.Binary) { + webSocket.binaryType = "arraybuffer"; + } + webSocket.onopen = function (_event) { + _this25._logger.log(_LogLevel.Information, "WebSocket connected to ".concat(url, ".")); + _this25._webSocket = webSocket; + opened = true; + resolve(); + }; + webSocket.onerror = function (event) { + var error = null; + // ErrorEvent is a browser only type we need to check if the type exists before using it + if (typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent) { + error = event.error; + } else { + error = "There was an error with the transport"; + } + _this25._logger.log(_LogLevel.Information, "(WebSockets transport) ".concat(error, ".")); + }; + webSocket.onmessage = function (message) { + _this25._logger.log(_LogLevel.Trace, "(WebSockets transport) data received. ".concat(getDataDetail(message.data, _this25._logMessageContent), ".")); + if (_this25.onreceive) { + try { + _this25.onreceive(message.data); + } catch (error) { + _this25._close(error); + return; + } + } + }; + webSocket.onclose = function (event) { + // Don't call close handler if connection was never established + // We'll reject the connect call instead + if (opened) { + _this25._close(event); + } else { + var error = null; + // ErrorEvent is a browser only type we need to check if the type exists before using it + if (typeof ErrorEvent !== "undefined" && event instanceof ErrorEvent) { + error = event.error; + } else { + error = "WebSocket failed to connect. The connection could not be found on the server," + " either the endpoint may not be a SignalR endpoint," + " the connection ID is not present on the server, or there is a proxy blocking WebSockets." + " If you have multiple servers check that sticky sessions are enabled."; + } + reject(new Error(error)); + } + }; + })); + } + }, _callee18, this); + })); + function connect(_x18, _x19) { + return _connect3.apply(this, arguments); + } + return connect; + }() + }, { + key: "send", + value: function send(data) { + if (this._webSocket && this._webSocket.readyState === this._webSocketConstructor.OPEN) { + this._logger.log(_LogLevel.Trace, "(WebSockets transport) sending data. ".concat(getDataDetail(data, this._logMessageContent), ".")); + this._webSocket.send(data); + return Promise.resolve(); + } + return Promise.reject("WebSocket is not in the OPEN state"); + } + }, { + key: "stop", + value: function stop() { + if (this._webSocket) { + // Manually invoke onclose callback inline so we know the HttpConnection was closed properly before returning + // This also solves an issue where websocket.onclose could take 18+ seconds to trigger during network disconnects + this._close(undefined); + } + return Promise.resolve(); + } + }, { + key: "_close", + value: function _close(event) { + // webSocket will be null if the transport did not start successfully + if (this._webSocket) { + // Clear websocket handlers because we are considering the socket closed now + this._webSocket.onclose = function () {}; + this._webSocket.onmessage = function () {}; + this._webSocket.onerror = function () {}; + this._webSocket.close(); + this._webSocket = undefined; + } + this._logger.log(_LogLevel.Trace, "(WebSockets transport) socket closed."); + if (this.onclose) { + if (this._isCloseEvent(event) && (event.wasClean === false || event.code !== 1000)) { + this.onclose(new Error("WebSocket closed with status code: ".concat(event.code, " (").concat(event.reason || "no reason given", ")."))); + } else if (event instanceof Error) { + this.onclose(event); + } else { + this.onclose(); + } + } + } + }, { + key: "_isCloseEvent", + value: function _isCloseEvent(event) { + return event && typeof event.wasClean === "boolean" && typeof event.code === "number"; + } + }]); + }(); + ; // ./src/HttpConnection.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + var MAX_REDIRECTS = 100; + /** @private */ + var HttpConnection = /*#__PURE__*/function () { + function HttpConnection(url) { + var options = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + _classCallCheck(this, HttpConnection); + this._stopPromiseResolver = function () {}; + this.features = {}; + this._negotiateVersion = 1; + Arg.isRequired(url, "url"); + this._logger = createLogger(options.logger); + this.baseUrl = this._resolveUrl(url); + options = options || {}; + options.logMessageContent = options.logMessageContent === undefined ? false : options.logMessageContent; + if (typeof options.withCredentials === "boolean" || options.withCredentials === undefined) { + options.withCredentials = options.withCredentials === undefined ? true : options.withCredentials; + } else { + throw new Error("withCredentials option was not a 'boolean' or 'undefined' value"); + } + options.timeout = options.timeout === undefined ? 100 * 1000 : options.timeout; + var webSocketModule = null; + var eventSourceModule = null; + if (Platform.isNode && "function" !== "undefined") { + // In order to ignore the dynamic require in webpack builds we need to do this magic + // @ts-ignore: TS doesn't know about these names + var requireFunc = true ? require : 0; + webSocketModule = requireFunc("ws"); + eventSourceModule = requireFunc("eventsource"); + } + if (!Platform.isNode && typeof WebSocket !== "undefined" && !options.WebSocket) { + options.WebSocket = WebSocket; + } else if (Platform.isNode && !options.WebSocket) { + if (webSocketModule) { + options.WebSocket = webSocketModule; + } + } + if (!Platform.isNode && typeof EventSource !== "undefined" && !options.EventSource) { + options.EventSource = EventSource; + } else if (Platform.isNode && !options.EventSource) { + if (typeof eventSourceModule !== "undefined") { + options.EventSource = eventSourceModule; + } + } + this._httpClient = new AccessTokenHttpClient(options.httpClient || new _DefaultHttpClient(this._logger), options.accessTokenFactory); + this._connectionState = "Disconnected" /* ConnectionState.Disconnected */; + this._connectionStarted = false; + this._options = options; + this.onreceive = null; + this.onclose = null; + } + return _createClass(HttpConnection, [{ + key: "start", + value: function () { + var _start = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee19(transferFormat) { + var message, _message3; + return _regenerator().w(function (_context19) { + while (1) switch (_context19.n) { + case 0: + transferFormat = transferFormat || _TransferFormat.Binary; + Arg.isIn(transferFormat, _TransferFormat, "transferFormat"); + this._logger.log(_LogLevel.Debug, "Starting connection with transfer format '".concat(_TransferFormat[transferFormat], "'.")); + if (!(this._connectionState !== "Disconnected" /* ConnectionState.Disconnected */)) { + _context19.n = 1; + break; + } + return _context19.a(2, Promise.reject(new Error("Cannot start an HttpConnection that is not in the 'Disconnected' state."))); + case 1: + this._connectionState = "Connecting" /* ConnectionState.Connecting */; + this._startInternalPromise = this._startInternal(transferFormat); + _context19.n = 2; + return this._startInternalPromise; + case 2: + if (!(this._connectionState === "Disconnecting" /* ConnectionState.Disconnecting */)) { + _context19.n = 4; + break; + } + // stop() was called and transitioned the client into the Disconnecting state. + message = "Failed to start the HttpConnection before stop() was called."; + this._logger.log(_LogLevel.Error, message); + // We cannot await stopPromise inside startInternal since stopInternal awaits the startInternalPromise. + _context19.n = 3; + return this._stopPromise; + case 3: + return _context19.a(2, Promise.reject(new _AbortError(message))); + case 4: + if (!(this._connectionState !== "Connected" /* ConnectionState.Connected */)) { + _context19.n = 5; + break; + } + // stop() was called and transitioned the client into the Disconnecting state. + _message3 = "HttpConnection.startInternal completed gracefully but didn't enter the connection into the connected state!"; + this._logger.log(_LogLevel.Error, _message3); + return _context19.a(2, Promise.reject(new _AbortError(_message3))); + case 5: + this._connectionStarted = true; + case 6: + return _context19.a(2); + } + }, _callee19, this); + })); + function start(_x20) { + return _start.apply(this, arguments); + } + return start; + }() + }, { + key: "send", + value: function send(data) { + if (this._connectionState !== "Connected" /* ConnectionState.Connected */) { + return Promise.reject(new Error("Cannot send data if the connection is not in the 'Connected' State.")); + } + if (!this._sendQueue) { + this._sendQueue = new TransportSendQueue(this.transport); + } + // Transport will not be null if state is connected + return this._sendQueue.send(data); + } + }, { + key: "stop", + value: function () { + var _stop3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee20(error) { + var _this26 = this; + return _regenerator().w(function (_context20) { + while (1) switch (_context20.n) { + case 0: + if (!(this._connectionState === "Disconnected" /* ConnectionState.Disconnected */)) { + _context20.n = 1; + break; + } + this._logger.log(_LogLevel.Debug, "Call to HttpConnection.stop(".concat(error, ") ignored because the connection is already in the disconnected state.")); + return _context20.a(2, Promise.resolve()); + case 1: + if (!(this._connectionState === "Disconnecting" /* ConnectionState.Disconnecting */)) { + _context20.n = 2; + break; + } + this._logger.log(_LogLevel.Debug, "Call to HttpConnection.stop(".concat(error, ") ignored because the connection is already in the disconnecting state.")); + return _context20.a(2, this._stopPromise); + case 2: + this._connectionState = "Disconnecting" /* ConnectionState.Disconnecting */; + this._stopPromise = new Promise(function (resolve) { + // Don't complete stop() until stopConnection() completes. + _this26._stopPromiseResolver = resolve; + }); + // stopInternal should never throw so just observe it. + _context20.n = 3; + return this._stopInternal(error); + case 3: + _context20.n = 4; + return this._stopPromise; + case 4: + return _context20.a(2); + } + }, _callee20, this); + })); + function stop(_x21) { + return _stop3.apply(this, arguments); + } + return stop; + }() + }, { + key: "_stopInternal", + value: function () { + var _stopInternal2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee21(error) { + var _t13, _t14; + return _regenerator().w(function (_context21) { + while (1) switch (_context21.p = _context21.n) { + case 0: + // Set error as soon as possible otherwise there is a race between + // the transport closing and providing an error and the error from a close message + // We would prefer the close message error. + this._stopError = error; + _context21.p = 1; + _context21.n = 2; + return this._startInternalPromise; + case 2: + _context21.n = 4; + break; + case 3: + _context21.p = 3; + _t13 = _context21.v; + case 4: + if (!this.transport) { + _context21.n = 9; + break; + } + _context21.p = 5; + _context21.n = 6; + return this.transport.stop(); + case 6: + _context21.n = 8; + break; + case 7: + _context21.p = 7; + _t14 = _context21.v; + this._logger.log(_LogLevel.Error, "HttpConnection.transport.stop() threw error '".concat(_t14, "'.")); + this._stopConnection(); + case 8: + this.transport = undefined; + _context21.n = 10; + break; + case 9: + this._logger.log(_LogLevel.Debug, "HttpConnection.transport is undefined in HttpConnection.stop() because start() failed."); + case 10: + return _context21.a(2); + } + }, _callee21, this, [[5, 7], [1, 3]]); + })); + function _stopInternal(_x22) { + return _stopInternal2.apply(this, arguments); + } + return _stopInternal; + }() + }, { + key: "_startInternal", + value: function () { + var _startInternal3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee22(transferFormat) { + var _this27 = this; + var url, negotiateResponse, redirects, _loop2, _t15; + return _regenerator().w(function (_context23) { + while (1) switch (_context23.p = _context23.n) { + case 0: + // Store the original base url and the access token factory since they may change + // as part of negotiating + url = this.baseUrl; + this._accessTokenFactory = this._options.accessTokenFactory; + this._httpClient._accessTokenFactory = this._accessTokenFactory; + _context23.p = 1; + if (!this._options.skipNegotiation) { + _context23.n = 5; + break; + } + if (!(this._options.transport === _HttpTransportType.WebSockets)) { + _context23.n = 3; + break; + } + // No need to add a connection ID in this case + this.transport = this._constructTransport(_HttpTransportType.WebSockets); + // We should just call connect directly in this case. + // No fallback or negotiate in this case. + _context23.n = 2; + return this._startTransport(url, transferFormat); + case 2: + _context23.n = 4; + break; + case 3: + throw new Error("Negotiation can only be skipped when using the WebSocket transport directly."); + case 4: + _context23.n = 10; + break; + case 5: + negotiateResponse = null; + redirects = 0; + _loop2 = /*#__PURE__*/_regenerator().m(function _loop2() { + var accessToken; + return _regenerator().w(function (_context22) { + while (1) switch (_context22.n) { + case 0: + _context22.n = 1; + return _this27._getNegotiationResponse(url); + case 1: + negotiateResponse = _context22.v; + if (!(_this27._connectionState === "Disconnecting" /* ConnectionState.Disconnecting */ || _this27._connectionState === "Disconnected" /* ConnectionState.Disconnected */)) { + _context22.n = 2; + break; + } + throw new _AbortError("The connection was stopped during negotiation."); + case 2: + if (!negotiateResponse.error) { + _context22.n = 3; + break; + } + throw new Error(negotiateResponse.error); + case 3: + if (!negotiateResponse.ProtocolVersion) { + _context22.n = 4; + break; + } + throw new Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details."); + case 4: + if (negotiateResponse.url) { + url = negotiateResponse.url; + } + if (negotiateResponse.accessToken) { + // Replace the current access token factory with one that uses + // the returned access token + accessToken = negotiateResponse.accessToken; + _this27._accessTokenFactory = function () { + return accessToken; + }; + // set the factory to undefined so the AccessTokenHttpClient won't retry with the same token, since we know it won't change until a connection restart + _this27._httpClient._accessToken = accessToken; + _this27._httpClient._accessTokenFactory = undefined; + } + redirects++; + case 5: + return _context22.a(2); + } + }, _loop2); + }); + case 6: + return _context23.d(_regeneratorValues(_loop2()), 7); + case 7: + if (negotiateResponse.url && redirects < MAX_REDIRECTS) { + _context23.n = 6; + break; + } + case 8: + if (!(redirects === MAX_REDIRECTS && negotiateResponse.url)) { + _context23.n = 9; + break; + } + throw new Error("Negotiate redirection limit exceeded."); + case 9: + _context23.n = 10; + return this._createTransport(url, this._options.transport, negotiateResponse, transferFormat); + case 10: + if (this.transport instanceof LongPollingTransport) { + this.features.inherentKeepAlive = true; + } + if (this._connectionState === "Connecting" /* ConnectionState.Connecting */) { + // Ensure the connection transitions to the connected state prior to completing this.startInternalPromise. + // start() will handle the case when stop was called and startInternal exits still in the disconnecting state. + this._logger.log(_LogLevel.Debug, "The HttpConnection connected successfully."); + this._connectionState = "Connected" /* ConnectionState.Connected */; + } + // stop() is waiting on us via this.startInternalPromise so keep this.transport around so it can clean up. + // This is the only case startInternal can exit in neither the connected nor disconnected state because stopConnection() + // will transition to the disconnected state. start() will wait for the transition using the stopPromise. + _context23.n = 12; + break; + case 11: + _context23.p = 11; + _t15 = _context23.v; + this._logger.log(_LogLevel.Error, "Failed to start the connection: " + _t15); + this._connectionState = "Disconnected" /* ConnectionState.Disconnected */; + this.transport = undefined; + // if start fails, any active calls to stop assume that start will complete the stop promise + this._stopPromiseResolver(); + return _context23.a(2, Promise.reject(_t15)); + case 12: + return _context23.a(2); + } + }, _callee22, this, [[1, 11]]); + })); + function _startInternal(_x23) { + return _startInternal3.apply(this, arguments); + } + return _startInternal; + }() + }, { + key: "_getNegotiationResponse", + value: function () { + var _getNegotiationResponse2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee23(url) { + var headers, _getUserAgentHeader9, _getUserAgentHeader0, name, value, negotiateUrl, response, negotiateResponse, errorMessage, _t16; + return _regenerator().w(function (_context24) { + while (1) switch (_context24.p = _context24.n) { + case 0: + headers = {}; + _getUserAgentHeader9 = getUserAgentHeader(), _getUserAgentHeader0 = _slicedToArray(_getUserAgentHeader9, 2), name = _getUserAgentHeader0[0], value = _getUserAgentHeader0[1]; + headers[name] = value; + negotiateUrl = this._resolveNegotiateUrl(url); + this._logger.log(_LogLevel.Debug, "Sending negotiation request: ".concat(negotiateUrl, ".")); + _context24.p = 1; + _context24.n = 2; + return this._httpClient.post(negotiateUrl, { + content: "", + headers: _objectSpread(_objectSpread({}, headers), this._options.headers), + timeout: this._options.timeout, + withCredentials: this._options.withCredentials + }); + case 2: + response = _context24.v; + if (!(response.statusCode !== 200)) { + _context24.n = 3; + break; + } + return _context24.a(2, Promise.reject(new Error("Unexpected status code returned from negotiate '".concat(response.statusCode, "'")))); + case 3: + negotiateResponse = JSON.parse(response.content); + if (!negotiateResponse.negotiateVersion || negotiateResponse.negotiateVersion < 1) { + // Negotiate version 0 doesn't use connectionToken + // So we set it equal to connectionId so all our logic can use connectionToken without being aware of the negotiate version + negotiateResponse.connectionToken = negotiateResponse.connectionId; + } + if (!(negotiateResponse.useStatefulReconnect && this._options._useStatefulReconnect !== true)) { + _context24.n = 4; + break; + } + return _context24.a(2, Promise.reject(new FailedToNegotiateWithServerError("Client didn't negotiate Stateful Reconnect but the server did."))); + case 4: + return _context24.a(2, negotiateResponse); + case 5: + _context24.p = 5; + _t16 = _context24.v; + errorMessage = "Failed to complete negotiation with the server: " + _t16; + if (_t16 instanceof _HttpError) { + if (_t16.statusCode === 404) { + errorMessage = errorMessage + " Either this is not a SignalR endpoint or there is a proxy blocking the connection."; + } + } + this._logger.log(_LogLevel.Error, errorMessage); + return _context24.a(2, Promise.reject(new FailedToNegotiateWithServerError(errorMessage))); + } + }, _callee23, this, [[1, 5]]); + })); + function _getNegotiationResponse(_x24) { + return _getNegotiationResponse2.apply(this, arguments); + } + return _getNegotiationResponse; + }() + }, { + key: "_createConnectUrl", + value: function _createConnectUrl(url, connectionToken) { + if (!connectionToken) { + return url; + } + return url + (url.indexOf("?") === -1 ? "?" : "&") + "id=".concat(connectionToken); + } + }, { + key: "_createTransport", + value: function () { + var _createTransport2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee24(url, requestedTransport, negotiateResponse, requestedTransferFormat) { + var connectUrl, transportExceptions, transports, negotiate, _iterator8, _step8, endpoint, transportOrError, message, _t17, _t18, _t19; + return _regenerator().w(function (_context25) { + while (1) switch (_context25.p = _context25.n) { + case 0: + connectUrl = this._createConnectUrl(url, negotiateResponse.connectionToken); + if (!this._isITransport(requestedTransport)) { + _context25.n = 2; + break; + } + this._logger.log(_LogLevel.Debug, "Connection was provided an instance of ITransport, using that directly."); + this.transport = requestedTransport; + _context25.n = 1; + return this._startTransport(connectUrl, requestedTransferFormat); + case 1: + this.connectionId = negotiateResponse.connectionId; + return _context25.a(2); + case 2: + transportExceptions = []; + transports = negotiateResponse.availableTransports || []; + negotiate = negotiateResponse; + _iterator8 = _createForOfIteratorHelper(transports); + _context25.p = 3; + _iterator8.s(); + case 4: + if ((_step8 = _iterator8.n()).done) { + _context25.n = 14; + break; + } + endpoint = _step8.value; + transportOrError = this._resolveTransportOrError(endpoint, requestedTransport, requestedTransferFormat, (negotiate === null || negotiate === void 0 ? void 0 : negotiate.useStatefulReconnect) === true); + if (!(transportOrError instanceof Error)) { + _context25.n = 5; + break; + } + // Store the error and continue, we don't want to cause a re-negotiate in these cases + transportExceptions.push("".concat(endpoint.transport, " failed:")); + transportExceptions.push(transportOrError); + _context25.n = 13; + break; + case 5: + if (!this._isITransport(transportOrError)) { + _context25.n = 13; + break; + } + this.transport = transportOrError; + if (negotiate) { + _context25.n = 10; + break; + } + _context25.p = 6; + _context25.n = 7; + return this._getNegotiationResponse(url); + case 7: + negotiate = _context25.v; + _context25.n = 9; + break; + case 8: + _context25.p = 8; + _t17 = _context25.v; + return _context25.a(2, Promise.reject(_t17)); + case 9: + connectUrl = this._createConnectUrl(url, negotiate.connectionToken); + case 10: + _context25.p = 10; + _context25.n = 11; + return this._startTransport(connectUrl, requestedTransferFormat); + case 11: + this.connectionId = negotiate.connectionId; + return _context25.a(2); + case 12: + _context25.p = 12; + _t18 = _context25.v; + this._logger.log(_LogLevel.Error, "Failed to start the transport '".concat(endpoint.transport, "': ").concat(_t18)); + negotiate = undefined; + transportExceptions.push(new FailedToStartTransportError("".concat(endpoint.transport, " failed: ").concat(_t18), _HttpTransportType[endpoint.transport])); + if (!(this._connectionState !== "Connecting" /* ConnectionState.Connecting */)) { + _context25.n = 13; + break; + } + message = "Failed to select transport before stop() was called."; + this._logger.log(_LogLevel.Debug, message); + return _context25.a(2, Promise.reject(new _AbortError(message))); + case 13: + _context25.n = 4; + break; + case 14: + _context25.n = 16; + break; + case 15: + _context25.p = 15; + _t19 = _context25.v; + _iterator8.e(_t19); + case 16: + _context25.p = 16; + _iterator8.f(); + return _context25.f(16); + case 17: + if (!(transportExceptions.length > 0)) { + _context25.n = 18; + break; + } + return _context25.a(2, Promise.reject(new AggregateErrors("Unable to connect to the server with any of the available transports. ".concat(transportExceptions.join(" ")), transportExceptions))); + case 18: + return _context25.a(2, Promise.reject(new Error("None of the transports supported by the client are supported by the server."))); + } + }, _callee24, this, [[10, 12], [6, 8], [3, 15, 16, 17]]); + })); + function _createTransport(_x25, _x26, _x27, _x28) { + return _createTransport2.apply(this, arguments); + } + return _createTransport; + }() + }, { + key: "_constructTransport", + value: function _constructTransport(transport) { + switch (transport) { + case _HttpTransportType.WebSockets: + if (!this._options.WebSocket) { + throw new Error("'WebSocket' is not supported in your environment."); + } + return new WebSocketTransport(this._httpClient, this._accessTokenFactory, this._logger, this._options.logMessageContent, this._options.WebSocket, this._options.headers || {}); + case _HttpTransportType.ServerSentEvents: + if (!this._options.EventSource) { + throw new Error("'EventSource' is not supported in your environment."); + } + return new ServerSentEventsTransport(this._httpClient, this._httpClient._accessToken, this._logger, this._options); + case _HttpTransportType.LongPolling: + return new LongPollingTransport(this._httpClient, this._logger, this._options); + default: + throw new Error("Unknown transport: ".concat(transport, ".")); + } + } + }, { + key: "_startTransport", + value: function _startTransport(url, transferFormat) { + var _this28 = this; + this.transport.onreceive = this.onreceive; + if (this.features.reconnect) { + this.transport.onclose = /*#__PURE__*/function () { + var _ref3 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee25(e) { + var callStop, _t20; + return _regenerator().w(function (_context26) { + while (1) switch (_context26.p = _context26.n) { + case 0: + callStop = false; + if (!_this28.features.reconnect) { + _context26.n = 6; + break; + } + _context26.p = 1; + _this28.features.disconnected(); + _context26.n = 2; + return _this28.transport.connect(url, transferFormat); + case 2: + _context26.n = 3; + return _this28.features.resend(); + case 3: + _context26.n = 5; + break; + case 4: + _context26.p = 4; + _t20 = _context26.v; + callStop = true; + case 5: + _context26.n = 7; + break; + case 6: + _this28._stopConnection(e); + return _context26.a(2); + case 7: + if (callStop) { + _this28._stopConnection(e); + } + case 8: + return _context26.a(2); + } + }, _callee25, null, [[1, 4]]); + })); + return function (_x29) { + return _ref3.apply(this, arguments); + }; + }(); + } else { + this.transport.onclose = function (e) { + return _this28._stopConnection(e); + }; + } + return this.transport.connect(url, transferFormat); + } + }, { + key: "_resolveTransportOrError", + value: function _resolveTransportOrError(endpoint, requestedTransport, requestedTransferFormat, useStatefulReconnect) { + var transport = _HttpTransportType[endpoint.transport]; + if (transport === null || transport === undefined) { + this._logger.log(_LogLevel.Debug, "Skipping transport '".concat(endpoint.transport, "' because it is not supported by this client.")); + return new Error("Skipping transport '".concat(endpoint.transport, "' because it is not supported by this client.")); + } else { + if (transportMatches(requestedTransport, transport)) { + var transferFormats = endpoint.transferFormats.map(function (s) { + return _TransferFormat[s]; + }); + if (transferFormats.indexOf(requestedTransferFormat) >= 0) { + if (transport === _HttpTransportType.WebSockets && !this._options.WebSocket || transport === _HttpTransportType.ServerSentEvents && !this._options.EventSource) { + this._logger.log(_LogLevel.Debug, "Skipping transport '".concat(_HttpTransportType[transport], "' because it is not supported in your environment.'")); + return new UnsupportedTransportError("'".concat(_HttpTransportType[transport], "' is not supported in your environment."), transport); + } else { + this._logger.log(_LogLevel.Debug, "Selecting transport '".concat(_HttpTransportType[transport], "'.")); + try { + this.features.reconnect = transport === _HttpTransportType.WebSockets ? useStatefulReconnect : undefined; + return this._constructTransport(transport); + } catch (ex) { + return ex; + } + } + } else { + this._logger.log(_LogLevel.Debug, "Skipping transport '".concat(_HttpTransportType[transport], "' because it does not support the requested transfer format '").concat(_TransferFormat[requestedTransferFormat], "'.")); + return new Error("'".concat(_HttpTransportType[transport], "' does not support ").concat(_TransferFormat[requestedTransferFormat], ".")); + } + } else { + this._logger.log(_LogLevel.Debug, "Skipping transport '".concat(_HttpTransportType[transport], "' because it was disabled by the client.")); + return new DisabledTransportError("'".concat(_HttpTransportType[transport], "' is disabled by the client."), transport); + } + } + } + }, { + key: "_isITransport", + value: function _isITransport(transport) { + return transport && _typeof(transport) === "object" && "connect" in transport; + } + }, { + key: "_stopConnection", + value: function _stopConnection(error) { + var _this29 = this; + this._logger.log(_LogLevel.Debug, "HttpConnection.stopConnection(".concat(error, ") called while in state ").concat(this._connectionState, ".")); + this.transport = undefined; + // If we have a stopError, it takes precedence over the error from the transport + error = this._stopError || error; + this._stopError = undefined; + if (this._connectionState === "Disconnected" /* ConnectionState.Disconnected */) { + this._logger.log(_LogLevel.Debug, "Call to HttpConnection.stopConnection(".concat(error, ") was ignored because the connection is already in the disconnected state.")); + return; + } + if (this._connectionState === "Connecting" /* ConnectionState.Connecting */) { + this._logger.log(_LogLevel.Warning, "Call to HttpConnection.stopConnection(".concat(error, ") was ignored because the connection is still in the connecting state.")); + throw new Error("HttpConnection.stopConnection(".concat(error, ") was called while the connection is still in the connecting state.")); + } + if (this._connectionState === "Disconnecting" /* ConnectionState.Disconnecting */) { + // A call to stop() induced this call to stopConnection and needs to be completed. + // Any stop() awaiters will be scheduled to continue after the onclose callback fires. + this._stopPromiseResolver(); + } + if (error) { + this._logger.log(_LogLevel.Error, "Connection disconnected with error '".concat(error, "'.")); + } else { + this._logger.log(_LogLevel.Information, "Connection disconnected."); + } + if (this._sendQueue) { + this._sendQueue.stop()["catch"](function (e) { + _this29._logger.log(_LogLevel.Error, "TransportSendQueue.stop() threw error '".concat(e, "'.")); + }); + this._sendQueue = undefined; + } + this.connectionId = undefined; + this._connectionState = "Disconnected" /* ConnectionState.Disconnected */; + if (this._connectionStarted) { + this._connectionStarted = false; + try { + if (this.onclose) { + this.onclose(error); + } + } catch (e) { + this._logger.log(_LogLevel.Error, "HttpConnection.onclose(".concat(error, ") threw error '").concat(e, "'.")); + } + } + } + }, { + key: "_resolveUrl", + value: function _resolveUrl(url) { + // startsWith is not supported in IE + if (url.lastIndexOf("https://", 0) === 0 || url.lastIndexOf("http://", 0) === 0) { + return url; + } + if (!Platform.isBrowser) { + throw new Error("Cannot resolve '".concat(url, "'.")); + } + // Setting the url to the href propery of an anchor tag handles normalization + // for us. There are 3 main cases. + // 1. Relative path normalization e.g "b" -> "http://localhost:5000/a/b" + // 2. Absolute path normalization e.g "/a/b" -> "http://localhost:5000/a/b" + // 3. Networkpath reference normalization e.g "//localhost:5000/a/b" -> "http://localhost:5000/a/b" + var aTag = window.document.createElement("a"); + aTag.href = url; + this._logger.log(_LogLevel.Information, "Normalizing '".concat(url, "' to '").concat(aTag.href, "'.")); + return aTag.href; + } + }, { + key: "_resolveNegotiateUrl", + value: function _resolveNegotiateUrl(url) { + var negotiateUrl = new URL(url); + if (negotiateUrl.pathname.endsWith('/')) { + negotiateUrl.pathname += "negotiate"; + } else { + negotiateUrl.pathname += "/negotiate"; + } + var searchParams = new URLSearchParams(negotiateUrl.searchParams); + if (!searchParams.has("negotiateVersion")) { + searchParams.append("negotiateVersion", this._negotiateVersion.toString()); + } + if (searchParams.has("useStatefulReconnect")) { + if (searchParams.get("useStatefulReconnect") === "true") { + this._options._useStatefulReconnect = true; + } + } else if (this._options._useStatefulReconnect === true) { + searchParams.append("useStatefulReconnect", "true"); + } + negotiateUrl.search = searchParams.toString(); + return negotiateUrl.toString(); + } + }]); + }(); + function transportMatches(requestedTransport, actualTransport) { + return !requestedTransport || (actualTransport & requestedTransport) !== 0; + } + /** @private */ + var TransportSendQueue = /*#__PURE__*/function () { + function TransportSendQueue(_transport) { + _classCallCheck(this, TransportSendQueue); + this._transport = _transport; + this._buffer = []; + this._executing = true; + this._sendBufferedData = new PromiseSource(); + this._transportResult = new PromiseSource(); + this._sendLoopPromise = this._sendLoop(); + } + return _createClass(TransportSendQueue, [{ + key: "send", + value: function send(data) { + this._bufferData(data); + if (!this._transportResult) { + this._transportResult = new PromiseSource(); + } + return this._transportResult.promise; + } + }, { + key: "stop", + value: function stop() { + this._executing = false; + this._sendBufferedData.resolve(); + return this._sendLoopPromise; + } + }, { + key: "_bufferData", + value: function _bufferData(data) { + if (this._buffer.length && _typeof(this._buffer[0]) !== _typeof(data)) { + throw new Error("Expected data to be of type ".concat(_typeof(this._buffer), " but was of type ").concat(_typeof(data))); + } + this._buffer.push(data); + this._sendBufferedData.resolve(); + } + }, { + key: "_sendLoop", + value: function () { + var _sendLoop2 = _asyncToGenerator(/*#__PURE__*/_regenerator().m(function _callee26() { + var transportResult, data, _t21; + return _regenerator().w(function (_context27) { + while (1) switch (_context27.p = _context27.n) { + case 0: + if (!true) { + _context27.n = 7; + break; + } + _context27.n = 1; + return this._sendBufferedData.promise; + case 1: + if (this._executing) { + _context27.n = 2; + break; + } + if (this._transportResult) { + this._transportResult.reject("Connection stopped."); + } + return _context27.a(3, 7); + case 2: + this._sendBufferedData = new PromiseSource(); + transportResult = this._transportResult; + this._transportResult = undefined; + data = typeof this._buffer[0] === "string" ? this._buffer.join("") : TransportSendQueue._concatBuffers(this._buffer); + this._buffer.length = 0; + _context27.p = 3; + _context27.n = 4; + return this._transport.send(data); + case 4: + transportResult.resolve(); + _context27.n = 6; + break; + case 5: + _context27.p = 5; + _t21 = _context27.v; + transportResult.reject(_t21); + case 6: + _context27.n = 0; + break; + case 7: + return _context27.a(2); + } + }, _callee26, this, [[3, 5]]); + })); + function _sendLoop() { + return _sendLoop2.apply(this, arguments); + } + return _sendLoop; + }() + }], [{ + key: "_concatBuffers", + value: function _concatBuffers(arrayBuffers) { + var totalLength = arrayBuffers.map(function (b) { + return b.byteLength; + }).reduce(function (a, b) { + return a + b; + }); + var result = new Uint8Array(totalLength); + var offset = 0; + var _iterator9 = _createForOfIteratorHelper(arrayBuffers), + _step9; + try { + for (_iterator9.s(); !(_step9 = _iterator9.n()).done;) { + var item = _step9.value; + result.set(new Uint8Array(item), offset); + offset += item.byteLength; + } + } catch (err) { + _iterator9.e(err); + } finally { + _iterator9.f(); + } + return result.buffer; + } + }]); + }(); + var PromiseSource = /*#__PURE__*/function () { + function PromiseSource() { + var _this30 = this; + _classCallCheck(this, PromiseSource); + this.promise = new Promise(function (resolve, reject) { + var _ref4; + return _ref4 = [resolve, reject], _this30._resolver = _ref4[0], _this30._rejecter = _ref4[1], _ref4; + }); + } + return _createClass(PromiseSource, [{ + key: "resolve", + value: function resolve() { + this._resolver(); + } + }, { + key: "reject", + value: function reject(reason) { + this._rejecter(reason); + } + }]); + }(); + ; // ./src/JsonHubProtocol.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + var JSON_HUB_PROTOCOL_NAME = "json"; + /** Implements the JSON Hub Protocol. */ + var _JsonHubProtocol = /*#__PURE__*/function () { + function _JsonHubProtocol() { + _classCallCheck(this, _JsonHubProtocol); + /** @inheritDoc */ + this.name = JSON_HUB_PROTOCOL_NAME; + /** @inheritDoc */ + this.version = 2; + /** @inheritDoc */ + this.transferFormat = _TransferFormat.Text; + } + /** Creates an array of {@link @microsoft/signalr.HubMessage} objects from the specified serialized representation. + * + * @param {string} input A string containing the serialized representation. + * @param {ILogger} logger A logger that will be used to log messages that occur during parsing. + */ + return _createClass(_JsonHubProtocol, [{ + key: "parseMessages", + value: function parseMessages(input, logger) { + // The interface does allow "ArrayBuffer" to be passed in, but this implementation does not. So let's throw a useful error. + if (typeof input !== "string") { + throw new Error("Invalid input for JSON hub protocol. Expected a string."); + } + if (!input) { + return []; + } + if (logger === null) { + logger = _NullLogger.instance; + } + // Parse the messages + var messages = TextMessageFormat.parse(input); + var hubMessages = []; + var _iterator0 = _createForOfIteratorHelper(messages), + _step0; + try { + for (_iterator0.s(); !(_step0 = _iterator0.n()).done;) { + var message = _step0.value; + var parsedMessage = JSON.parse(message); + if (typeof parsedMessage.type !== "number") { + throw new Error("Invalid payload."); + } + switch (parsedMessage.type) { + case _MessageType.Invocation: + this._isInvocationMessage(parsedMessage); + break; + case _MessageType.StreamItem: + this._isStreamItemMessage(parsedMessage); + break; + case _MessageType.Completion: + this._isCompletionMessage(parsedMessage); + break; + case _MessageType.Ping: + // Single value, no need to validate + break; + case _MessageType.Close: + // All optional values, no need to validate + break; + case _MessageType.Ack: + this._isAckMessage(parsedMessage); + break; + case _MessageType.Sequence: + this._isSequenceMessage(parsedMessage); + break; + default: + // Future protocol changes can add message types, old clients can ignore them + logger.log(_LogLevel.Information, "Unknown message type '" + parsedMessage.type + "' ignored."); + continue; + } + hubMessages.push(parsedMessage); + } + } catch (err) { + _iterator0.e(err); + } finally { + _iterator0.f(); + } + return hubMessages; + } + /** Writes the specified {@link @microsoft/signalr.HubMessage} to a string and returns it. + * + * @param {HubMessage} message The message to write. + * @returns {string} A string containing the serialized representation of the message. + */ + }, { + key: "writeMessage", + value: function writeMessage(message) { + return TextMessageFormat.write(JSON.stringify(message)); + } + }, { + key: "_isInvocationMessage", + value: function _isInvocationMessage(message) { + this._assertNotEmptyString(message.target, "Invalid payload for Invocation message."); + if (message.invocationId !== undefined) { + this._assertNotEmptyString(message.invocationId, "Invalid payload for Invocation message."); + } + } + }, { + key: "_isStreamItemMessage", + value: function _isStreamItemMessage(message) { + this._assertNotEmptyString(message.invocationId, "Invalid payload for StreamItem message."); + if (message.item === undefined) { + throw new Error("Invalid payload for StreamItem message."); + } + } + }, { + key: "_isCompletionMessage", + value: function _isCompletionMessage(message) { + if (message.result && message.error) { + throw new Error("Invalid payload for Completion message."); + } + if (!message.result && message.error) { + this._assertNotEmptyString(message.error, "Invalid payload for Completion message."); + } + this._assertNotEmptyString(message.invocationId, "Invalid payload for Completion message."); + } + }, { + key: "_isAckMessage", + value: function _isAckMessage(message) { + if (typeof message.sequenceId !== 'number') { + throw new Error("Invalid SequenceId for Ack message."); + } + } + }, { + key: "_isSequenceMessage", + value: function _isSequenceMessage(message) { + if (typeof message.sequenceId !== 'number') { + throw new Error("Invalid SequenceId for Sequence message."); + } + } + }, { + key: "_assertNotEmptyString", + value: function _assertNotEmptyString(value, errorMessage) { + if (typeof value !== "string" || value === "") { + throw new Error(errorMessage); + } + } + }]); + }(); + ; // ./src/HubConnectionBuilder.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + var LogLevelNameMapping = { + trace: _LogLevel.Trace, + debug: _LogLevel.Debug, + info: _LogLevel.Information, + information: _LogLevel.Information, + warn: _LogLevel.Warning, + warning: _LogLevel.Warning, + error: _LogLevel.Error, + critical: _LogLevel.Critical, + none: _LogLevel.None + }; + function parseLogLevel(name) { + // Case-insensitive matching via lower-casing + // Yes, I know case-folding is a complicated problem in Unicode, but we only support + // the ASCII strings defined in LogLevelNameMapping anyway, so it's fine -anurse. + var mapping = LogLevelNameMapping[name.toLowerCase()]; + if (typeof mapping !== "undefined") { + return mapping; + } else { + throw new Error("Unknown log level: ".concat(name)); + } + } + /** A builder for configuring {@link @microsoft/signalr.HubConnection} instances. */ + var _HubConnectionBuilder = /*#__PURE__*/function () { + function _HubConnectionBuilder() { + _classCallCheck(this, _HubConnectionBuilder); + } + return _createClass(_HubConnectionBuilder, [{ + key: "configureLogging", + value: function configureLogging(logging) { + Arg.isRequired(logging, "logging"); + if (isLogger(logging)) { + this.logger = logging; + } else if (typeof logging === "string") { + var logLevel = parseLogLevel(logging); + this.logger = new ConsoleLogger(logLevel); + } else { + this.logger = new ConsoleLogger(logging); + } + return this; + } + }, { + key: "withUrl", + value: function withUrl(url, transportTypeOrOptions) { + Arg.isRequired(url, "url"); + Arg.isNotEmpty(url, "url"); + this.url = url; + // Flow-typing knows where it's at. Since HttpTransportType is a number and IHttpConnectionOptions is guaranteed + // to be an object, we know (as does TypeScript) this comparison is all we need to figure out which overload was called. + if (_typeof(transportTypeOrOptions) === "object") { + this.httpConnectionOptions = _objectSpread(_objectSpread({}, this.httpConnectionOptions), transportTypeOrOptions); + } else { + this.httpConnectionOptions = _objectSpread(_objectSpread({}, this.httpConnectionOptions), {}, { + transport: transportTypeOrOptions + }); + } + return this; + } + /** Configures the {@link @microsoft/signalr.HubConnection} to use the specified Hub Protocol. + * + * @param {IHubProtocol} protocol The {@link @microsoft/signalr.IHubProtocol} implementation to use. + */ + }, { + key: "withHubProtocol", + value: function withHubProtocol(protocol) { + Arg.isRequired(protocol, "protocol"); + this.protocol = protocol; + return this; + } + }, { + key: "withAutomaticReconnect", + value: function withAutomaticReconnect(retryDelaysOrReconnectPolicy) { + if (this.reconnectPolicy) { + throw new Error("A reconnectPolicy has already been set."); + } + if (!retryDelaysOrReconnectPolicy) { + this.reconnectPolicy = new DefaultReconnectPolicy(); + } else if (Array.isArray(retryDelaysOrReconnectPolicy)) { + this.reconnectPolicy = new DefaultReconnectPolicy(retryDelaysOrReconnectPolicy); + } else { + this.reconnectPolicy = retryDelaysOrReconnectPolicy; + } + return this; + } + /** Configures {@link @microsoft/signalr.HubConnection.serverTimeoutInMilliseconds} for the {@link @microsoft/signalr.HubConnection}. + * + * @returns The {@link @microsoft/signalr.HubConnectionBuilder} instance, for chaining. + */ + }, { + key: "withServerTimeout", + value: function withServerTimeout(milliseconds) { + Arg.isRequired(milliseconds, "milliseconds"); + this._serverTimeoutInMilliseconds = milliseconds; + return this; + } + /** Configures {@link @microsoft/signalr.HubConnection.keepAliveIntervalInMilliseconds} for the {@link @microsoft/signalr.HubConnection}. + * + * @returns The {@link @microsoft/signalr.HubConnectionBuilder} instance, for chaining. + */ + }, { + key: "withKeepAliveInterval", + value: function withKeepAliveInterval(milliseconds) { + Arg.isRequired(milliseconds, "milliseconds"); + this._keepAliveIntervalInMilliseconds = milliseconds; + return this; + } + /** Enables and configures options for the Stateful Reconnect feature. + * + * @returns The {@link @microsoft/signalr.HubConnectionBuilder} instance, for chaining. + */ + }, { + key: "withStatefulReconnect", + value: function withStatefulReconnect(options) { + if (this.httpConnectionOptions === undefined) { + this.httpConnectionOptions = {}; + } + this.httpConnectionOptions._useStatefulReconnect = true; + this._statefulReconnectBufferSize = options === null || options === void 0 ? void 0 : options.bufferSize; + return this; + } + /** Creates a {@link @microsoft/signalr.HubConnection} from the configuration options specified in this builder. + * + * @returns {HubConnection} The configured {@link @microsoft/signalr.HubConnection}. + */ + }, { + key: "build", + value: function build() { + // If httpConnectionOptions has a logger, use it. Otherwise, override it with the one + // provided to configureLogger + var httpConnectionOptions = this.httpConnectionOptions || {}; + // If it's 'null', the user **explicitly** asked for null, don't mess with it. + if (httpConnectionOptions.logger === undefined) { + // If our logger is undefined or null, that's OK, the HttpConnection constructor will handle it. + httpConnectionOptions.logger = this.logger; + } + // Now create the connection + if (!this.url) { + throw new Error("The 'HubConnectionBuilder.withUrl' method must be called before building the connection."); + } + var connection = new HttpConnection(this.url, httpConnectionOptions); + return _HubConnection.create(connection, this.logger || _NullLogger.instance, this.protocol || new _JsonHubProtocol(), this.reconnectPolicy, this._serverTimeoutInMilliseconds, this._keepAliveIntervalInMilliseconds, this._statefulReconnectBufferSize); + } + }]); + }(); + function isLogger(logger) { + return logger.log !== undefined; + } + ; // ./src/index.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + + ; // ./src/browser-index.ts + // Licensed to the .NET Foundation under one or more agreements. + // The .NET Foundation licenses this file to you under the MIT license. + // This is where we add any polyfills we'll need for the browser. It is the entry module for browser-specific builds. + // Copy from Array.prototype into Uint8Array to polyfill on IE. It's OK because the implementations of indexOf and slice use properties + // that exist on Uint8Array with the same name, and JavaScript is magic. + // We make them 'writable' because the Buffer polyfill messes with it as well. + if (!Uint8Array.prototype.indexOf) { + Object.defineProperty(Uint8Array.prototype, "indexOf", { + value: Array.prototype.indexOf, + writable: true + }); + } + if (!Uint8Array.prototype.slice) { + Object.defineProperty(Uint8Array.prototype, "slice", { + // wrap the slice in Uint8Array so it looks like a Uint8Array.slice call + // eslint-disable-next-line object-shorthand + value: function value(start, end) { + return new Uint8Array(Array.prototype.slice.call(this, start, end)); + }, + writable: true + }); + } + if (!Uint8Array.prototype.forEach) { + Object.defineProperty(Uint8Array.prototype, "forEach", { + value: Array.prototype.forEach, + writable: true + }); + } + + /******/ + return __webpack_exports__; + /******/ + }(); +}); \ No newline at end of file diff --git a/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.min.js b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.min.js new file mode 100644 index 000000000..db8c8db25 --- /dev/null +++ b/src/Startup/CrestApps.OrchardCore.Asterisk.Web/wwwroot/js/signalr.min.js @@ -0,0 +1 @@ +function _regeneratorValues(e){if(null!=e){var t=e["function"==typeof Symbol&&Symbol.iterator||"@@iterator"],n=0;if(t)return t.call(e);if("function"==typeof e.next)return e;if(!isNaN(e.length))return{next:function(){return e&&n>=e.length&&(e=void 0),{value:e&&e[n++],done:!e}}}}throw new TypeError(_typeof(e)+" is not iterable")}function _toConsumableArray(e){return _arrayWithoutHoles(e)||_iterableToArray(e)||_unsupportedIterableToArray(e)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArray(e){if("undefined"!=typeof Symbol&&null!=e[Symbol.iterator]||null!=e["@@iterator"])return Array.from(e)}function _arrayWithoutHoles(e){if(Array.isArray(e))return _arrayLikeToArray(e)}function _slicedToArray(e,t){return _arrayWithHoles(e)||_iterableToArrayLimit(e,t)||_unsupportedIterableToArray(e,t)||_nonIterableRest()}function _nonIterableRest(){throw new TypeError("Invalid attempt to destructure non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}function _iterableToArrayLimit(e,t){var n=null==e?null:"undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(null!=n){var r,o,i,s,a=[],c=!0,u=!1;try{if(i=(n=n.call(e)).next,0===t){if(Object(n)!==n)return;c=!1}else for(;!(c=(r=i.call(n)).done)&&(a.push(r.value),a.length!==t);c=!0);}catch(e){u=!0,o=e}finally{try{if(!c&&null!=n.return&&(s=n.return(),Object(s)!==s))return}finally{if(u)throw o}}return a}}function _arrayWithHoles(e){if(Array.isArray(e))return e}function _createForOfIteratorHelper(e,t){var n="undefined"!=typeof Symbol&&e[Symbol.iterator]||e["@@iterator"];if(!n){if(Array.isArray(e)||(n=_unsupportedIterableToArray(e))||t&&e&&"number"==typeof e.length){n&&(e=n);var r=0,o=function(){};return{s:o,n:function(){return r>=e.length?{done:!0}:{done:!1,value:e[r++]}},e:function(e){throw e},f:o}}throw new TypeError("Invalid attempt to iterate non-iterable instance.\nIn order to be iterable, non-array objects must have a [Symbol.iterator]() method.")}var i,s=!0,a=!1;return{s:function(){n=n.call(e)},n:function(){var e=n.next();return s=e.done,e},e:function(e){a=!0,i=e},f:function(){try{s||null==n.return||n.return()}finally{if(a)throw i}}}}function _unsupportedIterableToArray(e,t){if(e){if("string"==typeof e)return _arrayLikeToArray(e,t);var n={}.toString.call(e).slice(8,-1);return"Object"===n&&e.constructor&&(n=e.constructor.name),"Map"===n||"Set"===n?Array.from(e):"Arguments"===n||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(n)?_arrayLikeToArray(e,t):void 0}}function _arrayLikeToArray(e,t){(null==t||t>e.length)&&(t=e.length);for(var n=0,r=Array(t);n3?(o=p===r)&&(c=i[(a=i[4])?5:(a=3,3)],i[4]=i[5]=e):i[0]<=_&&((o=n<2&&_r||r>p)&&(i[4]=n,i[5]=r,f.n=p,a=0))}if(o||n>1)return s;throw h=!0,r}return function(o,l,p){if(u>1)throw TypeError("Generator is already running");for(h&&1===l&&_(l,p),a=l,c=p;(t=a<2?e:c)||!h;){i||(a?a<3?(a>1&&(f.n=-1),_(a,c)):f.n=c:f.v=c);try{if(u=2,i){if(a||(o="next"),t=i[o]){if(!(t=t.call(i,c)))throw TypeError("iterator result is not an object");if(!t.done)return t;c=t.value,a<2&&(a=0)}else 1===a&&(t=i.return)&&t.call(i),a<2&&(c=TypeError("The iterator does not provide a '"+o+"' method"),a=1);i=e}else if((t=(h=f.n<0)?c:n.call(r,f))!==s)break}catch(t){i=e,a=1,c=t}finally{u=1}}return{value:t,done:h}}}(n,o,i),!0),u}var s={};function a(){}function c(){}function u(){}t=Object.getPrototypeOf;var l=[][r]?t(t([][r]())):(_regeneratorDefine2(t={},r,function(){return this}),t),h=u.prototype=a.prototype=Object.create(l);function f(e){return Object.setPrototypeOf?Object.setPrototypeOf(e,u):(e.__proto__=u,_regeneratorDefine2(e,o,"GeneratorFunction")),e.prototype=Object.create(h),e}return c.prototype=u,_regeneratorDefine2(h,"constructor",u),_regeneratorDefine2(u,"constructor",c),c.displayName="GeneratorFunction",_regeneratorDefine2(u,o,"GeneratorFunction"),_regeneratorDefine2(h),_regeneratorDefine2(h,o,"Generator"),_regeneratorDefine2(h,r,function(){return this}),_regeneratorDefine2(h,"toString",function(){return"[object Generator]"}),(_regenerator=function(){return{w:i,m:f}})()}function _regeneratorDefine2(e,t,n,r){var o=Object.defineProperty;try{o({},"",{})}catch(e){o=0}_regeneratorDefine2=function(e,t,n,r){function i(t,n){_regeneratorDefine2(e,t,function(e){return this._invoke(t,n,e)})}t?o?o(e,t,{value:n,enumerable:!r,configurable:!r,writable:!r}):e[t]=n:(i("next",0),i("throw",1),i("return",2))},_regeneratorDefine2(e,t,n,r)}function asyncGeneratorStep(e,t,n,r,o,i,s){try{var a=e[i](s),c=a.value}catch(e){return void n(e)}a.done?t(c):Promise.resolve(c).then(r,o)}function _asyncToGenerator(e){return function(){var t=this,n=arguments;return new Promise(function(r,o){var i=e.apply(t,n);function s(e){asyncGeneratorStep(i,r,o,s,a,"next",e)}function a(e){asyncGeneratorStep(i,r,o,s,a,"throw",e)}s(void 0)})}}function ownKeys(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function _objectSpread(e){for(var t=1;t0&&void 0!==arguments[0]?arguments[0]:"A timeout occurred.";_classCallCheck(this,e);var r=(this instanceof e?this.constructor:void 0).prototype;return(t=_callSuper(this,e,[n])).__proto__=r,t}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),s=function(){function e(){var t,n=arguments.length>0&&void 0!==arguments[0]?arguments[0]:"An abort occurred.";_classCallCheck(this,e);var r=(this instanceof e?this.constructor:void 0).prototype;return(t=_callSuper(this,e,[n])).__proto__=r,t}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),a=function(){function e(t,n){var r;_classCallCheck(this,e);var o=(this instanceof e?this.constructor:void 0).prototype;return(r=_callSuper(this,e,[t])).transport=n,r.errorType="UnsupportedTransportError",r.__proto__=o,r}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),c=function(){function e(t,n){var r;_classCallCheck(this,e);var o=(this instanceof e?this.constructor:void 0).prototype;return(r=_callSuper(this,e,[t])).transport=n,r.errorType="DisabledTransportError",r.__proto__=o,r}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),u=function(){function e(t,n){var r;_classCallCheck(this,e);var o=(this instanceof e?this.constructor:void 0).prototype;return(r=_callSuper(this,e,[t])).transport=n,r.errorType="FailedToStartTransportError",r.__proto__=o,r}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),l=function(){function e(t){var n;_classCallCheck(this,e);var r=(this instanceof e?this.constructor:void 0).prototype;return(n=_callSuper(this,e,[t])).errorType="FailedToNegotiateWithServerError",n.__proto__=r,n}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),h=function(){function e(t,n){var r;_classCallCheck(this,e);var o=(this instanceof e?this.constructor:void 0).prototype;return(r=_callSuper(this,e,[t])).innerErrors=n,r.__proto__=o,r}return _inherits(e,_wrapNativeSuper(Error)),_createClass(e)}(),f=_createClass(function e(t,n,r){_classCallCheck(this,e),this.statusCode=t,this.statusText=n,this.content=r}),_=function(){return _createClass(function e(){_classCallCheck(this,e)},[{key:"get",value:function(e,t){return this.send(_objectSpread(_objectSpread({},t),{},{method:"GET",url:e}))}},{key:"post",value:function(e,t){return this.send(_objectSpread(_objectSpread({},t),{},{method:"POST",url:e}))}},{key:"delete",value:function(e,t){return this.send(_objectSpread(_objectSpread({},t),{},{method:"DELETE",url:e}))}},{key:"getCookieString",value:function(e){return""}}])}();(r=n||(n={}))[r.Trace=0]="Trace",r[r.Debug=1]="Debug",r[r.Information=2]="Information",r[r.Warning=3]="Warning",r[r.Error=4]="Error",r[r.Critical=5]="Critical",r[r.None=6]="None";var p=function(){return _createClass(function e(){_classCallCheck(this,e)},[{key:"log",value:function(e,t){}}])}();p.instance=new p;var g="10.0.0",d=function(){return _createClass(function e(){_classCallCheck(this,e)},null,[{key:"isRequired",value:function(e,t){if(null==e)throw new Error("The '".concat(t,"' argument is required."))}},{key:"isNotEmpty",value:function(e,t){if(!e||e.match(/^\s*$/))throw new Error("The '".concat(t,"' argument should not be empty."))}},{key:"isIn",value:function(e,t,n){if(!(e in t))throw new Error("Unknown ".concat(n," value: ").concat(e,"."))}}])}(),v=function(){function e(){_classCallCheck(this,e)}return _createClass(e,null,[{key:"isBrowser",get:function(){return!e.isNode&&"object"===("undefined"==typeof window?"undefined":_typeof(window))&&"object"===_typeof(window.document)}},{key:"isWebWorker",get:function(){return!e.isNode&&"object"===("undefined"==typeof self?"undefined":_typeof(self))&&"importScripts"in self}},{key:"isReactNative",get:function(){return!e.isNode&&"object"===("undefined"==typeof window?"undefined":_typeof(window))&&void 0===window.document}},{key:"isNode",get:function(){return"undefined"!=typeof process&&process.release&&"node"===process.release.name}}])}();function y(e,t){var n="";return b(e)?(n="Binary data of length ".concat(e.byteLength),t&&(n+=". Content: '".concat(function(e){var t=new Uint8Array(e),n="";return t.forEach(function(e){n+="0x".concat(e<16?"0":"").concat(e.toString(16)," ")}),n.substring(0,n.length-1)}(e),"'"))):"string"==typeof e&&(n="String data of length ".concat(e.length),t&&(n+=". Content: '".concat(e,"'"))),n}function b(e){return e&&"undefined"!=typeof ArrayBuffer&&(e instanceof ArrayBuffer||e.constructor&&"ArrayBuffer"===e.constructor.name)}function m(e,t,n,r,o,i){return k.apply(this,arguments)}function k(){return(k=_asyncToGenerator(_regenerator().m(function e(t,r,o,i,s,a){var c,u,l,h,f,_,p;return _regenerator().w(function(e){for(;;)switch(e.n){case 0:return c={},u=S(),l=_slicedToArray(u,2),h=l[0],f=l[1],c[h]=f,t.log(n.Trace,"(".concat(r," transport) sending data. ").concat(y(s,a.logMessageContent),".")),_=b(s)?"arraybuffer":"text",e.n=1,o.post(i,{content:s,headers:_objectSpread(_objectSpread({},c),a.headers),responseType:_,timeout:a.timeout,withCredentials:a.withCredentials});case 1:p=e.v,t.log(n.Trace,"(".concat(r," transport) request complete. Response status: ").concat(p.statusCode,"."));case 2:return e.a(2)}},e)}))).apply(this,arguments)}var w=function(){return _createClass(function e(t,n){_classCallCheck(this,e),this._subject=t,this._observer=n},[{key:"dispose",value:function(){var e=this._subject.observers.indexOf(this._observer);e>-1&&this._subject.observers.splice(e,1),0===this._subject.observers.length&&this._subject.cancelCallback&&this._subject.cancelCallback().catch(function(e){})}}])}(),C=function(){return _createClass(function e(t){_classCallCheck(this,e),this._minLevel=t,this.out=console},[{key:"log",value:function(e,t){if(e>=this._minLevel){var r="[".concat((new Date).toISOString(),"] ").concat(n[e],": ").concat(t);switch(e){case n.Critical:case n.Error:this.out.error(r);break;case n.Warning:this.out.warn(r);break;case n.Information:this.out.info(r);break;default:this.out.log(r)}}}}])}();function S(){var e="X-SignalR-User-Agent";return v.isNode&&(e="User-Agent"),[e,T(g,I(),P(),E())]}function T(e,t,n,r){var o="Microsoft SignalR/",i=e.split(".");return o+="".concat(i[0],".").concat(i[1]),o+=" (".concat(e,"; "),o+=t&&""!==t?"".concat(t,"; "):"Unknown OS; ",o+="".concat(n),o+=r?"; ".concat(r):"; Unknown Runtime Version",o+=")"}function I(){if(!v.isNode)return"";switch(process.platform){case"win32":return"Windows NT";case"darwin":return"macOS";case"linux":return"Linux";default:return process.platform}}function E(){if(v.isNode)return process.versions.node}function P(){return v.isNode?"NodeJS":"Browser"}function R(e){return e.stack?e.stack:e.message?e.message:"".concat(e)}var j=function(t){function r(t){var n;if(_classCallCheck(this,r),(n=_callSuper(this,r))._logger=t,"undefined"==typeof fetch||v.isNode){var o=require;n._jar=new(o("tough-cookie").CookieJar),"undefined"==typeof fetch?n._fetchType=o("node-fetch"):n._fetchType=fetch,n._fetchType=o("fetch-cookie")(n._fetchType,n._jar)}else n._fetchType=fetch.bind(function(){if("undefined"!=typeof globalThis)return globalThis;if("undefined"!=typeof self)return self;if("undefined"!=typeof window)return window;if(void 0!==e.g)return e.g;throw new Error("could not find global")}());if("undefined"==typeof AbortController){var i=require;n._abortControllerType=i("abort-controller")}else n._abortControllerType=AbortController;return n}return _inherits(r,t),_createClass(r,[{key:"send",value:(a=_asyncToGenerator(_regenerator().m(function e(t){var r,a,c,u,l,h,_,p,g,d=this;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:if(!t.abortSignal||!t.abortSignal.aborted){e.n=1;break}throw new s;case 1:if(t.method){e.n=2;break}throw new Error("No method defined.");case 2:if(t.url){e.n=3;break}throw new Error("No url defined.");case 3:return r=new this._abortControllerType,t.abortSignal&&(t.abortSignal.onabort=function(){r.abort(),a=new s}),c=null,t.timeout&&(u=t.timeout,c=setTimeout(function(){r.abort(),d._logger.log(n.Warning,"Timeout from HTTP request."),a=new i},u)),""===t.content&&(t.content=void 0),t.content&&(t.headers=t.headers||{},b(t.content)?t.headers["Content-Type"]="application/octet-stream":t.headers["Content-Type"]="text/plain;charset=UTF-8"),e.p=4,e.n=5,this._fetchType(t.url,{body:t.content,cache:"no-cache",credentials:!0===t.withCredentials?"include":"same-origin",headers:_objectSpread({"X-Requested-With":"XMLHttpRequest"},t.headers),method:t.method,mode:"cors",redirect:"follow",signal:r.signal});case 5:l=e.v,e.n=8;break;case 6:if(e.p=6,g=e.v,!a){e.n=7;break}throw a;case 7:throw this._logger.log(n.Warning,"Error from HTTP request. ".concat(g,".")),g;case 8:return e.p=8,c&&clearTimeout(c),t.abortSignal&&(t.abortSignal.onabort=null),e.f(8);case 9:if(l.ok){e.n=11;break}return e.n=10,D(l,"text");case 10:throw h=e.v,new o(h||l.statusText,l.status);case 11:return _=D(l,t.responseType),e.n=12,_;case 12:return p=e.v,e.a(2,new f(l.status,l.statusText,p))}},e,this,[[4,6,8,9]])})),function(e){return a.apply(this,arguments)})},{key:"getCookieString",value:function(e){var t="";return v.isNode&&this._jar&&this._jar.getCookies(e,function(e,n){return t=n.join("; ")}),t}}]);var a}(_);function D(e,t){var n;switch(t){case"arraybuffer":n=e.arrayBuffer();break;case"text":default:n=e.text();break;case"blob":case"document":case"json":throw new Error("".concat(t," is not supported."))}return n}var O=function(e){function t(e){var n;return _classCallCheck(this,t),(n=_callSuper(this,t))._logger=e,n}return _inherits(t,e),_createClass(t,[{key:"send",value:function(e){var t=this;return e.abortSignal&&e.abortSignal.aborted?Promise.reject(new s):e.method?e.url?new Promise(function(r,a){var c=new XMLHttpRequest;c.open(e.method,e.url,!0),c.withCredentials=void 0===e.withCredentials||e.withCredentials,c.setRequestHeader("X-Requested-With","XMLHttpRequest"),""===e.content&&(e.content=void 0),e.content&&(b(e.content)?c.setRequestHeader("Content-Type","application/octet-stream"):c.setRequestHeader("Content-Type","text/plain;charset=UTF-8"));var u=e.headers;u&&Object.keys(u).forEach(function(e){c.setRequestHeader(e,u[e])}),e.responseType&&(c.responseType=e.responseType),e.abortSignal&&(e.abortSignal.onabort=function(){c.abort(),a(new s)}),e.timeout&&(c.timeout=e.timeout),c.onload=function(){e.abortSignal&&(e.abortSignal.onabort=null),c.status>=200&&c.status<300?r(new f(c.status,c.statusText,c.response||c.responseText)):a(new o(c.response||c.responseText||c.statusText,c.status))},c.onerror=function(){t._logger.log(n.Warning,"Error from HTTP request. ".concat(c.status,": ").concat(c.statusText,".")),a(new o(c.statusText,c.status))},c.ontimeout=function(){t._logger.log(n.Warning,"Timeout from HTTP request."),a(new i)},c.send(e.content)}):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}}])}(_),A=function(e){function t(e){var n;if(_classCallCheck(this,t),n=_callSuper(this,t),"undefined"!=typeof fetch||v.isNode)n._httpClient=new j(e);else{if("undefined"==typeof XMLHttpRequest)throw new Error("No usable HttpClient found.");n._httpClient=new O(e)}return n}return _inherits(t,e),_createClass(t,[{key:"send",value:function(e){return e.abortSignal&&e.abortSignal.aborted?Promise.reject(new s):e.method?e.url?this._httpClient.send(e):Promise.reject(new Error("No url defined.")):Promise.reject(new Error("No method defined."))}},{key:"getCookieString",value:function(e){return this._httpClient.getCookieString(e)}}])}(_),H=function(){function e(){_classCallCheck(this,e)}return _createClass(e,null,[{key:"write",value:function(t){return"".concat(t).concat(e.RecordSeparator)}},{key:"parse",value:function(t){if(t[t.length-1]!==e.RecordSeparator)throw new Error("Message is incomplete.");var n=t.split(e.RecordSeparator);return n.pop(),n}}])}();H.RecordSeparatorCode=30,H.RecordSeparator=String.fromCharCode(H.RecordSeparatorCode);var M,N,x=function(){return _createClass(function e(){_classCallCheck(this,e)},[{key:"writeHandshakeRequest",value:function(e){return H.write(JSON.stringify(e))}},{key:"parseHandshakeResponse",value:function(e){var t,n;if(b(e)){var r=new Uint8Array(e),o=r.indexOf(H.RecordSeparatorCode);if(-1===o)throw new Error("Message is incomplete.");var i=o+1;t=String.fromCharCode.apply(null,Array.prototype.slice.call(r.slice(0,i))),n=r.byteLength>i?r.slice(i).buffer:null}else{var s=e,a=s.indexOf(H.RecordSeparator);if(-1===a)throw new Error("Message is incomplete.");var c=a+1;t=s.substring(0,c),n=s.length>c?s.substring(c):null}var u=H.parse(t),l=JSON.parse(u[0]);if(l.type)throw new Error("Expected a handshake response from the server.");return[n,l]}}])}();(N=M||(M={}))[N.Invocation=1]="Invocation",N[N.StreamItem=2]="StreamItem",N[N.Completion=3]="Completion",N[N.StreamInvocation=4]="StreamInvocation",N[N.CancelInvocation=5]="CancelInvocation",N[N.Ping=6]="Ping",N[N.Close=7]="Close",N[N.Ack=8]="Ack",N[N.Sequence=9]="Sequence";var q,W,L=function(){return _createClass(function e(){_classCallCheck(this,e),this.observers=[]},[{key:"next",value:function(e){var t,n=_createForOfIteratorHelper(this.observers);try{for(n.s();!(t=n.n()).done;){t.value.next(e)}}catch(e){n.e(e)}finally{n.f()}}},{key:"error",value:function(e){var t,n=_createForOfIteratorHelper(this.observers);try{for(n.s();!(t=n.n()).done;){var r=t.value;r.error&&r.error(e)}}catch(e){n.e(e)}finally{n.f()}}},{key:"complete",value:function(){var e,t=_createForOfIteratorHelper(this.observers);try{for(t.s();!(e=t.n()).done;){var n=e.value;n.complete&&n.complete()}}catch(e){t.e(e)}finally{t.f()}}},{key:"subscribe",value:function(e){return this.observers.push(e),new w(this,e)}}])}(),F=function(){return _createClass(function e(t,n,r){_classCallCheck(this,e),this._bufferSize=1e5,this._messages=[],this._totalMessageCount=0,this._waitForSequenceMessage=!1,this._nextReceivingSequenceId=1,this._latestReceivedSequenceId=0,this._bufferedByteCount=0,this._reconnectInProgress=!1,this._protocol=t,this._connection=n,this._bufferSize=r},[{key:"_send",value:(t=_asyncToGenerator(_regenerator().m(function e(t){var n,r,o,i;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:if(n=this._protocol.writeMessage(t),r=Promise.resolve(),this._isInvocationMessage(t)&&(this._totalMessageCount++,o=function(){},i=function(){},b(n)?this._bufferedByteCount+=n.byteLength:this._bufferedByteCount+=n.length,this._bufferedByteCount>=this._bufferSize&&(r=new Promise(function(e,t){o=e,i=t})),this._messages.push(new B(n,this._totalMessageCount,o,i))),e.p=1,this._reconnectInProgress){e.n=2;break}return e.n=2,this._connection.send(n);case 2:e.n=4;break;case 3:e.p=3,e.v,this._disconnected();case 4:return e.n=5,r;case 5:return e.a(2)}},e,this,[[1,3]])})),function(e){return t.apply(this,arguments)})},{key:"_ack",value:function(e){for(var t=-1,n=0;nthis._nextReceivingSequenceId?this._connection.stop(new Error("Sequence ID greater than amount of messages we've received.")):this._nextReceivingSequenceId=e.sequenceId}},{key:"_disconnected",value:function(){this._reconnectInProgress=!0,this._waitForSequenceMessage=!0}},{key:"_resend",value:(e=_asyncToGenerator(_regenerator().m(function e(){var t,n,r,o,i,s;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return t=0!==this._messages.length?this._messages[0]._id:this._totalMessageCount+1,e.n=1,this._connection.send(this._protocol.writeMessage({type:M.Sequence,sequenceId:t}));case 1:n=this._messages,r=_createForOfIteratorHelper(n),e.p=2,r.s();case 3:if((o=r.n()).done){e.n=5;break}return i=o.value,e.n=4,this._connection.send(i._message);case 4:e.n=3;break;case 5:e.n=7;break;case 6:e.p=6,s=e.v,r.e(s);case 7:return e.p=7,r.f(),e.f(7);case 8:this._reconnectInProgress=!1;case 9:return e.a(2)}},e,this,[[2,6,7,8]])})),function(){return e.apply(this,arguments)})},{key:"_dispose",value:function(e){null!=e||(e=new Error("Unable to reconnect to server."));var t,n=_createForOfIteratorHelper(this._messages);try{for(n.s();!(t=n.n()).done;){t.value._rejector(e)}}catch(e){n.e(e)}finally{n.f()}}},{key:"_isInvocationMessage",value:function(e){switch(e.type){case M.Invocation:case M.StreamItem:case M.Completion:case M.StreamInvocation:case M.CancelInvocation:return!0;case M.Close:case M.Sequence:case M.Ping:case M.Ack:return!1}}},{key:"_ackTimer",value:function(){var e=this;void 0===this._ackTimerHandle&&(this._ackTimerHandle=setTimeout(_asyncToGenerator(_regenerator().m(function t(){return _regenerator().w(function(t){for(;;)switch(t.p=t.n){case 0:if(t.p=0,e._reconnectInProgress){t.n=1;break}return t.n=1,e._connection.send(e._protocol.writeMessage({type:M.Ack,sequenceId:e._latestReceivedSequenceId}));case 1:t.n=3;break;case 2:t.p=2,t.v;case 3:clearTimeout(e._ackTimerHandle),e._ackTimerHandle=void 0;case 4:return t.a(2)}},t,null,[[0,2]])})),1e3))}}]);var e,t}(),B=_createClass(function e(t,n,r,o){_classCallCheck(this,e),this._message=t,this._id=n,this._resolver=r,this._rejector=o});(W=q||(q={})).Disconnected="Disconnected",W.Connecting="Connecting",W.Connected="Connected",W.Disconnecting="Disconnecting",W.Reconnecting="Reconnecting";var U=function(){function e(t,r,o,i,s,a,c){var u=this;_classCallCheck(this,e),this._nextKeepAlive=0,this._freezeEventListener=function(){u._logger.log(n.Warning,"The page is being frozen, this will likely lead to the connection being closed and messages being lost. For more information see the docs at https://learn.microsoft.com/aspnet/core/signalr/javascript-client#bsleep")},d.isRequired(t,"connection"),d.isRequired(r,"logger"),d.isRequired(o,"protocol"),this.serverTimeoutInMilliseconds=null!=s?s:3e4,this.keepAliveIntervalInMilliseconds=null!=a?a:15e3,this._statefulReconnectBufferSize=null!=c?c:1e5,this._logger=r,this._protocol=o,this.connection=t,this._reconnectPolicy=i,this._handshakeProtocol=new x,this.connection.onreceive=function(e){return u._processIncomingData(e)},this.connection.onclose=function(e){return u._connectionClosed(e)},this._callbacks={},this._methods={},this._closedCallbacks=[],this._reconnectingCallbacks=[],this._reconnectedCallbacks=[],this._invocationId=0,this._receivedHandshakeResponse=!1,this._connectionState=q.Disconnected,this._connectionStarted=!1,this._cachedPingMessage=this._protocol.writeMessage({type:M.Ping})}return _createClass(e,[{key:"state",get:function(){return this._connectionState}},{key:"connectionId",get:function(){return this.connection&&this.connection.connectionId||null}},{key:"baseUrl",get:function(){return this.connection.baseUrl||""},set:function(e){if(this._connectionState!==q.Disconnected&&this._connectionState!==q.Reconnecting)throw new Error("The HubConnection must be in the Disconnected or Reconnecting state to change the url.");if(!e)throw new Error("The HubConnection url must be a valid url.");this.connection.baseUrl=e}},{key:"start",value:function(){return this._startPromise=this._startWithStateTransitions(),this._startPromise}},{key:"_startWithStateTransitions",value:(u=_asyncToGenerator(_regenerator().m(function e(){var t;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:if(this._connectionState===q.Disconnected){e.n=1;break}return e.a(2,Promise.reject(new Error("Cannot start a HubConnection that is not in the 'Disconnected' state.")));case 1:return this._connectionState=q.Connecting,this._logger.log(n.Debug,"Starting HubConnection."),e.p=2,e.n=3,this._startInternal();case 3:v.isBrowser&&window.document.addEventListener("freeze",this._freezeEventListener),this._connectionState=q.Connected,this._connectionStarted=!0,this._logger.log(n.Debug,"HubConnection connected successfully."),e.n=5;break;case 4:return e.p=4,t=e.v,this._connectionState=q.Disconnected,this._logger.log(n.Debug,"HubConnection failed to start successfully because of error '".concat(t,"'.")),e.a(2,Promise.reject(t));case 5:return e.a(2)}},e,this,[[2,4]])})),function(){return u.apply(this,arguments)})},{key:"_startInternal",value:(c=_asyncToGenerator(_regenerator().m(function e(){var t,r,o,i,s=this;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return this._stopDuringStartError=void 0,this._receivedHandshakeResponse=!1,t=new Promise(function(e,t){s._handshakeResolver=e,s._handshakeRejecter=t}),e.n=1,this.connection.start(this._protocol.transferFormat);case 1:return e.p=1,r=this._protocol.version,this.connection.features.reconnect||(r=1),o={protocol:this._protocol.name,version:r},this._logger.log(n.Debug,"Sending handshake request."),e.n=2,this._sendMessage(this._handshakeProtocol.writeHandshakeRequest(o));case 2:return this._logger.log(n.Information,"Using HubProtocol '".concat(this._protocol.name,"'.")),this._cleanupTimeout(),this._resetTimeoutPeriod(),this._resetKeepAliveInterval(),e.n=3,t;case 3:if(!this._stopDuringStartError){e.n=4;break}throw this._stopDuringStartError;case 4:if(!!this.connection.features.reconnect&&(this._messageBuffer=new F(this._protocol,this.connection,this._statefulReconnectBufferSize),this.connection.features.disconnected=this._messageBuffer._disconnected.bind(this._messageBuffer),this.connection.features.resend=function(){if(s._messageBuffer)return s._messageBuffer._resend()}),this.connection.features.inherentKeepAlive){e.n=5;break}return e.n=5,this._sendMessage(this._cachedPingMessage);case 5:e.n=8;break;case 6:return e.p=6,i=e.v,this._logger.log(n.Debug,"Hub handshake failed with error '".concat(i,"' during start(). Stopping HubConnection.")),this._cleanupTimeout(),this._cleanupPingTimer(),e.n=7,this.connection.stop(i);case 7:throw i;case 8:return e.a(2)}},e,this,[[1,6]])})),function(){return c.apply(this,arguments)})},{key:"stop",value:(a=_asyncToGenerator(_regenerator().m(function e(){var t;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return t=this._startPromise,this.connection.features.reconnect=!1,this._stopPromise=this._stopInternal(),e.n=1,this._stopPromise;case 1:return e.p=1,e.n=2,t;case 2:e.n=4;break;case 3:e.p=3,e.v;case 4:return e.a(2)}},e,this,[[1,3]])})),function(){return a.apply(this,arguments)})},{key:"_stopInternal",value:function(e){if(this._connectionState===q.Disconnected)return this._logger.log(n.Debug,"Call to HubConnection.stop(".concat(e,") ignored because it is already in the disconnected state.")),Promise.resolve();if(this._connectionState===q.Disconnecting)return this._logger.log(n.Debug,"Call to HttpConnection.stop(".concat(e,") ignored because the connection is already in the disconnecting state.")),this._stopPromise;var t=this._connectionState;return this._connectionState=q.Disconnecting,this._logger.log(n.Debug,"Stopping HubConnection."),this._reconnectDelayHandle?(this._logger.log(n.Debug,"Connection stopped during reconnect delay. Done reconnecting."),clearTimeout(this._reconnectDelayHandle),this._reconnectDelayHandle=void 0,this._completeClose(),Promise.resolve()):(t===q.Connected&&this._sendCloseMessage(),this._cleanupTimeout(),this._cleanupPingTimer(),this._stopDuringStartError=e||new s("The connection was stopped before the hub handshake could complete."),this.connection.stop(e))}},{key:"_sendCloseMessage",value:(i=_asyncToGenerator(_regenerator().m(function e(){return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return e.p=0,e.n=1,this._sendWithProtocol(this._createCloseMessage());case 1:e.n=3;break;case 2:e.p=2,e.v;case 3:return e.a(2)}},e,this,[[0,2]])})),function(){return i.apply(this,arguments)})},{key:"stream",value:function(e){for(var t=this,n=arguments.length,r=new Array(n>1?n-1:0),o=1;o1?t-1:0),r=1;r1?n-1:0),o=1;o0)){e.n=2;break}return n=!1,e.n=1,this._accessTokenFactory();case 1:this._accessToken=e.v;case 2:return this._setAuthorizationHeader(t),e.n=3,this._innerClient.send(t);case 3:if(r=e.v,!n||401!==r.statusCode||!this._accessTokenFactory){e.n=6;break}return e.n=4,this._accessTokenFactory();case 4:return this._accessToken=e.v,this._setAuthorizationHeader(t),e.n=5,this._innerClient.send(t);case 5:return e.a(2,e.v);case 6:return e.a(2,r)}},e,this)})),function(e){return n.apply(this,arguments)})},{key:"_setAuthorizationHeader",value:function(e){e.headers||(e.headers={}),this._accessToken?e.headers[K.Authorization]="Bearer ".concat(this._accessToken):this._accessTokenFactory&&e.headers[K.Authorization]&&delete e.headers[K.Authorization]}},{key:"getCookieString",value:function(e){return this._innerClient.getCookieString(e)}}]);var n}(_);(X=V||(V={}))[X.None=0]="None",X[X.WebSockets=1]="WebSockets",X[X.ServerSentEvents=2]="ServerSentEvents",X[X.LongPolling=4]="LongPolling",(Q=J||(J={}))[Q.Text=1]="Text",Q[Q.Binary=2]="Binary";var Y=function(){return _createClass(function e(){_classCallCheck(this,e),this._isAborted=!1,this.onabort=null},[{key:"abort",value:function(){this._isAborted||(this._isAborted=!0,this.onabort&&this.onabort())}},{key:"signal",get:function(){return this}},{key:"aborted",get:function(){return this._isAborted}}])}(),Z=function(){return _createClass(function e(t,n,r){_classCallCheck(this,e),this._httpClient=t,this._logger=n,this._pollAbort=new Y,this._options=r,this._running=!1,this.onreceive=null,this.onclose=null},[{key:"pollAborted",get:function(){return this._pollAbort.aborted}},{key:"connect",value:(s=_asyncToGenerator(_regenerator().m(function e(t,r){var i,s,a,c,u,l,h,f;return _regenerator().w(function(e){for(;;)switch(e.n){case 0:if(d.isRequired(t,"url"),d.isRequired(r,"transferFormat"),d.isIn(r,J,"transferFormat"),this._url=t,this._logger.log(n.Trace,"(LongPolling transport) Connecting."),r!==J.Binary||"undefined"==typeof XMLHttpRequest||"string"==typeof(new XMLHttpRequest).responseType){e.n=1;break}throw new Error("Binary protocols over XmlHttpRequest not implementing advanced features are not supported.");case 1:return i=S(),s=_slicedToArray(i,2),a=s[0],c=s[1],u=_objectSpread(_defineProperty({},a,c),this._options.headers),l={abortSignal:this._pollAbort.signal,headers:u,timeout:1e5,withCredentials:this._options.withCredentials},r===J.Binary&&(l.responseType="arraybuffer"),h="".concat(t,"&_=").concat(Date.now()),this._logger.log(n.Trace,"(LongPolling transport) polling: ".concat(h,".")),e.n=2,this._httpClient.get(h,l);case 2:200!==(f=e.v).statusCode?(this._logger.log(n.Error,"(LongPolling transport) Unexpected response code: ".concat(f.statusCode,".")),this._closeError=new o(f.statusText||"",f.statusCode),this._running=!1):this._running=!0,this._receiving=this._poll(this._url,l);case 3:return e.a(2)}},e,this)})),function(e,t){return s.apply(this,arguments)})},{key:"_poll",value:(r=_asyncToGenerator(_regenerator().m(function e(t,r){var s,a,c;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:e.p=0;case 1:if(!this._running){e.n=6;break}return e.p=2,s="".concat(t,"&_=").concat(Date.now()),this._logger.log(n.Trace,"(LongPolling transport) polling: ".concat(s,".")),e.n=3,this._httpClient.get(s,r);case 3:204===(a=e.v).statusCode?(this._logger.log(n.Information,"(LongPolling transport) Poll terminated by server."),this._running=!1):200!==a.statusCode?(this._logger.log(n.Error,"(LongPolling transport) Unexpected response code: ".concat(a.statusCode,".")),this._closeError=new o(a.statusText||"",a.statusCode),this._running=!1):a.content?(this._logger.log(n.Trace,"(LongPolling transport) data received. ".concat(y(a.content,this._options.logMessageContent),".")),this.onreceive&&this.onreceive(a.content)):this._logger.log(n.Trace,"(LongPolling transport) Poll timed out, reissuing."),e.n=5;break;case 4:e.p=4,c=e.v,this._running?c instanceof i?this._logger.log(n.Trace,"(LongPolling transport) Poll timed out, reissuing."):(this._closeError=c,this._running=!1):this._logger.log(n.Trace,"(LongPolling transport) Poll errored after shutdown: ".concat(c.message));case 5:e.n=1;break;case 6:return e.p=6,this._logger.log(n.Trace,"(LongPolling transport) Polling complete."),this.pollAborted||this._raiseOnClose(),e.f(6);case 7:return e.a(2)}},e,this,[[2,4],[0,,6,7]])})),function(e,t){return r.apply(this,arguments)})},{key:"send",value:(t=_asyncToGenerator(_regenerator().m(function e(t){return _regenerator().w(function(e){for(;;)switch(e.n){case 0:if(this._running){e.n=1;break}return e.a(2,Promise.reject(new Error("Cannot send until the transport is connected")));case 1:return e.a(2,m(this._logger,"LongPolling",this._httpClient,this._url,t,this._options))}},e,this)})),function(e){return t.apply(this,arguments)})},{key:"stop",value:(e=_asyncToGenerator(_regenerator().m(function e(){var t,r,i,s,a,c,u,l;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return this._logger.log(n.Trace,"(LongPolling transport) Stopping polling."),this._running=!1,this._pollAbort.abort(),e.p=1,e.n=2,this._receiving;case 2:return this._logger.log(n.Trace,"(LongPolling transport) sending DELETE request to ".concat(this._url,".")),t={},r=S(),i=_slicedToArray(r,2),s=i[0],a=i[1],t[s]=a,c={headers:_objectSpread(_objectSpread({},t),this._options.headers),timeout:this._options.timeout,withCredentials:this._options.withCredentials},e.p=3,e.n=4,this._httpClient.delete(this._url,c);case 4:e.n=6;break;case 5:e.p=5,l=e.v,u=l;case 6:u?u instanceof o&&(404===u.statusCode?this._logger.log(n.Trace,"(LongPolling transport) A 404 response was returned from sending a DELETE request."):this._logger.log(n.Trace,"(LongPolling transport) Error sending a DELETE request: ".concat(u))):this._logger.log(n.Trace,"(LongPolling transport) DELETE request accepted.");case 7:return e.p=7,this._logger.log(n.Trace,"(LongPolling transport) Stop finished."),this._raiseOnClose(),e.f(7);case 8:return e.a(2)}},e,this,[[3,5],[1,,7,8]])})),function(){return e.apply(this,arguments)})},{key:"_raiseOnClose",value:function(){if(this.onclose){var e="(LongPolling transport) Firing onclose event.";this._closeError&&(e+=" Error: "+this._closeError),this._logger.log(n.Trace,e),this.onclose(this._closeError)}}}]);var e,t,r,s}(),ee=function(){return _createClass(function e(t,n,r,o){_classCallCheck(this,e),this._httpClient=t,this._accessToken=n,this._logger=r,this._options=o,this.onreceive=null,this.onclose=null},[{key:"connect",value:(t=_asyncToGenerator(_regenerator().m(function e(t,r){var o=this;return _regenerator().w(function(e){for(;;)if(0===e.n)return d.isRequired(t,"url"),d.isRequired(r,"transferFormat"),d.isIn(r,J,"transferFormat"),this._logger.log(n.Trace,"(SSE transport) Connecting."),this._url=t,this._accessToken&&(t+=(t.indexOf("?")<0?"?":"&")+"access_token=".concat(encodeURIComponent(this._accessToken))),e.a(2,new Promise(function(e,i){var s=!1;if(r===J.Text){var a;if(v.isBrowser||v.isWebWorker)a=new o._options.EventSource(t,{withCredentials:o._options.withCredentials});else{var c=o._httpClient.getCookieString(t),u={};u.Cookie=c;var l=_slicedToArray(S(),2),h=l[0],f=l[1];u[h]=f,a=new o._options.EventSource(t,{withCredentials:o._options.withCredentials,headers:_objectSpread(_objectSpread({},u),o._options.headers)})}try{a.onmessage=function(e){if(o.onreceive)try{o._logger.log(n.Trace,"(SSE transport) data received. ".concat(y(e.data,o._options.logMessageContent),".")),o.onreceive(e.data)}catch(e){return void o._close(e)}},a.onerror=function(e){s?o._close():i(new Error("EventSource failed to connect. The connection could not be found on the server, either the connection ID is not present on the server, or a proxy is refusing/buffering the connection. If you have multiple servers check that sticky sessions are enabled."))},a.onopen=function(){o._logger.log(n.Information,"SSE connected to ".concat(o._url)),o._eventSource=a,s=!0,e()}}catch(e){return void i(e)}}else i(new Error("The Server-Sent Events transport only supports the 'Text' transfer format"))}))},e,this)})),function(e,n){return t.apply(this,arguments)})},{key:"send",value:(e=_asyncToGenerator(_regenerator().m(function e(t){return _regenerator().w(function(e){for(;;)switch(e.n){case 0:if(this._eventSource){e.n=1;break}return e.a(2,Promise.reject(new Error("Cannot send until the transport is connected")));case 1:return e.a(2,m(this._logger,"SSE",this._httpClient,this._url,t,this._options))}},e,this)})),function(t){return e.apply(this,arguments)})},{key:"stop",value:function(){return this._close(),Promise.resolve()}},{key:"_close",value:function(e){this._eventSource&&(this._eventSource.close(),this._eventSource=void 0,this.onclose&&this.onclose(e))}}]);var e,t}(),te=function(){return _createClass(function e(t,n,r,o,i,s){_classCallCheck(this,e),this._logger=r,this._accessTokenFactory=n,this._logMessageContent=o,this._webSocketConstructor=i,this._httpClient=t,this.onreceive=null,this.onclose=null,this._headers=s},[{key:"connect",value:(e=_asyncToGenerator(_regenerator().m(function e(t,r){var o,i=this;return _regenerator().w(function(e){for(;;)switch(e.n){case 0:if(d.isRequired(t,"url"),d.isRequired(r,"transferFormat"),d.isIn(r,J,"transferFormat"),this._logger.log(n.Trace,"(WebSockets transport) Connecting."),!this._accessTokenFactory){e.n=2;break}return e.n=1,this._accessTokenFactory();case 1:o=e.v;case 2:return e.a(2,new Promise(function(e,s){var a;t=t.replace(/^http/,"ws");var c=i._httpClient.getCookieString(t),u=!1;if(v.isNode||v.isReactNative){var l={},h=_slicedToArray(S(),2),f=h[0],_=h[1];l[f]=_,o&&(l[K.Authorization]="Bearer ".concat(o)),c&&(l[K.Cookie]=c),a=new i._webSocketConstructor(t,void 0,{headers:_objectSpread(_objectSpread({},l),i._headers)})}else o&&(t+=(t.indexOf("?")<0?"?":"&")+"access_token=".concat(encodeURIComponent(o)));a||(a=new i._webSocketConstructor(t)),r===J.Binary&&(a.binaryType="arraybuffer"),a.onopen=function(r){i._logger.log(n.Information,"WebSocket connected to ".concat(t,".")),i._webSocket=a,u=!0,e()},a.onerror=function(e){var t=null;t="undefined"!=typeof ErrorEvent&&e instanceof ErrorEvent?e.error:"There was an error with the transport",i._logger.log(n.Information,"(WebSockets transport) ".concat(t,"."))},a.onmessage=function(e){if(i._logger.log(n.Trace,"(WebSockets transport) data received. ".concat(y(e.data,i._logMessageContent),".")),i.onreceive)try{i.onreceive(e.data)}catch(e){return void i._close(e)}},a.onclose=function(e){if(u)i._close(e);else{var t=null;t="undefined"!=typeof ErrorEvent&&e instanceof ErrorEvent?e.error:"WebSocket failed to connect. The connection could not be found on the server, either the endpoint may not be a SignalR endpoint, the connection ID is not present on the server, or there is a proxy blocking WebSockets. If you have multiple servers check that sticky sessions are enabled.",s(new Error(t))}}}))}},e,this)})),function(t,n){return e.apply(this,arguments)})},{key:"send",value:function(e){return this._webSocket&&this._webSocket.readyState===this._webSocketConstructor.OPEN?(this._logger.log(n.Trace,"(WebSockets transport) sending data. ".concat(y(e,this._logMessageContent),".")),this._webSocket.send(e),Promise.resolve()):Promise.reject("WebSocket is not in the OPEN state")}},{key:"stop",value:function(){return this._webSocket&&this._close(void 0),Promise.resolve()}},{key:"_close",value:function(e){this._webSocket&&(this._webSocket.onclose=function(){},this._webSocket.onmessage=function(){},this._webSocket.onerror=function(){},this._webSocket.close(),this._webSocket=void 0),this._logger.log(n.Trace,"(WebSockets transport) socket closed."),this.onclose&&(!this._isCloseEvent(e)||!1!==e.wasClean&&1e3===e.code?e instanceof Error?this.onclose(e):this.onclose():this.onclose(new Error("WebSocket closed with status code: ".concat(e.code," (").concat(e.reason||"no reason given",")."))))}},{key:"_isCloseEvent",value:function(e){return e&&"boolean"==typeof e.wasClean&&"number"==typeof e.code}}]);var e}(),ne=function(){return _createClass(function e(t){var r,o=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};if(_classCallCheck(this,e),this._stopPromiseResolver=function(){},this.features={},this._negotiateVersion=1,d.isRequired(t,"url"),this._logger=void 0===(r=o.logger)?new C(n.Information):null===r?p.instance:void 0!==r.log?r:new C(r),this.baseUrl=this._resolveUrl(t),(o=o||{}).logMessageContent=void 0!==o.logMessageContent&&o.logMessageContent,"boolean"!=typeof o.withCredentials&&void 0!==o.withCredentials)throw new Error("withCredentials option was not a 'boolean' or 'undefined' value");o.withCredentials=void 0===o.withCredentials||o.withCredentials,o.timeout=void 0===o.timeout?1e5:o.timeout;var i=null,s=null;if(v.isNode){var a=require;i=a("ws"),s=a("eventsource")}v.isNode||"undefined"==typeof WebSocket||o.WebSocket?v.isNode&&!o.WebSocket&&i&&(o.WebSocket=i):o.WebSocket=WebSocket,v.isNode||"undefined"==typeof EventSource||o.EventSource?v.isNode&&!o.EventSource&&void 0!==s&&(o.EventSource=s):o.EventSource=EventSource,this._httpClient=new $(o.httpClient||new A(this._logger),o.accessTokenFactory),this._connectionState="Disconnected",this._connectionStarted=!1,this._options=o,this.onreceive=null,this.onclose=null},[{key:"start",value:(_=_asyncToGenerator(_regenerator().m(function e(t){var r,o;return _regenerator().w(function(e){for(;;)switch(e.n){case 0:if(t=t||J.Binary,d.isIn(t,J,"transferFormat"),this._logger.log(n.Debug,"Starting connection with transfer format '".concat(J[t],"'.")),"Disconnected"===this._connectionState){e.n=1;break}return e.a(2,Promise.reject(new Error("Cannot start an HttpConnection that is not in the 'Disconnected' state.")));case 1:return this._connectionState="Connecting",this._startInternalPromise=this._startInternal(t),e.n=2,this._startInternalPromise;case 2:if("Disconnecting"!==this._connectionState){e.n=4;break}return r="Failed to start the HttpConnection before stop() was called.",this._logger.log(n.Error,r),e.n=3,this._stopPromise;case 3:return e.a(2,Promise.reject(new s(r)));case 4:if("Connected"===this._connectionState){e.n=5;break}return o="HttpConnection.startInternal completed gracefully but didn't enter the connection into the connected state!",this._logger.log(n.Error,o),e.a(2,Promise.reject(new s(o)));case 5:this._connectionStarted=!0;case 6:return e.a(2)}},e,this)})),function(e){return _.apply(this,arguments)})},{key:"send",value:function(e){return"Connected"!==this._connectionState?Promise.reject(new Error("Cannot send data if the connection is not in the 'Connected' State.")):(this._sendQueue||(this._sendQueue=new re(this.transport)),this._sendQueue.send(e))}},{key:"stop",value:(f=_asyncToGenerator(_regenerator().m(function e(t){var r=this;return _regenerator().w(function(e){for(;;)switch(e.n){case 0:if("Disconnected"!==this._connectionState){e.n=1;break}return this._logger.log(n.Debug,"Call to HttpConnection.stop(".concat(t,") ignored because the connection is already in the disconnected state.")),e.a(2,Promise.resolve());case 1:if("Disconnecting"!==this._connectionState){e.n=2;break}return this._logger.log(n.Debug,"Call to HttpConnection.stop(".concat(t,") ignored because the connection is already in the disconnecting state.")),e.a(2,this._stopPromise);case 2:return this._connectionState="Disconnecting",this._stopPromise=new Promise(function(e){r._stopPromiseResolver=e}),e.n=3,this._stopInternal(t);case 3:return e.n=4,this._stopPromise;case 4:return e.a(2)}},e,this)})),function(e){return f.apply(this,arguments)})},{key:"_stopInternal",value:(i=_asyncToGenerator(_regenerator().m(function e(t){var r;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return this._stopError=t,e.p=1,e.n=2,this._startInternalPromise;case 2:e.n=4;break;case 3:e.p=3,e.v;case 4:if(!this.transport){e.n=9;break}return e.p=5,e.n=6,this.transport.stop();case 6:e.n=8;break;case 7:e.p=7,r=e.v,this._logger.log(n.Error,"HttpConnection.transport.stop() threw error '".concat(r,"'.")),this._stopConnection();case 8:this.transport=void 0,e.n=10;break;case 9:this._logger.log(n.Debug,"HttpConnection.transport is undefined in HttpConnection.stop() because start() failed.");case 10:return e.a(2)}},e,this,[[5,7],[1,3]])})),function(e){return i.apply(this,arguments)})},{key:"_startInternal",value:(r=_asyncToGenerator(_regenerator().m(function e(t){var r,o,i,a,c,u=this;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:if(r=this.baseUrl,this._accessTokenFactory=this._options.accessTokenFactory,this._httpClient._accessTokenFactory=this._accessTokenFactory,e.p=1,!this._options.skipNegotiation){e.n=5;break}if(this._options.transport!==V.WebSockets){e.n=3;break}return this.transport=this._constructTransport(V.WebSockets),e.n=2,this._startTransport(r,t);case 2:e.n=4;break;case 3:throw new Error("Negotiation can only be skipped when using the WebSocket transport directly.");case 4:e.n=10;break;case 5:o=null,i=0,a=_regenerator().m(function e(){var t;return _regenerator().w(function(e){for(;;)switch(e.n){case 0:return e.n=1,u._getNegotiationResponse(r);case 1:if(o=e.v,"Disconnecting"!==u._connectionState&&"Disconnected"!==u._connectionState){e.n=2;break}throw new s("The connection was stopped during negotiation.");case 2:if(!o.error){e.n=3;break}throw new Error(o.error);case 3:if(!o.ProtocolVersion){e.n=4;break}throw new Error("Detected a connection attempt to an ASP.NET SignalR Server. This client only supports connecting to an ASP.NET Core SignalR Server. See https://aka.ms/signalr-core-differences for details.");case 4:o.url&&(r=o.url),o.accessToken&&(t=o.accessToken,u._accessTokenFactory=function(){return t},u._httpClient._accessToken=t,u._httpClient._accessTokenFactory=void 0),i++;case 5:return e.a(2)}},e)});case 6:return e.d(_regeneratorValues(a()),7);case 7:if(o.url&&i<100){e.n=6;break}case 8:if(100!==i||!o.url){e.n=9;break}throw new Error("Negotiate redirection limit exceeded.");case 9:return e.n=10,this._createTransport(r,this._options.transport,o,t);case 10:this.transport instanceof Z&&(this.features.inherentKeepAlive=!0),"Connecting"===this._connectionState&&(this._logger.log(n.Debug,"The HttpConnection connected successfully."),this._connectionState="Connected"),e.n=12;break;case 11:return e.p=11,c=e.v,this._logger.log(n.Error,"Failed to start the connection: "+c),this._connectionState="Disconnected",this.transport=void 0,this._stopPromiseResolver(),e.a(2,Promise.reject(c));case 12:return e.a(2)}},e,this,[[1,11]])})),function(e){return r.apply(this,arguments)})},{key:"_getNegotiationResponse",value:(t=_asyncToGenerator(_regenerator().m(function e(t){var r,i,s,a,c,u,h,f,_,p;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:return r={},i=S(),s=_slicedToArray(i,2),a=s[0],c=s[1],r[a]=c,u=this._resolveNegotiateUrl(t),this._logger.log(n.Debug,"Sending negotiation request: ".concat(u,".")),e.p=1,e.n=2,this._httpClient.post(u,{content:"",headers:_objectSpread(_objectSpread({},r),this._options.headers),timeout:this._options.timeout,withCredentials:this._options.withCredentials});case 2:if(200===(h=e.v).statusCode){e.n=3;break}return e.a(2,Promise.reject(new Error("Unexpected status code returned from negotiate '".concat(h.statusCode,"'"))));case 3:if((!(f=JSON.parse(h.content)).negotiateVersion||f.negotiateVersion<1)&&(f.connectionToken=f.connectionId),!f.useStatefulReconnect||!0===this._options._useStatefulReconnect){e.n=4;break}return e.a(2,Promise.reject(new l("Client didn't negotiate Stateful Reconnect but the server did.")));case 4:return e.a(2,f);case 5:return e.p=5,p=e.v,_="Failed to complete negotiation with the server: "+p,p instanceof o&&404===p.statusCode&&(_+=" Either this is not a SignalR endpoint or there is a proxy blocking the connection."),this._logger.log(n.Error,_),e.a(2,Promise.reject(new l(_)))}},e,this,[[1,5]])})),function(e){return t.apply(this,arguments)})},{key:"_createConnectUrl",value:function(e,t){return t?e+(-1===e.indexOf("?")?"?":"&")+"id=".concat(t):e}},{key:"_createTransport",value:(e=_asyncToGenerator(_regenerator().m(function e(t,r,o,i){var a,c,l,f,_,p,g,d,v,y,b,m;return _regenerator().w(function(e){for(;;)switch(e.p=e.n){case 0:if(a=this._createConnectUrl(t,o.connectionToken),!this._isITransport(r)){e.n=2;break}return this._logger.log(n.Debug,"Connection was provided an instance of ITransport, using that directly."),this.transport=r,e.n=1,this._startTransport(a,i);case 1:return this.connectionId=o.connectionId,e.a(2);case 2:c=[],l=o.availableTransports||[],f=o,_=_createForOfIteratorHelper(l),e.p=3,_.s();case 4:if((p=_.n()).done){e.n=14;break}if(g=p.value,!((d=this._resolveTransportOrError(g,r,i,!0===(null==f?void 0:f.useStatefulReconnect)))instanceof Error)){e.n=5;break}c.push("".concat(g.transport," failed:")),c.push(d),e.n=13;break;case 5:if(!this._isITransport(d)){e.n=13;break}if(this.transport=d,f){e.n=10;break}return e.p=6,e.n=7,this._getNegotiationResponse(t);case 7:f=e.v,e.n=9;break;case 8:return e.p=8,y=e.v,e.a(2,Promise.reject(y));case 9:a=this._createConnectUrl(t,f.connectionToken);case 10:return e.p=10,e.n=11,this._startTransport(a,i);case 11:return this.connectionId=f.connectionId,e.a(2);case 12:if(e.p=12,b=e.v,this._logger.log(n.Error,"Failed to start the transport '".concat(g.transport,"': ").concat(b)),f=void 0,c.push(new u("".concat(g.transport," failed: ").concat(b),V[g.transport])),"Connecting"===this._connectionState){e.n=13;break}return v="Failed to select transport before stop() was called.",this._logger.log(n.Debug,v),e.a(2,Promise.reject(new s(v)));case 13:e.n=4;break;case 14:e.n=16;break;case 15:e.p=15,m=e.v,_.e(m);case 16:return e.p=16,_.f(),e.f(16);case 17:if(!(c.length>0)){e.n=18;break}return e.a(2,Promise.reject(new h("Unable to connect to the server with any of the available transports. ".concat(c.join(" ")),c)));case 18:return e.a(2,Promise.reject(new Error("None of the transports supported by the client are supported by the server.")))}},e,this,[[10,12],[6,8],[3,15,16,17]])})),function(t,n,r,o){return e.apply(this,arguments)})},{key:"_constructTransport",value:function(e){switch(e){case V.WebSockets:if(!this._options.WebSocket)throw new Error("'WebSocket' is not supported in your environment.");return new te(this._httpClient,this._accessTokenFactory,this._logger,this._options.logMessageContent,this._options.WebSocket,this._options.headers||{});case V.ServerSentEvents:if(!this._options.EventSource)throw new Error("'EventSource' is not supported in your environment.");return new ee(this._httpClient,this._httpClient._accessToken,this._logger,this._options);case V.LongPolling:return new Z(this._httpClient,this._logger,this._options);default:throw new Error("Unknown transport: ".concat(e,"."))}}},{key:"_startTransport",value:function(e,t){var n=this;return this.transport.onreceive=this.onreceive,this.features.reconnect?this.transport.onclose=function(){var r=_asyncToGenerator(_regenerator().m(function r(o){var i;return _regenerator().w(function(r){for(;;)switch(r.p=r.n){case 0:if(i=!1,!n.features.reconnect){r.n=6;break}return r.p=1,n.features.disconnected(),r.n=2,n.transport.connect(e,t);case 2:return r.n=3,n.features.resend();case 3:r.n=5;break;case 4:r.p=4,r.v,i=!0;case 5:r.n=7;break;case 6:return n._stopConnection(o),r.a(2);case 7:i&&n._stopConnection(o);case 8:return r.a(2)}},r,null,[[1,4]])}));return function(e){return r.apply(this,arguments)}}():this.transport.onclose=function(e){return n._stopConnection(e)},this.transport.connect(e,t)}},{key:"_resolveTransportOrError",value:function(e,t,r,o){var i=V[e.transport];if(null==i)return this._logger.log(n.Debug,"Skipping transport '".concat(e.transport,"' because it is not supported by this client.")),new Error("Skipping transport '".concat(e.transport,"' because it is not supported by this client."));if(!function(e,t){return!e||0!==(t&e)}(t,i))return this._logger.log(n.Debug,"Skipping transport '".concat(V[i],"' because it was disabled by the client.")),new c("'".concat(V[i],"' is disabled by the client."),i);if(!(e.transferFormats.map(function(e){return J[e]}).indexOf(r)>=0))return this._logger.log(n.Debug,"Skipping transport '".concat(V[i],"' because it does not support the requested transfer format '").concat(J[r],"'.")),new Error("'".concat(V[i],"' does not support ").concat(J[r],"."));if(i===V.WebSockets&&!this._options.WebSocket||i===V.ServerSentEvents&&!this._options.EventSource)return this._logger.log(n.Debug,"Skipping transport '".concat(V[i],"' because it is not supported in your environment.'")),new a("'".concat(V[i],"' is not supported in your environment."),i);this._logger.log(n.Debug,"Selecting transport '".concat(V[i],"'."));try{return this.features.reconnect=i===V.WebSockets?o:void 0,this._constructTransport(i)}catch(e){return e}}},{key:"_isITransport",value:function(e){return e&&"object"===_typeof(e)&&"connect"in e}},{key:"_stopConnection",value:function(e){var t=this;if(this._logger.log(n.Debug,"HttpConnection.stopConnection(".concat(e,") called while in state ").concat(this._connectionState,".")),this.transport=void 0,e=this._stopError||e,this._stopError=void 0,"Disconnected"!==this._connectionState){if("Connecting"===this._connectionState)throw this._logger.log(n.Warning,"Call to HttpConnection.stopConnection(".concat(e,") was ignored because the connection is still in the connecting state.")),new Error("HttpConnection.stopConnection(".concat(e,") was called while the connection is still in the connecting state."));if("Disconnecting"===this._connectionState&&this._stopPromiseResolver(),e?this._logger.log(n.Error,"Connection disconnected with error '".concat(e,"'.")):this._logger.log(n.Information,"Connection disconnected."),this._sendQueue&&(this._sendQueue.stop().catch(function(e){t._logger.log(n.Error,"TransportSendQueue.stop() threw error '".concat(e,"'."))}),this._sendQueue=void 0),this.connectionId=void 0,this._connectionState="Disconnected",this._connectionStarted){this._connectionStarted=!1;try{this.onclose&&this.onclose(e)}catch(t){this._logger.log(n.Error,"HttpConnection.onclose(".concat(e,") threw error '").concat(t,"'."))}}}else this._logger.log(n.Debug,"Call to HttpConnection.stopConnection(".concat(e,") was ignored because the connection is already in the disconnected state."))}},{key:"_resolveUrl",value:function(e){if(0===e.lastIndexOf("https://",0)||0===e.lastIndexOf("http://",0))return e;if(!v.isBrowser)throw new Error("Cannot resolve '".concat(e,"'."));var t=window.document.createElement("a");return t.href=e,this._logger.log(n.Information,"Normalizing '".concat(e,"' to '").concat(t.href,"'.")),t.href}},{key:"_resolveNegotiateUrl",value:function(e){var t=new URL(e);t.pathname.endsWith("/")?t.pathname+="negotiate":t.pathname+="/negotiate";var n=new URLSearchParams(t.searchParams);return n.has("negotiateVersion")||n.append("negotiateVersion",this._negotiateVersion.toString()),n.has("useStatefulReconnect")?"true"===n.get("useStatefulReconnect")&&(this._options._useStatefulReconnect=!0):!0===this._options._useStatefulReconnect&&n.append("useStatefulReconnect","true"),t.search=n.toString(),t.toString()}}]);var e,t,r,i,f,_}();var re=function(){function e(t){_classCallCheck(this,e),this._transport=t,this._buffer=[],this._executing=!0,this._sendBufferedData=new oe,this._transportResult=new oe,this._sendLoopPromise=this._sendLoop()}return _createClass(e,[{key:"send",value:function(e){return this._bufferData(e),this._transportResult||(this._transportResult=new oe),this._transportResult.promise}},{key:"stop",value:function(){return this._executing=!1,this._sendBufferedData.resolve(),this._sendLoopPromise}},{key:"_bufferData",value:function(e){if(this._buffer.length&&_typeof(this._buffer[0])!==_typeof(e))throw new Error("Expected data to be of type ".concat(_typeof(this._buffer)," but was of type ").concat(_typeof(e)));this._buffer.push(e),this._sendBufferedData.resolve()}},{key:"_sendLoop",value:(t=_asyncToGenerator(_regenerator().m(function t(){var n,r,o;return _regenerator().w(function(t){for(;;)switch(t.p=t.n){case 0:return t.n=1,this._sendBufferedData.promise;case 1:if(this._executing){t.n=2;break}return this._transportResult&&this._transportResult.reject("Connection stopped."),t.a(3,7);case 2:return this._sendBufferedData=new oe,n=this._transportResult,this._transportResult=void 0,r="string"==typeof this._buffer[0]?this._buffer.join(""):e._concatBuffers(this._buffer),this._buffer.length=0,t.p=3,t.n=4,this._transport.send(r);case 4:n.resolve(),t.n=6;break;case 5:t.p=5,o=t.v,n.reject(o);case 6:t.n=0;break;case 7:return t.a(2)}},t,this,[[3,5]])})),function(){return t.apply(this,arguments)})}],[{key:"_concatBuffers",value:function(e){var t,n=e.map(function(e){return e.byteLength}).reduce(function(e,t){return e+t}),r=new Uint8Array(n),o=0,i=_createForOfIteratorHelper(e);try{for(i.s();!(t=i.n()).done;){var s=t.value;r.set(new Uint8Array(s),o),o+=s.byteLength}}catch(e){i.e(e)}finally{i.f()}return r.buffer}}]);var t}(),oe=function(){return _createClass(function e(){var t=this;_classCallCheck(this,e),this.promise=new Promise(function(e,n){var r;return r=[e,n],t._resolver=r[0],t._rejecter=r[1],r})},[{key:"resolve",value:function(){this._resolver()}},{key:"reject",value:function(e){this._rejecter(e)}}])}(),ie=function(){return _createClass(function e(){_classCallCheck(this,e),this.name="json",this.version=2,this.transferFormat=J.Text},[{key:"parseMessages",value:function(e,t){if("string"!=typeof e)throw new Error("Invalid input for JSON hub protocol. Expected a string.");if(!e)return[];null===t&&(t=p.instance);var r,o=[],i=_createForOfIteratorHelper(H.parse(e));try{for(i.s();!(r=i.n()).done;){var s=r.value,a=JSON.parse(s);if("number"!=typeof a.type)throw new Error("Invalid payload.");switch(a.type){case M.Invocation:this._isInvocationMessage(a);break;case M.StreamItem:this._isStreamItemMessage(a);break;case M.Completion:this._isCompletionMessage(a);break;case M.Ping:case M.Close:break;case M.Ack:this._isAckMessage(a);break;case M.Sequence:this._isSequenceMessage(a);break;default:t.log(n.Information,"Unknown message type '"+a.type+"' ignored.");continue}o.push(a)}}catch(e){i.e(e)}finally{i.f()}return o}},{key:"writeMessage",value:function(e){return H.write(JSON.stringify(e))}},{key:"_isInvocationMessage",value:function(e){this._assertNotEmptyString(e.target,"Invalid payload for Invocation message."),void 0!==e.invocationId&&this._assertNotEmptyString(e.invocationId,"Invalid payload for Invocation message.")}},{key:"_isStreamItemMessage",value:function(e){if(this._assertNotEmptyString(e.invocationId,"Invalid payload for StreamItem message."),void 0===e.item)throw new Error("Invalid payload for StreamItem message.")}},{key:"_isCompletionMessage",value:function(e){if(e.result&&e.error)throw new Error("Invalid payload for Completion message.");!e.result&&e.error&&this._assertNotEmptyString(e.error,"Invalid payload for Completion message."),this._assertNotEmptyString(e.invocationId,"Invalid payload for Completion message.")}},{key:"_isAckMessage",value:function(e){if("number"!=typeof e.sequenceId)throw new Error("Invalid SequenceId for Ack message.")}},{key:"_isSequenceMessage",value:function(e){if("number"!=typeof e.sequenceId)throw new Error("Invalid SequenceId for Sequence message.")}},{key:"_assertNotEmptyString",value:function(e,t){if("string"!=typeof e||""===e)throw new Error(t)}}])}(),se={trace:n.Trace,debug:n.Debug,info:n.Information,information:n.Information,warn:n.Warning,warning:n.Warning,error:n.Error,critical:n.Critical,none:n.None};var ae=function(){return _createClass(function e(){_classCallCheck(this,e)},[{key:"configureLogging",value:function(e){if(d.isRequired(e,"logging"),void 0!==e.log)this.logger=e;else if("string"==typeof e){var t=function(e){var t=se[e.toLowerCase()];if(void 0!==t)return t;throw new Error("Unknown log level: ".concat(e))}(e);this.logger=new C(t)}else this.logger=new C(e);return this}},{key:"withUrl",value:function(e,t){return d.isRequired(e,"url"),d.isNotEmpty(e,"url"),this.url=e,"object"===_typeof(t)?this.httpConnectionOptions=_objectSpread(_objectSpread({},this.httpConnectionOptions),t):this.httpConnectionOptions=_objectSpread(_objectSpread({},this.httpConnectionOptions),{},{transport:t}),this}},{key:"withHubProtocol",value:function(e){return d.isRequired(e,"protocol"),this.protocol=e,this}},{key:"withAutomaticReconnect",value:function(e){if(this.reconnectPolicy)throw new Error("A reconnectPolicy has already been set.");return e?Array.isArray(e)?this.reconnectPolicy=new z(e):this.reconnectPolicy=e:this.reconnectPolicy=new z,this}},{key:"withServerTimeout",value:function(e){return d.isRequired(e,"milliseconds"),this._serverTimeoutInMilliseconds=e,this}},{key:"withKeepAliveInterval",value:function(e){return d.isRequired(e,"milliseconds"),this._keepAliveIntervalInMilliseconds=e,this}},{key:"withStatefulReconnect",value:function(e){return void 0===this.httpConnectionOptions&&(this.httpConnectionOptions={}),this.httpConnectionOptions._useStatefulReconnect=!0,this._statefulReconnectBufferSize=null==e?void 0:e.bufferSize,this}},{key:"build",value:function(){var e=this.httpConnectionOptions||{};if(void 0===e.logger&&(e.logger=this.logger),!this.url)throw new Error("The 'HubConnectionBuilder.withUrl' method must be called before building the connection.");var t=new ne(this.url,e);return U.create(t,this.logger||p.instance,this.protocol||new ie,this.reconnectPolicy,this._serverTimeoutInMilliseconds,this._keepAliveIntervalInMilliseconds,this._statefulReconnectBufferSize)}}])}();return Uint8Array.prototype.indexOf||Object.defineProperty(Uint8Array.prototype,"indexOf",{value:Array.prototype.indexOf,writable:!0}),Uint8Array.prototype.slice||Object.defineProperty(Uint8Array.prototype,"slice",{value:function(e,t){return new Uint8Array(Array.prototype.slice.call(this,e,t))},writable:!0}),Uint8Array.prototype.forEach||Object.defineProperty(Uint8Array.prototype,"forEach",{value:Array.prototype.forEach,writable:!0}),t}()}); diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs index e0e94614d..d8f68ac7c 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ActivityAssignmentServiceTests.cs @@ -1,6 +1,7 @@ using CrestApps.OrchardCore.ContactCenter.Core.Models; using CrestApps.OrchardCore.ContactCenter.Core.Services; using CrestApps.OrchardCore.ContactCenter.Models; +using Microsoft.Extensions.Logging.Abstractions; using Moq; using OrchardCore.Locking.Distributed; using OrchardCore.Modules; @@ -215,7 +216,8 @@ private static ActivityAssignmentService CreateService( businessHours.Object, new Mock().Object, distributedLock.Object, - clock.Object); + clock.Object, + NullLogger.Instance); } private static Mock CreateDistributedLock(bool locked) diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs index 36f0b2c56..42ab770db 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/AgentPresenceManagerServiceTests.cs @@ -262,6 +262,46 @@ public async Task StartWrapUpAsync_MovesBusyAgentIntoWrapUp() Assert.Null(profile.ActiveReservationId); } + [Fact] + public async Task StartWrapUpAsync_WhenAlreadyInWrapUp_DoesNotPublishDuplicateChange() + { + // Arrange + var existing = new AgentProfile + { + ItemId = "a1", + UserId = "u1", + PresenceStatus = AgentPresenceStatus.WrapUp, + }; + + var agentManager = new Mock(); + agentManager.Setup(m => m.FindByIdAsync("a1", It.IsAny())).ReturnsAsync(existing); + var publisher = new Mock(); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(_now); + var service = new AgentPresenceManagerService( + agentManager.Object, + [], + [], + publisher.Object, + CreateDistributedLock().Object, + clock.Object, + new Mock>().Object); + + // Act + var profile = await service.StartWrapUpAsync("a1", TestContext.Current.CancellationToken); + + // Assert + Assert.Same(existing, profile); + agentManager.Verify( + m => m.UpdateAsync(It.IsAny(), null, It.IsAny()), + Times.Never); + publisher.Verify( + m => m.PublishAsync( + It.IsAny(), + It.IsAny()), + Times.Never); + } + [Fact] public async Task CompleteWorkAsync_WhenBreakRequested_AppliesBreak() { diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs index 9f21df663..baaa5ea17 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderCallStateSynchronizationServiceTests.cs @@ -195,6 +195,59 @@ public async Task RefreshInteractionAsync_WhenProviderAndSessionMatch_DoesNotRep Times.Never); } + [Fact] + public async Task RefreshInteractionAsync_WhenTerminalSessionHasNonTerminalInteraction_RepairsInteractionAndOfferState() + { + // Arrange + var interaction = CreateInteraction(); + interaction.Status = InteractionStatus.Ringing; + var callSessionManager = new Mock(); + callSessionManager + .Setup(manager => manager.FindByInteractionIdAsync("interaction-1", It.IsAny())) + .ReturnsAsync(new CallSession + { + ItemId = "session-1", + InteractionId = "interaction-1", + ProviderCallId = "call-1", + State = ContactCenterCallState.Ended, + StartedUtc = _now.AddMinutes(-2), + EndedUtc = _now.AddMinutes(-1), + }); + var interactionManager = new Mock(); + var eventService = new Mock(); + var offerSynchronizationService = new Mock(); + var resolver = new Mock(); + var service = CreateService( + interactionManager, + callSessionManager, + eventService, + offerSynchronizationService, + resolver); + + // Act + var refreshed = await service.RefreshInteractionAsync(interaction, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(InteractionStatus.Ended, refreshed.Status); + Assert.Equal(_now.AddMinutes(-2), refreshed.StartedUtc); + Assert.Equal(_now.AddMinutes(-1), refreshed.EndedUtc); + interactionManager.Verify( + manager => manager.UpdateAsync( + It.Is(value => value.Status == InteractionStatus.Ended), + It.IsAny(), + It.IsAny()), + Times.Once); + offerSynchronizationService.Verify( + service => service.ReconcileEndedOfferAsync("interaction-1", It.IsAny()), + Times.Once); + eventService.Verify( + service => service.IngestAsync(It.IsAny(), It.IsAny()), + Times.Never); + resolver.Verify( + value => value.GetAsync(It.IsAny()), + Times.Never); + } + [Fact] public async Task TenantActivation_PerformsImmediateProviderReconciliation() { @@ -279,6 +332,32 @@ private static ProviderCallStateSynchronizationService CreateService( interactionManager.Object, callSessionManager.Object, eventService.Object, + new Mock().Object, + resolver.Object, + distributedLock.Object, + clock.Object, + NullLogger.Instance); + } + + private static ProviderCallStateSynchronizationService CreateService( + Mock interactionManager, + Mock callSessionManager, + Mock eventService, + Mock offerSynchronizationService, + Mock resolver) + { + var distributedLock = new Mock(); + distributedLock + .Setup(value => value.TryAcquireLockAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((null, true)); + var clock = new Mock(); + clock.SetupGet(value => value.UtcNow).Returns(_now); + + return new ProviderCallStateSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + eventService.Object, + offerSynchronizationService.Object, resolver.Object, distributedLock.Object, clock.Object, diff --git a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs index 125797f59..043e40b23 100644 --- a/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs +++ b/tests/CrestApps.OrchardCore.Tests/Modules/ContactCenter/ProviderVoiceOfferSynchronizationServiceTests.cs @@ -20,6 +20,7 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue ActivityItemId = "act1", AgentId = "agent-1", Status = InteractionStatus.Ended, + AnsweredUtc = new DateTime(2026, 7, 10, 11, 59, 0, DateTimeKind.Utc), }; var session = new CallSession { @@ -28,6 +29,7 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue ActivityItemId = "act1", AgentId = "agent-1", State = ContactCenterCallState.Ended, + AnsweredUtc = interaction.AnsweredUtc, }; var queueItem = new QueueItem { @@ -47,7 +49,7 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue { ItemId = "agent-1", ActiveReservationId = "res-1", - PresenceStatus = AgentPresenceStatus.Reserved, + PresenceStatus = AgentPresenceStatus.WrapUp, QueueIds = ["queue-1"], }; var activity = new OmnichannelActivity @@ -87,6 +89,7 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue reservationManager.Object, agentManager.Object, activityManager.Object, + CreateServiceProvider(), clock.Object, logger.Object); @@ -123,6 +126,75 @@ public async Task ReconcileEndedOfferAsync_WhenPreConnectOfferEnded_RemovesQueue Times.Once); } + [Fact] + public async Task ReconcileEndedOfferAsync_WhenTerminalInteractionIsStillWaiting_RemovesQueueItem() + { + // Arrange + var interaction = new Interaction + { + ItemId = "int1", + ActivityItemId = "act1", + Status = InteractionStatus.Ended, + }; + var queueItem = new QueueItem + { + ItemId = "queue-1", + ActivityItemId = "act1", + Status = QueueItemStatus.Waiting, + }; + var activity = new OmnichannelActivity + { + ItemId = "act1", + AssignmentStatus = ActivityAssignmentStatus.Available, + }; + + var interactionManager = new Mock(); + interactionManager.Setup(m => m.FindByIdAsync("int1", It.IsAny())).ReturnsAsync(interaction); + var callSessionManager = new Mock(); + callSessionManager + .Setup(m => m.FindByInteractionIdAsync("int1", It.IsAny())) + .ReturnsAsync(new CallSession + { + InteractionId = "int1", + State = ContactCenterCallState.Ended, + }); + var queueItemManager = new Mock(); + queueItemManager.Setup(m => m.FindByActivityIdAsync("act1", It.IsAny())).ReturnsAsync(queueItem); + var reservationManager = new Mock(); + reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync([]); + var activityManager = new Mock(); + activityManager.Setup(m => m.FindByIdAsync("act1", It.IsAny())).ReturnsAsync(activity); + var clock = new Mock(); + clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); + var service = new ProviderVoiceOfferSynchronizationService( + interactionManager.Object, + callSessionManager.Object, + queueItemManager.Object, + reservationManager.Object, + new Mock().Object, + activityManager.Object, + CreateServiceProvider(), + clock.Object, + new Mock>().Object); + + // Act + await service.ReconcileEndedOfferAsync("int1", TestContext.Current.CancellationToken); + + // Assert + queueItemManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.Status == QueueItemStatus.Removed && value.DequeuedUtc.HasValue), + null, + It.IsAny()), + Times.Once); + activityManager.Verify( + m => m.UpdateAsync( + It.Is(value => value.AssignmentStatus == ActivityAssignmentStatus.Released), + null, + It.IsAny()), + Times.Once); + } + [Fact] public async Task ReconcileEndedOfferAsync_WhenAnsweredCallEnded_CompletesAssignedQueueItem() { @@ -167,6 +239,7 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallEnded_CompletesAssign var reservationManager = new Mock(); reservationManager.Setup(m => m.ListActiveByActivityAsync("act1", It.IsAny())).ReturnsAsync([]); var agentManager = new Mock(); + var presenceManager = new Mock(); var activityManager = new Mock(); var service = new ProviderVoiceOfferSynchronizationService( interactionManager.Object, @@ -175,6 +248,7 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallEnded_CompletesAssign reservationManager.Object, agentManager.Object, activityManager.Object, + CreateServiceProvider(presenceManager.Object), clock.Object, new Mock>().Object); @@ -197,6 +271,9 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallEnded_CompletesAssign activityManager.Verify( m => m.UpdateAsync(It.IsAny(), null, It.IsAny()), Times.Never); + presenceManager.Verify( + m => m.StartWrapUpAsync("agent-1", It.IsAny()), + Times.Once); } [Fact] @@ -251,6 +328,7 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallTransferred_Completes reservationManager.Object, new Mock().Object, new Mock().Object, + CreateServiceProvider(), clock.Object, new Mock>().Object); @@ -333,6 +411,7 @@ public async Task ReconcileEndedOfferAsync_WhenMultipleReservationsExist_Cancels reservationManager.Object, agentManager.Object, activityManager.Object, + CreateServiceProvider(), clock.Object, new Mock>().Object); @@ -402,6 +481,7 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallHasLingeringReservati var agentManager = new Mock(); agentManager.Setup(m => m.FindByIdAsync("agent-1", It.IsAny())).ReturnsAsync(agent); + var presenceManager = new Mock(); var clock = new Mock(); clock.SetupGet(c => c.UtcNow).Returns(new DateTime(2026, 7, 10, 12, 0, 0, DateTimeKind.Utc)); @@ -413,6 +493,7 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallHasLingeringReservati reservationManager.Object, agentManager.Object, new Mock().Object, + CreateServiceProvider(presenceManager.Object), clock.Object, new Mock>().Object); @@ -432,11 +513,18 @@ public async Task ReconcileEndedOfferAsync_WhenAnsweredCallHasLingeringReservati null, It.IsAny()), Times.Once); - agentManager.Verify( - m => m.UpdateAsync( - It.Is(value => value.ActiveReservationId == null && value.PresenceStatus == AgentPresenceStatus.WrapUp), - null, - It.IsAny()), + presenceManager.Verify( + m => m.StartWrapUpAsync("agent-1", It.IsAny()), Times.Once); } + + private static IServiceProvider CreateServiceProvider(IAgentPresenceManager presenceManager = null) + { + var serviceProvider = new Mock(); + serviceProvider + .Setup(provider => provider.GetService(typeof(IAgentPresenceManager))) + .Returns(presenceManager ?? new Mock().Object); + + return serviceProvider.Object; + } }