diff --git a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs index 70214c011..e98275dc5 100644 --- a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs @@ -17,44 +17,45 @@ public class DepartmentActionHandler : IChatbotActionHandler private readonly IUserProfileService _userProfileService; private readonly IUsersService _usersService; private readonly ILimitsService _limitsService; + private readonly IChatbotDepartmentConfigService _departmentConfigService; public DepartmentActionHandler( IDepartmentsService departmentsService, IUserProfileService userProfileService, IUsersService usersService, - ILimitsService limitsService) + ILimitsService limitsService, + IChatbotDepartmentConfigService departmentConfigService) { _departmentsService = departmentsService; _userProfileService = userProfileService; _usersService = usersService; _limitsService = limitsService; + _departmentConfigService = departmentConfigService; } /// - /// The user's non-deleted memberships restricted to departments that support SMS (i.e. are on a - /// non-free plan). SMS operations — including switching — are only offered for these, so the list - /// the user sees and the indexes they pick from are the SMS-supporting departments, in a stable order. + /// The user's non-deleted memberships restricted to departments the CHATBOT can serve here: on a + /// plan that supports SMS AND with the chatbot enabled for the current platform (per-department + /// config; no config row means enabled). Listing/switching to a department whose chatbot is + /// disabled would strand the user behind the ingress config gate with no in-band way back. The + /// legacy SMS SWITCH command uses the unfiltered plan-based list — switching is account-wide and + /// a chatbot-disabled department is still valid for legacy text commands. /// - private async Task> GetSmsSupportingMembershipsAsync(string userId) + private async Task> GetSmsSupportingMembershipsAsync(string userId, ChatbotPlatform platform) { - var allMemberRecords = await _departmentsService.GetAllDepartmentsForUserAsync(userId); - if (allMemberRecords == null || allMemberRecords.Count == 0) - return new System.Collections.Generic.List(); - - var candidates = allMemberRecords - .Where(m => !m.IsDeleted) - .OrderByDescending(m => m.IsActive) - .ThenBy(m => m.DepartmentId) - .ToList(); - - var supported = new System.Collections.Generic.List(); - foreach (var membership in candidates) + // Same base list (and order) as the legacy SMS surfaces, then chatbot-eligibility filtering. + // The ingress restricted mode applies the identical filter, so the numbered options it shows + // map to the same memberships this handler resolves. + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(userId); + + var usable = new System.Collections.Generic.List(); + foreach (var membership in supported) { - if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) - supported.Add(membership); + if (await _departmentConfigService.IsChatbotUsableForDepartmentAsync(membership.DepartmentId, platform)) + usable.Add(membership); } - return supported; + return usable; } public ChatbotIntentType IntentType => ChatbotIntentType.ListDepartments; @@ -86,8 +87,9 @@ private async Task ListDepartmentsAsync(ChatbotSession session) var culture = session.Culture; try { - // Only SMS-supporting (non-free plan) departments are switchable, so only list those. - var activeMemberships = await GetSmsSupportingMembershipsAsync(session.UserId); + // Only departments the chatbot can serve here (non-free plan + chatbot enabled for this + // platform) are switchable, so only list those. + var activeMemberships = await GetSmsSupportingMembershipsAsync(session.UserId, session.Platform); if (activeMemberships.Count == 0) return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoActiveMemberships", culture), Processed = true }; @@ -185,9 +187,10 @@ private async Task SwitchDepartmentAsync(ChatbotIntent intent, if (string.IsNullOrWhiteSpace(departmentIdentifier)) return new ChatbotResponse { Text = ChatbotResources.Get("Dept_SwitchSpecify", culture), Processed = true }; - // Switching is limited to SMS-supporting (non-free plan) departments — the same set, in the - // same order, that ListDepartments shows, so a numeric pick maps to the displayed list. - var activeMemberships = await GetSmsSupportingMembershipsAsync(session.UserId); + // Switching is limited to chatbot-usable (non-free plan, chatbot enabled for this platform) + // departments — the same set, in the same order, that ListDepartments shows, so a numeric + // pick maps to the displayed list. + var activeMemberships = await GetSmsSupportingMembershipsAsync(session.UserId, session.Platform); if (activeMemberships.Count == 0) return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoActiveMemberships", culture), Processed = true }; diff --git a/Core/Resgrid.Chatbot/Interfaces/IChatbotDepartmentConfigService.cs b/Core/Resgrid.Chatbot/Interfaces/IChatbotDepartmentConfigService.cs index 4f42e6889..cf6b4054a 100644 --- a/Core/Resgrid.Chatbot/Interfaces/IChatbotDepartmentConfigService.cs +++ b/Core/Resgrid.Chatbot/Interfaces/IChatbotDepartmentConfigService.cs @@ -26,6 +26,14 @@ public interface IChatbotDepartmentConfigService /// Task SaveConfigAsync(ChatbotDepartmentConfig config, string newPlaintextLlmKey = null); + /// + /// Whether the chatbot is usable for a department on the given platform: no config row means + /// system defaults (enabled, all platforms); otherwise the row's IsEnabled and AllowedPlatforms + /// both have to permit it. Used to keep switch/list targets to departments the chatbot can + /// actually serve on the requesting platform. + /// + Task IsChatbotUsableForDepartmentAsync(int departmentId, ChatbotPlatform platform); + Task InvalidateCacheAsync(int departmentId); } } diff --git a/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs b/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs index 3315832f0..69e58a38b 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotDepartmentConfigService.cs @@ -49,6 +49,35 @@ public async Task GetConfigAsync(int departmentId, bool return await fetch(); } + public async Task IsChatbotUsableForDepartmentAsync(int departmentId, ChatbotPlatform platform) + { + var config = await GetConfigAsync(departmentId); + if (config == null) + return true; // No row: system defaults — enabled, all platforms. + + if (!config.IsEnabled) + return false; + + return IsPlatformAllowed(config.AllowedPlatforms, platform); + } + + // Mirrors ChatbotIngressService.IsPlatformAllowed: null/blank/"*" = all platforms, otherwise a + // comma-separated list of ChatbotPlatform names. + private static bool IsPlatformAllowed(string allowedPlatforms, ChatbotPlatform platform) + { + if (string.IsNullOrWhiteSpace(allowedPlatforms) || allowedPlatforms.Trim() == "*") + return true; + + var platformName = platform.ToString(); + foreach (var entry in allowedPlatforms.Split(',')) + { + if (string.Equals(entry.Trim(), platformName, StringComparison.OrdinalIgnoreCase)) + return true; + } + + return false; + } + public async Task GetLlmOverrideAsync(int departmentId) { var config = await GetConfigAsync(departmentId); diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 4d134c444..4d3307b74 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -153,19 +153,43 @@ await _userIdentityService.LinkUserAsync( message.Platform, new ChatbotIntent { Type = ChatbotIntentType.Unknown }); } - // 3b. Check department plan supports chatbot features + // 3b. Check the ACTIVE department's plan supports chatbot features. When it doesn't but the + // user belongs to other departments that do, don't hard-block: they must still be able to + // list departments and SWITCH their active department (restricted mode, enforced after + // intent classification below). Only when there is no supported alternative is this a dead end. var isAuthorized = await _limitsService.CanDepartmentProvisionNumberAsync(department.DepartmentId); + List switchableDepartments = null; if (!isAuthorized) { - return await _templateRenderer.RenderResponseAsync("error", - new Services.ErrorModel { Message = "Your department's plan does not support chatbot features. Please upgrade your plan." }, - message.Platform, new ChatbotIntent { Type = ChatbotIntentType.Unknown }); + // A switch target must be one the chatbot can actually serve on this platform (plan + // supports SMS AND chatbot enabled per department config) — the same filter + // DepartmentActionHandler applies, so the numbered options shown below map to the + // memberships its switch handler resolves. Plan-only filtering could strand the user in + // a department whose chatbot config blocks everything, with no in-band way back. + var smsSupported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(identity.UserId) + ?? new List(); + + switchableDepartments = new List(); + foreach (var membership in smsSupported) + { + if (await _departmentConfigService.IsChatbotUsableForDepartmentAsync(membership.DepartmentId, message.Platform)) + switchableDepartments.Add(membership); + } + + if (switchableDepartments.Count == 0) + { + return await _templateRenderer.RenderResponseAsync("error", + new Services.ErrorModel { Message = "Your department's plan does not support chatbot features. Please upgrade your plan." }, + message.Platform, new ChatbotIntent { Type = ChatbotIntentType.Unknown }); + } } // 3c. Per-department configuration gates (when a config row exists; otherwise the // system defaults apply and the chatbot stays enabled for backward compatibility). + // Skipped in restricted (switch-only) mode: the unsupported department's config must not + // block the user from switching OUT of it. var deptConfig = await _departmentConfigService.GetConfigAsync(department.DepartmentId); - if (deptConfig != null) + if (deptConfig != null && isAuthorized) { if (!deptConfig.IsEnabled) { @@ -224,6 +248,40 @@ await _userIdentityService.LinkUserAsync( if (session.RecentMessages.Count > 20) session.RecentMessages.RemoveAt(0); + // Restricted (switch-only) mode: the active department's plan doesn't support the chatbot + // but the user belongs to other departments that do. Only department list/switch/active + // intents are honored — so the user can move to a supported department — and anything else + // gets the switch options. Confirmation/PIN/continuation states are intentionally skipped: + // no destructive intent can be pending for a department that can't dispatch them. + if (!isAuthorized) + { + var restrictedIntent = await _intentRouter.ClassifyIntentAsync(message, session); + if (restrictedIntent.Type == ChatbotIntentType.ListDepartments + || restrictedIntent.Type == ChatbotIntentType.SwitchDepartment + || restrictedIntent.Type == ChatbotIntentType.GetActiveDepartment) + { + var restrictedHandler = _actionHandlers.FirstOrDefault(h => h.CanHandle(restrictedIntent.Type)); + if (restrictedHandler != null) + { + var restrictedResponse = await restrictedHandler.HandleAsync(message, restrictedIntent, session); + restrictedResponse.Intent = restrictedIntent; + await _sessionManager.SaveSessionAsync(session); + return restrictedResponse; + } + } + + var optionsBuilder = new System.Text.StringBuilder(); + optionsBuilder.AppendLine("Your active department's plan does not support chatbot features, but you belong to other departments that do:"); + for (int i = 0; i < switchableDepartments.Count; i++) + { + var switchableDept = await _departmentsService.GetDepartmentByIdAsync(switchableDepartments[i].DepartmentId); + optionsBuilder.AppendLine($"{i + 1}: {switchableDept?.Name ?? ("Department " + switchableDepartments[i].DepartmentId)}"); + } + optionsBuilder.Append("Reply SWITCH to change your active department."); + + return new ChatbotResponse { Text = optionsBuilder.ToString(), Processed = true }; + } + // 5. Handle session state machine via ConversationEngine (Phase 2) // 5a. Confirmation of a destructive action (CloseCall, DispatchCall, SetUnitStatus, ...). diff --git a/Core/Resgrid.Model/Services/IDepartmentsService.cs b/Core/Resgrid.Model/Services/IDepartmentsService.cs index 593926558..9e813dd51 100644 --- a/Core/Resgrid.Model/Services/IDepartmentsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentsService.cs @@ -71,15 +71,20 @@ Task AddUserToDepartmentAsync(int departmentId, string userId, Task GetDepartmentByUserIdAsync(string userId, bool bypassCache = false); /// - /// Resolves the department to use for a user's SMS/chatbot operations: their active (then default, - /// then first) membership, preferring one whose plan supports SMS (non-free). If the preferred - /// department is on a free plan but another of the user's departments supports SMS, that one is - /// returned so a user isn't dead-ended; if none support SMS the preferred one is returned so the - /// downstream plan gate can produce the proper "plan doesn't support" message. Null when the user has - /// no non-deleted memberships. + /// Resolves the department to use for a user's SMS/chatbot operations: their ACTIVE (then default, + /// then first) membership. No plan-based auto-picking — if the active department's plan doesn't + /// support SMS the downstream plan gate handles it (offering a department switch when the user has + /// other supported memberships). Null when the user has no non-deleted memberships. /// Task GetActiveSmsDepartmentForUserAsync(string userId, bool bypassCache = false); + /// + /// The user's non-deleted memberships restricted to departments whose plan supports SMS/chatbot + /// (non-free), in a stable order (active first, then by department id) so a numeric pick from a + /// displayed list maps to the same membership across stateless SMS requests. + /// + Task> GetSmsSupportedMembershipsForUserAsync(string userId); + Task GetValidateUserForDepartmentInfoAsync(string userName, bool bypassCache = true); diff --git a/Core/Resgrid.Model/Services/ITextDepartmentSwitchService.cs b/Core/Resgrid.Model/Services/ITextDepartmentSwitchService.cs new file mode 100644 index 000000000..98fdfcf92 --- /dev/null +++ b/Core/Resgrid.Model/Services/ITextDepartmentSwitchService.cs @@ -0,0 +1,30 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Shared handling for the inbound-SMS department SWITCH flow — used by both the Twilio and + /// SignalWire controllers so the plan-gate/STOP/switch behavior and reply wording stay identical + /// across providers. Methods return the reply body text; the caller wraps it in the + /// provider-specific response (TwiML or LaML). + /// + public interface ITextDepartmentSwitchService + { + /// + /// The identified user's ACTIVE department failed the inbound-text plan gate. STOP is honored + /// first (opt-out must always work), a SWITCH command is processed, any other text gets the + /// switch options, and with no supported alternatives the plan message. Never returns null. + /// + Task BuildUnsupportedActiveDepartmentResponseAsync(string messageText, int departmentId, UserProfile profile, + string logPrefix, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Processes a SWITCH [name/number] command: changes the user's active department when the + /// identifier resolves to one of their SMS-supported memberships (list index, department id, + /// then exact/partial name or code). Empty identifier returns the options list. Never returns null. + /// + Task BuildSwitchCommandResponseAsync(UserProfile profile, string departmentIdentifier, + string logPrefix, CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Model/TextCommandTypes.cs b/Core/Resgrid.Model/TextCommandTypes.cs index 5f54dac71..88f022ad8 100644 --- a/Core/Resgrid.Model/TextCommandTypes.cs +++ b/Core/Resgrid.Model/TextCommandTypes.cs @@ -12,6 +12,7 @@ public enum TextCommandTypes Units = 7, MyStatus = 8, CustomAction = 9, - CustomStaffing = 10 + CustomStaffing = 10, + SwitchDepartment = 11 } } \ No newline at end of file diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index 736958c79..c87cdcdff 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -443,32 +443,46 @@ public async Task GetActiveSmsDepartmentForUserAsync(string userId, if (allMembers == null || allMembers.Count == 0) return null; - // Prefer the member marked IsActive, then IsDefault, then first. - var ordered = allMembers + // The user's ACTIVE department (IsActive, then IsDefault, then first) — no plan-based + // auto-picking. If the active department's plan doesn't support SMS the caller's plan gate + // handles it (offering a switch when the user has other supported departments) rather than + // this resolver silently acting in a department the user didn't select. + var preferred = allMembers .Where(m => !m.IsDeleted) .OrderByDescending(m => m.IsActive) .ThenByDescending(m => m.IsDefault) - .ToList(); + .FirstOrDefault(); - if (ordered.Count == 0) + if (preferred == null) return null; - var preferred = ordered[0]; + return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); + } - // If the preferred department supports SMS (non-free plan) use it; otherwise auto-pick the first - // of the user's departments that does, so a user whose active department is on the free plan still - // lands in an SMS-capable department. If none support SMS, fall back to the preferred one so the - // caller's plan gate returns the proper "plan doesn't support" message. - var canProvisionNumber = await Task.WhenAll(ordered.Select(membership => - _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId))); + public async Task> GetSmsSupportedMembershipsForUserAsync(string userId) + { + var allMembers = await GetAllDepartmentsForUserAsync(userId); + if (allMembers == null || allMembers.Count == 0) + return new List(); + + // Stable order (active first, then by id) so a numeric pick from a displayed list maps to the + // same membership when the follow-up "switch N" message arrives in a later, stateless request. + // Disabled memberships (IsDisabled is nullable; null means not disabled) are excluded — a user + // deactivated in a department must not be offered it as a switch target. + var candidates = allMembers + .Where(m => !m.IsDeleted && m.IsDisabled != true) + .OrderByDescending(m => m.IsActive) + .ThenBy(m => m.DepartmentId) + .ToList(); - for (var i = 0; i < ordered.Count; i++) + var supported = new List(); + foreach (var membership in candidates) { - if (canProvisionNumber[i]) - return await GetDepartmentByIdAsync(ordered[i].DepartmentId, bypassCache); + if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) + supported.Add(membership); } - return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); + return supported; } diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 44dc5fbf9..5fde5b71a 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -94,6 +94,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.Register(_ => diff --git a/Core/Resgrid.Services/TextCommandService.cs b/Core/Resgrid.Services/TextCommandService.cs index ce255841b..b8a4e1b75 100644 --- a/Core/Resgrid.Services/TextCommandService.cs +++ b/Core/Resgrid.Services/TextCommandService.cs @@ -20,6 +20,19 @@ public TextCommandPayload DetermineType(string message, CustomState customAction if (String.IsNullOrWhiteSpace(message)) return payload; + // SWITCH [department name/number]: change the user's active department. Checked first — the + // custom staffing branch below claims any message starting with 's' and would swallow it. + // Ordinal comparison (culture-sensitive lowercasing breaks "SWITCH" under e.g. the Turkish + // locale) and any whitespace after the command (spaces or tabs) before the argument. + var trimmedMessage = message.Trim(); + if (trimmedMessage.StartsWith("switch", StringComparison.OrdinalIgnoreCase) + && (trimmedMessage.Length == 6 || char.IsWhiteSpace(trimmedMessage[6]))) + { + payload.Type = TextCommandTypes.SwitchDepartment; + payload.Data = trimmedMessage.Length > 6 ? trimmedMessage.Substring(6).Trim() : ""; + return payload; + } + if (customActions != null && customActions.IsDeleted == false && customActions.GetActiveDetails() != null && customActions.GetActiveDetails().Any()) { int actionId = 0; diff --git a/Core/Resgrid.Services/TextDepartmentSwitchService.cs b/Core/Resgrid.Services/TextDepartmentSwitchService.cs new file mode 100644 index 000000000..996fa3b5b --- /dev/null +++ b/Core/Resgrid.Services/TextDepartmentSwitchService.cs @@ -0,0 +1,141 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + /// + /// See . Single implementation of the inbound-SMS + /// department switch flow shared by the Twilio and SignalWire controllers. + /// + public class TextDepartmentSwitchService : ITextDepartmentSwitchService + { + private readonly IDepartmentsService _departmentsService; + private readonly IUserProfileService _userProfileService; + private readonly IUsersService _usersService; + private readonly ITextCommandService _textCommandService; + + public TextDepartmentSwitchService(IDepartmentsService departmentsService, IUserProfileService userProfileService, + IUsersService usersService, ITextCommandService textCommandService) + { + _departmentsService = departmentsService; + _userProfileService = userProfileService; + _usersService = usersService; + _textCommandService = textCommandService; + } + + public async Task BuildUnsupportedActiveDepartmentResponseAsync(string messageText, int departmentId, UserProfile profile, + string logPrefix, CancellationToken cancellationToken = default(CancellationToken)) + { + // STOP always works — checked before the plan/alternatives logic so a verified user whose + // active department is on a free plan can still unsubscribe (telecom opt-out compliance). + var payload = _textCommandService.DetermineType(messageText); + if (payload.Type == TextCommandTypes.Stop) + { + await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId, cancellationToken); + return "Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."; + } + + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(profile.UserId); + if (supported.Count == 0) + { + Logging.LogInfo($"{logPrefix} DepartmentId={departmentId} not authorized for inbound text (plan gate); user {profile.UserId} has no SMS-supported departments; replying with unsupported message."); + return "Resgrid: Inbound text messaging isn't available on your department's current plan. Please upgrade to a paid plan to enable text commands."; + } + + if (payload.Type == TextCommandTypes.SwitchDepartment) + return await BuildSwitchCommandResponseAsync(profile, payload.Data, logPrefix, cancellationToken); + + Logging.LogInfo($"{logPrefix} DepartmentId={departmentId} not authorized for inbound text (plan gate); user {profile.UserId} has {supported.Count} SMS-supported department(s); replying with switch options."); + return await BuildSwitchOptionsMessageAsync(supported, + "Resgrid: Your active department's plan doesn't include inbound text messaging, but you belong to other departments that do."); + } + + public async Task BuildSwitchCommandResponseAsync(UserProfile profile, string departmentIdentifier, + string logPrefix, CancellationToken cancellationToken = default(CancellationToken)) + { + var supported = await _departmentsService.GetSmsSupportedMembershipsForUserAsync(profile.UserId); + if (supported.Count == 0) + return "Resgrid: None of your departments' current plans include inbound text messaging. Please upgrade to a paid plan to enable text commands."; + + if (string.IsNullOrWhiteSpace(departmentIdentifier)) + return await BuildSwitchOptionsMessageAsync(supported, "Resgrid: Your departments that support text messaging:"); + + DepartmentMember target = null; + var trimmedId = departmentIdentifier.Trim(); + + // A small number is a pick from the displayed list; otherwise try a department id, then name/code. + if (int.TryParse(trimmedId, out var listIndex) && listIndex >= 1 && listIndex <= supported.Count) + target = supported[listIndex - 1]; + + if (target == null && int.TryParse(trimmedId, out var deptId)) + target = supported.FirstOrDefault(m => m.DepartmentId == deptId); + + if (target == null) + { + foreach (var membership in supported) + { + // Cached read (bypassCache: false): the default bypasses the cache and pulls the + // department WITH members per iteration — an N+1 query for a name/code comparison. + var dept = await _departmentsService.GetDepartmentByIdAsync(membership.DepartmentId, false); + if (dept == null) + continue; + + if (string.Equals(dept.Name, trimmedId, StringComparison.OrdinalIgnoreCase) + || string.Equals(dept.Code, trimmedId, StringComparison.OrdinalIgnoreCase)) + { + target = membership; + break; + } + + if (target == null && !string.IsNullOrWhiteSpace(dept.Name) && dept.Name.IndexOf(trimmedId, StringComparison.OrdinalIgnoreCase) >= 0) + target = membership; + } + } + + if (target == null) + return await BuildSwitchOptionsMessageAsync(supported, $"Resgrid: Couldn't find a department matching \"{trimmedId}\"."); + + if (target.IsActive) + { + var alreadyActive = await _departmentsService.GetDepartmentByIdAsync(target.DepartmentId, false); + return $"Resgrid: {alreadyActive?.Name ?? "That department"} is already your active department."; + } + + var identityUser = _usersService.GetUserById(profile.UserId); + var success = await _departmentsService.SetActiveDepartmentForUserAsync(profile.UserId, target.DepartmentId, identityUser, cancellationToken); + + var targetDept = await _departmentsService.GetDepartmentByIdAsync(target.DepartmentId, false); + if (success) + { + Logging.LogInfo($"{logPrefix} User {profile.UserId} switched active department to {target.DepartmentId} via text command."); + return $"Resgrid: Your active department is now {targetDept?.Name ?? ("department " + target.DepartmentId)}. Text commands now apply to this department."; + } + + return "Resgrid: Unable to switch departments right now. Please try again later."; + } + + private async Task BuildSwitchOptionsMessageAsync(System.Collections.Generic.List supported, string prefix) + { + var sb = new StringBuilder(); + sb.Append(prefix + Environment.NewLine); + + for (int i = 0; i < supported.Count; i++) + { + // Cached read (bypassCache: false) — the default hits the database (department + members) + // once per listed department just to render its name. + var dept = await _departmentsService.GetDepartmentByIdAsync(supported[i].DepartmentId, false); + var activeMarker = supported[i].IsActive ? " (active)" : ""; + sb.Append($"{i + 1}: {dept?.Name ?? ("Department " + supported[i].DepartmentId)}{activeMarker}" + Environment.NewLine); + } + + sb.Append("Reply SWITCH to change your active department."); + return sb.ToString(); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs index 1ed969c7b..2216903a7 100644 --- a/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs +++ b/Tests/Resgrid.Tests/Web/Services/TwilioControllerVoiceVerificationTests.cs @@ -148,7 +148,8 @@ private TwilioController BuildController() _communicationTestServiceMock.Object, _encryptionServiceMock.Object, _twilioVoiceResponseServiceMock.Object, - _featureToggleServiceMock.Object); + _featureToggleServiceMock.Object, + Mock.Of()); } private static string InvokeBuildDispatchPrompt(Type controllerType, Call call, string address) diff --git a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs index f6b651e51..16f62fac3 100644 --- a/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs +++ b/Web/Resgrid.Web.Services/Controllers/SignalWireController.cs @@ -1,4 +1,4 @@ -using Newtonsoft.Json; +using Newtonsoft.Json; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Models; using Resgrid.Model; @@ -47,13 +47,16 @@ public class SignalWireController : ControllerBase private readonly IUnitsService _unitsService; private readonly ICommunicationTestService _communicationTestService; private readonly IChatbotIngressService _chatbotIngressService; + private readonly IUsersService _usersService; + private readonly ITextDepartmentSwitchService _textDepartmentSwitchService; public SignalWireController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, IUserProfileService userProfileService, ITextCommandService textCommandService, IActionLogsService actionLogsService, IUserStateService userStateService, ICommunicationService communicationService, IGeoLocationProvider geoLocationProvider, IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, - ICommunicationTestService communicationTestService, IChatbotIngressService chatbotIngressService) + ICommunicationTestService communicationTestService, IChatbotIngressService chatbotIngressService, IUsersService usersService, + ITextDepartmentSwitchService textDepartmentSwitchService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -72,6 +75,8 @@ public SignalWireController(IDepartmentSettingsService departmentSettingsService _unitsService = unitsService; _communicationTestService = communicationTestService; _chatbotIngressService = chatbotIngressService; + _usersService = usersService; + _textDepartmentSwitchService = textDepartmentSwitchService; } #endregion Private Readonly Properties and Constructors @@ -168,31 +173,41 @@ public async Task Receive(CancellationToken cancellationToken) try { - UserProfile profile = null; - var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); - - if (!departmentId.HasValue) + // Parity with TwilioController: the sender's profile identifies them and their ACTIVE + // department drives routing. The TextToCallNumber lookup is a legacy path used only when no + // profile matched (dispatch centers sending text-to-call imports, unknown senders). + UserProfile profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); + + // SECURITY: an unverified mobile number may be used for ROUTING (department resolution, + // plan gate) so the sender lands in their real department, but it must never authorize + // ACTIONS. Downstream handling blocks commands for unverified senders and prompts them to + // verify. The single exception is STOP (handled below): opting out must always work. + bool senderNumberUnverified = profile != null && profile.MobileNumberVerified != true; + if (senderNumberUnverified) + Framework.Logging.LogInfo($"[SignalWire SMS] MessageSid={textMessage.MessageId} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; using profile for routing only, actions blocked until verified."); + + int? departmentId = null; + if (profile != null) { - profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - - // SECURITY: an unverified mobile number must never identify/act as the matched user. - // Verification (MobileNumberVerified == true) is the bar for trusting an inbound sender. - if (profile != null && profile.MobileNumberVerified != true) - { - Framework.Logging.LogInfo($"[SignalWire SMS] MessageSid={textMessage.MessageId} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; not linking sender to user."); - profile = null; - } - - if (profile != null) - { - var department = await _departmentsService.GetDepartmentByUserIdAsync(profile.UserId); - - if (department != null) - departmentId = department.DepartmentId; - } + var activeDepartment = await _departmentsService.GetActiveSmsDepartmentForUserAsync(profile.UserId); + if (activeDepartment != null) + departmentId = activeDepartment.DepartmentId; + } + else + { + departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); } - if (departmentId.HasValue) + // STOP from an unverified sender: honor the opt-out immediately — turning off messages must + // always work, regardless of verification, plan or department state. + if (senderNumberUnverified && _textCommandService.DetermineType(textMessage.Text).Type == TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[SignalWire SMS] MessageSid={textMessage.MessageId} unverified sender texted STOP; disabling SMS messaging on the matched profile."); + await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId, cancellationToken); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); + } + else if (departmentId.HasValue) { var department = await _departmentsService.GetDepartmentByIdAsync(departmentId.Value); var textToCallEnabled = await _departmentSettingsService.GetDepartmentIsTextCallImportEnabledAsync(departmentId.Value); @@ -263,17 +278,54 @@ public async Task Receive(CancellationToken cancellationToken) var result = await _chatbotIngressService.ProcessMessageAsync(request); messageEvent.Processed = true; - response = LaMLResponse.Message.Respond(result.Text); + if (result != null && !string.IsNullOrWhiteSpace(result.Text)) + response = LaMLResponse.Message.Respond(result.Text); } } - else if (textMessage.To == Config.NumberProviderConfig.SignalWireResgridNumber.Replace("+", "")) // Resgrid master text number + else if (senderNumberUnverified) + { + // Checked BEFORE the switch offer: switching departments is an action, and an + // unverified sender may not act as the matched user (parity with TwilioController). + Framework.Logging.LogInfo($"[SignalWire SMS] DepartmentId={departmentId.Value} not authorized for inbound text (plan gate) and sender is unverified; replying with verify-number message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + else if (profile != null) + { + // The user's ACTIVE department doesn't support inbound text. STOP is honored, a + // SWITCH command is processed, and other texts get the switch options (or the plan + // message when no supported alternatives exist). Shared with Twilio via the switch service. + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond(await _textDepartmentSwitchService.BuildUnsupportedActiveDepartmentResponseAsync( + textMessage.Text, departmentId.Value, profile, "[SignalWire SMS]", cancellationToken)); + } + else { - var payload = _textCommandService.DetermineType(textMessage.Text); + Framework.Logging.LogInfo($"[SignalWire SMS] DepartmentId={departmentId.Value} not authorized for inbound text (plan gate); replying with unsupported message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: Inbound text messaging isn't available on your department's current plan. Please upgrade to a paid plan to enable text commands."); + } + } + else if (textMessage.To == Config.NumberProviderConfig.SignalWireResgridNumber.Replace("+", "")) // Resgrid master text number + { + var payload = _textCommandService.DetermineType(textMessage.Text); + // A sender whose profile mobile number is unverified can't be identified, so commands + // can't be acted on — direct them to verify instead of replying "unknown command". HELP + // and STOP still work below: neither needs a trusted identity, and STOP must always opt out. + if (profile != null && profile.MobileNumberVerified != true + && payload.Type != TextCommandTypes.Help && payload.Type != TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[SignalWire SMS] Master number: sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; replying with verify-number message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + else + { switch (payload.Type) { case TextCommandTypes.None: - await _communicationService.SendTextMessageAsync(profile.UserId, "Resgrid TCI Help", "Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands.", department.DepartmentId, textMessage.To, profile); + response = LaMLResponse.Message.Respond("Resgrid (https://resgrid.com) Automated Text System. Unknown command, text help for supported commands."); break; case TextCommandTypes.Help: messageEvent.Processed = true; @@ -287,19 +339,32 @@ public async Task Receive(CancellationToken cancellationToken) help.Append("HELP: This help text" + Environment.NewLine); response = LaMLResponse.Message.Respond(help.ToString()); - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", help.ToString(), department.DepartmentId, textMessage.To, profile); break; case TextCommandTypes.Stop: messageEvent.Processed = true; + + if (profile == null) + { + response = LaMLResponse.Message.Respond("Unable to locate your profile. Please log in to Resgrid to manage your text message settings."); + break; + } + await _userProfileService.DisableTextMessagesForUserAsync(profile.UserId, cancellationToken); response = LaMLResponse.Message.Respond("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); - //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", "Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile.", department.DepartmentId, textMessage.To, profile); break; } } } + else if (senderNumberUnverified) + { + // No department resolved and this isn't the master number, but the sender did match a + // profile with an unverified mobile number — tell them the actionable fix. + Framework.Logging.LogInfo($"[SignalWire SMS] No department resolved for sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} (unverified profile match); replying with verify-number message."); + messageEvent.Processed = true; + response = LaMLResponse.Message.Respond("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } //return Ok(new StringContent(response, Encoding.UTF8, "application/xml")); return new ContentResult diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 8ff89bca4..e459ce818 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -55,6 +55,7 @@ public class TwilioController : ControllerBase private readonly IEncryptionService _encryptionService; private readonly ITwilioVoiceResponseService _twilioVoiceResponseService; private readonly IFeatureToggleService _featureToggleService; + private readonly ITextDepartmentSwitchService _textDepartmentSwitchService; public TwilioController(IDepartmentSettingsService departmentSettingsService, INumbersService numbersService, ILimitsService limitsService, ICallsService callsService, IQueueService queueService, IDepartmentsService departmentsService, @@ -63,7 +64,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN IDepartmentGroupsService departmentGroupsService, ICustomStateService customStateService, IUnitsService unitsService, IUsersService usersService, ICalendarService calendarService, ICommunicationTestService communicationTestService, IEncryptionService encryptionService, ITwilioVoiceResponseService twilioVoiceResponseService, - IFeatureToggleService featureToggleService) + IFeatureToggleService featureToggleService, ITextDepartmentSwitchService textDepartmentSwitchService) { _departmentSettingsService = departmentSettingsService; _numbersService = numbersService; @@ -86,6 +87,7 @@ public TwilioController(IDepartmentSettingsService departmentSettingsService, IN _encryptionService = encryptionService; _twilioVoiceResponseService = twilioVoiceResponseService; _featureToggleService = featureToggleService; + _textDepartmentSwitchService = textDepartmentSwitchService; } #endregion Private Readonly Properties and Constructors @@ -143,27 +145,32 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques // inbound number fall back to resolving the department from that number. UserProfile userProfile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - // SECURITY: an unverified mobile number must never identify/act as the matched user — - // anyone can type someone else's number into a profile. Verification (MobileNumberVerified - // == true) is the bar for trusting an inbound sender. + // SECURITY: an unverified mobile number may be used for ROUTING (department resolution, + // plan gate, feature flag) so the sender lands in their real department, but it must never + // authorize ACTIONS — anyone can type someone else's number into a profile. Downstream + // handlers block commands for unverified senders and prompt them to verify. The single + // exception is STOP (handled below): opting out of messages must always work. bool senderNumberUnverified = userProfile != null && userProfile.MobileNumberVerified != true; if (senderNumberUnverified) - { - Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; not linking sender to user."); - userProfile = null; - } + Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; using profile for routing only, actions blocked until verified."); int? departmentId = null; if (userProfile != null) { + // An identified user (verified or not) always operates in their ACTIVE department. The + // texted number must NOT override this — the TextToCallNumber mapping is a legacy path + // that can resolve a department the user isn't even a member of. var department = await _departmentsService.GetActiveSmsDepartmentForUserAsync(userProfile.UserId); if (department != null) departmentId = department.DepartmentId; } - - // Legacy fallback: a per-department provisioned inbound number identifies the department directly. - if (!departmentId.HasValue) + else + { + // Legacy fallback for senders with no usable profile only (dispatch centers sending + // text-to-call imports, unknown numbers): the provisioned inbound number identifies the + // department directly. departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); + } // Carry the resolved department onto the inbound message event so chatbot-routed events // retain the same department context the text-command path records. @@ -173,6 +180,24 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques // Diagnostic: did we resolve a department for this inbound text? If not, no reply is sent. Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} To={textMessage.To} From={Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} resolved DepartmentId={(departmentId.HasValue ? departmentId.Value.ToString() : "none")}"); + // STOP from an unverified sender: honor the opt-out immediately — turning off messages must + // always work, regardless of verification, plan or feature-flag state. + if (senderNumberUnverified && _textCommandService.DetermineType(textMessage.Text).Type == TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[Twilio SMS] MessageSid={request.MessageSid} unverified sender texted STOP; disabling SMS messaging on the matched profile."); + await _userProfileService.DisableTextMessagesForUserAsync(userProfile.UserId); + messageEvent.Processed = true; + response.Message("Text messages are now turned off for this user, to enable again log in to Resgrid and update your profile."); + + // The enclosing finally saves messageEvent and the shared tail returns the TwiML. + return new ContentResult + { + Content = response.ToString(), + ContentType = "application/xml", + StatusCode = 200 + }; + } + // Feature-flagged rollout: the chatbot ingress is the new path. When the flag is off // (globally or for this department) fall back to the original text-command handling so // existing behavior is preserved. @@ -361,6 +386,7 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t help.Append("CALLS: List active calls" + Environment.NewLine); help.Append("C[CallId]: Get Call Detail i.e. C1445" + Environment.NewLine); help.Append("UNITS: List unit statuses" + Environment.NewLine); + help.Append("SWITCH [name or number]: Change your active department" + Environment.NewLine); help.Append("---------------------" + Environment.NewLine); help.Append("Status or Action Commands" + Environment.NewLine); help.Append("---------------------" + Environment.NewLine); @@ -406,6 +432,10 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t //_communicationService.SendTextMessage(profile.UserId, "Resgrid TCI Help", help.ToString(), department.DepartmentId, textMessage.To, profile); break; + case TextCommandTypes.SwitchDepartment: + messageEvent.Processed = true; + response.Message(await _textDepartmentSwitchService.BuildSwitchCommandResponseAsync(profile, payload.Data, "[Twilio SMS]")); + break; case TextCommandTypes.Action: messageEvent.Processed = true; await _actionLogsService.SetUserActionAsync(profile.UserId, department.DepartmentId, (int)payload.GetActionType()); @@ -548,6 +578,24 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t } } } + else if (senderNumberUnverified) + { + // Checked BEFORE the switch offer: switching departments is an action, and an unverified + // sender may not act as the matched user. Their profile routed them to the right + // department; verifying the number is the prerequisite for everything else. + Framework.Logging.LogInfo($"[Twilio SMS] DepartmentId={departmentId.Value} not authorized for inbound text (plan gate) and sender is unverified; replying with verify-number message."); + messageEvent.Processed = true; + response.Message("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } + else if (userProfile != null) + { + // The user's ACTIVE department doesn't support inbound text. STOP is honored, a SWITCH + // command is processed, and other texts get the switch options (or the plan message + // when no supported alternatives exist). Shared with SignalWire via the switch service. + messageEvent.Processed = true; + response.Message(await _textDepartmentSwitchService.BuildUnsupportedActiveDepartmentResponseAsync( + textMessage.Text, departmentId.Value, userProfile, "[Twilio SMS]")); + } else { // Department resolved but its plan doesn't include inbound text messaging (only the free @@ -562,6 +610,18 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t var profile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); var payload = _textCommandService.DetermineType(textMessage.Text); + // A sender whose profile mobile number is unverified can't be identified, so commands can't + // be acted on — direct them to verify instead of replying "unknown command". HELP and STOP + // still work below: neither needs a trusted identity, and STOP must always opt out. + if (profile != null && profile.MobileNumberVerified != true + && payload.Type != TextCommandTypes.Help && payload.Type != TextCommandTypes.Stop) + { + Framework.Logging.LogInfo($"[Twilio SMS] Master number: sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} matched a profile but the mobile number is not verified; replying with verify-number message."); + messageEvent.Processed = true; + response.Message("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + return; + } + switch (payload.Type) { case TextCommandTypes.None: @@ -596,6 +656,15 @@ private async System.Threading.Tasks.Task ProcessTextCommandsAsync(TextMessage t break; } } + else if (senderNumberUnverified) + { + // No department resolved and this isn't the master number, but the sender did match a + // profile with an unverified mobile number. Previously this was silence; tell them the + // actionable fix instead. + Framework.Logging.LogInfo($"[Twilio SMS] No department resolved for sender {Framework.StringHelpers.MaskPhoneNumber(textMessage.Msisdn)} (unverified profile match); replying with verify-number message."); + messageEvent.Processed = true; + response.Message("Resgrid: This mobile number matches a Resgrid profile but hasn't been verified. Please verify your mobile number on your Resgrid profile page to use text commands."); + } } [HttpGet("VoiceCall")] diff --git a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml index d2d3eaabc..dd9c0cd55 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Subscription/Index.cshtml @@ -237,276 +237,278 @@
-
-
-
-
-
-
-

