Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 37 additions & 15 deletions Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines 21 to +31

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Resolve dependencies using the Service Locator pattern.

As per coding guidelines, dependencies should be resolved explicitly in constructors using Bootstrapper.GetKernel().Resolve<T>() rather than via constructor injection.

🛠️ Proposed fix
-		public DepartmentActionHandler(
-			IDepartmentsService departmentsService,
-			IUserProfileService userProfileService,
-			IUsersService usersService,
-			ILimitsService limitsService)
-		{
-			_departmentsService = departmentsService;
-			_userProfileService = userProfileService;
-			_usersService = usersService;
-			_limitsService = limitsService;
-		}
+		public DepartmentActionHandler()
+		{
+			_departmentsService = Bootstrapper.GetKernel().Resolve<IDepartmentsService>();
+			_userProfileService = Bootstrapper.GetKernel().Resolve<IUserProfileService>();
+			_usersService = Bootstrapper.GetKernel().Resolve<IUsersService>();
+			_limitsService = Bootstrapper.GetKernel().Resolve<ILimitsService>();
+		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public DepartmentActionHandler(
IDepartmentsService departmentsService,
IUserProfileService userProfileService,
IUsersService usersService)
IUsersService usersService,
ILimitsService limitsService)
{
_departmentsService = departmentsService;
_userProfileService = userProfileService;
_usersService = usersService;
_limitsService = limitsService;
}
public DepartmentActionHandler()
{
_departmentsService = Bootstrapper.GetKernel().Resolve<IDepartmentsService>();
_userProfileService = Bootstrapper.GetKernel().Resolve<IUserProfileService>();
_usersService = Bootstrapper.GetKernel().Resolve<IUsersService>();
_limitsService = Bootstrapper.GetKernel().Resolve<ILimitsService>();
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Chatbot/Handlers/DepartmentActionHandler.cs` around lines 21 -
31, Replace constructor injection in DepartmentActionHandler with explicit
Service Locator resolution using Bootstrapper.GetKernel().Resolve<T>() for
IDepartmentsService, IUserProfileService, IUsersService, and ILimitsService, and
update the constructor accordingly while preserving the existing private field
assignments.

Source: Coding guidelines


/// <summary>
/// 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.
/// </summary>
private async Task<System.Collections.Generic.List<DepartmentMember>> GetSmsSupportingMembershipsAsync(string userId)
{
var allMemberRecords = await _departmentsService.GetAllDepartmentsForUserAsync(userId);
if (allMemberRecords == null || allMemberRecords.Count == 0)
return new System.Collections.Generic.List<DepartmentMember>();

var candidates = allMemberRecords
.Where(m => !m.IsDeleted)
.OrderByDescending(m => m.IsActive)
.ThenBy(m => m.DepartmentId)
.ToList();

var supported = new System.Collections.Generic.List<DepartmentMember>();
foreach (var membership in candidates)
{
if (await _limitsService.CanDepartmentProvisionNumberAsync(membership.DepartmentId))
supported.Add(membership);
}

return supported;
}

public ChatbotIntentType IntentType => ChatbotIntentType.ListDepartments;
Expand Down Expand Up @@ -56,15 +86,8 @@ private async Task<ChatbotResponse> 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 };
Expand Down Expand Up @@ -162,13 +185,12 @@ private async Task<ChatbotResponse> 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();
Expand Down
23 changes: 4 additions & 19 deletions Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -395,25 +395,10 @@ await _userIdentityService.LinkUserAsync(

private async Task<Model.Department> 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)
Expand Down
10 changes: 10 additions & 0 deletions Core/Resgrid.Model/Services/IDepartmentsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ Task<DepartmentMember> AddUserToDepartmentAsync(int departmentId, string userId,

Task<Department> GetDepartmentByUserIdAsync(string userId, bool bypassCache = false);

/// <summary>
/// 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.
/// </summary>
Task<Department> GetActiveSmsDepartmentForUserAsync(string userId, bool bypassCache = false);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Propagate bypassCache through the resolver implementation.

This contract exposes bypassCache, but DepartmentsService.GetActiveSmsDepartmentForUserAsync still calls GetAllDepartmentsForUserAsync(userId) without forwarding it. As a result, callers requesting fresh SMS department resolution can receive stale membership/active-department data from cache.

Update the implementation to honor bypassCache when loading the user’s memberships. As per coding guidelines, fresh data must use bypassCache: true or cache invalidation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Core/Resgrid.Model/Services/IDepartmentsService.cs` at line 81, Update
DepartmentsService.GetActiveSmsDepartmentForUserAsync to forward the bypassCache
argument when calling GetAllDepartmentsForUserAsync(userId). Ensure fresh
resolution uses bypassCache: true while preserving cached behavior when it is
false.

Source: Coding guidelines


Task<ValidateUserForDepartmentResult> GetValidateUserForDepartmentInfoAsync(string userName,
bool bypassCache = true);

Expand Down
34 changes: 34 additions & 0 deletions Core/Resgrid.Services/DepartmentsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,40 @@ public async Task<Department> GetDepartmentByUserIdAsync(string userId, bool byp
return await _departmentRepository.GetDepartmentForUserByUserIdAsync(userId);
}

public async Task<Department> 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<ValidateUserForDepartmentResult> GetValidateUserForDepartmentInfoAsync(string userName, bool bypassCache = true)
{
Expand Down
12 changes: 7 additions & 5 deletions Core/Resgrid.Services/LimitsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,16 @@ public async Task<bool> 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;
}

Expand Down
6 changes: 5 additions & 1 deletion Core/Resgrid.Services/SubscriptionsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,11 @@ public List<int> 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;
}

/// <summary>
Expand Down
27 changes: 15 additions & 12 deletions Web/Resgrid.Web.Services/Controllers/TwilioController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,24 @@ public async Task<ActionResult> 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)
Expand Down
Loading