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..593926558 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, 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 7bacf14b1..736958c79 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, bool bypassCache = false)
+ {
+ 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.
+ var canProvisionNumber = await Task.WhenAll(ordered.Select(membership =>
+ _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId)));
+
+ for (var i = 0; i < ordered.Count; i++)
+ {
+ if (canProvisionNumber[i])
+ return await GetDepartmentByIdAsync(ordered[i].DepartmentId, bypassCache);
+ }
+
+ return await GetDepartmentByIdAsync(preferred.DepartmentId, bypassCache);
+ }
+
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..f56d00e56 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 == (int)Plans.Beta2YrPlanId || planId == (int)Plans.OpenPreviewPlanId || planId == (int)Plans.UnlimitedFreePlanId;
}
///
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)