- @Model.Plan.Name +
+
+
+
+
+
+

+ @Model.Plan.Name - @if (Model.Payment.Cancelled) - { - @localizer["Canceled"] - } - else - { - @localizer["Active"] - } -

@if (Model.Payment.Cancelled) { - @localizer["PlanExpires"]: @Model.Expires + @localizer["Canceled"] } else { - @localizer["PlanRenews"]: @Model.Expires + @localizer["Active"] } -
+

+ @if (Model.Payment.Cancelled) + { + @localizer["PlanExpires"]: @Model.Expires + } + else + { + @localizer["PlanRenews"]: @Model.Expires + }
-
-
-

- @if (Model.Plan.Frequency == (int)PlanFrequency.Yearly) - { - - @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["Year"] - - } - else - { - - @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["month"] - - } -

+
+
+
+

+ @if (Model.Plan.Frequency == (int)PlanFrequency.Yearly) + { + + @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["Year"] + + } + else + { + + @currencySymbol@Model.Plan.Cost.ToString("N2")/@localizer["month"] + + } +

+
+
+
+
+

+ @Model.PersonnelCount/@Model.PersonnelLimit + @localizer["Entities"] +

+
+
+
+ + @if (ViewBag.SubscriptionErrorMessage != null) + {
-

- @Model.PersonnelCount/@Model.PersonnelLimit - @localizer["Entities"] -

-
-
+
+

@localizer["Warning"]

+ @ViewBag.SubscriptionErrorMessage
- - @if (ViewBag.SubscriptionErrorMessage != null) - { -
-
-
-

@localizer["Warning"]

- @ViewBag.SubscriptionErrorMessage + } +
+ + +
+
+
+
+
+
-
- } -
+
+
+
+ @if (Model.Plan == null || Model.Plan.PlanId == 1) + { +

Select the number of Entities (Users + Units) you require using the slider or text box below. + @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } + additional pack of 10 entities is billed at the rate shown. Select "Buy Yearly" or "Buy Monthly" to proceed to checkout.

