From d1302a0e2405f8c690d5c5cdf9ea51b85bae14d2 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 15 Jul 2026 17:04:24 -0700 Subject: [PATCH 1/3] RG-T125 Twilio fixes --- .../Handlers/DepartmentActionHandler.cs | 52 +++++++++++++------ .../Services/ChatbotIngressService.cs | 23 ++------ .../Services/IDepartmentsService.cs | 10 ++++ Core/Resgrid.Services/DepartmentsService.cs | 34 ++++++++++++ Core/Resgrid.Services/LimitsService.cs | 12 +++-- Core/Resgrid.Services/SubscriptionsService.cs | 6 ++- .../Controllers/TwilioController.cs | 27 +++++----- 7 files changed, 112 insertions(+), 52 deletions(-) diff --git a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs index 2611b8f9e..70214c011 100644 --- a/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs @@ -16,15 +16,45 @@ public class DepartmentActionHandler : IChatbotActionHandler private readonly IDepartmentsService _departmentsService; private readonly IUserProfileService _userProfileService; private readonly IUsersService _usersService; + private readonly ILimitsService _limitsService; public DepartmentActionHandler( IDepartmentsService departmentsService, IUserProfileService userProfileService, - IUsersService usersService) + IUsersService usersService, + ILimitsService limitsService) { _departmentsService = departmentsService; _userProfileService = userProfileService; _usersService = usersService; + _limitsService = limitsService; + } + + /// + /// 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. + /// + private async Task> GetSmsSupportingMembershipsAsync(string userId) + { + 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) + { + if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) + supported.Add(membership); + } + + return supported; } public ChatbotIntentType IntentType => ChatbotIntentType.ListDepartments; @@ -56,15 +86,8 @@ private async Task ListDepartmentsAsync(ChatbotSession session) var culture = session.Culture; try { - var allMemberRecords = await _departmentsService.GetAllDepartmentsForUserAsync(session.UserId); - if (allMemberRecords == null || allMemberRecords.Count == 0) - return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoMembership", culture), Processed = true }; - - var activeMemberships = allMemberRecords - .Where(m => !m.IsDeleted) - .OrderByDescending(m => m.IsActive) - .ThenBy(m => m.DepartmentId) - .ToList(); + // Only SMS-supporting (non-free plan) departments are switchable, so only list those. + var activeMemberships = await GetSmsSupportingMembershipsAsync(session.UserId); if (activeMemberships.Count == 0) return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoActiveMemberships", culture), Processed = true }; @@ -162,13 +185,12 @@ private async Task SwitchDepartmentAsync(ChatbotIntent intent, if (string.IsNullOrWhiteSpace(departmentIdentifier)) return new ChatbotResponse { Text = ChatbotResources.Get("Dept_SwitchSpecify", culture), Processed = true }; - var allMemberRecords = await _departmentsService.GetAllDepartmentsForUserAsync(session.UserId); - var activeMemberships = allMemberRecords - .Where(m => !m.IsDeleted) - .ToList(); + // 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); if (activeMemberships.Count == 0) - return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoMembership", culture), Processed = true }; + return new ChatbotResponse { Text = ChatbotResources.Get("Dept_NoActiveMemberships", culture), Processed = true }; DepartmentMember targetMembership = null; var trimmedId = departmentIdentifier.Trim(); diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 8410c4718..d44d6bcc2 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -395,25 +395,10 @@ await _userIdentityService.LinkUserAsync( private async Task ResolveActiveDepartmentAsync(string userId) { - // Get all department memberships for this user (non-deleted) - var allMembers = await _departmentsService.GetAllDepartmentsForUserAsync(userId); - if (allMembers == null || allMembers.Count == 0) - return null; - - var activeMemberships = allMembers.Where(m => !m.IsDeleted).ToList(); - if (activeMemberships.Count == 0) - return null; - - // Prefer the member marked IsActive, then IsDefault, then first - var activeMember = activeMemberships - .OrderByDescending(m => m.IsActive) - .ThenByDescending(m => m.IsDefault) - .FirstOrDefault(); - - if (activeMember != null) - return await _departmentsService.GetDepartmentByIdAsync(activeMember.DepartmentId); - - return null; + // Shared resolution: the user's active (then default, then first) membership, preferring one whose + // plan supports SMS. The TwilioController uses the same resolver for the master-number sender path + // so the flag evaluation and the chatbot agree on which department the sender operates in. + return await _departmentsService.GetActiveSmsDepartmentForUserAsync(userId); } private static bool IsPlatformAllowed(string allowedPlatforms, ChatbotPlatform platform) diff --git a/Core/Resgrid.Model/Services/IDepartmentsService.cs b/Core/Resgrid.Model/Services/IDepartmentsService.cs index 18a2e5c05..581db1ca4 100644 --- a/Core/Resgrid.Model/Services/IDepartmentsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentsService.cs @@ -70,6 +70,16 @@ 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. + /// + Task GetActiveSmsDepartmentForUserAsync(string userId); + Task GetValidateUserForDepartmentInfoAsync(string userName, bool bypassCache = true); diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index 7bacf14b1..27d67071b 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -437,6 +437,40 @@ public async Task GetDepartmentByUserIdAsync(string userId, bool byp return await _departmentRepository.GetDepartmentForUserByUserIdAsync(userId); } + public async Task GetActiveSmsDepartmentForUserAsync(string userId) + { + var allMembers = await GetAllDepartmentsForUserAsync(userId); + if (allMembers == null || allMembers.Count == 0) + return null; + + // Prefer the member marked IsActive, then IsDefault, then first. + var ordered = allMembers + .Where(m => !m.IsDeleted) + .OrderByDescending(m => m.IsActive) + .ThenByDescending(m => m.IsDefault) + .ToList(); + + if (ordered.Count == 0) + return null; + + var preferred = ordered[0]; + + // 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. + if (await _limitsService.CanDepartmentProvisionNumberAsync(preferred.DepartmentId)) + return await GetDepartmentByIdAsync(preferred.DepartmentId); + + foreach (var membership in ordered) + { + if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) + return await GetDepartmentByIdAsync(membership.DepartmentId); + } + + return await GetDepartmentByIdAsync(preferred.DepartmentId); + } + public async Task GetValidateUserForDepartmentInfoAsync(string userName, bool bypassCache = true) { diff --git a/Core/Resgrid.Services/LimitsService.cs b/Core/Resgrid.Services/LimitsService.cs index ea6175e55..8468960d6 100644 --- a/Core/Resgrid.Services/LimitsService.cs +++ b/Core/Resgrid.Services/LimitsService.cs @@ -153,14 +153,16 @@ public async Task CanDepartmentProvisionNumberAsync(int departmentId) return false; } - if (plan.PlanId == 4 || plan.PlanId == 5 || plan.PlanId == 10 || plan.PlanId == 14 || plan.PlanId == 15 || plan.PlanId == 16 || plan.PlanId == 17 || - plan.PlanId == 18 || plan.PlanId == 19 || plan.PlanId == 20 || plan.PlanId == 21 || plan.PlanId == 26 || plan.PlanId == 27 || plan.PlanId == 28 || - plan.PlanId == 29 || plan.PlanId == 30 || plan.PlanId == 31 || plan.PlanId == 32 || plan.PlanId == 33 || plan.PlanId == 36 || plan.PlanId == 37) + // SMS/text features are included on every non-free plan (since the move to Entity-based plans SMS + // was removed from the free tiers and all non-free plans carry the full feature set). Use a + // non-free check rather than a hardcoded plan-id allow-list so new plans (e.g. Entity 36/37) work + // automatically without having to be added here. + if (!_subscriptionsService.IsPlanRestrictedOrFree(plan.PlanId)) return true; // Diagnostic: this gate silently blocks the inbound-SMS text-command reply path. Log the - // resolved plan so it is clear *why* a department gets no reply (plan not in the allow-list). - Resgrid.Framework.Logging.LogInfo($"[Twilio SMS] CanDepartmentProvisionNumber=false for DepartmentId={departmentId} PlanId={plan.PlanId} — plan is not in the number/text-feature allow-list, so no SMS reply will be generated."); + // resolved plan so it is clear *why* a department gets no reply (free/restricted plan). + Resgrid.Framework.Logging.LogInfo($"[Twilio SMS] CanDepartmentProvisionNumber=false for DepartmentId={departmentId} PlanId={plan.PlanId} — plan is a free/restricted tier without SMS/text features, so no SMS reply will be generated."); return false; } diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index 7db9f1fef..386a30606 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -604,7 +604,11 @@ public List GetPossibleDowngradesForPlan(int planId) public bool IsPlanRestrictedOrFree(int planId) { - return false; + // Free / restricted tiers that do NOT include the paid feature set (SMS/chatbot, etc.). Since the + // move to Entity-based plans, SMS was removed from the free tiers, so "supports SMS/paid features" + // is simply "not one of these". Mirrors the authoritative set in the CommonApis billing service. + // 1 = Free, 6 = Beta (2yr), 7 = Open Preview, 8 = Unlimited Free. + return planId == FreePlanId || planId == 6 || planId == 7 || planId == 8; } /// diff --git a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs index 626b531ad..2eb185ce0 100644 --- a/Web/Resgrid.Web.Services/Controllers/TwilioController.cs +++ b/Web/Resgrid.Web.Services/Controllers/TwilioController.cs @@ -136,21 +136,24 @@ public async Task IncomingMessage([FromQuery] TwilioMessage reques try { - // Resolve the department that owns the inbound number (falling back to the sender's - // linked profile) so the Chatbot Twilio integration flag can be evaluated per-department. - UserProfile userProfile = null; - var departmentId = await _departmentSettingsService.GetDepartmentIdByTextToCallNumberAsync(textMessage.To); - if (!departmentId.HasValue) + // Going-forward model: one master Resgrid number handles all inbound SMS, so the department is + // identified from the SENDER's profile → their active SMS-capable department (NOT the number + // that was texted). This keeps the flag evaluation and the chatbot pipeline agreeing on which + // department the sender operates in. Legacy clients that still text a per-department provisioned + // inbound number fall back to resolving the department from that number. + UserProfile userProfile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); + int? departmentId = null; + if (userProfile != null) { - userProfile = await _userProfileService.GetProfileByMobileNumberAsync(textMessage.Msisdn); - if (userProfile != null) - { - var department = await _departmentsService.GetDepartmentByUserIdAsync(userProfile.UserId); - if (department != null) - departmentId = department.DepartmentId; - } + 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) + 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. if (departmentId.HasValue) From 3b54d2e000edf4a383654a02f53bc20b1752850a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 15 Jul 2026 17:16:09 -0700 Subject: [PATCH 2/3] RG-T125 PR#423 fixes --- Core/Resgrid.Model/Services/IDepartmentsService.cs | 2 +- Core/Resgrid.Services/DepartmentsService.cs | 9 +++------ 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/Core/Resgrid.Model/Services/IDepartmentsService.cs b/Core/Resgrid.Model/Services/IDepartmentsService.cs index 581db1ca4..593926558 100644 --- a/Core/Resgrid.Model/Services/IDepartmentsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentsService.cs @@ -78,7 +78,7 @@ Task AddUserToDepartmentAsync(int departmentId, string userId, /// downstream plan gate can produce the proper "plan doesn't support" message. Null when the user has /// no non-deleted memberships. /// - Task GetActiveSmsDepartmentForUserAsync(string userId); + Task GetActiveSmsDepartmentForUserAsync(string userId, bool bypassCache = false); Task GetValidateUserForDepartmentInfoAsync(string userName, bool bypassCache = true); diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index 27d67071b..d87d91e81 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -437,7 +437,7 @@ public async Task GetDepartmentByUserIdAsync(string userId, bool byp return await _departmentRepository.GetDepartmentForUserByUserIdAsync(userId); } - public async Task GetActiveSmsDepartmentForUserAsync(string userId) + public async Task GetActiveSmsDepartmentForUserAsync(string userId, bool bypassCache = false) { var allMembers = await GetAllDepartmentsForUserAsync(userId); if (allMembers == null || allMembers.Count == 0) @@ -459,16 +459,13 @@ public async Task GetActiveSmsDepartmentForUserAsync(string userId) // 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. - if (await _limitsService.CanDepartmentProvisionNumberAsync(preferred.DepartmentId)) - return await GetDepartmentByIdAsync(preferred.DepartmentId); - foreach (var membership in ordered) { if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) - return await GetDepartmentByIdAsync(membership.DepartmentId); + return await GetDepartmentByIdAsync(membership.DepartmentId, bypassCache); } - return await GetDepartmentByIdAsync(preferred.DepartmentId); + return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); } From 913735bdd24c588a29b95c53bbdc70775f37284d Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 15 Jul 2026 17:26:15 -0700 Subject: [PATCH 3/3] RG-T125 PR#423 fixes --- Core/Resgrid.Services/DepartmentsService.cs | 9 ++++++--- Core/Resgrid.Services/SubscriptionsService.cs | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index d87d91e81..736958c79 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -459,10 +459,13 @@ public async Task GetActiveSmsDepartmentForUserAsync(string userId, // 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. - foreach (var membership in ordered) + var canProvisionNumber = await Task.WhenAll(ordered.Select(membership => + _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId))); + + for (var i = 0; i < ordered.Count; i++) { - if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)) - return await GetDepartmentByIdAsync(membership.DepartmentId, bypassCache); + if (canProvisionNumber[i]) + return await GetDepartmentByIdAsync(ordered[i].DepartmentId, bypassCache); } return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache); diff --git a/Core/Resgrid.Services/SubscriptionsService.cs b/Core/Resgrid.Services/SubscriptionsService.cs index 386a30606..f56d00e56 100644 --- a/Core/Resgrid.Services/SubscriptionsService.cs +++ b/Core/Resgrid.Services/SubscriptionsService.cs @@ -608,7 +608,7 @@ public bool IsPlanRestrictedOrFree(int planId) // move to Entity-based plans, SMS was removed from the free tiers, so "supports SMS/paid features" // is simply "not one of these". Mirrors the authoritative set in the CommonApis billing service. // 1 = Free, 6 = Beta (2yr), 7 = Open Preview, 8 = Unlimited Free. - return planId == FreePlanId || planId == 6 || planId == 7 || planId == 8; + return planId == FreePlanId || planId == (int)Plans.Beta2YrPlanId || planId == (int)Plans.OpenPreviewPlanId || planId == (int)Plans.UnlimitedFreePlanId; } ///