-
-
-
-
-
- -
-
-
-
-
- - @if (Model.Plan == null || Model.Plan.PlanId == 1) - { -

Select the number of Entities (Users + Units) you require using the slider or text box below. - @if (!isEU) { Your first 10 entities are included at no charge — each } else { Each } - additional pack of 10 entities is billed at the rate shown. Select "Buy Yearly" or "Buy Monthly" to proceed to checkout.

- @if (isEU) - { -
- European Region — Pricing includes GDPR-compliant data hosting. All prices shown in EUR (@currencySymbol) with a regional adjustment. No free tier is available. -
- } +
-
+
- - -
-

Entities

- Users or Units sold in packs of 10 -
-
-
- - - -
+
+

Entities

+ Users or Units sold in packs of 10 +
+
+
+ + +
+
-
-
-
- Entities -
+
+
+
+ Entities
-
- - Monthly billing amount -
- -

0.00

-
+
+
+ + Monthly billing amount +
+ +

0.00

-
- - Yearly (annual) billing amount -
- -

0.00

-
+
+
+ + Yearly (annual) billing amount +
+ +

0.00

-
-
-
- @if (Model.IsPaddleDepartment) { - @localizer["BuyYearly"] - } else { - @localizer["BuyYearly"] - } -
-
- @if (Model.IsPaddleDepartment) { - @localizer["BuyMonthly"] - } else { - @localizer["BuyMonthly"] - } -
+
+
+
+
+ @if (Model.IsPaddleDepartment) { + @localizer["BuyYearly"] + } else { + @localizer["BuyYearly"] + } +
+
+ @if (Model.IsPaddleDepartment) { + @localizer["BuyMonthly"] + } else { + @localizer["BuyMonthly"] + }
-
- +
-
- } - else if (Model.Plan.PlanId >= 36) - { -
-

Active Subscription

- You currently have an activate subscription, thank you! To update the amount of entities you need, update your billing information, or cancel your subscription please use the "Manage Your Subscription" button. If you are a Invoice customer, please contact us to update your subscription. + + +
+ } + else if (Model.Plan.PlanId >= 36) + { +
+

Active Subscription

+ You currently have an activate subscription, thank you! To update the amount of entities you need, update your billing information, or cancel your subscription please use the "Manage Your Subscription" button. If you are a Invoice customer, please contact us to update your subscription. +
+ +
+ @if (Model.IsPaddleDepartment) + { + @localizer["CancelSub"] + } + else if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) + { + @localizer["ManageYourSubscription"] + } +
+ } + else if (Model.Plan.PlanId != 1) + { +
+

Active Legacy Subscription

+ You currently have an active legacy subscription. To change over to the Entity based subscription, please cancel your current subscription and then sign up for a new one. If you are a Invoice customer, please contact us to update your subscription. Click on the "Legacy Plan" tab to see more information about your current plan. +
+ } +
+
+
+
+
+

@localizer["Warning"]

+ You are on a Legacy Plan. We do not offer these plans anymore. You can stay on this plan as long as you wish, but you will not be able to upgrade or downgrade your plan. If you wish to change your plan, you will need to cancel your current plan and then sign up for a new plan. We do not raise prices on departments with active billing relationships, so you can stay on this plan as long as you wish, you will need to use the "Change Billing Info" button below if you are using a Credit Card to pay. If you bill via Invoice your cost will stay the same if you renew.
- @if (Model.IsPaddleDepartment) + @if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) { + @localizer["ChangeBillingInfo"] @localizer["CancelSub"] } - else if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) - { - @localizer["ManageYourSubscription"] - } -
- } - else if (Model.Plan.PlanId != 1) - { -
-

Active Legacy Subscription

- You currently have an active legacy subscription. To change over to the Entity based subscription, please cancel your current subscription and then sign up for a new one. If you are a Invoice customer, please contact us to update your subscription. Click on the "Legacy Plan" tab to see more information about your current plan. -
- } -
-
-
-
-
-

@localizer["Warning"]

- You are on a Legacy Plan. We do not offer these plans anymore. You can stay on this plan as long as you wish, but you will not be able to upgrade or downgrade your plan. If you wish to change your plan, you will need to cancel your current plan and then sign up for a new plan. We do not raise prices on departments with active billing relationships, so you can stay on this plan as long as you wish, you will need to use the "Change Billing Info" button below if you are using a Credit Card to pay. If you bill via Invoice your cost will stay the same if you renew. -
- -
- @if (!string.IsNullOrWhiteSpace(Model.StripeCustomer)) - { - @localizer["ChangeBillingInfo"] - @localizer["CancelSub"] - } -
-
+
+
- @if (Model.HasActiveSubscription) - { -
-
-
-
-
-
-
-

Push-To-Talk Addon

-
-
-
-
-
    -
  • Two-Way IP Voice Communications
  • -
  • Up to 50 Channels
  • -
  • Radios with worldwide reach
  • -
  • Real-Time Voice, Push-To-Talk
  • -
  • Android, iOS and Web
  • -
-
-
-
- -

$35/mo

per 10 user pack -
-
-
-

The Push-To-Talk Addon is only available on paid plans. You can buy as many 10 user packs as you wish, and they are billed monthly separately from your Resgrid bill. Allowing you to size up and down as you need. You can have up to 50 active people per channel. Push-To-Talk requires devices with an active Internet (data) connection and uses VOIP technology.

- @if (Model.IsPaddleDepartment) - { - Manage PTT - } - else - { - Manage PTT - } + @if (Model.HasActiveSubscription) + { +
+
+
+
+
+
+
+

Push-To-Talk Addon

+
+
+
+
+
    +
  • Two-Way IP Voice Communications
  • +
  • Up to 50 Channels
  • +
  • Radios with worldwide reach
  • +
  • Real-Time Voice, Push-To-Talk
  • +
  • Android, iOS and Web
  • +
+
+ +

$35/mo

per 10 user pack +
+
+
+

The Push-To-Talk Addon is only available on paid plans. You can buy as many 10 user packs as you wish, and they are billed monthly separately from your Resgrid bill. Allowing you to size up and down as you need. You can have up to 50 active people per channel. Push-To-Talk requires devices with an active Internet (data) connection and uses VOIP technology.

+ @if (Model.IsPaddleDepartment) + { + Manage PTT + } + else + { + Manage PTT + } +
- } +
+ } -
+
+
+
@@ -522,7 +524,7 @@