From 6ddf1c7a3e45796f0e477dca92491ebda8128942 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 17 Jul 2026 11:20:00 -0700 Subject: [PATCH 1/3] RG-T117 Chatbot work --- .../Providers/KeywordIntentClassifier.cs | 59 +++ .../Providers/OpenAiCompatibleNluProvider.cs | 13 + Core/Resgrid.Chatbot/ChatbotModule.cs | 28 ++ .../Handlers/AvailabilityActionHandler.cs | 140 ++++++ .../Handlers/CallDispatchedActionHandler.cs | 128 +++++ .../Handlers/CallRespondersActionHandler.cs | 251 ++++++++++ .../Handlers/HelpActionHandler.cs | 13 +- .../Handlers/MyCallsActionHandler.cs | 138 ++++++ .../Handlers/MyScheduleActionHandler.cs | 156 +++++++ .../Handlers/PollCreateHandler.cs | 128 +++++ .../Handlers/UnitsAvailableActionHandler.cs | 86 ++++ .../Localization/ChatbotResources.cs | 442 ++++++++++++++++++ Core/Resgrid.Chatbot/Models/ChatbotIntent.cs | 10 +- .../Services/ChatbotIngressService.cs | 72 ++- Core/Resgrid.Chatbot/Services/IntentMapper.cs | 8 + Core/Resgrid.Services/CustomStateService.cs | 9 + ...hatbotAvailabilityIntentClassifierTests.cs | 125 +++++ 17 files changed, 1802 insertions(+), 4 deletions(-) create mode 100644 Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs create mode 100644 Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs create mode 100644 Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs create mode 100644 Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs create mode 100644 Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs create mode 100644 Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs create mode 100644 Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs create mode 100644 Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs diff --git a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs index dcd07a1ed..970110db3 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs @@ -53,6 +53,8 @@ public class KeywordIntentClassifier : INLUProvider // "my staffing" — the my_status handler reports both status and staffing. (R(@"^(my\s+)?staffing$"), "my_status", null), (R(@"^(messages?|msgs?)$"), "list_messages", null), + // Unread/new message forms route to the same list handler (it lists unread only). + (R(@"^(any\s+|my\s+)?(unread|new)\s+(messages?|msgs?)$"), "list_messages", null), (R(@"^(calendar|events?|cal)$"), "list_calendar", null), (R(@"^shifts?$"), "list_shifts", null), (R(@"^(personnel|staff)$"), "personnel_lookup", null), @@ -87,6 +89,63 @@ public class KeywordIntentClassifier : INLUProvider (R(@"^(reply|respond)\s+(yes|no|acknowledge|ack)\s+to\s+(message|msg|#)?\s*#?(\d+)"), "respond_to_message", m => P2("response", m.Groups[2].Value, "messageId", m.Groups[4].Value)), + // === Availability / Call Responder Queries (must precede the generic + // "who is X" personnel_lookup and "what ... calls" list_calls patterns) === + + // "who's available?", "who is around?", "anyone free?", "who can respond?" + (R(@"^(who'?s|who\s+is|who\s+are|anyone|anybody|any\s*one)\s+(around|available|free)(\s+to\s+respond)?$"), + "who_available", null), + (R(@"^who\s+can\s+respond$"), + "who_available", null), + + // "units available?", "available units", "what units are available/free/in service" + (R(@"^(available|free)\s+units?$"), + "units_available", null), + (R(@"^units?\s+(are\s+)?(available|free|in\s+service)$"), + "units_available", null), + (R(@"^(what|which)\s+units?\s+(are\s+)?(available|free|in\s+service)$"), + "units_available", null), + + // "who's on scene at the fire" — on-scene responders for a call. + (R(@"^(who'?s|who\s+is|who\s+are)\s+on\s*scene(\s+(?:at|on|for)\s+(.+))?$"), + "call_responders", m => P2("mode", "onscene", "callRef", m.Groups[3].Value.Trim())), + + // "who's in route to the fire", "who is responding to c1445", "who's coming" + (R(@"^(who'?s|who\s+is|who\s+are)\s+((?:in|en)\s*route|responding|headed|heading|going|coming)(\s+(?:to|for)\s+(.+))?$"), + "call_responders", m => P2("mode", "enroute", "callRef", m.Groups[4].Value.Trim())), + + // "who got dispatched to the medical", "who's dispatched to 26-1" — the full dispatch + // list (personnel, groups, roles and units) rather than live statuses. + (R(@"^(who'?s|who\s+is|who\s+are|who\s+got|who\s+was|who\s+were|who)\s+dispatched(\s+(?:to|on|for)\s+(.+))?$"), + "call_dispatched", m => P("callRef", m.Groups[3].Value.Trim())), + + // "who's on call 26-1", "who is on the fire" — responding + on-scene for a call. + (R(@"^(who'?s|who\s+is|who\s+are)\s+on(\s+call)?(\s+(.+))?$"), + "call_responders", m => P2("mode", "all", "callRef", m.Groups[4].Value.Trim())), + + // "what calls am I on?", "my calls" — calls the user was dispatched to. + (R(@"^(what\s+)?calls?\s+am\s+i\s+(on|dispatched\s+to|assigned\s+to)\b.*$"), + "my_calls", null), + (R(@"^my\s+calls?$"), + "my_calls", null), + (R(@"^what\s+am\s+i\s+dispatched\s+to$"), + "my_calls", null), + + // "what calls is Rescue 6 on?" — calls a unit was dispatched to. + (R(@"^(what\s+)?calls?\s+(is|are)\s+(.+?)\s+(on|dispatched\s+to|assigned\s+to)$"), + "unit_calls", m => P("unitName", m.Groups[3].Value.Trim())), + (R(@"^what\s+is\s+(.+?)\s+dispatched\s+to$"), + "unit_calls", m => P("unitName", m.Groups[1].Value.Trim())), + + // "what's my schedule?", "my schedule for 7/22" — shifts + RSVP'd events. + (R(@"^(what'?s\s+|what\s+is\s+)?my\s+schedule(\s+(?:for\s+|on\s+)?(.+))?$"), + "my_schedule", m => P("day", m.Groups[3].Value.Trim())), + + // "poll members to see who's available for a red flag on 7/22" — the handler strips + // leading audience/verb filler from the question text. + (R(@"^(send\s+a\s+poll|send\s+poll|poll)\s+(.+)$"), + "create_poll", m => P("question", m.Groups[2].Value.Trim())), + // === Natural Language Query Commands === (R(@"^(show|list|get|what)\s+(are\s+)?(active|open)?\s*(calls|incidents)"), "list_calls", null), diff --git a/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs b/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs index f42566b47..dc6009937 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs @@ -70,6 +70,14 @@ public class OpenAiCompatibleNluProvider : INLUProvider - shift_signup: User wants to sign up for a shift - shift_drop: User wants to drop a shift - personnel_lookup: User asks about personnel (who is X, where is X, list personnel) +- who_available: User asks who is available/around/free to respond right now (e.g. ""who's around?"", ""who is available?"") +- units_available: User asks which units are available/free/in service right now +- call_responders: User asks who is on a call, responding, en route or on scene for a call (e.g. ""who's on call 26-1?"", ""who's in route to the fire?"") +- call_dispatched: User asks who was dispatched/assigned to a call (the dispatch list: personnel, groups, roles, units) +- my_calls: User asks what calls THEY are on/dispatched to (e.g. ""what calls am I on?"") +- unit_calls: User asks what calls a specific unit is on/dispatched to (e.g. ""what calls is Rescue 6 on?"") +- create_poll: User wants to poll/survey department members with a yes/no question (e.g. ""poll members to see who's available for a red flag on 7/22"") +- my_schedule: User asks for their schedule (their shifts plus calendar events they RSVP'd to), optionally for a specific day - weather_alert: User asks about weather alerts or warnings - emergency_mayday: User signals emergency/distress (mayday, officer down, firefighter down) - link_account: User wants to link/authenticate their account @@ -98,6 +106,11 @@ public class OpenAiCompatibleNluProvider : INLUProvider - For personnel_lookup: ""query"" (the name/description) - For dispatch_call: ""description"" (the call description) - For switch_department: ""departmentIdentifier"" (the department name, code, or number to switch to) +- For call_responders: ""callRef"" (call id, number or shorthand like ""the fire""; empty if not given), ""mode"" (""all"", ""enroute"" or ""onscene"" — enroute when the user asks who is responding/in route, onscene when they ask who is on scene, all otherwise) +- For call_dispatched: ""callRef"" (call id, number or shorthand; empty if not given) +- For unit_calls: ""unitName"" (the unit name, number or id) +- For create_poll: ""question"" (the yes/no question to ask members, without the leading ""poll members to see"" filler) +- For my_schedule: ""day"" (the day asked about, e.g. ""today"", ""tomorrow"", ""7/22""; empty for today) - For list_departments: no parameters needed - For get_active_department: no parameters needed diff --git a/Core/Resgrid.Chatbot/ChatbotModule.cs b/Core/Resgrid.Chatbot/ChatbotModule.cs index e6efb6f93..67225820a 100644 --- a/Core/Resgrid.Chatbot/ChatbotModule.cs +++ b/Core/Resgrid.Chatbot/ChatbotModule.cs @@ -165,6 +165,34 @@ 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.RegisterType() + .As() + .InstancePerLifetimeScope(); + + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); } } } diff --git a/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs new file mode 100644 index 000000000..348152140 --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs @@ -0,0 +1,140 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Reporting; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Answers "who's available/around?" (intent ): + /// personnel whose staffing says they are around (not unavailable) and whose status classifies as + /// Available, so they can actually be assigned. Availability classification is the canonical + /// resolution (custom statuses resolve via BaseType). + /// + public class AvailabilityActionHandler : IChatbotActionHandler + { + private const int MaxPersonnelToList = 15; + + private readonly IUsersService _usersService; + private readonly IActionLogsService _actionLogsService; + private readonly IUserStateService _userStateService; + private readonly ICustomStateService _customStateService; + private readonly IPlatformReportingService _platformReportingService; + private readonly IAuthorizationService _authorizationService; + + public AvailabilityActionHandler( + IUsersService usersService, + IActionLogsService actionLogsService, + IUserStateService userStateService, + ICustomStateService customStateService, + IPlatformReportingService platformReportingService, + IAuthorizationService authorizationService) + { + _usersService = usersService; + _actionLogsService = actionLogsService; + _userStateService = userStateService; + _customStateService = customStateService; + _platformReportingService = platformReportingService; + _authorizationService = authorizationService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.WhoIsAvailable; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + if (!await _authorizationService.CanUserViewAllPeopleAsync(session.UserId, session.DepartmentId)) + return new ChatbotResponse { Text = ChatbotResources.Get("Personnel_NoPermission", culture), Processed = false }; + + var allUsers = await _usersService.GetUserGroupAndRolesByDepartmentIdInLimitAsync(session.DepartmentId, false, false, false); + if (allUsers == null || !allUsers.Any()) + return new ChatbotResponse { Text = ChatbotResources.Get("Personnel_None", culture), Processed = true }; + + var lastActionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(session.DepartmentId); + var userStates = await _userStateService.GetLatestStatesForDepartmentAsync(session.DepartmentId); + + var availableLines = new StringBuilder(); + var availableCount = 0; + + foreach (var user in allUsers.OrderBy(u => u.LastName).ThenBy(u => u.FirstName)) + { + var lastAction = lastActionLogs?.FirstOrDefault(x => x.UserId == user.UserId); + var state = userStates?.FirstOrDefault(x => x.UserId == user.UserId); + + // Status must classify as Available (able to respond) and staffing must say they are + // around (anything but an unavailable staffing level). + var statusClass = lastAction != null + ? await _platformReportingService.ClassifyPersonnelAvailabilityAsync(session.DepartmentId, lastAction.ActionTypeId) + : AvailabilityClass.Unknown; + var staffingClass = await ClassifyStaffingAsync(session.DepartmentId, state); + + if (statusClass != AvailabilityClass.Available || staffingClass == AvailabilityClass.Unavailable) + continue; + + availableCount++; + if (availableCount > MaxPersonnelToList) + continue; + + var status = await _customStateService.GetCustomPersonnelStatusAsync(session.DepartmentId, lastAction); + var staffing = await _customStateService.GetCustomPersonnelStaffingAsync(session.DepartmentId, state); + var unknown = ChatbotResources.Get("Personnel_Unknown", culture); + + availableLines.AppendLine(ChatbotResources.Get("Avail_Line", culture, + user.LastName, user.FirstName, + status?.ButtonText ?? unknown, + staffing?.ButtonText ?? ChatbotResources.Get("Personnel_NA", culture))); + } + + if (availableCount == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("Avail_None", culture), Processed = true }; + + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get("Avail_Header", culture, availableCount)); + sb.AppendLine("----------------------"); + sb.Append(availableLines); + + if (availableCount > MaxPersonnelToList) + sb.AppendLine(ChatbotResources.Get("Msg_AndMore", culture, availableCount - MaxPersonnelToList)); + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("Avail_Error", culture), Processed = false }; + } + } + + // Staffing availability: UserState.State holds a built-in UserStateTypes value (<= 25 by the same + // convention CustomStateService uses) or a CustomStateDetailId whose BaseType classifies it. + private async Task ClassifyStaffingAsync(int departmentId, UserState state) + { + if (state == null) + return AvailabilityClass.Unknown; + + if (state.State <= 25) + { + return (UserStateTypes)state.State switch + { + UserStateTypes.Available => AvailabilityClass.Available, + UserStateTypes.Delayed => AvailabilityClass.Delayed, + UserStateTypes.Unavailable => AvailabilityClass.Unavailable, + UserStateTypes.Committed => AvailabilityClass.Committed, + UserStateTypes.OnShift => AvailabilityClass.Available, + _ => AvailabilityClass.Unknown + }; + } + + var detail = await _customStateService.GetCustomDetailForDepartmentAsync(departmentId, state.State); + return detail != null ? AvailabilityMatrix.ForCustomBaseType(detail.BaseType) : AvailabilityClass.Unknown; + } + } +} diff --git a/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs new file mode 100644 index 000000000..322b99147 --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs @@ -0,0 +1,128 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Answers "who got dispatched to the medical?" (intent ): + /// the call's recorded dispatch lists — personnel, groups, roles and units — regardless of what those + /// resources' current statuses are (unlike ). + /// + public class CallDispatchedActionHandler : IChatbotActionHandler + { + private const int MaxLinesPerSection = 15; + + private readonly ICallsService _callsService; + private readonly IUserProfileService _userProfileService; + private readonly IAuthorizationService _authorizationService; + + public CallDispatchedActionHandler( + ICallsService callsService, + IUserProfileService userProfileService, + IAuthorizationService authorizationService) + { + _callsService = callsService; + _userProfileService = userProfileService; + _authorizationService = authorizationService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.CallDispatched; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + intent.Parameters.TryGetValue("callRef", out var reference); + if (string.IsNullOrWhiteSpace(reference)) + intent.Parameters.TryGetValue("callId", out reference); + + Call call; + if (!string.IsNullOrWhiteSpace(reference)) + { + call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference); + if (call == null) + return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true }; + } + else + { + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId); + if (activeCalls?.Count == 1) + call = activeCalls[0]; + else + return new ChatbotResponse { Text = ChatbotResources.Get("CallResp_Specify", culture), Processed = false }; + } + + if (!await _authorizationService.CanUserViewCallAsync(session.UserId, call.CallId)) + return new ChatbotResponse { Text = ChatbotResources.Get("CallDetail_NoPermission", culture), Processed = false }; + + call = await _callsService.PopulateCallData(call, true, false, false, true, true, true, false, false, false); + + var callLabel = string.IsNullOrWhiteSpace(call.Number) ? call.Name : $"{call.Number} {call.Name}"; + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get("CallDisp_Header", culture, callLabel)); + sb.AppendLine("----------------------"); + + var any = false; + + if (call.Dispatches != null && call.Dispatches.Any()) + { + any = true; + sb.AppendLine(ChatbotResources.Get("Section_Personnel", culture)); + + var userIds = call.Dispatches.Select(d => d.UserId).Distinct().ToList(); + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(userIds); + foreach (var userId in userIds.Take(MaxLinesPerSection)) + { + var profile = profiles?.FirstOrDefault(p => p.UserId == userId); + sb.AppendLine(profile?.FullName?.AsFirstNameLastName ?? ChatbotResources.Get("Personnel_Unknown", culture)); + } + + if (userIds.Count > MaxLinesPerSection) + sb.AppendLine(ChatbotResources.Get("Msg_AndMore", culture, userIds.Count - MaxLinesPerSection)); + } + + if (call.GroupDispatches != null && call.GroupDispatches.Any()) + { + any = true; + sb.AppendLine(ChatbotResources.Get("Section_Groups", culture)); + foreach (var groupDispatch in call.GroupDispatches.Take(MaxLinesPerSection)) + sb.AppendLine(groupDispatch.Group?.Name ?? $"Group {groupDispatch.DepartmentGroupId}"); + } + + if (call.RoleDispatches != null && call.RoleDispatches.Any()) + { + any = true; + sb.AppendLine(ChatbotResources.Get("Section_Roles", culture)); + foreach (var roleDispatch in call.RoleDispatches.Take(MaxLinesPerSection)) + sb.AppendLine(roleDispatch.Role?.Name ?? $"Role {roleDispatch.RoleId}"); + } + + if (call.UnitDispatches != null && call.UnitDispatches.Any()) + { + any = true; + sb.AppendLine(ChatbotResources.Get("Section_Units", culture)); + foreach (var unitDispatch in call.UnitDispatches.Take(MaxLinesPerSection)) + sb.AppendLine(unitDispatch.Unit?.Name ?? $"Unit {unitDispatch.UnitId}"); + } + + if (!any) + return new ChatbotResponse { Text = ChatbotResources.Get("CallDisp_None", culture, callLabel), Processed = true }; + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("CallDisp_Error", culture), Processed = false }; + } + } + } +} diff --git a/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs new file mode 100644 index 000000000..98bb4720d --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs @@ -0,0 +1,251 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Answers "who's on call X?", "who's in route to the fire?" and "who's on scene?" (intent + /// ): personnel and units whose CURRENT status is tied + /// to the call (ActionLog/UnitState DestinationId) and whose base state means en-route or on-scene. + /// The "mode" parameter narrows the buckets: "enroute", "onscene" or "all" (both). + /// + public class CallRespondersActionHandler : IChatbotActionHandler + { + private enum ResponderBucket + { + None, + EnRoute, + OnScene + } + + private readonly ICallsService _callsService; + private readonly IActionLogsService _actionLogsService; + private readonly IUnitsService _unitsService; + private readonly ICustomStateService _customStateService; + private readonly IUserProfileService _userProfileService; + private readonly IAuthorizationService _authorizationService; + + public CallRespondersActionHandler( + ICallsService callsService, + IActionLogsService actionLogsService, + IUnitsService unitsService, + ICustomStateService customStateService, + IUserProfileService userProfileService, + IAuthorizationService authorizationService) + { + _callsService = callsService; + _actionLogsService = actionLogsService; + _unitsService = unitsService; + _customStateService = customStateService; + _userProfileService = userProfileService; + _authorizationService = authorizationService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.CallResponders; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + intent.Parameters.TryGetValue("mode", out var mode); + mode = string.IsNullOrWhiteSpace(mode) ? "all" : mode.Trim().ToLowerInvariant(); + + var call = await ResolveCallAsync(intent, session.DepartmentId); + if (call == null) + return new ChatbotResponse { Text = ChatbotResources.Get("CallResp_Specify", culture), Processed = false }; + + if (!await _authorizationService.CanUserViewCallAsync(session.UserId, call.CallId)) + return new ChatbotResponse { Text = ChatbotResources.Get("CallDetail_NoPermission", culture), Processed = false }; + + var callLabel = string.IsNullOrWhiteSpace(call.Number) ? call.Name : $"{call.Number} {call.Name}"; + + // Latest status per person tied to this call. + var actionLogs = await _actionLogsService.GetActionLogsForCallAsync(session.DepartmentId, call.CallId); + var latestPerUser = (actionLogs ?? new List()) + .GroupBy(x => x.UserId) + .Select(g => g.OrderByDescending(x => x.Timestamp).First()) + .ToList(); + + var personnelLines = new StringBuilder(); + var personnelCount = 0; + if (latestPerUser.Count > 0) + { + var profiles = await _userProfileService.GetSelectedUserProfilesAsync(latestPerUser.Select(x => x.UserId).Distinct().ToList()); + foreach (var log in latestPerUser) + { + var bucket = await ClassifyPersonnelAsync(session.DepartmentId, log); + if (!BucketMatches(bucket, mode)) + continue; + + var profile = profiles?.FirstOrDefault(p => p.UserId == log.UserId); + var name = profile?.FullName?.AsFirstNameLastName ?? ChatbotResources.Get("Personnel_Unknown", culture); + var status = await _customStateService.GetCustomPersonnelStatusAsync(session.DepartmentId, log); + personnelLines.AppendLine(ChatbotResources.Get("CallResp_Line", culture, name, status?.ButtonText ?? ChatbotResources.Get("Personnel_Unknown", culture))); + personnelCount++; + } + } + + // Latest status per unit tied to this call. + var unitStates = await _unitsService.GetUnitStatesForCallAsync(session.DepartmentId, call.CallId); + var latestPerUnit = (unitStates ?? new List()) + .GroupBy(x => x.UnitId) + .Select(g => g.OrderByDescending(x => x.Timestamp).First()) + .ToList(); + + var unitLines = new StringBuilder(); + var unitCount = 0; + if (latestPerUnit.Count > 0) + { + // Unit navigation may not be populated on the call-scoped state query; a department + // unit map backfills names (and the DepartmentId that custom-state resolution needs). + var departmentUnits = await _unitsService.GetUnitsForDepartmentAsync(session.DepartmentId); + foreach (var unitState in latestPerUnit) + { + var bucket = await ClassifyUnitAsync(session.DepartmentId, unitState.State); + if (!BucketMatches(bucket, mode)) + continue; + + if (unitState.Unit == null) + unitState.Unit = departmentUnits?.FirstOrDefault(u => u.UnitId == unitState.UnitId); + + var status = await _customStateService.GetCustomUnitStateAsync(unitState); + var name = unitState.Unit?.Name ?? ChatbotResources.Get("Personnel_Unknown", culture); + unitLines.AppendLine(ChatbotResources.Get("CallResp_Line", culture, name, status?.ButtonText ?? ChatbotResources.Get("Personnel_Unknown", culture))); + unitCount++; + } + } + + if (personnelCount == 0 && unitCount == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("CallResp_None", culture, callLabel), Processed = true }; + + var headerKey = mode switch + { + "enroute" => "CallResp_HeaderEnroute", + "onscene" => "CallResp_HeaderOnScene", + _ => "CallResp_HeaderAll" + }; + + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get(headerKey, culture, callLabel)); + sb.AppendLine("----------------------"); + + if (personnelCount > 0) + { + sb.AppendLine(ChatbotResources.Get("Section_Personnel", culture)); + sb.Append(personnelLines); + } + + if (unitCount > 0) + { + sb.AppendLine(ChatbotResources.Get("Section_Units", culture)); + sb.Append(unitLines); + } + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("CallResp_Error", culture), Processed = false }; + } + } + + private async Task ResolveCallAsync(ChatbotIntent intent, int departmentId) + { + intent.Parameters.TryGetValue("callRef", out var reference); + if (string.IsNullOrWhiteSpace(reference)) + intent.Parameters.TryGetValue("callId", out reference); + + // "who's on call?" leaves a bare filler word behind; treat it as no reference. + var cleaned = reference?.Trim(); + if (string.Equals(cleaned, "call", StringComparison.OrdinalIgnoreCase) + || string.Equals(cleaned, "the call", StringComparison.OrdinalIgnoreCase) + || string.Equals(cleaned, "scene", StringComparison.OrdinalIgnoreCase)) + cleaned = null; + + if (!string.IsNullOrWhiteSpace(cleaned)) + return await Services.CallReferenceResolver.ResolveAsync(_callsService, departmentId, cleaned); + + // No reference: when exactly one call is active it is unambiguous. + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId); + return activeCalls?.Count == 1 ? activeCalls[0] : null; + } + + private static bool BucketMatches(ResponderBucket bucket, string mode) + { + return mode switch + { + "enroute" => bucket == ResponderBucket.EnRoute, + "onscene" => bucket == ResponderBucket.OnScene, + _ => bucket != ResponderBucket.None + }; + } + + private async Task ClassifyPersonnelAsync(int departmentId, ActionLog log) + { + if (log == null) + return ResponderBucket.None; + + if (log.ActionTypeId <= 25) + { + return (ActionTypes)log.ActionTypeId switch + { + ActionTypes.Responding => ResponderBucket.EnRoute, + ActionTypes.RespondingToScene => ResponderBucket.EnRoute, + ActionTypes.RespondingToStation => ResponderBucket.EnRoute, + ActionTypes.OnScene => ResponderBucket.OnScene, + _ => ResponderBucket.None + }; + } + + var detail = await _customStateService.GetCustomDetailForDepartmentAsync(departmentId, log.ActionTypeId); + return BucketForBaseType(detail?.BaseType); + } + + private async Task ClassifyUnitAsync(int departmentId, int state) + { + if (state <= 25) + { + return (UnitStateTypes)state switch + { + UnitStateTypes.Responding => ResponderBucket.EnRoute, + UnitStateTypes.Enroute => ResponderBucket.EnRoute, + UnitStateTypes.OnScene => ResponderBucket.OnScene, + UnitStateTypes.Staging => ResponderBucket.OnScene, + _ => ResponderBucket.None + }; + } + + var detail = await _customStateService.GetCustomDetailForDepartmentAsync(departmentId, state); + return BucketForBaseType(detail?.BaseType); + } + + private static ResponderBucket BucketForBaseType(int? baseType) + { + if (!baseType.HasValue) + return ResponderBucket.None; + + return (ActionBaseTypes)baseType.Value switch + { + ActionBaseTypes.Responding => ResponderBucket.EnRoute, + ActionBaseTypes.Enroute => ResponderBucket.EnRoute, + ActionBaseTypes.Transporting => ResponderBucket.EnRoute, + ActionBaseTypes.OnScene => ResponderBucket.OnScene, + ActionBaseTypes.MadeContact => ResponderBucket.OnScene, + ActionBaseTypes.AtPatient => ResponderBucket.OnScene, + ActionBaseTypes.Staging => ResponderBucket.OnScene, + ActionBaseTypes.Searching => ResponderBucket.OnScene, + _ => ResponderBucket.None + }; + } + } +} diff --git a/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs index b6e125a75..3bf3f8047 100644 --- a/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/HelpActionHandler.cs @@ -123,6 +123,9 @@ private static string BuildCallsHelp() + "CALLS: list active calls\n" + "C: call detail (e.g. C1445)\n" + "RESPOND TO C: mark responding to a call\n" + + "WHO'S ON : who is responding/on scene\n" + + "WHO'S DISPATCHED TO : the dispatch list\n" + + "WHAT CALLS AM I ON: your dispatched calls\n" + "DISPATCH
: create a new call\n" + "CLOSE CALL C: close a call"; } @@ -130,9 +133,10 @@ private static string BuildCallsHelp() private static string BuildMessagesHelp() { return "Messages:\n" - + "MESSAGES or MSG: list your messages\n" + + "MESSAGES or NEW MESSAGES: list unread messages\n" + "#: read a message\n" + "REPLY YES/NO TO #: respond\n" + + "YES or NO: answers your latest poll\n" + "DELETE MSG : delete\n" + "SEND MESSAGE TO : "; } @@ -141,6 +145,8 @@ private static string BuildUnitsHelp() { return "Units:\n" + "UNITS: list unit statuses\n" + + "UNITS AVAILABLE: units free to respond\n" + + "WHAT CALLS IS ON: a unit's dispatched calls\n" + "SET UNIT TO "; } @@ -148,6 +154,7 @@ private static string BuildShiftsHelp() { return "Shifts:\n" + "SHIFTS: list your shifts\n" + + "MY SCHEDULE [FOR ]: shifts + RSVP'd events\n" + "SIGNUP SHIFT : take a shift\n" + "DROP SHIFT : release a shift"; } @@ -163,7 +170,9 @@ private static string BuildPersonnelHelp() { return "Personnel:\n" + "PERSONNEL: personnel status list\n" - + "WHO IS / WHERE IS "; + + "WHO'S AVAILABLE: who can respond right now\n" + + "WHO IS / WHERE IS \n" + + "POLL : yes/no poll to all members (admins)"; } private static string BuildDepartmentsHelp() diff --git a/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs new file mode 100644 index 000000000..5aad78416 --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Answers "what calls am I on?" (intent ) and "what calls is + /// Rescue 6 on?" (intent ): the ACTIVE calls whose dispatch + /// lists include the requesting user (direct personnel dispatch) or the named unit. + /// + public class MyCallsActionHandler : IChatbotActionHandler + { + private const int MaxActiveCallsToScan = 25; + private const int MaxCallsToList = 10; + + private readonly ICallsService _callsService; + private readonly IUnitsService _unitsService; + + public MyCallsActionHandler(ICallsService callsService, IUnitsService unitsService) + { + _callsService = callsService; + _unitsService = unitsService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.MyCalls; + + public bool CanHandle(ChatbotIntentType intentType) + => intentType == ChatbotIntentType.MyCalls || intentType == ChatbotIntentType.UnitCalls; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + if (intent.Type == ChatbotIntentType.UnitCalls) + return await HandleUnitCallsAsync(intent, session, culture); + + return await HandleMyCallsAsync(session, culture); + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("MyCalls_Error", culture), Processed = false }; + } + } + + private async Task HandleMyCallsAsync(ChatbotSession session, string culture) + { + var myCalls = new List(); + foreach (var call in await GetActiveCallsWithDispatchesAsync(session.DepartmentId, forUnits: false)) + { + if (call.Dispatches != null && call.Dispatches.Any(d => d.UserId == session.UserId)) + myCalls.Add(call); + } + + if (myCalls.Count == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("MyCalls_None", culture), Processed = true }; + + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get("MyCalls_Header", culture)); + sb.AppendLine("----------------------"); + foreach (var call in myCalls.Take(MaxCallsToList)) + sb.AppendLine(ChatbotResources.Get("Calls_Line", culture, call.CallId, call.Name?.Truncate(25), call.NatureOfCall?.Truncate(40))); + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + + private async Task HandleUnitCallsAsync(ChatbotIntent intent, ChatbotSession session, string culture) + { + intent.Parameters.TryGetValue("unitName", out var unitName); + if (string.IsNullOrWhiteSpace(unitName)) + return new ChatbotResponse { Text = ChatbotResources.Get("UnitCalls_Specify", culture), Processed = false }; + + var units = await _unitsService.GetUnitsForDepartmentAsync(session.DepartmentId); + var query = unitName.Trim().ToLowerInvariant(); + Unit unit = null; + + if (int.TryParse(query, out var unitId)) + unit = units?.FirstOrDefault(u => u.UnitId == unitId); + + unit ??= units?.FirstOrDefault(u => u.Name != null && u.Name.ToLowerInvariant() == query) + ?? units?.FirstOrDefault(u => u.Name != null && u.Name.ToLowerInvariant().Contains(query)); + + if (unit == null) + return new ChatbotResponse { Text = ChatbotResources.Get("Unit_NotFound", culture, unitName), Processed = true }; + + var unitCalls = new List(); + foreach (var call in await GetActiveCallsWithDispatchesAsync(session.DepartmentId, forUnits: true)) + { + if (call.UnitDispatches != null && call.UnitDispatches.Any(d => d.UnitId == unit.UnitId)) + unitCalls.Add(call); + } + + if (unitCalls.Count == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("UnitCalls_None", culture, unit.Name), Processed = true }; + + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get("UnitCalls_Header", culture, unit.Name)); + sb.AppendLine("----------------------"); + foreach (var call in unitCalls.Take(MaxCallsToList)) + sb.AppendLine(ChatbotResources.Get("Calls_Line", culture, call.CallId, call.Name?.Truncate(25), call.NatureOfCall?.Truncate(40))); + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + + private async Task> GetActiveCallsWithDispatchesAsync(int departmentId, bool forUnits) + { + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId); + var populated = new List(); + + foreach (var call in (activeCalls ?? new List()).OrderByDescending(c => c.LoggedOn).Take(MaxActiveCallsToScan)) + { + populated.Add(await _callsService.PopulateCallData(call, + getDispatches: !forUnits, + getAttachments: false, + getNotes: false, + getGroupDispatches: false, + getUnitDispatches: forUnits, + getRoleDispatches: false, + getProtocols: false, + getReferences: false, + getContacts: false)); + } + + return populated; + } + } +} diff --git a/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs new file mode 100644 index 000000000..1bae66d71 --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Answers "what's my schedule?" / "my schedule for 7/22" (intent ): + /// the user's shifts (assigned or signed up) plus calendar events they RSVP'd to, for the requested day + /// (default today) in the department's local time. + /// + public class MyScheduleActionHandler : IChatbotActionHandler + { + private readonly IShiftsService _shiftsService; + private readonly ICalendarService _calendarService; + private readonly IDepartmentsService _departmentsService; + + public MyScheduleActionHandler( + IShiftsService shiftsService, + ICalendarService calendarService, + IDepartmentsService departmentsService) + { + _shiftsService = shiftsService; + _calendarService = calendarService; + _departmentsService = departmentsService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.MySchedule; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); + var nowLocal = DateTime.UtcNow.TimeConverter(department); + + intent.Parameters.TryGetValue("day", out var dayText); + var targetDate = ParseDay(dayText, nowLocal); + if (targetDate == null) + return new ChatbotResponse { Text = ChatbotResources.Get("Sched_BadDate", culture), Processed = false }; + + var dateLabel = targetDate.Value.ToString("ddd M/d", CultureInfo.InvariantCulture); + var lines = new List(); + + // Shifts: shift days on the target date the user is assigned to (shift personnel) or has + // signed up for. + var shiftDays = await _shiftsService.GetShiftDaysForDayAsync(targetDate.Value, session.DepartmentId); + if (shiftDays != null && shiftDays.Count > 0) + { + var assignedShiftIds = (await _shiftsService.GetShiftPersonsForUserAsync(session.UserId) ?? new List()) + .Select(p => p.ShiftId) + .ToHashSet(); + var signups = (await _shiftsService.GetShiftSignupsForUserAsync(session.UserId) ?? new List()) + .Where(s => !s.Denied && s.ShiftDay.Date == targetDate.Value.Date) + .ToList(); + + foreach (var shiftDay in shiftDays.Where(d => d.Day.Date == targetDate.Value.Date)) + { + var onThisDay = assignedShiftIds.Contains(shiftDay.ShiftId) + || signups.Any(s => s.ShiftId == shiftDay.ShiftId); + if (!onThisDay) + continue; + + var shift = shiftDay.Shift ?? await _shiftsService.GetShiftByIdAsync(shiftDay.ShiftId); + var times = $"{shiftDay.Start:t} - {shiftDay.End:t}"; + lines.Add(ChatbotResources.Get("Sched_ShiftLine", culture, shift?.Name ?? $"Shift {shiftDay.ShiftId}", times)); + } + } + + // Calendar: events on the target date the user RSVP'd to (yes/required/maybe — anything + // but Not Attending). Events are stored UTC; comparison happens in department local time, + // so the fetch window pads a day each side. + var windowStartUtc = targetDate.Value.Date.AddDays(-1); + var windowEndUtc = targetDate.Value.Date.AddDays(2); + var events = await _calendarService.GetAllCalendarItemsForDepartmentInRangeAsync(session.DepartmentId, windowStartUtc, windowEndUtc); + + foreach (var item in (events ?? new List()).OrderBy(i => i.Start)) + { + var startLocal = item.Start.TimeConverter(department); + if (startLocal.Date != targetDate.Value.Date) + continue; + + var attendee = await _calendarService.GetCalendarItemAttendeeByUserAsync(item.CalendarItemId, session.UserId); + if (attendee == null || attendee.AttendeeType == (int)CalendarItemAttendeeTypes.NotAttending) + continue; + + var timeText = item.IsAllDay ? ChatbotResources.Get("Sched_AllDay", culture) : startLocal.ToString("t"); + lines.Add(ChatbotResources.Get("Sched_EventLine", culture, timeText, item.Title?.Truncate(50))); + } + + if (lines.Count == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("Sched_None", culture, dateLabel), Processed = true }; + + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get("Sched_Header", culture, dateLabel)); + sb.AppendLine("----------------------"); + foreach (var line in lines) + sb.AppendLine(line); + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("Sched_Error", culture), Processed = false }; + } + } + + // Accepts: empty (today), "today", "tomorrow", a weekday name (next occurrence, today included), + // or a date ("7/22", "7/22/2026", "2026-07-22"). Returns null when unparseable. + private static DateTime? ParseDay(string dayText, DateTime nowLocal) + { + if (string.IsNullOrWhiteSpace(dayText)) + return nowLocal.Date; + + var text = dayText.Trim().TrimEnd('?', '!', '.', ',').ToLowerInvariant(); + + if (text == "today") + return nowLocal.Date; + if (text == "tomorrow") + return nowLocal.Date.AddDays(1); + + foreach (DayOfWeek dow in Enum.GetValues(typeof(DayOfWeek))) + { + var name = dow.ToString().ToLowerInvariant(); + if (text == name || text == name.Substring(0, 3)) + { + var daysAhead = ((int)dow - (int)nowLocal.DayOfWeek + 7) % 7; + return nowLocal.Date.AddDays(daysAhead); + } + } + + if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + // "7/22" parses with the current year; a date months in the past most likely means next + // year (people ask about upcoming days). + if (parsed.Date < nowLocal.Date.AddMonths(-1) && !text.Any(char.IsLetter) && text.Count(c => c == '/') == 1) + parsed = parsed.AddYears(1); + return parsed.Date; + } + + return null; + } + } +} diff --git a/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs b/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs new file mode 100644 index 000000000..72ea7c827 --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs @@ -0,0 +1,128 @@ +using System; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Creates a yes/no poll message to all department members (intent ), + /// e.g. "poll members to see who's available for a red flag on 7/22". Department-admin only (it texts + /// the whole roster), with a confirmation pass before sending (security addendum §5): the session parks + /// in AwaitingConfirmation and the ingress re-dispatches with "__confirmed" on YES. Recipients answer by + /// replying YES or NO (bare replies resolve against the outstanding poll — see ChatbotIngressService). + /// + public class PollCreateHandler : IChatbotActionHandler + { + // "poll members to see who's available ..." — audience/verb filler ahead of the actual question. + private static readonly Regex LeadingFillerRegex = new Regex( + @"^((the\s+)?(members|everyone|everybody|all|personnel|department|dept)\s+)?((to\s+)?(see|ask|know|find\s+out)\s+)?(if\s+|whether\s+)?", + RegexOptions.IgnoreCase, TimeSpan.FromMilliseconds(200)); + + private readonly IMessageService _messageService; + private readonly IDepartmentsService _departmentsService; + private readonly IUserProfileService _userProfileService; + + public PollCreateHandler( + IMessageService messageService, + IDepartmentsService departmentsService, + IUserProfileService userProfileService) + { + _messageService = messageService; + _departmentsService = departmentsService; + _userProfileService = userProfileService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.CreatePoll; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + intent.Parameters.TryGetValue("question", out var question); + question = CleanQuestion(question); + + if (string.IsNullOrWhiteSpace(question)) + return new ChatbotResponse { Text = ChatbotResources.Get("Poll_Usage", culture), Processed = false }; + + // Polling texts the entire roster, so only department admins may do it. + var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); + var member = await _departmentsService.GetDepartmentMemberAsync(session.UserId, session.DepartmentId); + var isAdmin = department?.IsUserAnAdmin(session.UserId) == true || member?.IsAdmin == true; + if (!isAdmin) + return new ChatbotResponse { Text = ChatbotResources.Get("Poll_NoPermission", culture), Processed = false }; + + var users = await _departmentsService.GetAllUsersForDepartmentAsync(session.DepartmentId); + var recipients = users?.Where(u => u.UserId != session.UserId).Select(u => u.UserId).Distinct().ToList(); + if (recipients == null || recipients.Count == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("Poll_NoMembers", culture), Processed = true }; + + var confirmed = intent.Parameters.TryGetValue("__confirmed", out var confirmFlag) && confirmFlag == "true"; + if (!confirmed) + { + session.State = ChatbotDialogState.AwaitingConfirmation; + session.PendingIntent = ChatbotIntentType.CreatePoll; + session.Context["question"] = question; + return new ChatbotResponse + { + Text = ChatbotResources.Get("Poll_Confirm", culture, question, recipients.Count), + Processed = true + }; + } + + var profile = await _userProfileService.GetProfileByUserIdAsync(session.UserId); + var senderName = profile?.FullName?.AsFirstNameLastName ?? "Chatbot"; + + var msg = new Message + { + // The subject leads the SMS rendering ("{subject} via Resgrid : {body}"), so it carries + // the poll marker; the body carries the question and the reply instructions. + Subject = ("Poll: " + question).Truncate(150), + Body = (question + "\nReply YES or NO.").Truncate(4000), + SendingUserId = session.UserId, + SentOn = DateTime.UtcNow, + IsBroadcast = true, + Type = (int)MessageTypes.Poll + }; + + foreach (var userId in recipients) + msg.AddRecipient(userId); + + var saved = await _messageService.SaveMessageAsync(msg); + await _messageService.SendMessageAsync(saved, senderName, session.DepartmentId); + + return new ChatbotResponse { Text = ChatbotResources.Get("Poll_Sent", culture, recipients.Count), Processed = true }; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("Poll_Error", culture), Processed = false }; + } + } + + private static string CleanQuestion(string question) + { + if (string.IsNullOrWhiteSpace(question)) + return null; + + var cleaned = question.Trim(); + try + { + cleaned = LeadingFillerRegex.Replace(cleaned, string.Empty, 1).Trim(); + } + catch (RegexMatchTimeoutException) + { + // Keep the raw question when the filler strip times out. + } + + return string.IsNullOrWhiteSpace(cleaned) ? null : cleaned; + } + } +} diff --git a/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs new file mode 100644 index 000000000..4138fa978 --- /dev/null +++ b/Core/Resgrid.Chatbot/Handlers/UnitsAvailableActionHandler.cs @@ -0,0 +1,86 @@ +using System; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Model.Reporting; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Handlers +{ + /// + /// Answers "units available?" (intent ): units whose + /// latest state classifies as Available via the canonical resolution + /// (custom unit statuses resolve through their BaseType). + /// + public class UnitsAvailableActionHandler : IChatbotActionHandler + { + private const int MaxUnitsToList = 15; + + private readonly IUnitsService _unitsService; + private readonly ICustomStateService _customStateService; + private readonly IPlatformReportingService _platformReportingService; + + public UnitsAvailableActionHandler( + IUnitsService unitsService, + ICustomStateService customStateService, + IPlatformReportingService platformReportingService) + { + _unitsService = unitsService; + _customStateService = customStateService; + _platformReportingService = platformReportingService; + } + + public ChatbotIntentType IntentType => ChatbotIntentType.UnitsAvailable; + + public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) + { + var culture = session.Culture; + try + { + var unitStatuses = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(session.DepartmentId); + + if (unitStatuses == null || !unitStatuses.Any()) + return new ChatbotResponse { Text = ChatbotResources.Get("Units_None", culture), Processed = true }; + + var lines = new StringBuilder(); + var availableCount = 0; + + foreach (var unitState in unitStatuses.Where(u => u.Unit != null).OrderBy(u => u.Unit.Name)) + { + var availability = await _platformReportingService.ClassifyUnitAvailabilityAsync(session.DepartmentId, unitState.State); + if (availability != AvailabilityClass.Available) + continue; + + availableCount++; + if (availableCount > MaxUnitsToList) + continue; + + var status = await _customStateService.GetCustomUnitStateAsync(unitState); + var statusText = status?.ButtonText ?? ChatbotResources.Get("Personnel_Unknown", culture); + lines.AppendLine(ChatbotResources.Get("UnitsAvail_Line", culture, unitState.Unit.Name, statusText)); + } + + if (availableCount == 0) + return new ChatbotResponse { Text = ChatbotResources.Get("UnitsAvail_None", culture), Processed = true }; + + var sb = new StringBuilder(); + sb.AppendLine(ChatbotResources.Get("UnitsAvail_Header", culture, availableCount)); + sb.AppendLine("----------------------"); + sb.Append(lines); + + if (availableCount > MaxUnitsToList) + sb.AppendLine(ChatbotResources.Get("Msg_AndMore", culture, availableCount - MaxUnitsToList)); + + return new ChatbotResponse { Text = sb.ToString(), Processed = true }; + } + catch (Exception ex) + { + Framework.Logging.LogException(ex); + return new ChatbotResponse { Text = ChatbotResources.Get("UnitsAvail_Error", culture), Processed = false }; + } + } + } +} diff --git a/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs b/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs index 909de86e7..83409945e 100644 --- a/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs +++ b/Core/Resgrid.Chatbot/Localization/ChatbotResources.cs @@ -1550,6 +1550,448 @@ private static Dictionary EnOnly(string en) "Błąd podczas zmiany działu.", "Помилка зміни підрозділу.", "حدث خطأ أثناء تبديل القسم."), + + // ---- AvailabilityActionHandler (who's available/around) ---- + + ["Avail_Header"] = L( + "Available to Respond ({0}):", + "Disponibles para responder ({0}):", + "Tillgängliga att svara ({0}):", + "Einsatzbereit ({0}):", + "Disponibles pour intervenir ({0}) :", + "Disponibili a rispondere ({0}):", + "Dostępni do reagowania ({0}):", + "Доступні для реагування ({0}):", + "متاحون للاستجابة ({0}):"), + + ["Avail_None"] = L( + "No personnel are currently available to respond.", + "No hay personal disponible para responder en este momento.", + "Ingen personal är tillgänglig att svara just nu.", + "Derzeit ist kein Personal einsatzbereit.", + "Aucun personnel n'est actuellement disponible pour intervenir.", + "Nessun membro del personale è attualmente disponibile a rispondere.", + "Obecnie żaden personel nie jest dostępny do reagowania.", + "Наразі немає доступного персоналу для реагування.", + "لا يوجد أفراد متاحون للاستجابة حاليًا."), + + ["Avail_Line"] = EnOnly("{0}, {1}: {2} / {3}"), + + ["Avail_Error"] = L( + "Error retrieving availability.", + "Error al recuperar la disponibilidad.", + "Det gick inte att hämta tillgängligheten.", + "Fehler beim Abrufen der Verfügbarkeit.", + "Erreur lors de la récupération de la disponibilité.", + "Errore durante il recupero della disponibilità.", + "Błąd podczas pobierania dostępności.", + "Помилка отримання доступності.", + "حدث خطأ أثناء استرداد التوفر."), + + // ---- UnitsAvailableActionHandler (units available) ---- + + ["UnitsAvail_Header"] = L( + "Available Units ({0}):", + "Unidades disponibles ({0}):", + "Tillgängliga enheter ({0}):", + "Verfügbare Einheiten ({0}):", + "Unités disponibles ({0}) :", + "Unità disponibili ({0}):", + "Dostępne jednostki ({0}):", + "Доступні підрозділи ({0}):", + "الوحدات المتاحة ({0}):"), + + ["UnitsAvail_None"] = L( + "No units are currently available.", + "No hay unidades disponibles en este momento.", + "Inga enheter är tillgängliga just nu.", + "Derzeit sind keine Einheiten verfügbar.", + "Aucune unité n'est actuellement disponible.", + "Nessuna unità è attualmente disponibile.", + "Obecnie żadna jednostka nie jest dostępna.", + "Наразі немає доступних підрозділів.", + "لا توجد وحدات متاحة حاليًا."), + + ["UnitsAvail_Line"] = EnOnly("{0} - {1}"), + + ["UnitsAvail_Error"] = L( + "Error retrieving unit availability.", + "Error al recuperar la disponibilidad de unidades.", + "Det gick inte att hämta enheternas tillgänglighet.", + "Fehler beim Abrufen der Einheitenverfügbarkeit.", + "Erreur lors de la récupération de la disponibilité des unités.", + "Errore durante il recupero della disponibilità delle unità.", + "Błąd podczas pobierania dostępności jednostek.", + "Помилка отримання доступності підрозділів.", + "حدث خطأ أثناء استرداد توفر الوحدات."), + + // ---- CallRespondersActionHandler (who's on/en route/on scene for a call) ---- + + ["CallResp_Specify"] = L( + "Which call? Reply WHO'S ON (e.g. WHO'S ON C1445).", + "¿Qué llamada? Responde WHO'S ON (p. ej. WHO'S ON C1445).", + "Vilket larm? Svara WHO'S ON (t.ex. WHO'S ON C1445).", + "Welcher Einsatz? Antworten Sie WHO'S ON (z. B. WHO'S ON C1445).", + "Quel appel ? Répondez WHO'S ON (p. ex. WHO'S ON C1445).", + "Quale chiamata? Rispondi WHO'S ON (es. WHO'S ON C1445).", + "Które zgłoszenie? Odpowiedz WHO'S ON (np. WHO'S ON C1445).", + "Який виклик? Відповідайте WHO'S ON (напр. WHO'S ON C1445).", + "أي بلاغ؟ أرسل WHO'S ON <المعرف أو الرقم أو الكلمة المفتاحية> (مثال: WHO'S ON C1445)."), + + ["CallResp_HeaderAll"] = L( + "Responding & on scene for {0}:", + "Respondiendo y en escena para {0}:", + "Svarar och på plats för {0}:", + "Auf Anfahrt & vor Ort für {0}:", + "En route et sur les lieux pour {0} :", + "In risposta e sul posto per {0}:", + "W drodze i na miejscu dla {0}:", + "Реагують та на місці для {0}:", + "في الطريق وفي الموقع للبلاغ {0}:"), + + ["CallResp_HeaderEnroute"] = L( + "En route to {0}:", + "En camino a {0}:", + "På väg till {0}:", + "Auf Anfahrt zu {0}:", + "En route vers {0} :", + "In arrivo a {0}:", + "W drodze do {0}:", + "У дорозі до {0}:", + "في الطريق إلى {0}:"), + + ["CallResp_HeaderOnScene"] = L( + "On scene at {0}:", + "En escena en {0}:", + "På plats vid {0}:", + "Vor Ort bei {0}:", + "Sur les lieux de {0} :", + "Sul posto per {0}:", + "Na miejscu przy {0}:", + "На місці події {0}:", + "في موقع البلاغ {0}:"), + + ["CallResp_None"] = L( + "No personnel or units are currently responding to {0}.", + "Ningún personal ni unidad está respondiendo actualmente a {0}.", + "Ingen personal eller enhet svarar just nu på {0}.", + "Derzeit reagieren keine Einsatzkräfte oder Einheiten auf {0}.", + "Aucun personnel ni unité ne répond actuellement à {0}.", + "Nessun personale o unità sta attualmente rispondendo a {0}.", + "Żaden personel ani jednostka obecnie nie reaguje na {0}.", + "Наразі жоден персонал чи підрозділ не реагує на {0}.", + "لا يوجد أفراد أو وحدات يستجيبون حاليًا للبلاغ {0}."), + + ["CallResp_Line"] = EnOnly("{0} - {1}"), + + ["CallResp_Error"] = L( + "Error retrieving call responders.", + "Error al recuperar quiénes responden a la llamada.", + "Det gick inte att hämta vilka som svarar på larmet.", + "Fehler beim Abrufen der Einsatzkräfte.", + "Erreur lors de la récupération des intervenants.", + "Errore durante il recupero dei soccorritori della chiamata.", + "Błąd podczas pobierania osób reagujących na zgłoszenie.", + "Помилка отримання списку реагуючих на виклик.", + "حدث خطأ أثناء استرداد المستجيبين للبلاغ."), + + // ---- Shared list section labels ---- + + ["Section_Personnel"] = L( + "Personnel:", + "Personal:", + "Personal:", + "Personal:", + "Personnel :", + "Personale:", + "Personel:", + "Персонал:", + "الأفراد:"), + + ["Section_Units"] = L( + "Units:", + "Unidades:", + "Enheter:", + "Einheiten:", + "Unités :", + "Unità:", + "Jednostki:", + "Підрозділи:", + "الوحدات:"), + + ["Section_Groups"] = L( + "Groups:", + "Grupos:", + "Grupper:", + "Gruppen:", + "Groupes :", + "Gruppi:", + "Grupy:", + "Групи:", + "المجموعات:"), + + ["Section_Roles"] = L( + "Roles:", + "Roles:", + "Roller:", + "Rollen:", + "Rôles :", + "Ruoli:", + "Role:", + "Ролі:", + "الأدوار:"), + + // ---- CallDispatchedActionHandler (who got dispatched) ---- + + ["CallDisp_Header"] = L( + "Dispatched to {0}:", + "Despachados a {0}:", + "Utlarmade till {0}:", + "Alarmiert für {0}:", + "Engagés sur {0} :", + "Inviati a {0}:", + "Zadysponowani do {0}:", + "Відправлені на {0}:", + "تم إرسالهم إلى {0}:"), + + ["CallDisp_None"] = L( + "No dispatches are recorded for {0}.", + "No hay despachos registrados para {0}.", + "Inga utlarmningar är registrerade för {0}.", + "Für {0} sind keine Alarmierungen erfasst.", + "Aucun engagement enregistré pour {0}.", + "Nessun invio registrato per {0}.", + "Brak zarejestrowanych dyspozycji dla {0}.", + "Для {0} не зафіксовано жодних відправлень.", + "لا توجد عمليات إرسال مسجلة للبلاغ {0}."), + + ["CallDisp_Error"] = L( + "Error retrieving the dispatch list.", + "Error al recuperar la lista de despacho.", + "Det gick inte att hämta utlarmningslistan.", + "Fehler beim Abrufen der Alarmierungsliste.", + "Erreur lors de la récupération de la liste d'engagement.", + "Errore durante il recupero dell'elenco degli invii.", + "Błąd podczas pobierania listy dyspozycji.", + "Помилка отримання списку відправлень.", + "حدث خطأ أثناء استرداد قائمة الإرسال."), + + // ---- MyCallsActionHandler (my calls / unit calls) ---- + + ["MyCalls_Header"] = L( + "Your active calls:", + "Tus llamadas activas:", + "Dina aktiva larm:", + "Ihre aktiven Einsätze:", + "Vos appels actifs :", + "Le tue chiamate attive:", + "Twoje aktywne zgłoszenia:", + "Ваші активні виклики:", + "بلاغاتك النشطة:"), + + ["MyCalls_None"] = L( + "You are not dispatched to any active calls.", + "No estás despachado a ninguna llamada activa.", + "Du är inte utlarmad till några aktiva larm.", + "Sie sind zu keinem aktiven Einsatz alarmiert.", + "Vous n'êtes engagé sur aucun appel actif.", + "Non sei stato inviato a nessuna chiamata attiva.", + "Nie jesteś zadysponowany do żadnych aktywnych zgłoszeń.", + "Вас не відправлено на жодний активний виклик.", + "لم يتم إرسالك إلى أي بلاغات نشطة."), + + ["MyCalls_Error"] = L( + "Error retrieving your calls.", + "Error al recuperar tus llamadas.", + "Det gick inte att hämta dina larm.", + "Fehler beim Abrufen Ihrer Einsätze.", + "Erreur lors de la récupération de vos appels.", + "Errore durante il recupero delle tue chiamate.", + "Błąd podczas pobierania Twoich zgłoszeń.", + "Помилка отримання ваших викликів.", + "حدث خطأ أثناء استرداد بلاغاتك."), + + ["UnitCalls_Header"] = L( + "Active calls for {0}:", + "Llamadas activas de {0}:", + "Aktiva larm för {0}:", + "Aktive Einsätze für {0}:", + "Appels actifs pour {0} :", + "Chiamate attive per {0}:", + "Aktywne zgłoszenia dla {0}:", + "Активні виклики для {0}:", + "البلاغات النشطة للوحدة {0}:"), + + ["UnitCalls_None"] = L( + "{0} is not dispatched to any active calls.", + "{0} no está despachada a ninguna llamada activa.", + "{0} är inte utlarmad till några aktiva larm.", + "{0} ist zu keinem aktiven Einsatz alarmiert.", + "{0} n'est engagée sur aucun appel actif.", + "{0} non è stata inviata a nessuna chiamata attiva.", + "{0} nie jest zadysponowana do żadnych aktywnych zgłoszeń.", + "{0} не відправлено на жодний активний виклик.", + "لم يتم إرسال {0} إلى أي بلاغات نشطة."), + + ["UnitCalls_Specify"] = L( + "Which unit? Reply WHAT CALLS IS ON.", + "¿Qué unidad? Responde WHAT CALLS IS ON.", + "Vilken enhet? Svara WHAT CALLS IS ON.", + "Welche Einheit? Antworten Sie WHAT CALLS IS ON.", + "Quelle unité ? Répondez WHAT CALLS IS ON.", + "Quale unità? Rispondi WHAT CALLS IS ON.", + "Która jednostka? Odpowiedz WHAT CALLS IS ON.", + "Який підрозділ? Відповідайте WHAT CALLS IS <назва підрозділу> ON.", + "أي وحدة؟ أرسل WHAT CALLS IS <اسم الوحدة> ON."), + + // ---- PollCreateHandler / poll replies ---- + + ["Poll_Usage"] = L( + "To poll members: POLL , e.g. POLL Are you available for a red flag on 7/22?", + "Para encuestar a los miembros: POLL , p. ej. POLL ¿Estás disponible el 22/7?", + "För att fråga medlemmarna: POLL , t.ex. POLL Är du tillgänglig 22/7?", + "Um Mitglieder zu befragen: POLL , z. B. POLL Bist du am 22.7. verfügbar?", + "Pour sonder les membres : POLL , p. ex. POLL Es-tu disponible le 22/7 ?", + "Per un sondaggio ai membri: POLL , es. POLL Sei disponibile il 22/7?", + "Aby przeprowadzić ankietę: POLL , np. POLL Czy jesteś dostępny 22.07?", + "Щоб опитати членів: POLL <питання так/ні>, напр. POLL Чи доступні ви 22.07?", + "لاستطلاع الأعضاء: POLL <سؤال نعم/لا>، مثال: POLL هل أنت متاح يوم 22/7؟"), + + ["Poll_NoPermission"] = L( + "Only department admins can poll members.", + "Solo los administradores del departamento pueden encuestar a los miembros.", + "Endast avdelningsadministratörer kan skicka omröstningar till medlemmar.", + "Nur Abteilungsadministratoren können Mitglieder befragen.", + "Seuls les administrateurs du département peuvent sonder les membres.", + "Solo gli amministratori del dipartimento possono creare sondaggi.", + "Tylko administratorzy działu mogą przeprowadzać ankiety.", + "Лише адміністратори підрозділу можуть проводити опитування.", + "يمكن لمسؤولي القسم فقط استطلاع الأعضاء."), + + ["Poll_NoMembers"] = L( + "No members found to poll.", + "No se encontraron miembros para encuestar.", + "Inga medlemmar hittades att fråga.", + "Keine Mitglieder zum Befragen gefunden.", + "Aucun membre trouvé à sonder.", + "Nessun membro trovato per il sondaggio.", + "Nie znaleziono członków do ankiety.", + "Не знайдено членів для опитування.", + "لم يتم العثور على أعضاء للاستطلاع."), + + ["Poll_Confirm"] = L( + "Send the poll \"{0}\" to {1} members? Reply YES to send or NO to cancel.", + "¿Enviar la encuesta \"{0}\" a {1} miembros? Responde YES para enviar o NO para cancelar.", + "Skicka omröstningen \"{0}\" till {1} medlemmar? Svara YES för att skicka eller NO för att avbryta.", + "Umfrage \"{0}\" an {1} Mitglieder senden? Antworten Sie YES zum Senden oder NO zum Abbrechen.", + "Envoyer le sondage \"{0}\" à {1} membres ? Répondez YES pour envoyer ou NO pour annuler.", + "Inviare il sondaggio \"{0}\" a {1} membri? Rispondi YES per inviare o NO per annullare.", + "Wysłać ankietę \"{0}\" do {1} członków? Odpowiedz YES, aby wysłać, lub NO, aby anulować.", + "Надіслати опитування \"{0}\" {1} членам? Відповідайте YES, щоб надіслати, або NO, щоб скасувати.", + "إرسال الاستطلاع \"{0}\" إلى {1} من الأعضاء؟ أرسل YES للإرسال أو NO للإلغاء."), + + ["Poll_Sent"] = L( + "Poll sent to {0} members. They can answer by replying YES or NO.", + "Encuesta enviada a {0} miembros. Pueden contestar respondiendo YES o NO.", + "Omröstningen skickades till {0} medlemmar. De kan svara med YES eller NO.", + "Umfrage an {0} Mitglieder gesendet. Sie können mit YES oder NO antworten.", + "Sondage envoyé à {0} membres. Ils peuvent répondre par YES ou NO.", + "Sondaggio inviato a {0} membri. Possono rispondere con YES o NO.", + "Ankieta wysłana do {0} członków. Mogą odpowiedzieć YES lub NO.", + "Опитування надіслано {0} членам. Вони можуть відповісти YES або NO.", + "تم إرسال الاستطلاع إلى {0} من الأعضاء. يمكنهم الرد بـ YES أو NO."), + + ["Poll_Error"] = L( + "Error creating the poll.", + "Error al crear la encuesta.", + "Det gick inte att skapa omröstningen.", + "Fehler beim Erstellen der Umfrage.", + "Erreur lors de la création du sondage.", + "Errore durante la creazione del sondaggio.", + "Błąd podczas tworzenia ankiety.", + "Помилка створення опитування.", + "حدث خطأ أثناء إنشاء الاستطلاع."), + + ["Poll_ResponseRecorded"] = L( + "Recorded your \"{0}\" reply to {1}.", + "Se registró tu respuesta \"{0}\" a {1}.", + "Ditt svar \"{0}\" på {1} har registrerats.", + "Ihre Antwort \"{0}\" auf {1} wurde erfasst.", + "Votre réponse \"{0}\" à {1} a été enregistrée.", + "La tua risposta \"{0}\" a {1} è stata registrata.", + "Zapisano Twoją odpowiedź \"{0}\" na {1}.", + "Вашу відповідь \"{0}\" на {1} записано.", + "تم تسجيل ردك \"{0}\" على {1}."), + + // ---- MyScheduleActionHandler (my schedule) ---- + + ["Sched_Header"] = L( + "Your schedule for {0}:", + "Tu agenda para {0}:", + "Ditt schema för {0}:", + "Ihr Zeitplan für {0}:", + "Votre planning pour {0} :", + "Il tuo programma per {0}:", + "Twój harmonogram na {0}:", + "Ваш розклад на {0}:", + "جدولك ليوم {0}:"), + + ["Sched_None"] = L( + "Nothing on your schedule for {0}.", + "No hay nada en tu agenda para {0}.", + "Inget på ditt schema för {0}.", + "Nichts auf Ihrem Zeitplan für {0}.", + "Rien à votre planning pour {0}.", + "Niente in programma per {0}.", + "Brak pozycji w harmonogramie na {0}.", + "У вашому розкладі на {0} нічого немає.", + "لا يوجد شيء في جدولك ليوم {0}."), + + ["Sched_ShiftLine"] = L( + "Shift: {0} ({1})", + "Turno: {0} ({1})", + "Skift: {0} ({1})", + "Schicht: {0} ({1})", + "Garde : {0} ({1})", + "Turno: {0} ({1})", + "Zmiana: {0} ({1})", + "Зміна: {0} ({1})", + "المناوبة: {0} ({1})"), + + ["Sched_EventLine"] = EnOnly("{0}: {1}"), + + ["Sched_AllDay"] = L( + "All day", + "Todo el día", + "Hela dagen", + "Ganztägig", + "Toute la journée", + "Tutto il giorno", + "Cały dzień", + "Увесь день", + "طوال اليوم"), + + ["Sched_BadDate"] = L( + "I couldn't understand that date. Try MY SCHEDULE FOR 7/22.", + "No pude entender esa fecha. Prueba MY SCHEDULE FOR 7/22.", + "Jag förstod inte det datumet. Prova MY SCHEDULE FOR 7/22.", + "Ich konnte dieses Datum nicht verstehen. Versuchen Sie MY SCHEDULE FOR 7/22.", + "Je n'ai pas compris cette date. Essayez MY SCHEDULE FOR 7/22.", + "Non ho capito quella data. Prova MY SCHEDULE FOR 7/22.", + "Nie rozumiem tej daty. Spróbuj MY SCHEDULE FOR 7/22.", + "Не вдалося розпізнати цю дату. Спробуйте MY SCHEDULE FOR 7/22.", + "لم أفهم هذا التاريخ. جرّب MY SCHEDULE FOR 7/22."), + + ["Sched_Error"] = L( + "Error retrieving your schedule.", + "Error al recuperar tu agenda.", + "Det gick inte att hämta ditt schema.", + "Fehler beim Abrufen Ihres Zeitplans.", + "Erreur lors de la récupération de votre planning.", + "Errore durante il recupero del tuo programma.", + "Błąd podczas pobierania harmonogramu.", + "Помилка отримання вашого розкладу.", + "حدث خطأ أثناء استرداد جدولك."), }; } } diff --git a/Core/Resgrid.Chatbot/Models/ChatbotIntent.cs b/Core/Resgrid.Chatbot/Models/ChatbotIntent.cs index f9d26f262..2ffc30628 100644 --- a/Core/Resgrid.Chatbot/Models/ChatbotIntent.cs +++ b/Core/Resgrid.Chatbot/Models/ChatbotIntent.cs @@ -36,7 +36,15 @@ public enum ChatbotIntentType MessageDetail = 29, DeleteMessage = 30, RespondToMessage = 31, - ShiftDrop = 32 + ShiftDrop = 32, + WhoIsAvailable = 33, + UnitsAvailable = 34, + CallResponders = 35, + CallDispatched = 36, + MyCalls = 37, + UnitCalls = 38, + CreatePoll = 39, + MySchedule = 40 } public class ChatbotIntent diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 04db97031..7f292049e 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -26,9 +26,13 @@ public class ChatbotIngressService : IChatbotIngressService private readonly IChatbotDepartmentConfigService _departmentConfigService; private readonly IChatbotRateLimiter _rateLimiter; private readonly ISecurityPinService _securityPinService; + private readonly IMessageService _messageService; private const int MaxPinAttempts = 3; + // Newest-first outstanding-poll scan depth for bare YES/NO replies. + private const int MaxPollCandidatesToScan = 5; + public ChatbotIngressService( IChatbotUserIdentityService userIdentityService, IChatbotSessionManager sessionManager, @@ -43,7 +47,8 @@ public ChatbotIngressService( IAuthorizationService authorizationService, IChatbotDepartmentConfigService departmentConfigService, IChatbotRateLimiter rateLimiter, - ISecurityPinService securityPinService) + ISecurityPinService securityPinService, + IMessageService messageService) { _userIdentityService = userIdentityService; _sessionManager = sessionManager; @@ -59,6 +64,7 @@ public ChatbotIngressService( _departmentConfigService = departmentConfigService; _rateLimiter = rateLimiter; _securityPinService = securityPinService; + _messageService = messageService; } public async Task ProcessMessageAsync(ChatbotMessage message) @@ -455,6 +461,25 @@ await _userIdentityService.LinkUserAsync( } } + // 5c. Bare YES/NO with no dialog pending: the user is most likely answering an + // outstanding poll message ("Poll: ... Reply YES or NO."). Resolved BEFORE the classifier + // so the reply never hits the cloud NLU; when no unanswered poll exists this falls through. + if (session.State == ChatbotDialogState.Idle) + { + string pollAnswer = null; + if (Localization.ChatbotResources.IsAffirmative(message.Text, session.Culture)) + pollAnswer = "Yes"; + else if (Localization.ChatbotResources.IsNegative(message.Text, session.Culture)) + pollAnswer = "No"; + + if (pollAnswer != null) + { + var pollResponse = await TryRecordPollResponseAsync(identity.UserId, pollAnswer, session.Culture); + if (pollResponse != null) + return pollResponse; + } + } + // 6. Classify intent (explicit STOP/UNSUBSCRIBE already handled before the state machine). var intent = await _intentRouter.ClassifyIntentAsync(message, session); @@ -521,6 +546,51 @@ await _userIdentityService.LinkUserAsync( } } + /// + /// Records a bare YES/NO reply against the user's most recent unanswered poll message. Returns + /// null when the user has no outstanding poll (the reply then flows on to normal classification). + /// + private async Task TryRecordPollResponseAsync(string userId, string answer, string culture) + { + try + { + var inbox = await _messageService.GetInboxMessagesByUserIdAsync(userId); + var pollCandidates = inbox? + .Where(m => m.Type == (int)Model.MessageTypes.Poll && !m.IsDeleted + && (m.ExpireOn == null || m.ExpireOn > DateTime.UtcNow)) + .OrderByDescending(m => m.SentOn) + .Take(MaxPollCandidatesToScan); + + if (pollCandidates == null) + return null; + + foreach (var poll in pollCandidates) + { + var recipient = await _messageService.GetMessageRecipientByMessageAndUserAsync(poll.MessageId, userId); + if (recipient == null || !string.IsNullOrWhiteSpace(recipient.Response)) + continue; + + recipient.Response = answer; + if (recipient.ReadOn == null) + recipient.ReadOn = DateTime.UtcNow; + + await _messageService.SaveMessageRecipientAsync(recipient); + + return new ChatbotResponse + { + Text = Localization.ChatbotResources.Get("Poll_ResponseRecorded", culture, answer, poll.Subject), + Processed = true + }; + } + } + catch (Exception ex) + { + Logging.LogException(ex); + } + + return null; + } + private async Task ResolveUserIdentityAsync(ChatbotMessage message) { // Platform-specific identity (already linked). diff --git a/Core/Resgrid.Chatbot/Services/IntentMapper.cs b/Core/Resgrid.Chatbot/Services/IntentMapper.cs index 5800e77eb..e1f0ea40b 100644 --- a/Core/Resgrid.Chatbot/Services/IntentMapper.cs +++ b/Core/Resgrid.Chatbot/Services/IntentMapper.cs @@ -40,6 +40,14 @@ public class IntentMapper : IIntentMapper ["list_departments"] = ChatbotIntentType.ListDepartments, ["get_active_department"] = ChatbotIntentType.GetActiveDepartment, ["switch_department"] = ChatbotIntentType.SwitchDepartment, + ["who_available"] = ChatbotIntentType.WhoIsAvailable, + ["units_available"] = ChatbotIntentType.UnitsAvailable, + ["call_responders"] = ChatbotIntentType.CallResponders, + ["call_dispatched"] = ChatbotIntentType.CallDispatched, + ["my_calls"] = ChatbotIntentType.MyCalls, + ["unit_calls"] = ChatbotIntentType.UnitCalls, + ["create_poll"] = ChatbotIntentType.CreatePoll, + ["my_schedule"] = ChatbotIntentType.MySchedule, ["unknown"] = ChatbotIntentType.Unknown, }; diff --git a/Core/Resgrid.Services/CustomStateService.cs b/Core/Resgrid.Services/CustomStateService.cs index 11f6d9e77..b6ddae52c 100644 --- a/Core/Resgrid.Services/CustomStateService.cs +++ b/Core/Resgrid.Services/CustomStateService.cs @@ -260,6 +260,9 @@ public async Task GetCustomDetailForDepartmentAsync(int depar public async Task GetCustomPersonnelStaffingAsync(int departmentId, UserState state) { + if (state == null) + return null; + if (state.State <= 25) { var detail = new CustomStateDetail(); @@ -282,6 +285,9 @@ public async Task GetCustomPersonnelStaffingAsync(int departm public async Task GetCustomPersonnelStatusAsync(int departmentId, ActionLog state) { + if (state == null) + return null; + if (state.ActionTypeId <= 25) { var detail = new CustomStateDetail(); @@ -304,6 +310,9 @@ public async Task GetCustomPersonnelStatusAsync(int departmen public async Task GetCustomUnitStateAsync(UnitState state) { + if (state == null) + return null; + if (state.State <= 25) { var detail = new CustomStateDetail(); diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs new file mode 100644 index 000000000..312188124 --- /dev/null +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs @@ -0,0 +1,125 @@ +using System.Threading.Tasks; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Chatbot.NLU.Providers; + +namespace Resgrid.Tests.Chatbot +{ + /// + /// Keyword-classifier routing tests for the availability/call-responder/poll/schedule intents, + /// including ordering regressions (the new "who ..." patterns must not swallow personnel lookups + /// and the "what ... calls" patterns must not swallow the call list). + /// + [TestFixture] + public class ChatbotAvailabilityIntentClassifierTests + { + private KeywordIntentClassifier _classifier; + + [SetUp] + public void Setup() + { + _classifier = new KeywordIntentClassifier(); + } + + [TestCase("my status?", "my_status")] + [TestCase("my staffing?", "my_status")] + [TestCase("who's around?", "who_available")] + [TestCase("Who's available?", "who_available")] + [TestCase("who is available to respond", "who_available")] + [TestCase("anyone free", "who_available")] + [TestCase("who can respond?", "who_available")] + [TestCase("Units available?", "units_available")] + [TestCase("available units", "units_available")] + [TestCase("what units are available?", "units_available")] + [TestCase("What calls am I on?", "my_calls")] + [TestCase("my calls", "my_calls")] + [TestCase("Whats my schedule?", "my_schedule")] + [TestCase("my unread messages?", "list_messages")] + [TestCase("new messages", "list_messages")] + public async Task Classifies_intent(string text, string expectedIntent) + { + var result = await _classifier.ClassifyAsync(text); + result.IntentName.Should().Be(expectedIntent, because: $"\"{text}\" should classify as {expectedIntent}"); + } + + [Test] + public async Task WhoIsOnCall_extracts_call_reference() + { + var result = await _classifier.ClassifyAsync("Who's on call 26-1?"); + result.IntentName.Should().Be("call_responders"); + result.Parameters["mode"].Should().Be("all"); + result.Parameters["callRef"].Should().Be("26-1"); + } + + [Test] + public async Task WhoIsEnRoute_extracts_mode_and_reference() + { + var result = await _classifier.ClassifyAsync("Who's in route to the fire?"); + result.IntentName.Should().Be("call_responders"); + result.Parameters["mode"].Should().Be("enroute"); + result.Parameters["callRef"].Should().Be("the fire"); + } + + [Test] + public async Task WhoIsOnScene_extracts_mode() + { + var result = await _classifier.ClassifyAsync("who's on scene at the fire"); + result.IntentName.Should().Be("call_responders"); + result.Parameters["mode"].Should().Be("onscene"); + result.Parameters["callRef"].Should().Be("the fire"); + } + + [Test] + public async Task WhoGotDispatched_extracts_reference() + { + var result = await _classifier.ClassifyAsync("Who got dispatched to the medical?"); + result.IntentName.Should().Be("call_dispatched"); + result.Parameters["callRef"].Should().Be("the medical"); + } + + [Test] + public async Task UnitCalls_extracts_unit_name() + { + var result = await _classifier.ClassifyAsync("What calls is Rescue 6 on?"); + result.IntentName.Should().Be("unit_calls"); + result.Parameters["unitName"].Should().Be("Rescue 6"); + } + + [Test] + public async Task Poll_extracts_question() + { + var result = await _classifier.ClassifyAsync("Poll members to see who's available for a red flag on 7/22"); + result.IntentName.Should().Be("create_poll"); + result.Parameters["question"].Should().Contain("available for a red flag on 7/22"); + } + + [Test] + public async Task MySchedule_extracts_day() + { + var result = await _classifier.ClassifyAsync("my schedule for 7/22"); + result.IntentName.Should().Be("my_schedule"); + result.Parameters["day"].Should().Be("7/22"); + } + + // Ordering regressions: the new patterns must not swallow existing intents. + + [Test] + public async Task WhoIsName_still_personnel_lookup() + { + var result = await _classifier.ClassifyAsync("who is john"); + result.IntentName.Should().Be("personnel_lookup"); + result.Parameters["query"].Should().Be("john"); + } + + [TestCase("responding", "set_status")] + [TestCase("available", "set_staffing")] + [TestCase("units", "list_units")] + [TestCase("show active calls", "list_calls")] + [TestCase("messages", "list_messages")] + public async Task Existing_intents_unchanged(string text, string expectedIntent) + { + var result = await _classifier.ClassifyAsync(text); + result.IntentName.Should().Be(expectedIntent); + } + } +} From 939bd970f6524f21e3075afa46830a6a2f100648 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 17 Jul 2026 13:44:33 -0700 Subject: [PATCH 2/3] RG-T117 More chatbot work --- .../Providers/KeywordIntentClassifier.cs | 51 ++- .../Providers/OpenAiCompatibleNluProvider.cs | 5 +- Core/Resgrid.Chatbot/ChatbotModule.cs | 4 + .../Handlers/AvailabilityActionHandler.cs | 18 +- .../Handlers/DispatchCallHandler.cs | 15 +- .../Handlers/MyStatusActionHandler.cs | 6 +- .../Handlers/RespondToCallHandler.cs | 150 ++++++++- .../Handlers/SetUnitStatusHandler.cs | 49 ++- .../Handlers/StaffingActionHandler.cs | 127 +++++--- .../Handlers/StatusActionHandler.cs | 153 ++++++--- .../Interfaces/IConversationEngine.cs | 2 +- .../Interfaces/ITextResponseResolver.cs | 16 + Core/Resgrid.Chatbot/Models/ChatbotSession.cs | 7 +- .../Models/PendingTextResponse.cs | 16 + .../Services/ChatbotIngressService.cs | 269 ++++++++++++--- .../Services/ConversationEngine.cs | 99 +++--- .../Services/CustomStateMatcher.cs | 93 ++++++ .../Services/RedisSessionStore.cs | 18 +- .../Services/TextResponseResolver.cs | 152 +++++++++ Core/Resgrid.Model/MessageTypes.cs | 5 +- .../Messages/TextResponsePromptMetadata.cs | 27 ++ .../Services/ITextResponsePromptService.cs | 14 + Core/Resgrid.Services/CalendarService.cs | 41 ++- Core/Resgrid.Services/CommunicationService.cs | 24 ++ Core/Resgrid.Services/ServicesModule.cs | 1 + .../TextResponsePromptService.cs | 53 +++ ...hatbotAvailabilityIntentClassifierTests.cs | 18 +- .../Chatbot/ChatbotHandlerTests.cs | 284 +++++++++++++++- .../ChatbotTextResponseResolverTests.cs | 306 ++++++++++++++++++ .../Services/CommunicationServiceTests.cs | 21 +- .../Logic/CalendarNotifierLogic.cs | 36 ++- .../Logic/NotificationBroadcastLogic.cs | 27 ++ 32 files changed, 1886 insertions(+), 221 deletions(-) create mode 100644 Core/Resgrid.Chatbot/Interfaces/ITextResponseResolver.cs create mode 100644 Core/Resgrid.Chatbot/Models/PendingTextResponse.cs create mode 100644 Core/Resgrid.Chatbot/Services/CustomStateMatcher.cs create mode 100644 Core/Resgrid.Chatbot/Services/TextResponseResolver.cs create mode 100644 Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs create mode 100644 Core/Resgrid.Model/Services/ITextResponsePromptService.cs create mode 100644 Core/Resgrid.Services/TextResponsePromptService.cs create mode 100644 Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs diff --git a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs index 970110db3..3150f6062 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs @@ -17,22 +17,28 @@ public class KeywordIntentClassifier : INLUProvider // === Status Commands (rigid + natural language) === // The SMS shortcut numbers (1-4) are the user-facing scheme from the legacy text commands; // the emitted actionType values are ActionTypes enum values (Responding=2, NotResponding=1, - // OnScene=3, StandingBy/Available=0) — the handler passes them straight to SetUserActionAsync. - (R(@"^(responding|1)$"), "set_status", m => P("actionType", "2")), - (R(@"^(not\s*responding|2)$"), "set_status", m => P("actionType", "1")), + // OnScene=3, StandingBy/Available=0). The handler maps them through department custom states. + // Bare response phrases acknowledge the user's most recent call dispatch. Explicit status + // commands ("set my status to ...") continue through the general status handler. + (R(@"^(responding|omw|on\s+my\s+way|enroute|en\s+route)$"), + "respond_to_call", m => P("response", "yes")), + (R(@"^(not\s*responding|not\s+going|unable\s+to\s+respond)$"), + "respond_to_call", m => P("response", "no")), + (R(@"^1$"), "set_status", m => P("actionType", "2")), + (R(@"^2$"), "set_status", m => P("actionType", "1")), (R(@"^(on\s*scene|onscene|3)$"), "set_status", m => P("actionType", "3")), (R(@"^(standing\s*by|standingby|4)$"), "set_status", m => P("actionType", "0")), - // Responder shorthand for "responding" with no target call. - (R(@"^(omw|on\s+my\s+way|enroute|en\s+route)$"), - "set_status", m => P("actionType", "2")), - (R(@"^(i'?m|i\s+am)\s+(responding|on\s*scene|standing\s*by|en\s*route|available|not\s*responding)"), + (R(@"^(i'?m|i\s+am)\s+(responding|on\s+my\s+way|en\s*route|not\s*responding|not\s+going)$"), + "respond_to_call", m => P("response", IsNegativeCallResponse(m.Groups[2].Value) ? "no" : "yes")), + (R(@"^(i'?m|i\s+am)\s+(on\s*scene|standing\s*by|available)"), "set_status", m => P("actionType", MapStatusWord(m.Groups[2].Value))), (R(@"^(set|change|mark)\s+(my\s+)?status\s+to\s+(.+)"), "set_status", m => P("statusName", m.Groups[3].Value.Trim())), // === Staffing Commands === // S1-S5 is the user-facing scheme; emitted staffingType values are UserStateTypes enum values - // (Available=0, Delayed=1, Unavailable=2, Committed=3, OnShift=4) — passed straight to CreateUserState. + // (Available=0, Delayed=1, Unavailable=2, Committed=3, OnShift=4). The handler maps them + // through the department's configured staffing levels. (R(@"^(available|s1)$"), "set_staffing", m => P("staffingType", "0")), (R(@"^(delayed|s2)$"), "set_staffing", m => P("staffingType", "1")), (R(@"^(unavailable|s3)$"), "set_staffing", m => P("staffingType", "2")), @@ -108,20 +114,20 @@ public class KeywordIntentClassifier : INLUProvider // "who's on scene at the fire" — on-scene responders for a call. (R(@"^(who'?s|who\s+is|who\s+are)\s+on\s*scene(\s+(?:at|on|for)\s+(.+))?$"), - "call_responders", m => P2("mode", "onscene", "callRef", m.Groups[3].Value.Trim())), + "call_responders", m => P2("mode", "onscene", "callRef", CleanReference(m.Groups[3].Value))), // "who's in route to the fire", "who is responding to c1445", "who's coming" (R(@"^(who'?s|who\s+is|who\s+are)\s+((?:in|en)\s*route|responding|headed|heading|going|coming)(\s+(?:to|for)\s+(.+))?$"), - "call_responders", m => P2("mode", "enroute", "callRef", m.Groups[4].Value.Trim())), + "call_responders", m => P2("mode", "enroute", "callRef", CleanReference(m.Groups[4].Value))), // "who got dispatched to the medical", "who's dispatched to 26-1" — the full dispatch // list (personnel, groups, roles and units) rather than live statuses. (R(@"^(who'?s|who\s+is|who\s+are|who\s+got|who\s+was|who\s+were|who)\s+dispatched(\s+(?:to|on|for)\s+(.+))?$"), - "call_dispatched", m => P("callRef", m.Groups[3].Value.Trim())), + "call_dispatched", m => P("callRef", CleanReference(m.Groups[3].Value))), // "who's on call 26-1", "who is on the fire" — responding + on-scene for a call. (R(@"^(who'?s|who\s+is|who\s+are)\s+on(\s+call)?(\s+(.+))?$"), - "call_responders", m => P2("mode", "all", "callRef", m.Groups[4].Value.Trim())), + "call_responders", m => P2("mode", "all", "callRef", CleanReference(m.Groups[4].Value))), // "what calls am I on?", "my calls" — calls the user was dispatched to. (R(@"^(what\s+)?calls?\s+am\s+i\s+(on|dispatched\s+to|assigned\s+to)\b.*$"), @@ -193,13 +199,15 @@ public class KeywordIntentClassifier : INLUProvider "close_call", m => P("callId", m.Groups[2].Value)), // === Respond to Call === + (R(@"^(not\s*responding|not\s+going|unable\s+to\s+respond)\s+(?:to\s+)?(.+)$"), + "respond_to_call", m => P2("callRef", m.Groups[2].Value.Trim(), "response", "no")), (R(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)$"), - "respond_to_call", m => P("callId", m.Groups[2].Value)), + "respond_to_call", m => P2("callId", m.Groups[2].Value, "response", "yes")), // Responder shorthand: "omw to 26-1", "omw to fire", "enroute to c1445", "headed to Main St". // The reference can be a call id, a call number (yy-N), or a term matched against active // calls — resolved by the handler. (R(@"^(omw|on\s+my\s+way|respond(?:ing)?|going|headed|enroute|en\s+route)\s+(?:to\s+)?(.+)$"), - "respond_to_call", m => P("callRef", m.Groups[2].Value.Trim())), + "respond_to_call", m => P2("callRef", m.Groups[2].Value.Trim(), "response", "yes")), // === Shift Drop (must precede shift signup/detail so 'drop shift 5' isn't misread) === (R(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)"), @@ -261,9 +269,9 @@ public Task ClassifyAsync(string text, string context = null, int dep : new[] { trimmed }; // Check all patterns in priority order - foreach (var candidate in candidates) + foreach (var (pattern, intent, extractor) in _patterns) { - foreach (var (pattern, intent, extractor) in _patterns) + foreach (var candidate in candidates) { var match = pattern.Match(candidate); if (match.Success) @@ -329,6 +337,17 @@ private static Dictionary P2(string k1, string v1, string k2, st return new Dictionary { [k1] = v1, [k2] = v2 }; } + private static string CleanReference(string value) + { + return value?.Trim().TrimEnd('?', '!', '.', ','); + } + + private static bool IsNegativeCallResponse(string value) + { + var normalized = value?.ToLowerInvariant().Replace(" ", string.Empty); + return normalized == "notresponding" || normalized == "notgoing"; + } + // ActionTypes enum values: Responding=2, NotResponding=1, OnScene=3, StandingBy/Available=0. private static string MapStatusWord(string word) { diff --git a/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs b/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs index dc6009937..79c2b192d 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/OpenAiCompatibleNluProvider.cs @@ -53,7 +53,7 @@ public class OpenAiCompatibleNluProvider : INLUProvider - my_status: User asks about their current status or staffing level - list_calls: User wants to see active/open calls/incidents - call_detail: User asks about a specific call by number/name -- respond_to_call: User says they are responding/going/en route to a specific call +- respond_to_call: User acknowledges or declines a call response. Bare phrases such as ""responding"", ""on my way"", ""omw"", ""not responding"", or ""not going"" apply to the user's most recent dispatch; a call reference is optional - close_call: User wants to close/cancel/end a call - dispatch_call: User wants to create/draft a new call or incident (dispatch role) - list_units: User wants to see unit statuses @@ -96,7 +96,8 @@ public class OpenAiCompatibleNluProvider : INLUProvider Extract any parameters mentioned: - For set_status: ""statusName"" (the status name), or ""actionType"" (numeric ID 1-4) - For set_staffing: ""staffingName"" (the staffing name), or ""staffingType"" (numeric S1-S5) -- For call_detail/close_call/respond_to_call: ""callId"" (the call number) +- For call_detail/close_call: ""callId"" (the call number) +- For respond_to_call: ""callId"" or ""callRef"" when provided, and ""response"" (""yes"" for responding/going, ""no"" for not responding/not going) - For set_unit_status: ""unitName"", ""status"" - For send_message: ""recipient"", ""body"" - For respond_to_message: ""messageId"", ""response"" (yes/no) diff --git a/Core/Resgrid.Chatbot/ChatbotModule.cs b/Core/Resgrid.Chatbot/ChatbotModule.cs index 67225820a..87f77dbde 100644 --- a/Core/Resgrid.Chatbot/ChatbotModule.cs +++ b/Core/Resgrid.Chatbot/ChatbotModule.cs @@ -49,6 +49,10 @@ protected override void Load(ContainerBuilder builder) .As() .InstancePerLifetimeScope(); + builder.RegisterType() + .As() + .InstancePerLifetimeScope(); + // Default no-op Web Chat notifier; the real SignalR-backed notifier in the web layer // overrides this (PreserveExistingDefaults keeps the real one winning regardless of order). builder.RegisterType() diff --git a/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs index 348152140..6c01106ac 100644 --- a/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/AvailabilityActionHandler.cs @@ -134,7 +134,23 @@ private async Task ClassifyStaffingAsync(int departmentId, Us } var detail = await _customStateService.GetCustomDetailForDepartmentAsync(departmentId, state.State); - return detail != null ? AvailabilityMatrix.ForCustomBaseType(detail.BaseType) : AvailabilityClass.Unknown; + if (detail == null) + return AvailabilityClass.Unknown; + + var baseClassification = AvailabilityMatrix.ForCustomBaseType(detail.BaseType); + if (baseClassification != AvailabilityClass.Unknown) + return baseClassification; + + // Staffing state sets predate BaseType and many still use None. Preserve their operational + // meaning through the standard/custom template labels instead of treating Off Duty as available. + return Services.CustomStateMatcher.Normalize(detail.ButtonText) switch + { + "available" or "onduty" or "oncall" or "onshift" => AvailabilityClass.Available, + "delayed" or "onbreak" => AvailabilityClass.Delayed, + "unavailable" or "offduty" or "notavailable" or "away" => AvailabilityClass.Unavailable, + "committed" or "ontask" => AvailabilityClass.Committed, + _ => AvailabilityClass.Unknown + }; } } } diff --git a/Core/Resgrid.Chatbot/Handlers/DispatchCallHandler.cs b/Core/Resgrid.Chatbot/Handlers/DispatchCallHandler.cs index 539204b10..a89916162 100644 --- a/Core/Resgrid.Chatbot/Handlers/DispatchCallHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/DispatchCallHandler.cs @@ -21,11 +21,14 @@ public class DispatchCallHandler : IChatbotActionHandler { private readonly ICallsService _callsService; private readonly IAuthorizationService _authorizationService; + private readonly IChatbotDepartmentConfigService _departmentConfigService; - public DispatchCallHandler(ICallsService callsService, IAuthorizationService authorizationService) + public DispatchCallHandler(ICallsService callsService, IAuthorizationService authorizationService, + IChatbotDepartmentConfigService departmentConfigService = null) { _callsService = callsService; _authorizationService = authorizationService; + _departmentConfigService = departmentConfigService; } public ChatbotIntentType IntentType => ChatbotIntentType.DispatchCall; @@ -35,6 +38,16 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { + var config = _departmentConfigService == null + ? null + : await _departmentConfigService.GetConfigAsync(session.DepartmentId); + if (config != null && !config.AllowDispatchViaChatbot) + return new ChatbotResponse + { + Text = "Creating calls through the chatbot is disabled for this department.", + Processed = false + }; + if (!await _authorizationService.CanUserCreateCallAsync(session.UserId, session.DepartmentId)) return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoCreatePermission", culture), Processed = false }; diff --git a/Core/Resgrid.Chatbot/Handlers/MyStatusActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/MyStatusActionHandler.cs index a0d6dc7d9..77424bdf5 100644 --- a/Core/Resgrid.Chatbot/Handlers/MyStatusActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/MyStatusActionHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; @@ -39,8 +40,9 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn { var profile = await _userProfileService.GetProfileByUserIdAsync(session.UserId); var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); - var userStatus = await _actionLogsService.GetLastActionLogForUserAsync(session.UserId); - var userStaffing = await _userStateService.GetLastUserStateByUserIdAsync(session.UserId); + var userStatus = await _actionLogsService.GetLastActionLogForUserAsync(session.UserId, session.DepartmentId); + var departmentStaffing = await _userStateService.GetLatestStatesForDepartmentAsync(session.DepartmentId); + var userStaffing = departmentStaffing?.FirstOrDefault(x => x.UserId == session.UserId); var customStatus = await _customStateService.GetCustomPersonnelStatusAsync(session.DepartmentId, userStatus); var customStaffing = await _customStateService.GetCustomPersonnelStaffingAsync(session.DepartmentId, userStaffing); diff --git a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs index 858b7dc77..aaaa26fdf 100644 --- a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; @@ -16,13 +18,23 @@ namespace Resgrid.Chatbot.Handlers /// public class RespondToCallHandler : IChatbotActionHandler { + private static readonly TimeSpan RecentDispatchWindow = TimeSpan.FromDays(1); + private readonly ICallsService _callsService; private readonly IActionLogsService _actionLogsService; + private readonly ICustomStateService _customStateService; + private readonly IDepartmentGroupsService _departmentGroupsService; + private readonly IPersonnelRolesService _personnelRolesService; - public RespondToCallHandler(ICallsService callsService, IActionLogsService actionLogsService) + public RespondToCallHandler(ICallsService callsService, IActionLogsService actionLogsService, + ICustomStateService customStateService = null, IDepartmentGroupsService departmentGroupsService = null, + IPersonnelRolesService personnelRolesService = null) { _callsService = callsService; _actionLogsService = actionLogsService; + _customStateService = customStateService; + _departmentGroupsService = departmentGroupsService; + _personnelRolesService = personnelRolesService; } public ChatbotIntentType IntentType => ChatbotIntentType.RespondToCall; @@ -38,16 +50,36 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn if (string.IsNullOrWhiteSpace(reference)) intent.Parameters.TryGetValue("callRef", out reference); + Call call; if (string.IsNullOrWhiteSpace(reference)) - return new ChatbotResponse { Text = ChatbotResources.Get("Call_RespondWhich", culture), Processed = false }; - - var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference); + call = await ResolveMostRecentDispatchAsync(session.UserId, session.DepartmentId); + else + { + call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference); + } if (call == null) + { + if (string.IsNullOrWhiteSpace(reference)) + return new ChatbotResponse { Text = ChatbotResources.Get("Call_RespondWhich", culture), Processed = false }; + return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true }; + } + + intent.Parameters.TryGetValue("response", out var responseValue); + var isNegative = IsNegativeResponse(responseValue); + var status = await ResolveResponseStatusAsync(session.DepartmentId, isNegative); + var statusId = status?.CustomStateDetailId + ?? (isNegative ? (int)ActionTypes.NotResponding : (int)ActionTypes.Responding); + + await _actionLogsService.SetUserActionAsync(session.UserId, session.DepartmentId, statusId, + string.Empty, call.CallId, (int)DestinationEntityTypes.Call); - await _actionLogsService.SetUserActionAsync(session.UserId, session.DepartmentId, (int)ActionTypes.Responding, string.Empty, call.CallId); + var responseText = isNegative + ? ChatbotResources.Get("Status_Updated", culture, + $"{status?.ButtonText ?? "Not Responding"} for Call #{call.CallId} - {call.Name}") + : ChatbotResources.Get("Call_Responding", culture, call.CallId, call.Name); - return new ChatbotResponse { Text = ChatbotResources.Get("Call_Responding", culture, call.CallId, call.Name), Processed = true }; + return new ChatbotResponse { Text = responseText, Processed = true }; } catch (Exception ex) { @@ -55,5 +87,111 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn return new ChatbotResponse { Text = ChatbotResources.Get("Call_ErrorResponding", culture), Processed = false }; } } + + private async Task ResolveMostRecentDispatchAsync(string userId, int departmentId) + { + var cutoff = DateTime.UtcNow.Subtract(RecentDispatchWindow); + var candidates = new List<(Call Call, DateTime DispatchedOn)>(); + var groupMembership = new Dictionary(); + HashSet userRoleIds = null; + var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId); + + foreach (var activeCall in (activeCalls ?? new List()) + .OrderByDescending(x => x.LastDispatchedOn ?? x.DispatchOn ?? x.LoggedOn)) + { + var call = await _callsService.PopulateCallData(activeCall, + getDispatches: true, + getAttachments: false, + getNotes: false, + getGroupDispatches: true, + getUnitDispatches: false, + getRoleDispatches: true, + getProtocols: false, + getReferences: false, + getContacts: false); + + var dispatch = call?.Dispatches? + .Where(x => string.Equals(x.UserId, userId, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(x => x.LastDispatchedOn ?? x.DispatchedOn) + .FirstOrDefault(); + DateTime? dispatchedOn = dispatch == null + ? null + : ResolveDispatchTimestamp(dispatch.LastDispatchedOn, dispatch.DispatchedOn, call); + + if (dispatch == null && _departmentGroupsService != null) + { + foreach (var groupDispatch in call?.GroupDispatches ?? new List()) + { + if (!groupMembership.TryGetValue(groupDispatch.DepartmentGroupId, out var isMember)) + { + var members = await _departmentGroupsService.GetAllMembersForGroupAsync(groupDispatch.DepartmentGroupId); + isMember = members?.Any(x => string.Equals(x.UserId, userId, StringComparison.OrdinalIgnoreCase)) == true; + groupMembership[groupDispatch.DepartmentGroupId] = isMember; + } + + if (isMember) + dispatchedOn = Max(dispatchedOn, + ResolveDispatchTimestamp(groupDispatch.LastDispatchedOn, groupDispatch.DispatchedOn, call)); + } + } + + if (dispatch == null && _personnelRolesService != null && call?.RoleDispatches?.Any() == true) + { + if (userRoleIds == null) + { + var userRoles = await _personnelRolesService.GetRolesForUserAsync(userId, departmentId); + userRoleIds = new HashSet((userRoles ?? new List()).Select(x => x.PersonnelRoleId)); + } + + foreach (var roleDispatch in call.RoleDispatches.Where(x => userRoleIds.Contains(x.RoleId))) + dispatchedOn = Max(dispatchedOn, + ResolveDispatchTimestamp(roleDispatch.LastDispatchedOn, roleDispatch.DispatchedOn, call)); + } + + if (dispatchedOn.HasValue && dispatchedOn.Value >= cutoff) + candidates.Add((call, dispatchedOn.Value)); + } + + return candidates.OrderByDescending(x => x.DispatchedOn).Select(x => x.Call).FirstOrDefault(); + } + + private static DateTime ResolveDispatchTimestamp(DateTime? lastDispatchedOn, DateTime dispatchedOn, Call call) + { + return lastDispatchedOn + ?? (dispatchedOn == default(DateTime) ? (DateTime?)null : dispatchedOn) + ?? call.LastDispatchedOn + ?? call.DispatchOn + ?? call.LoggedOn; + } + + private static DateTime Max(DateTime? current, DateTime candidate) + { + return !current.HasValue || candidate > current.Value ? candidate : current.Value; + } + + private async Task ResolveResponseStatusAsync(int departmentId, bool isNegative) + { + var statuses = _customStateService == null + ? null + : await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(departmentId); + var callStatuses = statuses? + .Where(x => x != null && (x.DetailType == (int)CustomStateDetailTypes.None || x.DetailType.SupportsCalls())) + .ToList(); + + if (isNegative) + { + return Services.CustomStateMatcher.FindByName(callStatuses, "Not Responding") + ?? Services.CustomStateMatcher.FindByBaseType(callStatuses, ActionBaseTypes.NotResponding); + } + + return Services.CustomStateMatcher.FindByName(callStatuses, "Responding to Scene", "Responding", "En Route") + ?? Services.CustomStateMatcher.FindByBaseType(callStatuses, ActionBaseTypes.Responding, ActionBaseTypes.Enroute); + } + + private static bool IsNegativeResponse(string response) + { + var normalized = Services.CustomStateMatcher.Normalize(response); + return normalized == "no" || normalized == "notresponding" || normalized == "notgoing"; + } } } diff --git a/Core/Resgrid.Chatbot/Handlers/SetUnitStatusHandler.cs b/Core/Resgrid.Chatbot/Handlers/SetUnitStatusHandler.cs index 8928bc6c2..55c7ad3ef 100644 --- a/Core/Resgrid.Chatbot/Handlers/SetUnitStatusHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/SetUnitStatusHandler.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; @@ -53,12 +54,16 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn if (!await _authorizationService.CanUserModifyUnitAsync(session.UserId, unit.UnitId)) return new ChatbotResponse { Text = ChatbotResources.Get("Unit_NoPermission", culture), Processed = false }; - var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(session.DepartmentId); - var statusLower = statusName.Trim().ToLowerInvariant(); - var matchedState = customStates? - .Where(s => s.Type == (int)CustomStateTypes.Unit) - .SelectMany(s => s.GetActiveDetails()) - .FirstOrDefault(d => d.ButtonText != null && d.ButtonText.ToLowerInvariant() == statusLower); + var allowedStates = await GetAllowedStatesAsync(unit, session.DepartmentId); + var matchedState = Services.CustomStateMatcher.FindBySelection(allowedStates, statusName) + ?? Services.CustomStateMatcher.FindByName(allowedStates, statusName); + var baseType = Services.CustomStateMatcher.UnitBaseTypeFor(statusName); + if (matchedState == null && baseType.HasValue) + { + matchedState = Services.CustomStateMatcher.FindByBaseType(allowedStates, baseType.Value); + if (matchedState == null && baseType == ActionBaseTypes.Enroute) + matchedState = Services.CustomStateMatcher.FindByBaseType(allowedStates, ActionBaseTypes.Responding); + } if (matchedState == null) return new ChatbotResponse { Text = ChatbotResources.Get("Unit_UnknownStatus", culture, statusName), Processed = true }; @@ -68,6 +73,7 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn { session.State = ChatbotDialogState.AwaitingConfirmation; session.PendingIntent = ChatbotIntentType.SetUnitStatus; + session.Context.Clear(); session.Context["unitName"] = unit.Name; session.Context["status"] = matchedState.ButtonText; return new ChatbotResponse @@ -87,5 +93,36 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn return new ChatbotResponse { Text = ChatbotResources.Get("Unit_ErrorSet", culture), Processed = false }; } } + + private async Task> GetAllowedStatesAsync(Unit unit, int departmentId) + { + if (!string.IsNullOrWhiteSpace(unit.Type)) + { + var unitType = await _unitsService.GetUnitTypeByNameAsync(departmentId, unit.Type); + if (unitType?.CustomStatesId > 0) + { + var assignedState = await _customStateService.GetCustomSateByIdAsync(unitType.CustomStatesId.Value); + if (assignedState == null || assignedState.IsDeleted + || assignedState.DepartmentId != departmentId + || assignedState.Type != (int)CustomStateTypes.Unit) + return new List(); + + return assignedState.GetActiveDetails(); + } + + return _customStateService.GetDefaultUnitStatuses() ?? new List(); + } + + // Legacy units without a type retain the existing department-wide lookup. + var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId); + var details = customStates? + .Where(x => x.Type == (int)CustomStateTypes.Unit) + .SelectMany(x => x.GetActiveDetails()) + .ToList(); + + return details?.Count > 0 + ? details + : (_customStateService.GetDefaultUnitStatuses() ?? new List()); + } } } diff --git a/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs index 81c9dd130..e6d3ac2cc 100644 --- a/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs @@ -1,9 +1,11 @@ using System; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; using Resgrid.Chatbot.Models; +using Resgrid.Model; using Resgrid.Model.Services; namespace Resgrid.Chatbot.Handlers @@ -25,76 +27,119 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn { try { - int staffingId; + CustomStateDetail resolvedStaffing = null; - if (intent.Parameters.TryGetValue("customStaffingId", out var customIdStr) && int.TryParse(customIdStr, out var customStaffingId)) + if (intent.Parameters.TryGetValue("customStaffingId", out var customIdRaw) + && int.TryParse(customIdRaw, out var customId)) { - staffingId = customStaffingId; + resolvedStaffing = Services.CustomStateMatcher.FindById( + await GetAvailableStaffingAsync(session.DepartmentId), customId); } - else if (intent.Parameters.TryGetValue("staffingType", out var staffingTypeStr) && int.TryParse(staffingTypeStr, out var staffingTypeId)) + else if (intent.Parameters.TryGetValue("staffingType", out var staffingTypeRaw) + && int.TryParse(staffingTypeRaw, out var staffingType)) { - staffingId = staffingTypeId; + resolvedStaffing = await ResolveStaffingIdAsync(session.DepartmentId, staffingType); } - else if (intent.Parameters.TryGetValue("staffingName", out var staffingNameInput) && !string.IsNullOrWhiteSpace(staffingNameInput)) + else if (intent.Parameters.TryGetValue("staffingName", out var staffingName) + && !string.IsNullOrWhiteSpace(staffingName)) { - // "SET STAFFING TO ": resolve against the department's custom staffing states - // first (the only text form that can reach custom staffing levels), then the - // standard staffing words. - var resolved = await ResolveStaffingNameAsync(session.DepartmentId, staffingNameInput); - if (resolved == null) - return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_CouldNotDetermine", session.Culture), Processed = false }; - - staffingId = resolved.Value; - } - else - { - return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_CouldNotDetermine", session.Culture), Processed = false }; + resolvedStaffing = await ResolveStaffingNameAsync(session.DepartmentId, staffingName); } - await _userStateService.CreateUserState(session.UserId, session.DepartmentId, staffingId); + if (resolvedStaffing == null) + return new ChatbotResponse + { + Text = ChatbotResources.Get("Staffing_CouldNotDetermine", session.Culture), + Processed = false + }; - var userStaffing = await _userStateService.GetLastUserStateByUserIdAsync(session.UserId); - var staffingName = await _customStateService.GetCustomPersonnelStaffingAsync(session.DepartmentId, userStaffing); + await _userStateService.CreateUserState(session.UserId, session.DepartmentId, + resolvedStaffing.CustomStateDetailId); return new ChatbotResponse { - Text = ChatbotResources.Get("Staffing_Updated", session.Culture, staffingName?.ButtonText ?? staffingId.ToString()), + Text = ChatbotResources.Get("Staffing_Updated", session.Culture, resolvedStaffing.ButtonText), Processed = true }; } catch (Exception ex) { Framework.Logging.LogException(ex); - return new ChatbotResponse { Text = ChatbotResources.Get("Staffing_Error", session.Culture), Processed = false }; + return new ChatbotResponse + { + Text = ChatbotResources.Get("Staffing_Error", session.Culture), + Processed = false + }; } } - private async Task ResolveStaffingNameAsync(int departmentId, string staffingName) + private async Task ResolveStaffingNameAsync(int departmentId, string staffingName) { var name = staffingName.Trim().TrimEnd('?', '!', '.', ','); + var levels = await GetAvailableStaffingAsync(departmentId); + var match = Services.CustomStateMatcher.FindBySelection(levels, name) + ?? Services.CustomStateMatcher.FindByName(levels, name); + if (match != null) + return match; - var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId); - var customStaffing = customStates?.FirstOrDefault(x => x.Type == (int)Model.CustomStateTypes.Staffing); - if (customStaffing != null && !customStaffing.IsDeleted && customStaffing.GetActiveDetails()?.Any() == true) + var builtInId = Services.CustomStateMatcher.Normalize(name) switch { - var detail = customStaffing.GetActiveDetails() - .FirstOrDefault(d => string.Equals(d.ButtonText?.Trim(), name, StringComparison.OrdinalIgnoreCase) - || string.Equals(d.ButtonText?.Replace(" ", ""), name.Replace(" ", ""), StringComparison.OrdinalIgnoreCase)); + "available" => (int)UserStateTypes.Available, + "delayed" => (int)UserStateTypes.Delayed, + "unavailable" => (int)UserStateTypes.Unavailable, + "committed" => (int)UserStateTypes.Committed, + "onshift" => (int)UserStateTypes.OnShift, + _ => -1 + }; - if (detail != null) - return detail.CustomStateDetailId; - } + return builtInId >= 0 + ? await ResolveStaffingIdAsync(departmentId, builtInId, levels) + : null; + } + + private async Task ResolveStaffingIdAsync(int departmentId, int staffingId, + List levels = null) + { + levels ??= await GetAvailableStaffingAsync(departmentId); + var match = Services.CustomStateMatcher.FindById(levels, staffingId); + if (match != null) + return match; + + var fallback = FallbackStaffing(staffingId); + if (fallback == null) + return null; + + match = Services.CustomStateMatcher.FindByName(levels, fallback.ButtonText); + if (match != null) + return match; - // Standard staffing words (spaces optional) — UserStateTypes enum values. - return name.Replace(" ", "").ToLowerInvariant() switch + var active = levels?.Where(x => x != null && !x.IsDeleted).OrderBy(x => x.Order).ToList(); + if (active != null && staffingId >= 0 && staffingId < active.Count) + return active[staffingId]; + + return levels == null || levels.Count == 0 ? fallback : null; + } + + private async Task> GetAvailableStaffingAsync(int departmentId) + => _customStateService == null + ? null + : await _customStateService.GetCustomPersonnelStaffingsOrDefaultsAsync(departmentId); + + private static CustomStateDetail FallbackStaffing(int staffingId) + { + var name = staffingId switch { - "available" => (int)Model.UserStateTypes.Available, - "delayed" => (int)Model.UserStateTypes.Delayed, - "unavailable" => (int)Model.UserStateTypes.Unavailable, - "committed" => (int)Model.UserStateTypes.Committed, - "onshift" => (int)Model.UserStateTypes.OnShift, - _ => (int?)null + (int)UserStateTypes.Available => "Available", + (int)UserStateTypes.Delayed => "Delayed", + (int)UserStateTypes.Unavailable => "Unavailable", + (int)UserStateTypes.Committed => "Committed", + (int)UserStateTypes.OnShift => "On Shift", + _ => null }; + + return name == null + ? null + : new CustomStateDetail { CustomStateDetailId = staffingId, ButtonText = name }; } } } diff --git a/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs index 522ccd39c..9c4941724 100644 --- a/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/StatusActionHandler.cs @@ -4,6 +4,7 @@ using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; using Resgrid.Chatbot.Models; +using Resgrid.Model; using Resgrid.Model.Services; namespace Resgrid.Chatbot.Handlers @@ -12,11 +13,14 @@ public class StatusActionHandler : IChatbotActionHandler { private readonly IActionLogsService _actionLogsService; private readonly ICustomStateService _customStateService; + private readonly IChatbotDepartmentConfigService _departmentConfigService; - public StatusActionHandler(IActionLogsService actionLogsService, ICustomStateService customStateService) + public StatusActionHandler(IActionLogsService actionLogsService, ICustomStateService customStateService, + IChatbotDepartmentConfigService departmentConfigService = null) { _actionLogsService = actionLogsService; _customStateService = customStateService; + _departmentConfigService = departmentConfigService; } public ChatbotIntentType IntentType => ChatbotIntentType.SetStatus; @@ -25,41 +29,49 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn { try { - int statusId; + CustomStateDetail resolvedStatus = null; if (intent.Parameters.TryGetValue("customActionId", out var customIdStr) && int.TryParse(customIdStr, out var customStatusId)) { - statusId = customStatusId; + var statuses = await GetAvailableStatusesAsync(session.DepartmentId); + resolvedStatus = Services.CustomStateMatcher.FindById(statuses, customStatusId); } else if (intent.Parameters.TryGetValue("actionType", out var actionTypeStr) && int.TryParse(actionTypeStr, out var actionTypeId)) { - statusId = actionTypeId; + resolvedStatus = await ResolveStatusIdAsync(session.DepartmentId, actionTypeId); } else if (intent.Parameters.TryGetValue("statusName", out var statusNameInput) && !string.IsNullOrWhiteSpace(statusNameInput)) { - // "SET STATUS TO ": resolve against the department's custom personnel states - // first (this is the only text form that can reach custom statuses), then the - // standard status words. - var resolved = await ResolveStatusNameAsync(session.DepartmentId, statusNameInput); - if (resolved == null) - return new ChatbotResponse { Text = ChatbotResources.Get("Status_CouldNotDetermine", session.Culture), Processed = false }; - - statusId = resolved.Value; + resolvedStatus = await ResolveStatusNameAsync(session.DepartmentId, statusNameInput); } - else - { + + if (resolvedStatus == null) return new ChatbotResponse { Text = ChatbotResources.Get("Status_CouldNotDetermine", session.Culture), Processed = false }; + + var statusId = resolvedStatus.CustomStateDetailId; + + var confirmed = intent.Parameters.TryGetValue("__confirmed", out var confirmFlag) && confirmFlag == "true"; + var config = _departmentConfigService == null + ? null + : await _departmentConfigService.GetConfigAsync(session.DepartmentId); + if (config?.RequireConfirmationForStatusChange == true && !confirmed) + { + session.State = ChatbotDialogState.AwaitingConfirmation; + session.PendingIntent = ChatbotIntentType.SetStatus; + session.Context.Clear(); + session.Context["actionType"] = statusId.ToString(); + return new ChatbotResponse + { + Text = ChatbotResources.Get("Unit_SetConfirm", session.Culture, "your status", resolvedStatus.ButtonText), + Processed = true + }; } await _actionLogsService.SetUserActionAsync(session.UserId, session.DepartmentId, statusId); - var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(session.DepartmentId); - var action = await _actionLogsService.GetLastActionLogForUserAsync(session.UserId, session.DepartmentId); - var statusName = await _customStateService.GetCustomPersonnelStatusAsync(session.DepartmentId, action); - return new ChatbotResponse { - Text = ChatbotResources.Get("Status_Updated", session.Culture, statusName?.ButtonText ?? statusId.ToString()), + Text = ChatbotResources.Get("Status_Updated", session.Culture, resolvedStatus.ButtonText), Processed = true }; } @@ -70,32 +82,101 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn } } - private async Task ResolveStatusNameAsync(int departmentId, string statusName) + private async Task ResolveStatusNameAsync(int departmentId, string statusName) { var name = statusName.Trim().TrimEnd('?', '!', '.', ','); + var statuses = await GetAvailableStatusesAsync(departmentId); + var match = Services.CustomStateMatcher.FindBySelection(statuses, name) + ?? Services.CustomStateMatcher.FindByName(statuses, name); + if (match != null) + return match; - var customStates = await _customStateService.GetAllActiveCustomStatesForDepartmentAsync(departmentId); - var customActions = customStates?.FirstOrDefault(x => x.Type == (int)Model.CustomStateTypes.Personnel); - if (customActions != null && !customActions.IsDeleted && customActions.GetActiveDetails()?.Any() == true) + if (int.TryParse(name, out var selection)) { - var detail = customActions.GetActiveDetails() - .FirstOrDefault(d => string.Equals(d.ButtonText?.Trim(), name, StringComparison.OrdinalIgnoreCase) - || string.Equals(d.ButtonText?.Replace(" ", ""), name.Replace(" ", ""), StringComparison.OrdinalIgnoreCase)); + var legacyId = selection switch + { + 1 => (int)Model.ActionTypes.Responding, + 2 => (int)Model.ActionTypes.NotResponding, + 3 => (int)Model.ActionTypes.OnScene, + 4 => (int)Model.ActionTypes.StandingBy, + _ => -1 + }; + if (legacyId >= 0) + return await ResolveStatusIdAsync(departmentId, legacyId, statuses); + } - if (detail != null) - return detail.CustomStateDetailId; + var baseType = Services.CustomStateMatcher.PersonnelBaseTypeFor(name); + if (baseType.HasValue) + { + match = Services.CustomStateMatcher.FindByBaseType(statuses, baseType.Value); + if (match == null && baseType == Model.ActionBaseTypes.Standby) + match = Services.CustomStateMatcher.FindByBaseType(statuses, Model.ActionBaseTypes.Available); } - // Standard status words (spaces optional). - return name.Replace(" ", "").ToLowerInvariant() switch + if (match != null) + return match; + + var fallback = FallbackStatusForName(name); + return fallback != null && (statuses == null || statuses.Count == 0 + || statuses.All(x => x.CustomStateDetailId <= 25)) + ? fallback + : null; + } + + private async Task ResolveStatusIdAsync(int departmentId, int statusId, + System.Collections.Generic.List statuses = null) + { + statuses ??= await GetAvailableStatusesAsync(departmentId); + var match = Services.CustomStateMatcher.FindById(statuses, statusId); + if (match != null) + return match; + + var fallback = FallbackStatus(statusId); + if (fallback == null) + return null; + + match = Services.CustomStateMatcher.FindByName(statuses, fallback.ButtonText); + var baseType = Services.CustomStateMatcher.PersonnelBaseTypeFor(fallback.ButtonText); + if (match == null && baseType.HasValue) + match = Services.CustomStateMatcher.FindByBaseType(statuses, baseType.Value); + + return match ?? ((statuses == null || statuses.Count == 0) ? fallback : null); + } + + private async Task> GetAvailableStatusesAsync(int departmentId) + => _customStateService == null + ? null + : await _customStateService.GetCustomPersonnelStatusesOrDefaultsAsync(departmentId); + + private static CustomStateDetail FallbackStatusForName(string name) + { + return Services.CustomStateMatcher.Normalize(name) switch { - "responding" => (int)Model.ActionTypes.Responding, - "notresponding" => (int)Model.ActionTypes.NotResponding, - "onscene" => (int)Model.ActionTypes.OnScene, - "standingby" => (int)Model.ActionTypes.StandingBy, - "available" => (int)Model.ActionTypes.AvailableStation, - _ => (int?)null + "responding" => FallbackStatus((int)Model.ActionTypes.Responding), + "notresponding" => FallbackStatus((int)Model.ActionTypes.NotResponding), + "onscene" => FallbackStatus((int)Model.ActionTypes.OnScene), + "standingby" => FallbackStatus((int)Model.ActionTypes.StandingBy), + "available" => FallbackStatus((int)Model.ActionTypes.AvailableStation), + _ => null }; } + + private static CustomStateDetail FallbackStatus(int statusId) + { + var name = statusId switch + { + (int)Model.ActionTypes.StandingBy => "Standing By", + (int)Model.ActionTypes.NotResponding => "Not Responding", + (int)Model.ActionTypes.Responding => "Responding", + (int)Model.ActionTypes.OnScene => "On Scene", + (int)Model.ActionTypes.AvailableStation => "Available Station", + (int)Model.ActionTypes.RespondingToStation => "Responding to Station", + (int)Model.ActionTypes.RespondingToScene => "Responding to Scene", + (int)Model.ActionTypes.OnUnit => "On Unit", + _ => null + }; + + return name == null ? null : new CustomStateDetail { CustomStateDetailId = statusId, ButtonText = name }; + } } } diff --git a/Core/Resgrid.Chatbot/Interfaces/IConversationEngine.cs b/Core/Resgrid.Chatbot/Interfaces/IConversationEngine.cs index 3b4a76a33..c791d0c25 100644 --- a/Core/Resgrid.Chatbot/Interfaces/IConversationEngine.cs +++ b/Core/Resgrid.Chatbot/Interfaces/IConversationEngine.cs @@ -9,7 +9,7 @@ public interface IConversationEngine // Phase 2: Multi-turn dialog methods called by IngressService Task HandleContinuationAsync(ChatbotMessage message, ChatbotSession session); - bool IsMultiTurnIntent(ChatbotIntentType intentType); + bool NeedsParameterCollection(ChatbotIntent intent); Task BeginDialogAsync(ChatbotIntent intent, ChatbotSession session); } diff --git a/Core/Resgrid.Chatbot/Interfaces/ITextResponseResolver.cs b/Core/Resgrid.Chatbot/Interfaces/ITextResponseResolver.cs new file mode 100644 index 000000000..c0075cdb5 --- /dev/null +++ b/Core/Resgrid.Chatbot/Interfaces/ITextResponseResolver.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Resgrid.Chatbot.Models; + +namespace Resgrid.Chatbot.Interfaces +{ + public interface ITextResponseResolver + { + Task> GetPendingResponsesAsync(string userId, int departmentId, + DateTime sinceUtc); + + Task RecordResponseAsync(PendingTextResponse target, string answer, + ChatbotSession session); + } +} diff --git a/Core/Resgrid.Chatbot/Models/ChatbotSession.cs b/Core/Resgrid.Chatbot/Models/ChatbotSession.cs index dad0aac76..4ff237142 100644 --- a/Core/Resgrid.Chatbot/Models/ChatbotSession.cs +++ b/Core/Resgrid.Chatbot/Models/ChatbotSession.cs @@ -18,7 +18,12 @@ public enum ChatbotDialogState /// Step-up security-PIN prompt for a confirmed destructive action (the user already replied /// YES; the pending intent executes once the correct PIN is supplied). /// - AwaitingPin = 8 + AwaitingPin = 8, + + /// + /// The user sent a short response such as YES/NO that could apply to more than one recent prompt. + /// + AwaitingResponseTarget = 9 } public class ChatbotSession diff --git a/Core/Resgrid.Chatbot/Models/PendingTextResponse.cs b/Core/Resgrid.Chatbot/Models/PendingTextResponse.cs new file mode 100644 index 000000000..0ea660e73 --- /dev/null +++ b/Core/Resgrid.Chatbot/Models/PendingTextResponse.cs @@ -0,0 +1,16 @@ +namespace Resgrid.Chatbot.Models +{ + public enum PendingTextResponseType + { + Poll = 1, + CalendarRsvp = 2 + } + + public class PendingTextResponse + { + public PendingTextResponseType Type { get; set; } + public int SourceId { get; set; } + public int MessageId { get; set; } + public string Label { get; set; } + } +} diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 7f292049e..939b14c57 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Threading.Tasks; using Resgrid.Config; using Resgrid.Chatbot.Interfaces; @@ -26,12 +27,13 @@ public class ChatbotIngressService : IChatbotIngressService private readonly IChatbotDepartmentConfigService _departmentConfigService; private readonly IChatbotRateLimiter _rateLimiter; private readonly ISecurityPinService _securityPinService; - private readonly IMessageService _messageService; + private readonly ITextResponseResolver _textResponseResolver; private const int MaxPinAttempts = 3; - // Newest-first outstanding-poll scan depth for bare YES/NO replies. - private const int MaxPollCandidatesToScan = 5; + private const int MaxReplyTargets = 10; + private const string ReplyAnswerKey = "__replyAnswer"; + private const string ReplyCountKey = "__replyCount"; public ChatbotIngressService( IChatbotUserIdentityService userIdentityService, @@ -48,7 +50,7 @@ public ChatbotIngressService( IChatbotDepartmentConfigService departmentConfigService, IChatbotRateLimiter rateLimiter, ISecurityPinService securityPinService, - IMessageService messageService) + ITextResponseResolver textResponseResolver) { _userIdentityService = userIdentityService; _sessionManager = sessionManager; @@ -64,7 +66,7 @@ public ChatbotIngressService( _departmentConfigService = departmentConfigService; _rateLimiter = rateLimiter; _securityPinService = securityPinService; - _messageService = messageService; + _textResponseResolver = textResponseResolver; } public async Task ProcessMessageAsync(ChatbotMessage message) @@ -249,6 +251,9 @@ await _userIdentityService.LinkUserAsync( session.Culture = Localization.ChatbotResources.NormalizeCulture(cultureProfile?.Language); } + if (deptConfig?.SessionTtlMinutes > 0) + session.TtlMinutes = deptConfig.SessionTtlMinutes; + // Add message to session history session.RecentMessages.Add(message); if (session.RecentMessages.Count > 20) @@ -451,6 +456,20 @@ await _userIdentityService.LinkUserAsync( } } + if (session.State == ChatbotDialogState.AwaitingResponseTarget) + { + var selectionResponse = await HandleReplyTargetSelectionAsync(message, session); + await _sessionManager.SaveSessionAsync(session); + return selectionResponse; + } + + if (session.State == ChatbotDialogState.AwaitingParameter && session.PendingIntent.HasValue) + { + var parameterResponse = await HandleParameterContinuationAsync(message, session); + await _sessionManager.SaveSessionAsync(session); + return parameterResponse; + } + if (session.State != ChatbotDialogState.Idle) { var continuationResult = await _conversationEngine.HandleContinuationAsync(message, session); @@ -461,22 +480,24 @@ await _userIdentityService.LinkUserAsync( } } - // 5c. Bare YES/NO with no dialog pending: the user is most likely answering an - // outstanding poll message ("Poll: ... Reply YES or NO."). Resolved BEFORE the classifier - // so the reply never hits the cloud NLU; when no unanswered poll exists this falls through. + // 5c. Resolve a bare YES/NO against recent unanswered poll and calendar RSVP prompts before + // classification. One target is automatic; multiple targets start a numbered disambiguation. if (session.State == ChatbotDialogState.Idle) { - string pollAnswer = null; + string shortAnswer = null; if (Localization.ChatbotResources.IsAffirmative(message.Text, session.Culture)) - pollAnswer = "Yes"; - else if (Localization.ChatbotResources.IsNegative(message.Text, session.Culture)) - pollAnswer = "No"; + shortAnswer = "Yes"; + else if (IsExplicitNegativeAnswer(message.Text, session.Culture)) + shortAnswer = "No"; - if (pollAnswer != null) + if (shortAnswer != null) { - var pollResponse = await TryRecordPollResponseAsync(identity.UserId, pollAnswer, session.Culture); - if (pollResponse != null) - return pollResponse; + var shortResponse = await TryResolveShortResponseAsync(session, shortAnswer); + if (shortResponse != null) + { + await _sessionManager.SaveSessionAsync(session); + return shortResponse; + } } } @@ -507,7 +528,7 @@ await _userIdentityService.LinkUserAsync( } // 8. Begin conversation for new multi-turn intents - if (_conversationEngine.IsMultiTurnIntent(intent.Type)) + if (_conversationEngine.NeedsParameterCollection(intent)) { var turnResult = await _conversationEngine.BeginDialogAsync(intent, session); if (turnResult is ChatbotResponse dialogResponse && dialogResponse.Processed) @@ -546,49 +567,171 @@ await _userIdentityService.LinkUserAsync( } } - /// - /// Records a bare YES/NO reply against the user's most recent unanswered poll message. Returns - /// null when the user has no outstanding poll (the reply then flows on to normal classification). - /// - private async Task TryRecordPollResponseAsync(string userId, string answer, string culture) + private async Task TryResolveShortResponseAsync(ChatbotSession session, string answer) { - try - { - var inbox = await _messageService.GetInboxMessagesByUserIdAsync(userId); - var pollCandidates = inbox? - .Where(m => m.Type == (int)Model.MessageTypes.Poll && !m.IsDeleted - && (m.ExpireOn == null || m.ExpireOn > DateTime.UtcNow)) - .OrderByDescending(m => m.SentOn) - .Take(MaxPollCandidatesToScan); + var targets = (await _textResponseResolver.GetPendingResponsesAsync(session.UserId, + session.DepartmentId, DateTime.UtcNow.AddDays(-1))) + .Take(MaxReplyTargets) + .ToList(); - if (pollCandidates == null) - return null; + if (targets.Count == 0) + return null; + + if (targets.Count == 1) + return await _textResponseResolver.RecordResponseAsync(targets[0], answer, session); + + StoreReplyTargets(session, answer, targets); + return BuildReplyTargetPrompt(targets); + } + + private async Task HandleReplyTargetSelectionAsync(ChatbotMessage message, ChatbotSession session) + { + if (IsCancel(message.Text)) + { + session.Reset(); + return new ChatbotResponse { Text = "Cancelled.", Processed = true }; + } - foreach (var poll in pollCandidates) + var targets = ReadReplyTargets(session); + if (targets.Count == 0 || !session.Context.TryGetValue(ReplyAnswerKey, out var answer)) + { + session.Reset(); + return new ChatbotResponse { - var recipient = await _messageService.GetMessageRecipientByMessageAndUserAsync(poll.MessageId, userId); - if (recipient == null || !string.IsNullOrWhiteSpace(recipient.Response)) - continue; + Text = "That reply request expired. Please send YES or NO again.", + Processed = true + }; + } - recipient.Response = answer; - if (recipient.ReadOn == null) - recipient.ReadOn = DateTime.UtcNow; + var selected = SelectReplyTarget(message.Text, targets); + if (selected == null) + return BuildReplyTargetPrompt(targets, "I couldn't tell which one you meant."); - await _messageService.SaveMessageRecipientAsync(recipient); + var response = await _textResponseResolver.RecordResponseAsync(selected, answer, session); + session.Reset(); + return response ?? new ChatbotResponse + { + Text = "That item no longer needs a response. Send YES or NO again to check the remaining items.", + Processed = true + }; + } - return new ChatbotResponse - { - Text = Localization.ChatbotResources.Get("Poll_ResponseRecorded", culture, answer, poll.Subject), - Processed = true - }; - } + private async Task HandleParameterContinuationAsync(ChatbotMessage message, ChatbotSession session) + { + if (IsCancel(message.Text)) + { + session.Reset(); + return new ChatbotResponse { Text = "Cancelled.", Processed = true }; } - catch (Exception ex) + + var pendingType = session.PendingIntent.Value; + var handler = _actionHandlers.FirstOrDefault(h => h.CanHandle(pendingType)); + if (handler == null) { - Logging.LogException(ex); + session.Reset(); + return new ChatbotResponse { Text = "I couldn't continue that request. Please try the full command again.", Processed = false }; + } + + var intent = new ChatbotIntent { Type = pendingType, Confidence = 1.0 }; + if (pendingType == ChatbotIntentType.SetStatus) + intent.Parameters["statusName"] = message.Text.Trim(); + else if (pendingType == ChatbotIntentType.SetStaffing) + intent.Parameters["staffingName"] = message.Text.Trim(); + else + intent.Parameters["value"] = message.Text.Trim(); + + session.State = ChatbotDialogState.Idle; + session.PendingIntent = null; + session.Context.Clear(); + var response = await handler.HandleAsync(message, intent, session); + response.Intent = intent; + return response; + } + + private static void StoreReplyTargets(ChatbotSession session, string answer, + IReadOnlyList targets) + { + session.State = ChatbotDialogState.AwaitingResponseTarget; + session.PendingIntent = null; + session.Context.Clear(); + session.Context[ReplyAnswerKey] = answer; + session.Context[ReplyCountKey] = targets.Count.ToString(); + + for (var i = 0; i < targets.Count; i++) + { + var prefix = $"__reply:{i}:"; + session.Context[prefix + "type"] = ((int)targets[i].Type).ToString(); + session.Context[prefix + "source"] = targets[i].SourceId.ToString(); + session.Context[prefix + "message"] = targets[i].MessageId.ToString(); + session.Context[prefix + "label"] = targets[i].Label ?? string.Empty; + } + } + + private static List ReadReplyTargets(ChatbotSession session) + { + var targets = new List(); + if (!session.Context.TryGetValue(ReplyCountKey, out var countRaw) + || !int.TryParse(countRaw, out var count) || count < 1 || count > MaxReplyTargets) + return targets; + + for (var i = 0; i < count; i++) + { + var prefix = $"__reply:{i}:"; + if (!session.Context.TryGetValue(prefix + "type", out var typeRaw) + || !int.TryParse(typeRaw, out var type) + || !session.Context.TryGetValue(prefix + "source", out var sourceRaw) + || !int.TryParse(sourceRaw, out var sourceId) + || !session.Context.TryGetValue(prefix + "message", out var messageRaw) + || !int.TryParse(messageRaw, out var messageId)) + continue; + + session.Context.TryGetValue(prefix + "label", out var label); + targets.Add(new PendingTextResponse + { + Type = (PendingTextResponseType)type, + SourceId = sourceId, + MessageId = messageId, + Label = label + }); + } + + return targets; + } + + private static PendingTextResponse SelectReplyTarget(string text, IReadOnlyList targets) + { + var input = text?.Trim().TrimEnd('?', '!', '.', ',').TrimStart('#'); + if (int.TryParse(input, out var selection) && selection >= 1 && selection <= targets.Count) + return targets[selection - 1]; + + var matches = targets.Where(target => + (target.Type == PendingTextResponseType.Poll && input?.IndexOf("poll", StringComparison.OrdinalIgnoreCase) >= 0) + || (target.Type == PendingTextResponseType.CalendarRsvp + && (input?.IndexOf("calendar", StringComparison.OrdinalIgnoreCase) >= 0 + || input?.IndexOf("event", StringComparison.OrdinalIgnoreCase) >= 0)) + || (!string.IsNullOrWhiteSpace(target.Label) + && (input?.IndexOf(target.Label, StringComparison.OrdinalIgnoreCase) >= 0 + || (input?.Length >= 3 && target.Label.IndexOf(input, StringComparison.OrdinalIgnoreCase) >= 0)))) + .ToList(); + + return matches.Count == 1 ? matches[0] : null; + } + + private static ChatbotResponse BuildReplyTargetPrompt(IReadOnlyList targets, + string prefix = null) + { + var builder = new StringBuilder(); + if (!string.IsNullOrWhiteSpace(prefix)) + builder.AppendLine(prefix); + builder.AppendLine("Did you mean this YES/NO response for:"); + for (var i = 0; i < targets.Count; i++) + { + var type = targets[i].Type == PendingTextResponseType.Poll ? "Poll" : "Calendar"; + builder.AppendLine($"{i + 1}. {type}: {targets[i].Label}"); } + builder.Append("Reply with the number, or CANCEL."); - return null; + return new ChatbotResponse { Text = builder.ToString(), Processed = true }; } private async Task ResolveUserIdentityAsync(ChatbotMessage message) @@ -626,6 +769,34 @@ private static bool IsNegative(string text) return t == "no" || t == "n" || t == "cancel" || t == "stop"; } + private static bool IsCancel(string text) + { + var t = text?.Trim().TrimEnd('?', '!', '.', ','); + return string.Equals(t, "cancel", StringComparison.OrdinalIgnoreCase) + || string.Equals(t, "never mind", StringComparison.OrdinalIgnoreCase) + || string.Equals(t, "nevermind", StringComparison.OrdinalIgnoreCase); + } + + private static bool IsExplicitNegativeAnswer(string text, string culture) + { + if (string.IsNullOrWhiteSpace(text)) + return false; + + var input = text.Trim(); + foreach (var tokenList in new[] + { + Localization.ChatbotResources.Get("Confirm_NoTokens", "en"), + Localization.ChatbotResources.Get("Confirm_NoTokens", culture) + }) + { + var answerTokens = tokenList.Split(',').Take(2); + if (answerTokens.Any(token => string.Equals(token.Trim(), input, StringComparison.OrdinalIgnoreCase))) + return true; + } + + return false; + } + /// /// Executes the pending (already confirmed, and PIN-verified when required) destructive intent. /// The owning handler is located BEFORE any session state is mutated: if none can handle the diff --git a/Core/Resgrid.Chatbot/Services/ConversationEngine.cs b/Core/Resgrid.Chatbot/Services/ConversationEngine.cs index 96390e398..bcd5de975 100644 --- a/Core/Resgrid.Chatbot/Services/ConversationEngine.cs +++ b/Core/Resgrid.Chatbot/Services/ConversationEngine.cs @@ -1,14 +1,25 @@ using System; using System.Collections.Generic; +using System.Linq; +using System.Text; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Services; namespace Resgrid.Chatbot.Services { public class ConversationEngine : IConversationEngine { + private readonly ICustomStateService _customStateService; + + public ConversationEngine(ICustomStateService customStateService = null) + { + _customStateService = customStateService; + } + public Task ProcessAsync(ChatbotMessage message, ChatbotSession session, ChatbotIntent intent) { if (session == null) @@ -101,15 +112,22 @@ public async Task HandleContinuationAsync(ChatbotMessage messag /// /// Phase 2: Determines if an intent type requires multi-turn dialog. /// - public bool IsMultiTurnIntent(ChatbotIntentType intentType) + public bool NeedsParameterCollection(ChatbotIntent intent) { - return intentType switch + if (intent == null) + return false; + + return intent.Type switch { // SendMessage is handled single-turn by MessageSendHandler (recipient+body are captured // inline by the classifier). It is intentionally NOT multi-turn here: the previous // multi-turn path only *faked* the send. A guided "ask for the body" flow can be added later. - ChatbotIntentType.SetStatus => true, - ChatbotIntentType.SetStaffing => true, + ChatbotIntentType.SetStatus => !intent.Parameters.ContainsKey("actionType") + && !intent.Parameters.ContainsKey("customActionId") + && !intent.Parameters.ContainsKey("statusName"), + ChatbotIntentType.SetStaffing => !intent.Parameters.ContainsKey("staffingType") + && !intent.Parameters.ContainsKey("customStaffingId") + && !intent.Parameters.ContainsKey("staffingName"), // CloseCall/DispatchCall are single-turn: their handlers run a confirmation pass via // AwaitingConfirmation, which ChatbotIngressService re-dispatches on YES (addendum §5). _ => false @@ -119,21 +137,21 @@ public bool IsMultiTurnIntent(ChatbotIntentType intentType) /// /// Phase 2: Begins a multi-turn dialog for an intent, returning the first prompt. /// - public Task BeginDialogAsync(ChatbotIntent intent, ChatbotSession session) + public async Task BeginDialogAsync(ChatbotIntent intent, ChatbotSession session) { session.State = ChatbotDialogState.AwaitingParameter; session.PendingIntent = intent.Type; - var prompt = BuildParameterPrompt(intent, session.Culture); - return Task.FromResult(new ChatbotResponse + var prompt = await BuildParameterPromptAsync(intent, session); + return new ChatbotResponse { Text = prompt, Processed = true, Intent = intent - }); + }; } - private Task HandleNewIntent(ChatbotMessage message, ChatbotSession session, ChatbotIntent intent) + private async Task HandleNewIntent(ChatbotMessage message, ChatbotSession session, ChatbotIntent intent) { // Check if the intent requires multi-turn (needs parameters not provided) if (NeedsParameterCollection(intent)) @@ -141,8 +159,8 @@ private Task HandleNewIntent(ChatbotMessage message, Chatbot session.State = ChatbotDialogState.AwaitingParameter; session.PendingIntent = intent.Type; - var prompt = BuildParameterPrompt(intent, session.Culture); - return Task.FromResult(new ConversationResult + var prompt = await BuildParameterPromptAsync(intent, session); + return new ConversationResult { Response = new ChatbotResponse { @@ -153,17 +171,17 @@ private Task HandleNewIntent(ChatbotMessage message, Chatbot IsComplete = false, NeedsFollowUp = true, NextState = ChatbotDialogState.AwaitingParameter - }); + }; } // Simple intent - complete in one turn session.State = ChatbotDialogState.Completed; - return Task.FromResult(new ConversationResult + return new ConversationResult { IsComplete = true, NeedsFollowUp = false, NextState = ChatbotDialogState.Idle - }); + }; } private Task HandleParameterResponse(ChatbotMessage message, ChatbotSession session, ChatbotIntent intent) @@ -269,47 +287,56 @@ private Task HandleGeneralResponse(ChatbotMessage message, C }); } - private static bool NeedsParameterCollection(ChatbotIntent intent) + private async Task BuildParameterPromptAsync(ChatbotIntent intent, ChatbotSession session) { - if (intent == null) return false; + if (_customStateService != null && intent.Type == ChatbotIntentType.SetStatus) + { + var customState = await _customStateService.GetActivePersonnelStateForDepartmentAsync(session.DepartmentId); + var details = customState?.GetActiveDetails(); + if (details?.Any() == true) + return BuildOptionsPrompt("Which status? Reply with a number or name:", details); + } - return intent.Type switch + if (_customStateService != null && intent.Type == ChatbotIntentType.SetStaffing) { - // SendMessage is single-turn (handled inline by MessageSendHandler), so it is not - // collected here — kept consistent with IsMultiTurnIntent, which also excludes SendMessage. - ChatbotIntentType.SendMessage => false, - ChatbotIntentType.SetStatus => !intent.Parameters.ContainsKey("actionType") && !intent.Parameters.ContainsKey("customActionId") && !intent.Parameters.ContainsKey("statusName"), - ChatbotIntentType.SetStaffing => !intent.Parameters.ContainsKey("staffingType") && !intent.Parameters.ContainsKey("staffingName"), - ChatbotIntentType.DispatchCall => !intent.Parameters.ContainsKey("description"), - ChatbotIntentType.CloseCall => !intent.Parameters.ContainsKey("callId"), - _ => false - }; - } + var customState = await _customStateService.GetActiveStaffingLevelsForDepartmentAsync(session.DepartmentId); + var details = customState?.GetActiveDetails(); + if (details?.Any() == true) + return BuildOptionsPrompt("Which staffing level? Reply with a number or name:", details); + } - private static string BuildParameterPrompt(ChatbotIntent intent, string culture) - { return intent.Type switch { ChatbotIntentType.SendMessage when intent.Parameters.TryGetValue("recipient", out var recipient) - => ChatbotResources.Get("Conv_PromptSendMessageTo", culture, recipient), + => ChatbotResources.Get("Conv_PromptSendMessageTo", session.Culture, recipient), ChatbotIntentType.SendMessage - => ChatbotResources.Get("Conv_PromptSendMessage", culture), + => ChatbotResources.Get("Conv_PromptSendMessage", session.Culture), ChatbotIntentType.SetStatus - => ChatbotResources.Get("Conv_PromptSetStatus", culture), + => ChatbotResources.Get("Conv_PromptSetStatus", session.Culture), ChatbotIntentType.SetStaffing - => ChatbotResources.Get("Conv_PromptSetStaffing", culture), + => ChatbotResources.Get("Conv_PromptSetStaffing", session.Culture), ChatbotIntentType.DispatchCall - => ChatbotResources.Get("Conv_PromptDispatchCall", culture), + => ChatbotResources.Get("Conv_PromptDispatchCall", session.Culture), ChatbotIntentType.CloseCall - => ChatbotResources.Get("Conv_PromptCloseCall", culture), + => ChatbotResources.Get("Conv_PromptCloseCall", session.Culture), - _ => ChatbotResources.Get("Conv_PromptDefault", culture) + _ => ChatbotResources.Get("Conv_PromptDefault", session.Culture) }; } + + private static string BuildOptionsPrompt(string heading, IEnumerable details) + { + var builder = new StringBuilder(heading); + var options = details.Where(x => x != null && !x.IsDeleted).OrderBy(x => x.Order).Take(10).ToList(); + for (var i = 0; i < options.Count; i++) + builder.Append($"\n({i + 1}) {options[i].ButtonText}"); + + return builder.ToString(); + } } } diff --git a/Core/Resgrid.Chatbot/Services/CustomStateMatcher.cs b/Core/Resgrid.Chatbot/Services/CustomStateMatcher.cs new file mode 100644 index 000000000..3dc6ac215 --- /dev/null +++ b/Core/Resgrid.Chatbot/Services/CustomStateMatcher.cs @@ -0,0 +1,93 @@ +using System.Collections.Generic; +using System.Linq; +using Resgrid.Model; + +namespace Resgrid.Chatbot.Services +{ + internal static class CustomStateMatcher + { + public static CustomStateDetail FindById(IEnumerable details, int detailId) + => details?.FirstOrDefault(x => x != null && !x.IsDeleted && x.CustomStateDetailId == detailId); + + public static CustomStateDetail FindByName(IEnumerable details, params string[] names) + { + var normalizedNames = (names ?? new string[0]) + .Select(Normalize) + .Where(x => x.Length > 0) + .ToList(); + + return details?.FirstOrDefault(detail => detail != null && !detail.IsDeleted + && normalizedNames.Contains(Normalize(detail.ButtonText))); + } + + public static CustomStateDetail FindByBaseType(IEnumerable details, + params ActionBaseTypes[] baseTypes) + { + foreach (var baseType in baseTypes ?? new ActionBaseTypes[0]) + { + var match = details?.FirstOrDefault(detail => detail != null && !detail.IsDeleted + && detail.CustomStateDetailId > 25 + && detail.BaseType == (int)baseType); + if (match != null) + return match; + } + + return null; + } + + public static CustomStateDetail FindBySelection(IEnumerable details, string value) + { + var active = details?.Where(x => x != null && !x.IsDeleted).OrderBy(x => x.Order).ToList(); + if (active == null || !int.TryParse(value?.Trim().TrimStart('s', 'S'), out var selection) + || selection < 1 || selection > active.Count) + return null; + + return active[selection - 1]; + } + + public static ActionBaseTypes? PersonnelBaseTypeFor(string value) + { + return Normalize(value) switch + { + "responding" => ActionBaseTypes.Responding, + "respondingtoscene" => ActionBaseTypes.Responding, + "onmyway" => ActionBaseTypes.Responding, + "omw" => ActionBaseTypes.Responding, + "enroute" => ActionBaseTypes.Enroute, + "notresponding" => ActionBaseTypes.NotResponding, + "notgoing" => ActionBaseTypes.NotResponding, + "onscene" => ActionBaseTypes.OnScene, + "standingby" => ActionBaseTypes.Standby, + "standby" => ActionBaseTypes.Standby, + "available" => ActionBaseTypes.Available, + "availablestation" => ActionBaseTypes.Available, + "unavailable" => ActionBaseTypes.Unavailable, + _ => null + }; + } + + public static ActionBaseTypes? UnitBaseTypeFor(string value) + { + return Normalize(value) switch + { + "available" => ActionBaseTypes.Available, + "inservice" => ActionBaseTypes.Available, + "responding" => ActionBaseTypes.Responding, + "enroute" => ActionBaseTypes.Enroute, + "onmyway" => ActionBaseTypes.Enroute, + "onscene" => ActionBaseTypes.OnScene, + "staging" => ActionBaseTypes.Staging, + "returning" => ActionBaseTypes.Returning, + "unavailable" => ActionBaseTypes.Unavailable, + "outofservice" => ActionBaseTypes.Unavailable, + "maintenance" => ActionBaseTypes.Maintenance, + _ => null + }; + } + + public static string Normalize(string value) + => string.IsNullOrWhiteSpace(value) + ? string.Empty + : new string(value.Where(char.IsLetterOrDigit).Select(char.ToLowerInvariant).ToArray()); + } +} diff --git a/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs b/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs index 03031a860..113405588 100644 --- a/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs +++ b/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs @@ -32,9 +32,10 @@ public RedisSessionStore(ICacheProvider cacheProvider) _cacheProvider = cacheProvider; } - private TimeSpan Ttl => TimeSpan.FromMinutes(ChatbotConfig.DefaultSessionTimeoutMinutes > 0 - ? ChatbotConfig.DefaultSessionTimeoutMinutes - : 30); + private static TimeSpan GetTtl(ChatbotSession session) + => TimeSpan.FromMinutes(session?.TtlMinutes > 0 + ? session.TtlMinutes + : (ChatbotConfig.DefaultSessionTimeoutMinutes > 0 ? ChatbotConfig.DefaultSessionTimeoutMinutes : 30)); private bool UseRedis() { @@ -200,8 +201,9 @@ private ChatbotSession GetOrCreateInMemory(string userId, int departmentId, Chat private async Task SaveToRedisAsync(ChatbotSession session) { var json = JsonConvert.SerializeObject(session); - await _cacheProvider.SetStringAsync(SessionKey(session.SessionId), json, Ttl); - await _cacheProvider.SetStringAsync(ActiveKey(session.UserId, session.DepartmentId, session.Platform), session.SessionId, Ttl); + var ttl = GetTtl(session); + await _cacheProvider.SetStringAsync(SessionKey(session.SessionId), json, ttl); + await _cacheProvider.SetStringAsync(ActiveKey(session.UserId, session.DepartmentId, session.Platform), session.SessionId, ttl); } private async Task GetFromRedisAsync(string sessionId) @@ -222,6 +224,9 @@ private async Task GetFromRedisAsync(string sessionId) private static ChatbotSession CreateNewSession(string userId, int departmentId, ChatbotPlatform platform) { + var ttlMinutes = ChatbotConfig.DefaultSessionTimeoutMinutes > 0 + ? ChatbotConfig.DefaultSessionTimeoutMinutes + : 30; return new ChatbotSession { SessionId = Guid.NewGuid().ToString("N"), @@ -230,7 +235,8 @@ private static ChatbotSession CreateNewSession(string userId, int departmentId, Platform = platform, State = ChatbotDialogState.Idle, CreatedAt = DateTime.UtcNow, - LastActivity = DateTime.UtcNow + LastActivity = DateTime.UtcNow, + TtlMinutes = ttlMinutes }; } diff --git a/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs b/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs new file mode 100644 index 000000000..8cf80338c --- /dev/null +++ b/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Localization; +using Resgrid.Chatbot.Models; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Services; + +namespace Resgrid.Chatbot.Services +{ + public class TextResponseResolver : ITextResponseResolver + { + private const int MaxMessagesToScan = 25; + + private readonly IMessageService _messageService; + private readonly ICalendarService _calendarService; + private readonly IAuthorizationService _authorizationService; + + public TextResponseResolver(IMessageService messageService, ICalendarService calendarService, + IAuthorizationService authorizationService) + { + _messageService = messageService; + _calendarService = calendarService; + _authorizationService = authorizationService; + } + + public async Task> GetPendingResponsesAsync(string userId, + int departmentId, DateTime sinceUtc) + { + var pending = new List(); + var inbox = await _messageService.GetInboxMessagesByUserIdAsync(userId); + var candidates = inbox? + .Where(m => !m.IsDeleted && m.SentOn >= sinceUtc + && (m.ExpireOn == null || m.ExpireOn > DateTime.UtcNow) + && (m.Type == (int)MessageTypes.Poll || m.Type == (int)MessageTypes.CalendarRsvp)) + .OrderByDescending(m => m.SentOn) + .Take(MaxMessagesToScan) + .ToList(); + + if (candidates == null) + return pending; + + var seenCalendarItems = new HashSet(); + foreach (var message in candidates) + { + var recipient = await _messageService.GetMessageRecipientByMessageAndUserAsync(message.MessageId, userId); + if (recipient == null || recipient.IsDeleted || !string.IsNullOrWhiteSpace(recipient.Response)) + continue; + + if (message.Type == (int)MessageTypes.Poll) + { + pending.Add(new PendingTextResponse + { + Type = PendingTextResponseType.Poll, + SourceId = message.MessageId, + MessageId = message.MessageId, + Label = CleanLabel(message.Subject, "Poll", "Poll:") + }); + continue; + } + + if (!TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId) + || !seenCalendarItems.Add(calendarItemId)) + continue; + + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(calendarItemId); + if (calendarItem == null || calendarItem.DepartmentId != departmentId + || calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP + || await _calendarService.GetCalendarItemAttendeeByUserAsync(calendarItemId, userId) != null + || !await _authorizationService.CanUserCheckInToCalendarEventAsync(userId, calendarItemId)) + continue; + + pending.Add(new PendingTextResponse + { + Type = PendingTextResponseType.CalendarRsvp, + SourceId = calendarItemId, + MessageId = message.MessageId, + Label = CleanLabel(calendarItem.Title, "Calendar event") + }); + } + + return pending; + } + + public async Task RecordResponseAsync(PendingTextResponse target, string answer, + ChatbotSession session) + { + if (target == null || session == null || string.IsNullOrWhiteSpace(answer)) + return null; + + var recipient = await _messageService.GetMessageRecipientByMessageAndUserAsync(target.MessageId, session.UserId); + if (recipient == null || recipient.IsDeleted || !string.IsNullOrWhiteSpace(recipient.Response)) + return null; + + if (target.Type == PendingTextResponseType.Poll) + { + recipient.Response = answer; + recipient.ReadOn ??= DateTime.UtcNow; + await _messageService.SaveMessageRecipientAsync(recipient); + + return new ChatbotResponse + { + Text = ChatbotResources.Get("Poll_ResponseRecorded", session.Culture, answer, target.Label), + Processed = true + }; + } + + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(target.SourceId); + if (calendarItem == null || calendarItem.DepartmentId != session.DepartmentId + || calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP + || await _calendarService.GetCalendarItemAttendeeByUserAsync(target.SourceId, session.UserId) != null + || !await _authorizationService.CanUserCheckInToCalendarEventAsync(session.UserId, target.SourceId)) + return null; + + var isYes = string.Equals(answer, "Yes", StringComparison.OrdinalIgnoreCase); + var attendeeType = isYes + ? (int)CalendarItemAttendeeTypes.RSVP + : (int)CalendarItemAttendeeTypes.NotAttending; + var normalizedAnswer = isYes ? "Yes" : "No"; + + await _calendarService.SignupForEvent(calendarItem.CalendarItemId, session.UserId, + normalizedAnswer, attendeeType); + + recipient.Response = normalizedAnswer; + recipient.ReadOn ??= DateTime.UtcNow; + await _messageService.SaveMessageRecipientAsync(recipient); + + return new ChatbotResponse + { + Text = ChatbotResources.Get("Cal_RsvpDone", session.Culture, normalizedAnswer, calendarItem.Title), + Processed = true + }; + } + + private static string CleanLabel(string value, string fallback, string prefix = null) + { + if (string.IsNullOrWhiteSpace(value)) + return fallback; + + var label = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); + if (!string.IsNullOrWhiteSpace(prefix) + && label.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + label = label.Substring(prefix.Length).Trim(); + + return label.Truncate(100); + } + } +} diff --git a/Core/Resgrid.Model/MessageTypes.cs b/Core/Resgrid.Model/MessageTypes.cs index 73c51480e..8529beea2 100644 --- a/Core/Resgrid.Model/MessageTypes.cs +++ b/Core/Resgrid.Model/MessageTypes.cs @@ -5,6 +5,7 @@ public enum MessageTypes Normal = 0, Callback = 1, Poll = 2, - WeatherAlert = 3 + WeatherAlert = 3, + CalendarRsvp = 4 } -} \ No newline at end of file +} diff --git a/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs b/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs new file mode 100644 index 000000000..10957d060 --- /dev/null +++ b/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs @@ -0,0 +1,27 @@ +using System; + +namespace Resgrid.Model.Messages +{ + /// + /// Machine-readable metadata stored on a message recipient for prompts that can be answered from + /// any text channel. Keeping it on the recipient allows response state to remain user-specific. + /// + public static class TextResponsePromptMetadata + { + private const string CalendarRsvpPrefix = "calendar-rsvp:"; + + public static string ForCalendarRsvp(int calendarItemId) + => CalendarRsvpPrefix + calendarItemId; + + public static bool TryGetCalendarItemId(string note, out int calendarItemId) + { + calendarItemId = 0; + if (string.IsNullOrWhiteSpace(note) + || !note.StartsWith(CalendarRsvpPrefix, StringComparison.OrdinalIgnoreCase)) + return false; + + return int.TryParse(note.Substring(CalendarRsvpPrefix.Length), out calendarItemId) + && calendarItemId > 0; + } + } +} diff --git a/Core/Resgrid.Model/Services/ITextResponsePromptService.cs b/Core/Resgrid.Model/Services/ITextResponsePromptService.cs new file mode 100644 index 000000000..e1c7dc74a --- /dev/null +++ b/Core/Resgrid.Model/Services/ITextResponsePromptService.cs @@ -0,0 +1,14 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace Resgrid.Model.Services +{ + /// + /// Records outbound prompts whose replies may arrive later over SMS or another text channel. + /// + public interface ITextResponsePromptService + { + Task RecordCalendarRsvpPromptAsync(CalendarItem calendarItem, string userId, + CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index 46b0c3261..d76b7302e 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -25,11 +25,13 @@ public class CalendarService : ICalendarService private readonly IDepartmentSettingsService _departmentSettingsService; private readonly IEncryptionService _encryptionService; private readonly ICalendarItemCheckInRepository _calendarItemCheckInRepository; + private readonly ITextResponsePromptService _textResponsePromptService; public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalendarItemTypeRepository calendarItemTypeRepository, ICalendarItemAttendeeRepository calendarItemAttendeeRepository, IDepartmentsService departmentsService, ICommunicationService communicationService, IUserProfileService userProfileService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, - IEncryptionService encryptionService, ICalendarItemCheckInRepository calendarItemCheckInRepository) + IEncryptionService encryptionService, ICalendarItemCheckInRepository calendarItemCheckInRepository, + ITextResponsePromptService textResponsePromptService = null) { _calendarItemRepository = calendarItemRepository; _calendarItemTypeRepository = calendarItemTypeRepository; @@ -41,6 +43,7 @@ public CalendarService(ICalendarItemsRepository calendarItemRepository, ICalenda _departmentSettingsService = departmentSettingsService; _encryptionService = encryptionService; _calendarItemCheckInRepository = calendarItemCheckInRepository; + _textResponsePromptService = textResponsePromptService; } public async Task> GetAllCalendarItemsForDepartmentAsync(int departmentId) @@ -501,6 +504,9 @@ public async Task NotifyNewCalendarItemAsync(CalendarItem calendarItem) else message = $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()} at {calendarItem.Location}"; + if (calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + message += " Reply YES or NO."; + if (ConfigHelper.CanTransmit(department.DepartmentId)) { if (items.Any(x => x.StartsWith("D:"))) @@ -508,7 +514,7 @@ public async Task NotifyNewCalendarItemAsync(CalendarItem calendarItem) // Notify the entire department foreach (var profile in profiles) { - await _communicationService.SendCalendarAsync(profile.Key, calendarItem.DepartmentId, message, departmentNumber, title, profile.Value, department); + await SendCalendarPromptAsync(calendarItem, profile.Key, message, departmentNumber, title, profile.Value, department); } } else @@ -526,9 +532,9 @@ public async Task NotifyNewCalendarItemAsync(CalendarItem calendarItem) foreach (var member in group.Members) { if (profiles.ContainsKey(member.UserId)) - await _communicationService.SendCalendarAsync(member.UserId, calendarItem.DepartmentId, message, departmentNumber, title, profiles[member.UserId], department); + await SendCalendarPromptAsync(calendarItem, member.UserId, message, departmentNumber, title, profiles[member.UserId], department); else - await _communicationService.SendCalendarAsync(member.UserId, calendarItem.DepartmentId, message, departmentNumber, title, null, department); + await SendCalendarPromptAsync(calendarItem, member.UserId, message, departmentNumber, title, null, department); } } } @@ -559,20 +565,43 @@ public async Task NotifyUsersAboutCalendarItemAsync(CalendarItem calendarI ? $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()}" : $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()} at {calendarItem.Location}"; + if (calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + message += " Reply YES or NO."; + if (ConfigHelper.CanTransmit(department.DepartmentId)) { foreach (var userId in userIds) { if (profiles.ContainsKey(userId)) - await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, departmentNumber, title, profiles[userId], department); + await SendCalendarPromptAsync(calendarItem, userId, message, departmentNumber, title, profiles[userId], department); else - await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, departmentNumber, title, null, department); + await SendCalendarPromptAsync(calendarItem, userId, message, departmentNumber, title, null, department); } } return true; } + private async System.Threading.Tasks.Task SendCalendarPromptAsync(CalendarItem calendarItem, string userId, string message, + string departmentNumber, string title, UserProfile profile, Department department) + { + await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, + departmentNumber, title, profile, department); + + if (_textResponsePromptService == null + || calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP) + return; + + try + { + await _textResponsePromptService.RecordCalendarRsvpPromptAsync(calendarItem, userId); + } + catch (Exception ex) + { + Logging.LogException(ex, $"Unable to record calendar RSVP prompt for item {calendarItem.CalendarItemId} and user {userId}."); + } + } + public async Task> GetCalendarItemsToNotifyAsync(DateTime timestamp) { List itemsToNotify = new List(); diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index dbec0bef3..9a1f2ef5f 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -577,6 +577,18 @@ public async Task SendNotificationAsync(string userId, int departmentId, s if (profile == null) profile = await _userProfileService.GetProfileByUserIdAsync(userId, false); + if (profile == null || profile.SendNotificationSms) + { + try + { + await _smsService.SendNotificationAsync(userId, departmentId, $"{title} {message}", departmentNumber, profile); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + if (profile == null || profile.SendNotificationEmail) { if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending()) @@ -626,6 +638,18 @@ public async Task SendCalendarAsync(string userId, int departmentId, strin if (profile == null) profile = await _userProfileService.GetProfileByUserIdAsync(userId, false); + if (profile == null || profile.SendNotificationSms) + { + try + { + await _smsService.SendNotificationAsync(userId, departmentId, $"{title} {message}", departmentNumber, profile); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + if (profile == null || profile.SendNotificationEmail) { if (profile == null || profile.EmailVerified.IsContactMethodAllowedForSending()) diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs index 5fde5b71a..229892c66 100644 --- a/Core/Resgrid.Services/ServicesModule.cs +++ b/Core/Resgrid.Services/ServicesModule.cs @@ -35,6 +35,7 @@ protected override void Load(ContainerBuilder builder) builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().SingleInstance(); builder.RegisterType().As().InstancePerLifetimeScope(); + builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); builder.RegisterType().As().InstancePerLifetimeScope(); diff --git a/Core/Resgrid.Services/TextResponsePromptService.cs b/Core/Resgrid.Services/TextResponsePromptService.cs new file mode 100644 index 000000000..3c7d4a36c --- /dev/null +++ b/Core/Resgrid.Services/TextResponsePromptService.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Resgrid.Framework; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Services; + +namespace Resgrid.Services +{ + public class TextResponsePromptService : ITextResponsePromptService + { + private readonly IMessageService _messageService; + + public TextResponsePromptService(IMessageService messageService) + { + _messageService = messageService; + } + + public async Task RecordCalendarRsvpPromptAsync(CalendarItem calendarItem, string userId, + CancellationToken cancellationToken = default(CancellationToken)) + { + if (calendarItem == null || calendarItem.CalendarItemId <= 0 + || calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP + || string.IsNullOrWhiteSpace(userId)) + return; + + var now = DateTime.UtcNow; + var message = new Message + { + Subject = ("Calendar RSVP: " + calendarItem.Title).Truncate(150), + Body = ($"Event #{calendarItem.CalendarItemId}: {calendarItem.Title}. Reply YES or NO.").Truncate(4000), + SendingUserId = calendarItem.CreatorUserId, + SentOn = now, + ExpireOn = now.AddDays(1), + SystemGenerated = true, + IsBroadcast = true, + Type = (int)MessageTypes.CalendarRsvp, + MessageRecipients = new List + { + new MessageRecipient + { + UserId = userId, + Note = TextResponsePromptMetadata.ForCalendarRsvp(calendarItem.CalendarItemId) + } + } + }; + + await _messageService.SaveMessageAsync(message, cancellationToken); + } + } +} diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs index 312188124..afd306645 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs @@ -111,7 +111,11 @@ public async Task WhoIsName_still_personnel_lookup() result.Parameters["query"].Should().Be("john"); } - [TestCase("responding", "set_status")] + [TestCase("responding", "respond_to_call")] + [TestCase("on my way", "respond_to_call")] + [TestCase("omw", "respond_to_call")] + [TestCase("not responding", "respond_to_call")] + [TestCase("not going", "respond_to_call")] [TestCase("available", "set_staffing")] [TestCase("units", "list_units")] [TestCase("show active calls", "list_calls")] @@ -121,5 +125,17 @@ public async Task Existing_intents_unchanged(string text, string expectedIntent) var result = await _classifier.ClassifyAsync(text); result.IntentName.Should().Be(expectedIntent); } + + [TestCase("responding", "yes")] + [TestCase("omw", "yes")] + [TestCase("not responding", "no")] + [TestCase("not going", "no")] + public async Task Bare_call_response_extracts_response(string text, string expectedResponse) + { + var result = await _classifier.ClassifyAsync(text); + + result.IntentName.Should().Be("respond_to_call"); + result.Parameters["response"].Should().Be(expectedResponse); + } } } diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs index f06a17213..f2b372b26 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs @@ -275,6 +275,111 @@ public async Task CloseCall_Confirmed_ClosesTheCall() // ===================== DispatchCallHandler (destructive + confirmation) ===================== + [Test] + public async Task SetStatus_WhenDepartmentRequiresConfirmation_DoesNotChangeStatusOnFirstPass() + { + var actionLogs = new Mock(); + var config = new Mock(); + config.Setup(c => c.GetConfigAsync(1, false)).ReturnsAsync(new ChatbotDepartmentConfig + { + DepartmentId = 1, + RequireConfirmationForStatusChange = true + }); + var session = Session(); + var handler = new StatusActionHandler(actionLogs.Object, Mock.Of(), config.Object); + + var response = await handler.HandleAsync(Msg("set status responding"), + Intent(ChatbotIntentType.SetStatus, ("actionType", ((int)ActionTypes.Responding).ToString())), session); + + response.Processed.Should().BeTrue(); + session.State.Should().Be(ChatbotDialogState.AwaitingConfirmation); + session.PendingIntent.Should().Be(ChatbotIntentType.SetStatus); + actionLogs.Verify(a => a.SetUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task SetStatus_BuiltInResponding_UsesDepartmentCustomBaseType() + { + var actionLogs = new Mock(); + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(1)).ReturnsAsync(new List + { + new CustomStateDetail + { + CustomStateDetailId = 101, + ButtonText = "Rolling", + BaseType = (int)ActionBaseTypes.Responding + } + }); + + var handler = new StatusActionHandler(actionLogs.Object, customStates.Object); + var response = await handler.HandleAsync(Msg("set status responding"), + Intent(ChatbotIntentType.SetStatus, ("actionType", ((int)ActionTypes.Responding).ToString())), Session()); + + response.Text.Should().Contain("Rolling"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, 101, It.IsAny()), Times.Once); + } + + [Test] + public async Task SetStatus_AvailableName_UsesBuiltInAvailableStationWhenNoCustomSetExists() + { + var actionLogs = new Mock(); + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(1)).ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = (int)ActionTypes.Responding, ButtonText = "Responding" }, + new CustomStateDetail { CustomStateDetailId = (int)ActionTypes.AvailableStation, ButtonText = "Available Station" } + }); + + var handler = new StatusActionHandler(actionLogs.Object, customStates.Object); + await handler.HandleAsync(Msg("set my status to available"), + Intent(ChatbotIntentType.SetStatus, ("statusName", "available")), Session()); + + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, (int)ActionTypes.AvailableStation, + It.IsAny()), Times.Once); + } + + [Test] + public async Task SetStaffing_AvailableShortcut_UsesFirstConfiguredCustomLevel() + { + var userStates = new Mock(); + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStaffingsOrDefaultsAsync(1)).ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = 201, ButtonText = "On Duty", Order = 0 }, + new CustomStateDetail { CustomStateDetailId = 202, ButtonText = "Off Duty", Order = 1 } + }); + + var handler = new StaffingActionHandler(userStates.Object, customStates.Object); + var response = await handler.HandleAsync(Msg("available"), + Intent(ChatbotIntentType.SetStaffing, ("staffingType", ((int)UserStateTypes.Available).ToString())), Session()); + + response.Text.Should().Contain("On Duty"); + userStates.Verify(x => x.CreateUserState("user-1", 1, 201, It.IsAny()), Times.Once); + } + + [Test] + public async Task DispatchCall_WhenDisabledByDepartment_IsDeniedBeforeAuthorization() + { + var calls = new Mock(); + var authz = new Mock(); + var config = new Mock(); + config.Setup(c => c.GetConfigAsync(1, false)).ReturnsAsync(new ChatbotDepartmentConfig + { + DepartmentId = 1, + AllowDispatchViaChatbot = false + }); + + var handler = new DispatchCallHandler(calls.Object, authz.Object, config.Object); + var response = await handler.HandleAsync(Msg("dispatch structure fire"), + Intent(ChatbotIntentType.DispatchCall, ("description", "structure fire")), Session()); + + response.Processed.Should().BeFalse(); + response.Text.Should().Contain("disabled"); + authz.Verify(a => a.CanUserCreateCallAsync(It.IsAny(), It.IsAny()), Times.Never); + } + [Test] public async Task DispatchCall_WithoutPermission_IsDenied() { @@ -356,7 +461,153 @@ public async Task RespondToCall_Valid_SetsRespondingWithDestination() var response = await handler.HandleAsync(Msg("respond to c7"), Intent(ChatbotIntentType.RespondToCall, ("callId", "7")), Session(departmentId: 1)); response.Processed.Should().BeTrue(); - actionLogs.Verify(a => a.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, It.IsAny(), 7, It.IsAny()), Times.Once); + actionLogs.Verify(a => a.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, + It.IsAny(), 7, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + + [Test] + public async Task RespondToCall_BareResponse_UsesMostRecentDirectDispatchAndCustomStatus() + { + var recentCall = new Call + { + CallId = 8, + Name = "Medical", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = DateTime.UtcNow.AddMinutes(-10), + Dispatches = new List + { + new CallDispatch { UserId = "user-1", DispatchedOn = DateTime.UtcNow.AddMinutes(-5) } + } + }; + var unrelatedCall = new Call + { + CallId = 9, + Name = "Alarm", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = DateTime.UtcNow, + Dispatches = new List { new CallDispatch { UserId = "someone-else", DispatchedOn = DateTime.UtcNow } } + }; + var calls = new Mock(); + calls.Setup(x => x.GetActiveCallsByDepartmentAsync(1)).ReturnsAsync(new List { unrelatedCall, recentCall }); + calls.Setup(x => x.PopulateCallData(It.IsAny(), true, false, false, true, false, true, false, false, false, false)) + .ReturnsAsync((Call call, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _) => call); + + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(1)).ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = 101, ButtonText = "Rolling", BaseType = (int)ActionBaseTypes.Responding } + }); + var actionLogs = new Mock(); + + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, customStates.Object); + var response = await handler.HandleAsync(Msg("omw"), + Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); + + response.Processed.Should().BeTrue(); + response.Text.Should().Contain("Call #8"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, 101, It.IsAny(), 8, + (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + + [Test] + public async Task RespondToCall_BareResponse_FallsBackToGroupDispatchMembership() + { + var call = new Call + { + CallId = 10, + Name = "Group Call", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = DateTime.UtcNow.AddMinutes(-20), + Dispatches = new List(), + GroupDispatches = new List + { + new CallDispatchGroup { DepartmentGroupId = 5, DispatchedOn = DateTime.UtcNow.AddMinutes(-10) } + }, + RoleDispatches = new List() + }; + var calls = CallsWithPopulatedDispatches(call); + var groups = new Mock(); + groups.Setup(x => x.GetAllMembersForGroupAsync(5)).ReturnsAsync(new List + { + new DepartmentGroupMember { UserId = "user-1", DepartmentGroupId = 5 } + }); + var actionLogs = new Mock(); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, groups.Object); + + var response = await handler.HandleAsync(Msg("responding"), + Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); + + response.Text.Should().Contain("Call #10"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, + It.IsAny(), 10, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + + [Test] + public async Task RespondToCall_BareResponse_FallsBackToRoleDispatchMembership() + { + var call = new Call + { + CallId = 11, + Name = "Role Call", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = DateTime.UtcNow.AddMinutes(-20), + Dispatches = new List(), + GroupDispatches = new List(), + RoleDispatches = new List + { + new CallDispatchRole { RoleId = 7, DispatchedOn = DateTime.UtcNow.AddMinutes(-5) } + } + }; + var calls = CallsWithPopulatedDispatches(call); + var roles = new Mock(); + roles.Setup(x => x.GetRolesForUserAsync("user-1", 1)).ReturnsAsync(new List + { + new PersonnelRole { PersonnelRoleId = 7 } + }); + var actionLogs = new Mock(); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, null, roles.Object); + + var response = await handler.HandleAsync(Msg("omw"), + Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); + + response.Text.Should().Contain("Call #11"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, + It.IsAny(), 11, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + + [Test] + public async Task RespondToCall_NotGoing_UsesCustomNotRespondingStatus() + { + var calls = new Mock(); + calls.Setup(x => x.GetCallByIdAsync(7, It.IsAny())) + .ReturnsAsync(new Call { CallId = 7, Name = "Fire", DepartmentId = 1, State = (int)CallStates.Active }); + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStatusesOrDefaultsAsync(1)).ReturnsAsync(new List + { + new CustomStateDetail { CustomStateDetailId = 102, ButtonText = "Declined", BaseType = (int)ActionBaseTypes.NotResponding } + }); + var actionLogs = new Mock(); + + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, customStates.Object); + var response = await handler.HandleAsync(Msg("not going to c7"), + Intent(ChatbotIntentType.RespondToCall, ("callId", "7"), ("response", "no")), Session()); + + response.Text.Should().Contain("Declined").And.Contain("Call #7"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, 102, It.IsAny(), 7, + (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + + private static Mock CallsWithPopulatedDispatches(params Call[] callsToReturn) + { + var calls = new Mock(); + calls.Setup(x => x.GetActiveCallsByDepartmentAsync(1)).ReturnsAsync(new List(callsToReturn)); + calls.Setup(x => x.PopulateCallData(It.IsAny(), true, false, false, true, false, true, false, false, false, false)) + .ReturnsAsync((Call call, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _) => call); + return calls; } // ===================== SetUnitStatusHandler (destructive + confirmation) ===================== @@ -466,6 +717,37 @@ public async Task SetUnitStatus_Confirmed_SetsTheState() units.Verify(u => u.SetUnitStateAsync(7, 20, 1, It.IsAny()), Times.Once); } + [Test] + public async Task SetUnitStatus_UsesStateSetAssignedToUnitTypeAndMatchesBaseType() + { + var units = UnitsWith(7, "Engine 1"); + var state = (await units.Object.GetAllLatestStatusForUnitsByDepartmentIdAsync(1))[0]; + state.Unit.Type = "Engine"; + units.Setup(x => x.GetUnitTypeByNameAsync(1, "Engine")) + .ReturnsAsync(new UnitType { UnitTypeId = 3, DepartmentId = 1, Type = "Engine", CustomStatesId = 55 }); + + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomSateByIdAsync(55)).ReturnsAsync(new CustomState + { + CustomStateId = 55, + DepartmentId = 1, + Type = (int)CustomStateTypes.Unit, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = 301, ButtonText = "Rolling", BaseType = (int)ActionBaseTypes.Responding } + } + }); + var authz = new Mock(); + authz.Setup(x => x.CanUserModifyUnitAsync("user-1", 7)).ReturnsAsync(true); + + var handler = new SetUnitStatusHandler(units.Object, customStates.Object, authz.Object); + var response = await handler.HandleAsync(Msg("set unit Engine 1 to responding"), + Intent(ChatbotIntentType.SetUnitStatus, ("unitName", "Engine 1"), ("status", "Responding"), ("__confirmed", "true")), Session()); + + response.Text.Should().Contain("Rolling"); + units.Verify(x => x.SetUnitStateAsync(7, 301, 1, It.IsAny()), Times.Once); + } + // ===================== ShiftSignupHandler ===================== private static Mock ShiftsWith(int shiftDayId, int shiftId, int shiftDeptId) diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs new file mode 100644 index 000000000..77bced7fc --- /dev/null +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Chatbot.Interfaces; +using Resgrid.Chatbot.Models; +using Resgrid.Chatbot.Services; +using Resgrid.Model; +using Resgrid.Model.Messages; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Chatbot +{ + [TestFixture] + public class ChatbotTextResponseResolverTests + { + private Mock _messages; + private Mock _calendar; + private Mock _authorization; + private TextResponseResolver _resolver; + + [SetUp] + public void SetUp() + { + _messages = new Mock(); + _calendar = new Mock(); + _authorization = new Mock(); + _resolver = new TextResponseResolver(_messages.Object, _calendar.Object, _authorization.Object); + } + + [Test] + public async Task GetPendingResponses_ReturnsOnlyRecentUnansweredPolls() + { + var now = DateTime.UtcNow; + _messages.Setup(m => m.GetInboxMessagesByUserIdAsync("user-1")).ReturnsAsync(new List + { + new Message { MessageId = 1, Type = (int)MessageTypes.Poll, Subject = "Poll: Recent", SentOn = now.AddHours(-2) }, + new Message { MessageId = 2, Type = (int)MessageTypes.Poll, Subject = "Poll: Old", SentOn = now.AddHours(-25) }, + new Message { MessageId = 3, Type = (int)MessageTypes.Poll, Subject = "Poll: Answered", SentOn = now.AddHours(-1) } + }); + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(1, "user-1")) + .ReturnsAsync(new MessageRecipient { MessageId = 1, UserId = "user-1" }); + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(3, "user-1")) + .ReturnsAsync(new MessageRecipient { MessageId = 3, UserId = "user-1", Response = "Yes" }); + + var pending = await _resolver.GetPendingResponsesAsync("user-1", 10, now.AddDays(-1)); + + pending.Should().ContainSingle(); + pending[0].Type.Should().Be(PendingTextResponseType.Poll); + pending[0].Label.Should().Be("Recent"); + } + + [Test] + public async Task GetPendingResponses_ReturnsPollAndUnansweredCalendarRsvp() + { + var now = DateTime.UtcNow; + _messages.Setup(m => m.GetInboxMessagesByUserIdAsync("user-1")).ReturnsAsync(new List + { + new Message { MessageId = 5, Type = (int)MessageTypes.CalendarRsvp, Subject = "Calendar RSVP: Drill", SentOn = now.AddMinutes(-10) }, + new Message { MessageId = 4, Type = (int)MessageTypes.Poll, Subject = "Poll: Staffing", SentOn = now.AddMinutes(-20) } + }); + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(4, "user-1")) + .ReturnsAsync(new MessageRecipient { MessageId = 4, UserId = "user-1" }); + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(5, "user-1")) + .ReturnsAsync(new MessageRecipient + { + MessageId = 5, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForCalendarRsvp(77) + }); + _calendar.Setup(c => c.GetCalendarItemByIdAsync(77)).ReturnsAsync(new CalendarItem + { + CalendarItemId = 77, + DepartmentId = 10, + Title = "Drill", + SignupType = (int)CalendarItemSignupTypes.RSVP + }); + _calendar.Setup(c => c.GetCalendarItemAttendeeByUserAsync(77, "user-1")) + .ReturnsAsync((CalendarItemAttendee)null); + _authorization.Setup(a => a.CanUserCheckInToCalendarEventAsync("user-1", 77)).ReturnsAsync(true); + + var pending = await _resolver.GetPendingResponsesAsync("user-1", 10, now.AddDays(-1)); + + pending.Should().HaveCount(2); + pending.Should().Contain(p => p.Type == PendingTextResponseType.Poll && p.SourceId == 4); + pending.Should().Contain(p => p.Type == PendingTextResponseType.CalendarRsvp && p.SourceId == 77); + } + + [Test] + public async Task GetPendingResponses_ExcludesCalendarEventAlreadyAnswered() + { + var now = DateTime.UtcNow; + _messages.Setup(m => m.GetInboxMessagesByUserIdAsync("user-1")).ReturnsAsync(new List + { + new Message { MessageId = 5, Type = (int)MessageTypes.CalendarRsvp, Subject = "Calendar RSVP: Drill", SentOn = now } + }); + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(5, "user-1")) + .ReturnsAsync(new MessageRecipient { MessageId = 5, UserId = "user-1", Note = TextResponsePromptMetadata.ForCalendarRsvp(77) }); + _calendar.Setup(c => c.GetCalendarItemByIdAsync(77)).ReturnsAsync(new CalendarItem + { + CalendarItemId = 77, DepartmentId = 10, SignupType = (int)CalendarItemSignupTypes.RSVP + }); + _calendar.Setup(c => c.GetCalendarItemAttendeeByUserAsync(77, "user-1")) + .ReturnsAsync(new CalendarItemAttendee { CalendarItemId = 77, UserId = "user-1" }); + + var pending = await _resolver.GetPendingResponsesAsync("user-1", 10, now.AddDays(-1)); + + pending.Should().BeEmpty(); + } + + [Test] + public async Task RecordResponse_NoForCalendar_RecordsNotAttendingAndRecipientResponse() + { + var recipient = new MessageRecipient { MessageId = 5, UserId = "user-1" }; + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(5, "user-1")).ReturnsAsync(recipient); + _messages.Setup(m => m.SaveMessageRecipientAsync(recipient, It.IsAny())).ReturnsAsync(recipient); + _calendar.Setup(c => c.GetCalendarItemByIdAsync(77)).ReturnsAsync(new CalendarItem + { + CalendarItemId = 77, + DepartmentId = 10, + Title = "Drill", + SignupType = (int)CalendarItemSignupTypes.RSVP + }); + _calendar.Setup(c => c.GetCalendarItemAttendeeByUserAsync(77, "user-1")) + .ReturnsAsync((CalendarItemAttendee)null); + _calendar.Setup(c => c.SignupForEvent(77, "user-1", "No", + (int)CalendarItemAttendeeTypes.NotAttending, It.IsAny())) + .ReturnsAsync(new CalendarItemAttendee()); + _authorization.Setup(a => a.CanUserCheckInToCalendarEventAsync("user-1", 77)).ReturnsAsync(true); + + var response = await _resolver.RecordResponseAsync(new PendingTextResponse + { + Type = PendingTextResponseType.CalendarRsvp, + SourceId = 77, + MessageId = 5, + Label = "Drill" + }, "No", new ChatbotSession { UserId = "user-1", DepartmentId = 10, Culture = "en" }); + + response.Processed.Should().BeTrue(); + recipient.Response.Should().Be("No"); + recipient.ReadOn.Should().NotBeNull(); + _calendar.Verify(c => c.SignupForEvent(77, "user-1", "No", + (int)CalendarItemAttendeeTypes.NotAttending, It.IsAny()), Times.Once); + } + + [Test] + public async Task PromptService_StoresCalendarMetadataAndOneDayExpiry() + { + Message saved = null; + _messages.Setup(m => m.SaveMessageAsync(It.IsAny(), It.IsAny())) + .Callback((Message message, CancellationToken _) => saved = message) + .ReturnsAsync((Message message, CancellationToken _) => message); + var service = new TextResponsePromptService(_messages.Object); + + await service.RecordCalendarRsvpPromptAsync(new CalendarItem + { + CalendarItemId = 44, + Title = "Live Fire Training", + SignupType = (int)CalendarItemSignupTypes.RSVP, + CreatorUserId = "creator" + }, "user-1"); + + saved.Should().NotBeNull(); + saved.Type.Should().Be((int)MessageTypes.CalendarRsvp); + saved.ExpireOn.Should().BeCloseTo(saved.SentOn.AddDays(1), TimeSpan.FromSeconds(1)); + saved.MessageRecipients.Should().ContainSingle(); + saved.MessageRecipients.Should().ContainSingle(r => + r.UserId == "user-1" && r.Note == TextResponsePromptMetadata.ForCalendarRsvp(44)); + } + + [Test] + public void ConversationEngine_OnlyCollectsMissingStatusOrStaffingParameters() + { + var engine = new ConversationEngine(); + + engine.NeedsParameterCollection(new ChatbotIntent { Type = ChatbotIntentType.SetStatus }).Should().BeTrue(); + engine.NeedsParameterCollection(new ChatbotIntent + { + Type = ChatbotIntentType.SetStatus, + Parameters = new Dictionary { ["statusName"] = "Responding" } + }).Should().BeFalse(); + engine.NeedsParameterCollection(new ChatbotIntent { Type = ChatbotIntentType.SetStaffing }).Should().BeTrue(); + engine.NeedsParameterCollection(new ChatbotIntent + { + Type = ChatbotIntentType.SetStaffing, + Parameters = new Dictionary { ["staffingType"] = "1" } + }).Should().BeFalse(); + } + + [Test] + public async Task ConversationEngine_StatusPrompt_ListsDepartmentCustomOptions() + { + var customStates = new Mock(); + customStates.Setup(x => x.GetActivePersonnelStateForDepartmentAsync(10)).ReturnsAsync(new CustomState + { + DepartmentId = 10, + Type = (int)CustomStateTypes.Personnel, + Details = new List + { + new CustomStateDetail { CustomStateDetailId = 101, ButtonText = "Rolling", Order = 0 }, + new CustomStateDetail { CustomStateDetailId = 102, ButtonText = "Declined", Order = 1 } + } + }); + var engine = new ConversationEngine(customStates.Object); + + var response = await engine.BeginDialogAsync(new ChatbotIntent { Type = ChatbotIntentType.SetStatus }, + new ChatbotSession { DepartmentId = 10, Culture = "en" }); + + response.Text.Should().Contain("(1) Rolling").And.Contain("(2) Declined"); + } + + [Test] + public async Task Ingress_MultipleOutstandingItems_AsksForNumberThenAppliesSelection() + { + var identity = new ChatbotUserIdentity + { + Id = "identity-1", + UserId = "user-1", + Platform = ChatbotPlatform.WebChat, + PlatformUserId = "web-user", + LinkingMethod = "test" + }; + var identities = new Mock(); + identities.Setup(i => i.GetIdentityAsync(ChatbotPlatform.WebChat, "web-user")).ReturnsAsync(identity); + identities.Setup(i => i.LinkUserAsync(identity.UserId, identity.Platform, identity.PlatformUserId, + identity.PlatformUserName, identity.LinkingMethod)).ReturnsAsync(identity); + + var session = new ChatbotSession + { + SessionId = "session-1", + UserId = "user-1", + DepartmentId = 10, + Platform = ChatbotPlatform.WebChat, + Culture = "en", + State = ChatbotDialogState.Idle, + CreatedAt = DateTime.UtcNow, + LastActivity = DateTime.UtcNow + }; + var sessions = new Mock(); + sessions.Setup(s => s.GetOrCreateSessionAsync("user-1", 10, ChatbotPlatform.WebChat, "web-user")) + .ReturnsAsync(session); + + var departments = new Mock(); + departments.Setup(d => d.GetActiveSmsDepartmentForUserAsync("user-1", false)) + .ReturnsAsync(new Department { DepartmentId = 10, Name = "Test Department" }); + var limits = new Mock(); + limits.Setup(l => l.CanDepartmentProvisionNumberAsync(10)).ReturnsAsync(true); + var authorization = new Mock(); + authorization.Setup(a => a.IsUserValidWithinLimitsAsync("user-1", 10)).ReturnsAsync(true); + var rateLimiter = new Mock(); + rateLimiter.Setup(r => r.TryAcquireAsync("user-1", 10, It.IsAny(), It.IsAny())).ReturnsAsync(true); + + var poll = new PendingTextResponse + { + Type = PendingTextResponseType.Poll, SourceId = 4, MessageId = 4, Label = "Staffing" + }; + var calendar = new PendingTextResponse + { + Type = PendingTextResponseType.CalendarRsvp, SourceId = 77, MessageId = 5, Label = "Drill" + }; + var resolver = new Mock(); + resolver.Setup(r => r.GetPendingResponsesAsync("user-1", 10, It.IsAny())) + .ReturnsAsync(new List { poll, calendar }); + resolver.Setup(r => r.RecordResponseAsync(It.Is(p => p.SourceId == 77), "Yes", session)) + .ReturnsAsync(new ChatbotResponse { Text = "RSVP recorded", Processed = true }); + + var ingress = new ChatbotIngressService( + identities.Object, + sessions.Object, + Mock.Of(), + Array.Empty(), + new ConversationEngine(), + Mock.Of(), + Mock.Of(), + departments.Object, + Mock.Of(), + limits.Object, + authorization.Object, + Mock.Of(), + rateLimiter.Object, + Mock.Of(), + resolver.Object); + + var first = await ingress.ProcessMessageAsync(new ChatbotMessage + { + From = "web-user", Text = "YES", Platform = ChatbotPlatform.WebChat + }); + + first.Text.Should().Contain("1. Poll: Staffing").And.Contain("2. Calendar: Drill"); + session.State.Should().Be(ChatbotDialogState.AwaitingResponseTarget); + + var second = await ingress.ProcessMessageAsync(new ChatbotMessage + { + From = "web-user", Text = "2", Platform = ChatbotPlatform.WebChat + }); + + second.Text.Should().Be("RSVP recorded"); + session.State.Should().Be(ChatbotDialogState.Idle); + resolver.Verify(r => r.RecordResponseAsync(It.Is(p => p.SourceId == 77), "Yes", session), Times.Once); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index 76a25b10b..6177af128 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -28,7 +28,6 @@ public class with_the_communication_service : TestBase protected Mock _userStateServiceMock; protected Mock _departmentsServiceMock; protected Mock _chatbotOutboundServiceMock; - protected ICommunicationService _communicationService; protected with_the_communication_service() @@ -50,10 +49,10 @@ protected with_the_communication_service() .ReturnsAsync(new DepartmentMember()); _chatbotOutboundServiceMock = new Mock(); - _communicationService = new CommunicationService(_smsServiceMock.Object, _emailServiceMock.Object, _pushServiceMock.Object, _geoLocationProviderMock.Object, _outboundVoiceProviderMock.Object, _userProfileServiceMock.Object, _departmentSettingsServiceMock.Object, - _subscriptionsServiceMock.Object, _userStateServiceMock.Object, _chatbotOutboundServiceMock.Object, _departmentsServiceMock.Object); + _subscriptionsServiceMock.Object, _userStateServiceMock.Object, _chatbotOutboundServiceMock.Object, + _departmentsServiceMock.Object); } } @@ -121,6 +120,22 @@ public async Task should_be_able_to_send_call() _emailServiceMock.Verify(m => m.SendCallAsync(call, cd, null)); //_pushServiceMock.Verify(m => m.PushCall(It.IsAny(), Users.TestUser1Id)); } + + [Test] + public async Task calendar_notifications_are_sent_by_sms_when_enabled() + { + var profile = new UserProfile + { + UserId = TestData.Users.TestUser1Id, + SendNotificationSms = true + }; + + await _communicationService.SendCalendarAsync(profile.UserId, 1, + "on 7/22 at 18:00. Reply YES or NO.", "15555550100", "New: Drill", profile); + + _smsServiceMock.Verify(m => m.SendNotificationAsync(profile.UserId, 1, + "New: Drill on 7/22 at 18:00. Reply YES or NO.", "15555550100", profile), Times.Once); + } } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs index 1c6a18b09..e9a5994e6 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using Autofac; +using Resgrid.Model; using Resgrid.Model.Helpers; namespace Resgrid.Workers.Framework.Logic @@ -17,6 +18,7 @@ public class CalendarNotifierLogic private IDepartmentSettingsService _departmentSettingsService; private IDepartmentGroupsService _departmentGroupsService; private IDepartmentsService _departmentsService; + private ITextResponsePromptService _textResponsePromptService; public CalendarNotifierLogic() { @@ -26,6 +28,7 @@ public CalendarNotifierLogic() _calendarService = Bootstrapper.GetKernel().Resolve(); _departmentGroupsService = Bootstrapper.GetKernel().Resolve(); _departmentsService = Bootstrapper.GetKernel().Resolve(); + _textResponsePromptService = Bootstrapper.GetKernel().Resolve(); } public async Task> Process(CalendarNotifierQueueItem item) @@ -52,12 +55,15 @@ public async Task> Process(CalendarNotifierQueueItem item) else message = $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()} at {item.CalendarItem.Location}"; + if (item.CalendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + message += " Reply YES or NO."; + if (ConfigHelper.CanTransmit(department.DepartmentId)) { foreach (var person in item.CalendarItem.Attendees) { var profile = profiles.FirstOrDefault(x => x.UserId == person.UserId); - await _communicationService.SendNotificationAsync(person.UserId, item.CalendarItem.DepartmentId, message, departmentNumber, department, title, profile); + await SendCalendarReminderAsync(item.CalendarItem, person.UserId, message, departmentNumber, department, title, profile); } } } @@ -89,6 +95,9 @@ public async Task> Process(CalendarNotifierQueueItem item) else message = $"on {adjustedDateTime.ToShortDateString()} - {adjustedDateTime.ToShortTimeString()} at {item.CalendarItem.Location}"; + if (item.CalendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + message += " Reply YES or NO."; + if (ConfigHelper.CanTransmit(department.DepartmentId)) { if (items.Any(x => x.StartsWith("D:"))) @@ -96,7 +105,7 @@ public async Task> Process(CalendarNotifierQueueItem item) // Notify the entire department foreach (var profile in profiles) { - await _communicationService.SendNotificationAsync(profile.Key, item.CalendarItem.DepartmentId, message, departmentNumber, department, title, profile.Value); + await SendCalendarReminderAsync(item.CalendarItem, profile.Key, message, departmentNumber, department, title, profile.Value); } } else @@ -114,9 +123,9 @@ public async Task> Process(CalendarNotifierQueueItem item) foreach (var member in group.Members) { if (profiles.ContainsKey(member.UserId)) - await _communicationService.SendNotificationAsync(member.UserId, item.CalendarItem.DepartmentId, message, departmentNumber, department, title, profiles[member.UserId]); + await SendCalendarReminderAsync(item.CalendarItem, member.UserId, message, departmentNumber, department, title, profiles[member.UserId]); else - await _communicationService.SendNotificationAsync(member.UserId, item.CalendarItem.DepartmentId, message, departmentNumber, department, title, null); + await SendCalendarReminderAsync(item.CalendarItem, member.UserId, message, departmentNumber, department, title, null); } } } @@ -129,5 +138,24 @@ public async Task> Process(CalendarNotifierQueueItem item) return new Tuple(success, result); } + + private async Task SendCalendarReminderAsync(CalendarItem calendarItem, string userId, string message, + string departmentNumber, Department department, string title, UserProfile profile) + { + await _communicationService.SendNotificationAsync(userId, calendarItem.DepartmentId, message, + departmentNumber, department, title, profile); + + if (calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + { + try + { + await _textResponsePromptService.RecordCalendarRsvpPromptAsync(calendarItem, userId); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } + } } } diff --git a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs index fa18bd75f..fb1b757bc 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs @@ -22,6 +22,8 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri var _departmentsService = Bootstrapper.GetKernel().Resolve(); var _userProfileService = Bootstrapper.GetKernel().Resolve(); var _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); + var _calendarService = Bootstrapper.GetKernel().Resolve(); + var _textResponsePromptService = Bootstrapper.GetKernel().Resolve(); var item = new ProcessedNotification(); @@ -58,6 +60,18 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri foreach (var notification in notificaitons) { var text = await _notificationService.GetMessageForTypeAsync(notification); + CalendarItem rsvpItem = null; + if (notification.Type == EventTypes.CalendarEventAdded + || notification.Type == EventTypes.CalendarEventUpcoming + || notification.Type == EventTypes.CalendarEventUpdated) + { + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(notification.ItemId); + if (calendarItem?.SignupType == (int)CalendarItemSignupTypes.RSVP) + { + rsvpItem = calendarItem; + text += " Reply YES or NO."; + } + } if (!String.IsNullOrWhiteSpace(text)) { @@ -71,6 +85,17 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri profile.SendNotificationSms = false; await _communicationService.SendNotificationAsync(user, notification.DepartmentId, text, queueItem.DepartmentTextNumber, queueItem.Department, "Notification", profile); + if (rsvpItem != null) + { + try + { + await _textResponsePromptService.RecordCalendarRsvpPromptAsync(rsvpItem, user); + } + catch (Exception ex) + { + Logging.LogException(ex); + } + } } } } @@ -83,6 +108,8 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri _departmentsService = null; _userProfileService = null; _departmentSettingsService = null; + _calendarService = null; + _textResponsePromptService = null; } return true; From 0b2c2b97de2dbaeb0321cc5c8cd0664367f8872a Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Fri, 17 Jul 2026 18:33:18 -0700 Subject: [PATCH 3/3] RG-T117 PR#430 fixes --- .gitignore | 2 + .../Providers/KeywordIntentClassifier.cs | 4 +- .../Handlers/CallRespondersActionHandler.cs | 43 ++-- .../Handlers/MyCallsActionHandler.cs | 10 +- .../Handlers/MyScheduleActionHandler.cs | 33 ++- .../Handlers/PollCreateHandler.cs | 4 + .../Handlers/RespondToCallHandler.cs | 4 +- .../Handlers/StaffingActionHandler.cs | 5 - .../Interfaces/IChatbotSessionManager.cs | 3 +- .../Interfaces/IChatbotSessionStore.cs | 3 +- .../Services/ChatbotIngressService.cs | 9 +- .../Services/ChatbotSessionManager.cs | 5 +- .../Services/RedisSessionStore.cs | 30 ++- .../Services/TextResponseResolver.cs | 8 + Core/Resgrid.Framework/FileHelper.cs | 20 ++ .../Messages/TextResponsePromptMetadata.cs | 15 ++ Core/Resgrid.Model/Queue/CallQueueItem.cs | 52 +++++ Core/Resgrid.Services/CalendarService.cs | 7 +- Core/Resgrid.Services/CommunicationService.cs | 4 +- .../TextResponsePromptService.cs | 47 ++-- .../CallRespondersActionHandlerTests.cs | 195 ++++++++++++++++ ...hatbotAvailabilityIntentClassifierTests.cs | 11 + .../ChatbotDeptConfigAndSessionTests.cs | 95 ++++++++ .../Chatbot/ChatbotHandlerTests.cs | 115 ++++++++- .../ChatbotTextResponseResolverTests.cs | 218 +++++++++++++++++- .../Chatbot/MyCallsActionHandlerTests.cs | 105 +++++++++ .../Chatbot/MyScheduleActionHandlerTests.cs | 113 +++++++++ .../Framework/FileHelperTests.cs | 80 +++++++ .../Models/CallQueueItemTests.cs | 110 +++++++++ Tests/Resgrid.Tests/Models/DocumentTests.cs | 47 ++++ .../Services/CalendarServiceTests.cs | 110 ++++++++- .../Services/CommunicationServiceTests.cs | 38 ++- Tests/Resgrid.Tests/Web/DocumentPageTests.cs | 75 ++++++ .../Web/UploadFormEncodingTests.cs | 47 ++++ .../Controllers/v4/CallsController.cs | 1 + .../User/Controllers/DispatchController.cs | 1 + .../User/Controllers/DocumentsController.cs | 87 +++++-- .../User/Controllers/ProfileController.cs | 32 +-- .../User/Models/Documents/ViewDocumentView.cs | 14 ++ .../User/Views/Documents/EditDocument.cshtml | 8 +- .../Areas/User/Views/Documents/Index.cshtml | 86 ++++--- .../User/Views/Documents/ViewDocument.cshtml | 110 +++++++++ .../Views/Profile/AddCertification.cshtml | 2 +- .../Views/Profile/EditCertification.cshtml | 2 +- .../Helpers/ClaimsAuthorizationHelper.cs | 5 + .../resgrid.documents.newdocument.js | 2 +- .../Logic/CalendarNotifierLogic.cs | 4 +- .../Logic/CallBroadcast.cs | 1 + .../Logic/NotificationBroadcastLogic.cs | 19 +- 49 files changed, 1861 insertions(+), 180 deletions(-) create mode 100644 Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs create mode 100644 Tests/Resgrid.Tests/Chatbot/MyCallsActionHandlerTests.cs create mode 100644 Tests/Resgrid.Tests/Chatbot/MyScheduleActionHandlerTests.cs create mode 100644 Tests/Resgrid.Tests/Models/CallQueueItemTests.cs create mode 100644 Tests/Resgrid.Tests/Models/DocumentTests.cs create mode 100644 Tests/Resgrid.Tests/Web/DocumentPageTests.cs create mode 100644 Tests/Resgrid.Tests/Web/UploadFormEncodingTests.cs create mode 100644 Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs create mode 100644 Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml diff --git a/.gitignore b/.gitignore index 474811f4c..3da5a0af1 100644 --- a/.gitignore +++ b/.gitignore @@ -277,3 +277,5 @@ Web/Resgrid.WebCore/wwwroot/lib/* opencode.json .dual-graph-pro/ .mcp.json +/.codex +/.claude diff --git a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs index 3150f6062..4143e30f3 100644 --- a/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs +++ b/Core/Resgrid.Chatbot.NLU/Providers/KeywordIntentClassifier.cs @@ -200,14 +200,14 @@ public class KeywordIntentClassifier : INLUProvider // === Respond to Call === (R(@"^(not\s*responding|not\s+going|unable\s+to\s+respond)\s+(?:to\s+)?(.+)$"), - "respond_to_call", m => P2("callRef", m.Groups[2].Value.Trim(), "response", "no")), + "respond_to_call", m => P2("callRef", CleanReference(m.Groups[2].Value), "response", "no")), (R(@"^(respond|en\s*route|going)\s+to\s+c?(\d+)$"), "respond_to_call", m => P2("callId", m.Groups[2].Value, "response", "yes")), // Responder shorthand: "omw to 26-1", "omw to fire", "enroute to c1445", "headed to Main St". // The reference can be a call id, a call number (yy-N), or a term matched against active // calls — resolved by the handler. (R(@"^(omw|on\s+my\s+way|respond(?:ing)?|going|headed|enroute|en\s+route)\s+(?:to\s+)?(.+)$"), - "respond_to_call", m => P2("callRef", m.Groups[2].Value.Trim(), "response", "yes")), + "respond_to_call", m => P2("callRef", CleanReference(m.Groups[2].Value), "response", "yes")), // === Shift Drop (must precede shift signup/detail so 'drop shift 5' isn't misread) === (R(@"^(drop|cancel|release)\s+(my\s+)?shift\s+#?(\d+)"), diff --git a/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs index 98bb4720d..e94d7e8b7 100644 --- a/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs @@ -19,6 +19,13 @@ namespace Resgrid.Chatbot.Handlers /// public class CallRespondersActionHandler : IChatbotActionHandler { + private enum ResponderMode + { + All, + EnRoute, + OnScene + } + private enum ResponderBucket { None, @@ -56,8 +63,8 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var culture = session.Culture; try { - intent.Parameters.TryGetValue("mode", out var mode); - mode = string.IsNullOrWhiteSpace(mode) ? "all" : mode.Trim().ToLowerInvariant(); + intent.Parameters.TryGetValue("mode", out var modeValue); + var mode = ParseResponderMode(modeValue); var call = await ResolveCallAsync(intent, session.DepartmentId); if (call == null) @@ -68,11 +75,11 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var callLabel = string.IsNullOrWhiteSpace(call.Number) ? call.Name : $"{call.Number} {call.Name}"; - // Latest status per person tied to this call. + // Current status per person tied to this call. var actionLogs = await _actionLogsService.GetActionLogsForCallAsync(session.DepartmentId, call.CallId); + var currentActionLogs = await _actionLogsService.GetLastActionLogsForDepartmentAsync(session.DepartmentId) ?? new List(); var latestPerUser = (actionLogs ?? new List()) - .GroupBy(x => x.UserId) - .Select(g => g.OrderByDescending(x => x.Timestamp).First()) + .Where(x => currentActionLogs.Any(current => current.ActionLogId == x.ActionLogId && current.DestinationId == call.CallId)) .ToList(); var personnelLines = new StringBuilder(); @@ -94,11 +101,11 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn } } - // Latest status per unit tied to this call. + // Current status per unit tied to this call. var unitStates = await _unitsService.GetUnitStatesForCallAsync(session.DepartmentId, call.CallId); + var currentUnitStates = await _unitsService.GetAllLatestStatusForUnitsByDepartmentIdAsync(session.DepartmentId) ?? new List(); var latestPerUnit = (unitStates ?? new List()) - .GroupBy(x => x.UnitId) - .Select(g => g.OrderByDescending(x => x.Timestamp).First()) + .Where(x => currentUnitStates.Any(current => current.UnitStateId == x.UnitStateId && current.DestinationId == call.CallId)) .ToList(); var unitLines = new StringBuilder(); @@ -129,8 +136,8 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn var headerKey = mode switch { - "enroute" => "CallResp_HeaderEnroute", - "onscene" => "CallResp_HeaderOnScene", + ResponderMode.EnRoute => "CallResp_HeaderEnroute", + ResponderMode.OnScene => "CallResp_HeaderOnScene", _ => "CallResp_HeaderAll" }; @@ -180,12 +187,22 @@ private async Task ResolveCallAsync(ChatbotIntent intent, int departmentId return activeCalls?.Count == 1 ? activeCalls[0] : null; } - private static bool BucketMatches(ResponderBucket bucket, string mode) + private static ResponderMode ParseResponderMode(string value) + { + var normalized = value?.Trim(); + return !int.TryParse(normalized, out _) + && Enum.TryParse(normalized, true, out ResponderMode mode) + && Enum.IsDefined(typeof(ResponderMode), mode) + ? mode + : ResponderMode.All; + } + + private static bool BucketMatches(ResponderBucket bucket, ResponderMode mode) { return mode switch { - "enroute" => bucket == ResponderBucket.EnRoute, - "onscene" => bucket == ResponderBucket.OnScene, + ResponderMode.EnRoute => bucket == ResponderBucket.EnRoute, + ResponderMode.OnScene => bucket == ResponderBucket.OnScene, _ => bucket != ResponderBucket.None }; } diff --git a/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs index 5aad78416..bc85abc12 100644 --- a/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/MyCallsActionHandler.cs @@ -20,7 +20,7 @@ namespace Resgrid.Chatbot.Handlers /// public class MyCallsActionHandler : IChatbotActionHandler { - private const int MaxActiveCallsToScan = 25; + private const int MaxConcurrentCallPopulations = 5; private const int MaxCallsToList = 10; private readonly ICallsService _callsService; @@ -118,9 +118,10 @@ private async Task> GetActiveCallsWithDispatchesAsync(int departmentI var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId); var populated = new List(); - foreach (var call in (activeCalls ?? new List()).OrderByDescending(c => c.LoggedOn).Take(MaxActiveCallsToScan)) + var orderedCalls = (activeCalls ?? new List()).OrderByDescending(c => c.LoggedOn); + foreach (var batch in orderedCalls.Chunk(MaxConcurrentCallPopulations)) { - populated.Add(await _callsService.PopulateCallData(call, + var populatedBatch = await Task.WhenAll(batch.Select(call => _callsService.PopulateCallData(call, getDispatches: !forUnits, getAttachments: false, getNotes: false, @@ -129,7 +130,8 @@ private async Task> GetActiveCallsWithDispatchesAsync(int departmentI getRoleDispatches: false, getProtocols: false, getReferences: false, - getContacts: false)); + getContacts: false))); + populated.AddRange(populatedBatch); } return populated; diff --git a/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs index 1bae66d71..b9e4e27a3 100644 --- a/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/MyScheduleActionHandler.cs @@ -40,17 +40,18 @@ public MyScheduleActionHandler( public async Task HandleAsync(ChatbotMessage message, ChatbotIntent intent, ChatbotSession session) { var culture = session.Culture; + var cultureInfo = CultureInfo.GetCultureInfo(ChatbotResources.NormalizeCulture(culture)); try { var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId); var nowLocal = DateTime.UtcNow.TimeConverter(department); intent.Parameters.TryGetValue("day", out var dayText); - var targetDate = ParseDay(dayText, nowLocal); + var targetDate = ParseDay(dayText, nowLocal, cultureInfo); if (targetDate == null) return new ChatbotResponse { Text = ChatbotResources.Get("Sched_BadDate", culture), Processed = false }; - var dateLabel = targetDate.Value.ToString("ddd M/d", CultureInfo.InvariantCulture); + var dateLabel = targetDate.Value.ToString("ddd M/d", cultureInfo); var lines = new List(); // Shifts: shift days on the target date the user is assigned to (shift personnel) or has @@ -73,7 +74,7 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn continue; var shift = shiftDay.Shift ?? await _shiftsService.GetShiftByIdAsync(shiftDay.ShiftId); - var times = $"{shiftDay.Start:t} - {shiftDay.End:t}"; + var times = $"{shiftDay.Start.ToString("t", cultureInfo)} - {shiftDay.End.ToString("t", cultureInfo)}"; lines.Add(ChatbotResources.Get("Sched_ShiftLine", culture, shift?.Name ?? $"Shift {shiftDay.ShiftId}", times)); } } @@ -95,7 +96,7 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn if (attendee == null || attendee.AttendeeType == (int)CalendarItemAttendeeTypes.NotAttending) continue; - var timeText = item.IsAllDay ? ChatbotResources.Get("Sched_AllDay", culture) : startLocal.ToString("t"); + var timeText = item.IsAllDay ? ChatbotResources.Get("Sched_AllDay", culture) : startLocal.ToString("t", cultureInfo); lines.Add(ChatbotResources.Get("Sched_EventLine", culture, timeText, item.Title?.Truncate(50))); } @@ -119,33 +120,41 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn // Accepts: empty (today), "today", "tomorrow", a weekday name (next occurrence, today included), // or a date ("7/22", "7/22/2026", "2026-07-22"). Returns null when unparseable. - private static DateTime? ParseDay(string dayText, DateTime nowLocal) + private static DateTime? ParseDay(string dayText, DateTime nowLocal, CultureInfo cultureInfo) { if (string.IsNullOrWhiteSpace(dayText)) return nowLocal.Date; - var text = dayText.Trim().TrimEnd('?', '!', '.', ',').ToLowerInvariant(); + var text = dayText.Trim().TrimEnd('?', '!', '.', ','); - if (text == "today") + if (string.Equals(text, "today", StringComparison.OrdinalIgnoreCase)) return nowLocal.Date; - if (text == "tomorrow") + if (string.Equals(text, "tomorrow", StringComparison.OrdinalIgnoreCase)) return nowLocal.Date.AddDays(1); foreach (DayOfWeek dow in Enum.GetValues(typeof(DayOfWeek))) { - var name = dow.ToString().ToLowerInvariant(); - if (text == name || text == name.Substring(0, 3)) + var englishName = dow.ToString(); + var localizedName = cultureInfo.DateTimeFormat.GetDayName(dow); + var localizedAbbreviation = cultureInfo.DateTimeFormat.GetAbbreviatedDayName(dow).TrimEnd('.'); + if (string.Equals(text, englishName, StringComparison.OrdinalIgnoreCase) + || string.Equals(text, englishName.Substring(0, 3), StringComparison.OrdinalIgnoreCase) + || cultureInfo.CompareInfo.Compare(text, localizedName, CompareOptions.IgnoreCase) == 0 + || cultureInfo.CompareInfo.Compare(text, localizedAbbreviation, CompareOptions.IgnoreCase) == 0) { var daysAhead = ((int)dow - (int)nowLocal.DayOfWeek + 7) % 7; return nowLocal.Date.AddDays(daysAhead); } } - if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + if (DateTime.TryParse(text, cultureInfo, DateTimeStyles.None, out var parsed)) { // "7/22" parses with the current year; a date months in the past most likely means next // year (people ask about upcoming days). - if (parsed.Date < nowLocal.Date.AddMonths(-1) && !text.Any(char.IsLetter) && text.Count(c => c == '/') == 1) + var dateSeparator = cultureInfo.DateTimeFormat.DateSeparator; + var hasSingleDateSeparator = !string.IsNullOrEmpty(dateSeparator) + && text.Split(new[] { dateSeparator }, StringSplitOptions.None).Length == 2; + if (parsed.Date < nowLocal.Date.AddMonths(-1) && !text.Any(char.IsLetter) && hasSingleDateSeparator) parsed = parsed.AddYears(1); return parsed.Date; } diff --git a/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs b/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs index 72ea7c827..7d93c7768 100644 --- a/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs @@ -7,6 +7,7 @@ using Resgrid.Chatbot.Models; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Messages; using Resgrid.Model.Services; namespace Resgrid.Chatbot.Handlers @@ -93,7 +94,10 @@ public async Task HandleAsync(ChatbotMessage message, ChatbotIn }; foreach (var userId in recipients) + { msg.AddRecipient(userId); + msg.MessageRecipients.Last().Note = TextResponsePromptMetadata.ForPoll(session.DepartmentId); + } var saved = await _messageService.SaveMessageAsync(msg); await _messageService.SendMessageAsync(saved, senderName, session.DepartmentId); diff --git a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs index aaaa26fdf..e8b964438 100644 --- a/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs @@ -118,7 +118,7 @@ private async Task ResolveMostRecentDispatchAsync(string userId, int depar ? null : ResolveDispatchTimestamp(dispatch.LastDispatchedOn, dispatch.DispatchedOn, call); - if (dispatch == null && _departmentGroupsService != null) + if (_departmentGroupsService != null) { foreach (var groupDispatch in call?.GroupDispatches ?? new List()) { @@ -135,7 +135,7 @@ private async Task ResolveMostRecentDispatchAsync(string userId, int depar } } - if (dispatch == null && _personnelRolesService != null && call?.RoleDispatches?.Any() == true) + if (_personnelRolesService != null && call?.RoleDispatches?.Any() == true) { if (userRoleIds == null) { diff --git a/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs b/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs index e6d3ac2cc..250476392 100644 --- a/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs +++ b/Core/Resgrid.Chatbot/Handlers/StaffingActionHandler.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Localization; @@ -113,10 +112,6 @@ private async Task ResolveStaffingIdAsync(int departmentId, i if (match != null) return match; - var active = levels?.Where(x => x != null && !x.IsDeleted).OrderBy(x => x.Order).ToList(); - if (active != null && staffingId >= 0 && staffingId < active.Count) - return active[staffingId]; - return levels == null || levels.Count == 0 ? fallback : null; } diff --git a/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionManager.cs b/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionManager.cs index ee345ca39..d18587796 100644 --- a/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionManager.cs +++ b/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionManager.cs @@ -5,7 +5,8 @@ namespace Resgrid.Chatbot.Interfaces { public interface IChatbotSessionManager { - Task GetOrCreateSessionAsync(string userId, int departmentId, ChatbotPlatform platform, string fromIdentifier); + Task GetOrCreateSessionAsync(string userId, int departmentId, ChatbotPlatform platform, + string fromIdentifier, int ttlMinutes = 0); Task GetSessionAsync(string sessionId); Task SaveSessionAsync(ChatbotSession session); Task EndSessionAsync(string sessionId); diff --git a/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionStore.cs b/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionStore.cs index e26de046a..59fbd9aa5 100644 --- a/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionStore.cs +++ b/Core/Resgrid.Chatbot/Interfaces/IChatbotSessionStore.cs @@ -6,7 +6,8 @@ namespace Resgrid.Chatbot.Interfaces { public interface IChatbotSessionStore { - Task GetOrCreateAsync(string userId, int departmentId, ChatbotPlatform platform, string identifier); + Task GetOrCreateAsync(string userId, int departmentId, ChatbotPlatform platform, + string identifier, int ttlMinutes = 0); Task GetAsync(string sessionId); Task SaveAsync(ChatbotSession session); Task DeleteAsync(string sessionId); diff --git a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs index 939b14c57..51e962084 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotIngressService.cs @@ -237,11 +237,15 @@ await _userIdentityService.LinkUserAsync( } // 4. Get or create session + var sessionTtlMinutes = deptConfig?.SessionTtlMinutes > 0 + ? deptConfig.SessionTtlMinutes + : ChatbotConfig.DefaultSessionTimeoutMinutes; var session = await _sessionManager.GetOrCreateSessionAsync( identity.UserId, department.DepartmentId, message.Platform, - message.From); + message.From, + sessionTtlMinutes); // Resolve the user's preferred language once per session so responses are localized // (UserProfile.Language → supported culture; unknown/none falls back to English). @@ -251,9 +255,6 @@ await _userIdentityService.LinkUserAsync( session.Culture = Localization.ChatbotResources.NormalizeCulture(cultureProfile?.Language); } - if (deptConfig?.SessionTtlMinutes > 0) - session.TtlMinutes = deptConfig.SessionTtlMinutes; - // Add message to session history session.RecentMessages.Add(message); if (session.RecentMessages.Count > 20) diff --git a/Core/Resgrid.Chatbot/Services/ChatbotSessionManager.cs b/Core/Resgrid.Chatbot/Services/ChatbotSessionManager.cs index 385031bad..2afa1fbe1 100644 --- a/Core/Resgrid.Chatbot/Services/ChatbotSessionManager.cs +++ b/Core/Resgrid.Chatbot/Services/ChatbotSessionManager.cs @@ -18,8 +18,9 @@ public ChatbotSessionManager(IChatbotSessionStore sessionStore) _sessionStore = sessionStore; } - public Task GetOrCreateSessionAsync(string userId, int departmentId, ChatbotPlatform platform, string fromIdentifier) - => _sessionStore.GetOrCreateAsync(userId, departmentId, platform, fromIdentifier); + public Task GetOrCreateSessionAsync(string userId, int departmentId, ChatbotPlatform platform, + string fromIdentifier, int ttlMinutes = 0) + => _sessionStore.GetOrCreateAsync(userId, departmentId, platform, fromIdentifier, ttlMinutes); public Task GetSessionAsync(string sessionId) => _sessionStore.GetAsync(sessionId); diff --git a/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs b/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs index 113405588..802c07bd5 100644 --- a/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs +++ b/Core/Resgrid.Chatbot/Services/RedisSessionStore.cs @@ -52,8 +52,13 @@ private bool UseRedis() } } - public async Task GetOrCreateAsync(string userId, int departmentId, ChatbotPlatform platform, string identifier) + public async Task GetOrCreateAsync(string userId, int departmentId, ChatbotPlatform platform, + string identifier, int ttlMinutes = 0) { + ttlMinutes = ttlMinutes > 0 + ? ttlMinutes + : (ChatbotConfig.DefaultSessionTimeoutMinutes > 0 ? ChatbotConfig.DefaultSessionTimeoutMinutes : 30); + if (UseRedis()) { try @@ -63,6 +68,8 @@ public async Task GetOrCreateAsync(string userId, int department if (!string.IsNullOrWhiteSpace(existingId)) { var existing = await GetFromRedisAsync(existingId); + if (existing != null) + existing.TtlMinutes = ttlMinutes; if (existing != null && !existing.IsExpired()) { existing.LastActivity = DateTime.UtcNow; @@ -71,7 +78,7 @@ public async Task GetOrCreateAsync(string userId, int department } } - var created = CreateNewSession(userId, departmentId, platform); + var created = CreateNewSession(userId, departmentId, platform, ttlMinutes); await SaveToRedisAsync(created); return created; } @@ -82,7 +89,7 @@ public async Task GetOrCreateAsync(string userId, int department } } - return GetOrCreateInMemory(userId, departmentId, platform); + return GetOrCreateInMemory(userId, departmentId, platform, ttlMinutes); } public async Task GetAsync(string sessionId) @@ -165,9 +172,8 @@ public Task PruneExpiredAsync(DateTime? cutoff = null) { // Redis-backed sessions expire automatically via key TTL; only the in-memory fallback // needs explicit pruning. - var effectiveCutoff = cutoff ?? DateTime.UtcNow.AddMinutes(-ChatbotConfig.DefaultSessionTimeoutMinutes); var expired = _fallback.Values - .Where(s => s.LastActivity < effectiveCutoff) + .Where(s => cutoff.HasValue ? s.LastActivity < cutoff.Value : s.IsExpired()) .Select(s => s.SessionId) .ToList(); @@ -177,13 +183,17 @@ public Task PruneExpiredAsync(DateTime? cutoff = null) return Task.CompletedTask; } - private ChatbotSession GetOrCreateInMemory(string userId, int departmentId, ChatbotPlatform platform) + private ChatbotSession GetOrCreateInMemory(string userId, int departmentId, ChatbotPlatform platform, + int ttlMinutes) { var existing = _fallback.Values.FirstOrDefault(s => s.UserId == userId && s.DepartmentId == departmentId && s.Platform == platform); + if (existing != null) + existing.TtlMinutes = ttlMinutes; + if (existing != null && !existing.IsExpired()) { existing.LastActivity = DateTime.UtcNow; @@ -193,7 +203,7 @@ private ChatbotSession GetOrCreateInMemory(string userId, int departmentId, Chat if (existing != null) _fallback.TryRemove(existing.SessionId, out _); - var session = CreateNewSession(userId, departmentId, platform); + var session = CreateNewSession(userId, departmentId, platform, ttlMinutes); _fallback[session.SessionId] = session; return session; } @@ -222,11 +232,9 @@ private async Task GetFromRedisAsync(string sessionId) } } - private static ChatbotSession CreateNewSession(string userId, int departmentId, ChatbotPlatform platform) + private static ChatbotSession CreateNewSession(string userId, int departmentId, ChatbotPlatform platform, + int ttlMinutes) { - var ttlMinutes = ChatbotConfig.DefaultSessionTimeoutMinutes > 0 - ? ChatbotConfig.DefaultSessionTimeoutMinutes - : 30; return new ChatbotSession { SessionId = Guid.NewGuid().ToString("N"), diff --git a/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs b/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs index 8cf80338c..137eaf3ec 100644 --- a/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs +++ b/Core/Resgrid.Chatbot/Services/TextResponseResolver.cs @@ -53,6 +53,10 @@ public async Task> GetPendingResponsesAsync(s if (message.Type == (int)MessageTypes.Poll) { + if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.Note, out var pollDepartmentId) + || pollDepartmentId != departmentId) + continue; + pending.Add(new PendingTextResponse { Type = PendingTextResponseType.Poll, @@ -98,6 +102,10 @@ public async Task RecordResponseAsync(PendingTextResponse targe if (target.Type == PendingTextResponseType.Poll) { + if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.Note, out var pollDepartmentId) + || pollDepartmentId != session.DepartmentId) + return null; + recipient.Response = answer; recipient.ReadOn ??= DateTime.UtcNow; await _messageService.SaveMessageRecipientAsync(recipient); diff --git a/Core/Resgrid.Framework/FileHelper.cs b/Core/Resgrid.Framework/FileHelper.cs index c9f05d4e6..df4204585 100644 --- a/Core/Resgrid.Framework/FileHelper.cs +++ b/Core/Resgrid.Framework/FileHelper.cs @@ -1,6 +1,8 @@ using System; using System.IO; using System.Reflection; +using System.Threading; +using System.Threading.Tasks; namespace Resgrid.Framework { @@ -16,6 +18,24 @@ public static string GetFileExtensionWithoutDot(string fileName) return extension.TrimStart('.').ToLowerInvariant(); } + public static string GetSafeFileName(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + return string.Empty; + + return Path.GetFileName(fileName.Replace('\\', '/')); + } + + public static async Task ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken = default) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + using var buffer = new MemoryStream(); + await stream.CopyToAsync(buffer, cancellationToken); + return buffer.ToArray(); + } + public static string GetContentTypeByExtension(string strExtension) { switch (strExtension) diff --git a/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs b/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs index 10957d060..d9dcd161b 100644 --- a/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs +++ b/Core/Resgrid.Model/Messages/TextResponsePromptMetadata.cs @@ -9,6 +9,7 @@ namespace Resgrid.Model.Messages public static class TextResponsePromptMetadata { private const string CalendarRsvpPrefix = "calendar-rsvp:"; + private const string PollPrefix = "poll:"; public static string ForCalendarRsvp(int calendarItemId) => CalendarRsvpPrefix + calendarItemId; @@ -23,5 +24,19 @@ public static bool TryGetCalendarItemId(string note, out int calendarItemId) return int.TryParse(note.Substring(CalendarRsvpPrefix.Length), out calendarItemId) && calendarItemId > 0; } + + public static string ForPoll(int departmentId) + => PollPrefix + departmentId; + + public static bool TryGetPollDepartmentId(string note, out int departmentId) + { + departmentId = 0; + if (string.IsNullOrWhiteSpace(note) + || !note.StartsWith(PollPrefix, StringComparison.OrdinalIgnoreCase)) + return false; + + return int.TryParse(note.Substring(PollPrefix.Length), out departmentId) + && departmentId > 0; + } } } diff --git a/Core/Resgrid.Model/Queue/CallQueueItem.cs b/Core/Resgrid.Model/Queue/CallQueueItem.cs index 0b00c2012..9cdab395f 100644 --- a/Core/Resgrid.Model/Queue/CallQueueItem.cs +++ b/Core/Resgrid.Model/Queue/CallQueueItem.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Linq; using ProtoBuf; namespace Resgrid.Model.Queue @@ -23,5 +24,56 @@ public class CallQueueItem [ProtoMember(6)] public int CallDispatchAttachmentId { get; set; } + + [ProtoMember(7)] + public bool BroadcastOnlySelectedDispatches { get; set; } + + [ProtoMember(8)] + public List BroadcastUserIds { get; set; } + + [ProtoMember(9)] + public List BroadcastGroupIds { get; set; } + + [ProtoMember(10)] + public List BroadcastUnitIds { get; set; } + + [ProtoMember(11)] + public List BroadcastRoleIds { get; set; } + + public void SetBroadcastDispatches(IEnumerable userIds, IEnumerable groupIds, + IEnumerable unitIds, IEnumerable roleIds) + { + BroadcastOnlySelectedDispatches = true; + BroadcastUserIds = userIds?.Distinct().ToList() ?? new List(); + BroadcastGroupIds = groupIds?.Distinct().ToList() ?? new List(); + BroadcastUnitIds = unitIds?.Distinct().ToList() ?? new List(); + BroadcastRoleIds = roleIds?.Distinct().ToList() ?? new List(); + + if (Call != null) + { + Call = Serializer.DeepClone(Call); + ApplyBroadcastDispatchFilter(); + } + } + + public void ApplyBroadcastDispatchFilter() + { + if (!BroadcastOnlySelectedDispatches || Call == null) + return; + + var userIds = new HashSet(BroadcastUserIds ?? Enumerable.Empty()); + var groupIds = new HashSet(BroadcastGroupIds ?? Enumerable.Empty()); + var unitIds = new HashSet(BroadcastUnitIds ?? Enumerable.Empty()); + var roleIds = new HashSet(BroadcastRoleIds ?? Enumerable.Empty()); + + Call.Dispatches = (Call.Dispatches ?? Enumerable.Empty()) + .Where(x => userIds.Contains(x.UserId)).ToList(); + Call.GroupDispatches = (Call.GroupDispatches ?? Enumerable.Empty()) + .Where(x => groupIds.Contains(x.DepartmentGroupId)).ToList(); + Call.UnitDispatches = (Call.UnitDispatches ?? Enumerable.Empty()) + .Where(x => unitIds.Contains(x.UnitId)).ToList(); + Call.RoleDispatches = (Call.RoleDispatches ?? Enumerable.Empty()) + .Where(x => roleIds.Contains(x.RoleId)).ToList(); + } } } diff --git a/Core/Resgrid.Services/CalendarService.cs b/Core/Resgrid.Services/CalendarService.cs index d76b7302e..ae9569602 100644 --- a/Core/Resgrid.Services/CalendarService.cs +++ b/Core/Resgrid.Services/CalendarService.cs @@ -63,7 +63,7 @@ public async Task> GetAllCalendarItemsForDepartmentAsync(int public async Task> GetAllCalendarItemsForDepartmentInRangeAsync(int departmentId, DateTime startDate, DateTime endDate) { return (from ci in await GetAllCalendarItemsForDepartmentAsync(departmentId) - where ci.Start >= startDate && ci.End <= endDate + where ci.Start < endDate && ci.End >= startDate select ci).ToList(); } @@ -585,10 +585,11 @@ public async Task NotifyUsersAboutCalendarItemAsync(CalendarItem calendarI private async System.Threading.Tasks.Task SendCalendarPromptAsync(CalendarItem calendarItem, string userId, string message, string departmentNumber, string title, UserProfile profile, Department department) { - await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, + var sent = await _communicationService.SendCalendarAsync(userId, calendarItem.DepartmentId, message, departmentNumber, title, profile, department); - if (_textResponsePromptService == null + if (!sent + || _textResponsePromptService == null || calendarItem.SignupType != (int)CalendarItemSignupTypes.RSVP) return; diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index 9a1f2ef5f..3ae3818fa 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -577,7 +577,7 @@ public async Task SendNotificationAsync(string userId, int departmentId, s if (profile == null) profile = await _userProfileService.GetProfileByUserIdAsync(userId, false); - if (profile == null || profile.SendNotificationSms) + if (profile == null || (profile.SendNotificationSms && profile.MobileNumberVerified.IsContactMethodAllowedForSending())) { try { @@ -638,7 +638,7 @@ public async Task SendCalendarAsync(string userId, int departmentId, strin if (profile == null) profile = await _userProfileService.GetProfileByUserIdAsync(userId, false); - if (profile == null || profile.SendNotificationSms) + if (profile == null || (profile.SendNotificationSms && profile.MobileNumberVerified.IsContactMethodAllowedForSending())) { try { diff --git a/Core/Resgrid.Services/TextResponsePromptService.cs b/Core/Resgrid.Services/TextResponsePromptService.cs index 3c7d4a36c..35c8a34a6 100644 --- a/Core/Resgrid.Services/TextResponsePromptService.cs +++ b/Core/Resgrid.Services/TextResponsePromptService.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Resgrid.Framework; @@ -27,25 +28,41 @@ public async Task RecordCalendarRsvpPromptAsync(CalendarItem calendarItem, strin return; var now = DateTime.UtcNow; - var message = new Message + var metadata = TextResponsePromptMetadata.ForCalendarRsvp(calendarItem.CalendarItemId); + var inbox = await _messageService.GetInboxMessagesByUserIdAsync(userId); + var message = inbox?.Find(candidate => + !candidate.IsDeleted + && candidate.Type == (int)MessageTypes.CalendarRsvp + && (candidate.ExpireOn == null || candidate.ExpireOn > now) + && candidate.MessageRecipients?.Any(recipient => + !recipient.IsDeleted + && string.Equals(recipient.UserId, userId, StringComparison.Ordinal) + && TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId) + && calendarItemId == calendarItem.CalendarItemId) == true); + + if (message == null) { - Subject = ("Calendar RSVP: " + calendarItem.Title).Truncate(150), - Body = ($"Event #{calendarItem.CalendarItemId}: {calendarItem.Title}. Reply YES or NO.").Truncate(4000), - SendingUserId = calendarItem.CreatorUserId, - SentOn = now, - ExpireOn = now.AddDays(1), - SystemGenerated = true, - IsBroadcast = true, - Type = (int)MessageTypes.CalendarRsvp, - MessageRecipients = new List + message = new Message { - new MessageRecipient + MessageRecipients = new List { - UserId = userId, - Note = TextResponsePromptMetadata.ForCalendarRsvp(calendarItem.CalendarItemId) + new MessageRecipient + { + UserId = userId, + Note = metadata + } } - } - }; + }; + } + + message.Subject = ("Calendar RSVP: " + calendarItem.Title).Truncate(150); + message.Body = ($"Event #{calendarItem.CalendarItemId}: {calendarItem.Title}. Reply YES or NO.").Truncate(4000); + message.SendingUserId = calendarItem.CreatorUserId; + message.SentOn = now; + message.ExpireOn = now.AddDays(1); + message.SystemGenerated = true; + message.IsBroadcast = true; + message.Type = (int)MessageTypes.CalendarRsvp; await _messageService.SaveMessageAsync(message, cancellationToken); } diff --git a/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs new file mode 100644 index 000000000..b248f2763 --- /dev/null +++ b/Tests/Resgrid.Tests/Chatbot/CallRespondersActionHandlerTests.cs @@ -0,0 +1,195 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Chatbot.Handlers; +using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Chatbot +{ + [TestFixture] + public class CallRespondersActionHandlerTests + { + [Test] + public async Task HandleAsync_WhenCallScopedStatesAreNotCurrent_ExcludesPersonnelAndUnits() + { + // Arrange + var calls = new Mock(); + calls.Setup(x => x.GetCallByIdAsync(42, true)).ReturnsAsync(new Call + { + CallId = 42, + DepartmentId = 1, + Number = "26-1", + Name = "Structure Fire" + }); + + var authorization = new Mock(); + authorization.Setup(x => x.CanUserViewCallAsync("user-1", 42)).ReturnsAsync(true); + + var actionLogs = new Mock(); + actionLogs.Setup(x => x.GetActionLogsForCallAsync(1, 42)).ReturnsAsync(new List + { + new ActionLog + { + ActionLogId = 10, + DepartmentId = 1, + UserId = "responder-1", + ActionTypeId = (int)ActionTypes.OnScene, + DestinationId = 42 + } + }); + actionLogs.Setup(x => x.GetLastActionLogsForDepartmentAsync(1, false, false)).ReturnsAsync(new List + { + new ActionLog + { + ActionLogId = 11, + DepartmentId = 1, + UserId = "responder-1", + ActionTypeId = (int)ActionTypes.Responding, + DestinationId = 99 + } + }); + + var units = new Mock(); + units.Setup(x => x.GetUnitStatesForCallAsync(1, 42)).ReturnsAsync(new List + { + new UnitState + { + UnitStateId = 20, + UnitId = 5, + State = (int)UnitStateTypes.OnScene, + DestinationId = 42, + Unit = new Unit { UnitId = 5, DepartmentId = 1, Name = "Engine 1" } + } + }); + units.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(1)).ReturnsAsync(new List + { + new UnitState + { + UnitStateId = 21, + UnitId = 5, + State = (int)UnitStateTypes.Responding, + DestinationId = 99 + } + }); + units.Setup(x => x.GetUnitsForDepartmentAsync(1)).ReturnsAsync(new List + { + new Unit { UnitId = 5, DepartmentId = 1, Name = "Engine 1" } + }); + + var profiles = new Mock(); + profiles.Setup(x => x.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync(new List + { + new UserProfile { UserId = "responder-1", FirstName = "Alex", LastName = "Responder" } + }); + + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStatusAsync(1, It.IsAny())) + .ReturnsAsync(new CustomStateDetail { ButtonText = "On Scene" }); + customStates.Setup(x => x.GetCustomUnitStateAsync(It.IsAny())) + .ReturnsAsync(new CustomStateDetail { ButtonText = "On Scene" }); + + var handler = new CallRespondersActionHandler( + calls.Object, + actionLogs.Object, + units.Object, + customStates.Object, + profiles.Object, + authorization.Object); + + var intent = new ChatbotIntent { Type = ChatbotIntentType.CallResponders }; + intent.Parameters["callId"] = "42"; + intent.Parameters["mode"] = "all"; + var session = new ChatbotSession { UserId = "user-1", DepartmentId = 1 }; + + // Act + var response = await handler.HandleAsync(new ChatbotMessage { Text = "who is on call 42" }, intent, session); + + // Assert + response.Processed.Should().BeTrue(); + response.Text.Should().Contain("No personnel or units"); + response.Text.Should().NotContain("Alex Responder"); + response.Text.Should().NotContain("Engine 1"); + actionLogs.Verify(x => x.GetLastActionLogsForDepartmentAsync(1, false, false), Times.Once); + units.Verify(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(1), Times.Once); + } + + [TestCase("enroute", "Alex Enroute", "Blair Onscene")] + [TestCase("onscene", "Blair Onscene", "Alex Enroute")] + public async Task HandleAsync_WithResponderMode_FiltersUsingClassifierMode(string mode, + string expectedResponder, string excludedResponder) + { + // Arrange + var calls = new Mock(); + calls.Setup(x => x.GetCallByIdAsync(42, true)).ReturnsAsync(new Call + { + CallId = 42, + DepartmentId = 1, + Number = "26-1", + Name = "Structure Fire" + }); + + var authorization = new Mock(); + authorization.Setup(x => x.CanUserViewCallAsync("user-1", 42)).ReturnsAsync(true); + + var currentLogs = new List + { + new ActionLog + { + ActionLogId = 10, + DepartmentId = 1, + UserId = "enroute-user", + ActionTypeId = (int)ActionTypes.Responding, + DestinationId = 42 + }, + new ActionLog + { + ActionLogId = 11, + DepartmentId = 1, + UserId = "onscene-user", + ActionTypeId = (int)ActionTypes.OnScene, + DestinationId = 42 + } + }; + var actionLogs = new Mock(); + actionLogs.Setup(x => x.GetActionLogsForCallAsync(1, 42)).ReturnsAsync(currentLogs); + actionLogs.Setup(x => x.GetLastActionLogsForDepartmentAsync(1, false, false)).ReturnsAsync(currentLogs); + + var units = new Mock(); + units.Setup(x => x.GetUnitStatesForCallAsync(1, 42)).ReturnsAsync(new List()); + units.Setup(x => x.GetAllLatestStatusForUnitsByDepartmentIdAsync(1)).ReturnsAsync(new List()); + + var profiles = new Mock(); + profiles.Setup(x => x.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync(new List + { + new UserProfile { UserId = "enroute-user", FirstName = "Alex", LastName = "Enroute" }, + new UserProfile { UserId = "onscene-user", FirstName = "Blair", LastName = "Onscene" } + }); + + var customStates = new Mock(); + customStates.Setup(x => x.GetCustomPersonnelStatusAsync(1, It.IsAny())) + .ReturnsAsync((int _, ActionLog log) => new CustomStateDetail + { + ButtonText = log.ActionTypeId == (int)ActionTypes.Responding ? "Responding" : "On Scene" + }); + + var handler = new CallRespondersActionHandler(calls.Object, actionLogs.Object, units.Object, + customStates.Object, profiles.Object, authorization.Object); + var intent = new ChatbotIntent { Type = ChatbotIntentType.CallResponders }; + intent.Parameters["callId"] = "42"; + intent.Parameters["mode"] = mode; + var session = new ChatbotSession { UserId = "user-1", DepartmentId = 1, Culture = "en" }; + + // Act + var response = await handler.HandleAsync(new ChatbotMessage(), intent, session); + + // Assert + response.Processed.Should().BeTrue(); + response.Text.Should().Contain(expectedResponder); + response.Text.Should().NotContain(excludedResponder); + } + } +} diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs index afd306645..3f70bb9d5 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotAvailabilityIntentClassifierTests.cs @@ -137,5 +137,16 @@ public async Task Bare_call_response_extracts_response(string text, string expec result.IntentName.Should().Be("respond_to_call"); result.Parameters["response"].Should().Be(expectedResponse); } + + [TestCase("omw to 26-1?", "26-1", "yes")] + [TestCase("not responding to the fire!", "the fire", "no")] + public async Task Referenced_call_response_cleans_call_reference(string text, string expectedReference, string expectedResponse) + { + var result = await _classifier.ClassifyAsync(text); + + result.IntentName.Should().Be("respond_to_call"); + result.Parameters["callRef"].Should().Be(expectedReference); + result.Parameters["response"].Should().Be(expectedResponse); + } } } diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs index 306f596a8..f649407b1 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotDeptConfigAndSessionTests.cs @@ -1,3 +1,4 @@ +using System; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -137,6 +138,100 @@ public async Task SessionStore_InMemory_ReusesSessionForSameUserAndDepartment() fetched.SessionId.Should().Be(first.SessionId); } + [Test] + public async Task SessionStore_InMemory_AppliesRequestedTtlBeforeExpiryCheck() + { + // Arrange + ChatbotConfig.UseRedisSessionStore = false; + var store = new RedisSessionStore(Mock.Of()); + var userId = "session-ttl-expiry-user-001"; + var first = await store.GetOrCreateAsync(userId, 1, ChatbotPlatform.SmsTwilio, "", 30); + first.State = ChatbotDialogState.AwaitingConfirmation; + first.LastActivity = DateTime.UtcNow.AddMinutes(-10); + + // Act + var second = await store.GetOrCreateAsync(userId, 1, ChatbotPlatform.SmsTwilio, "", 5); + + // Assert + second.SessionId.Should().NotBe(first.SessionId); + second.State.Should().Be(ChatbotDialogState.Idle); + second.TtlMinutes.Should().Be(5); + } + + [Test] + public async Task SessionStore_InMemory_PreservesValidSessionStateWhenApplyingRequestedTtl() + { + // Arrange + ChatbotConfig.UseRedisSessionStore = false; + var store = new RedisSessionStore(Mock.Of()); + var userId = "session-ttl-valid-user-001"; + var first = await store.GetOrCreateAsync(userId, 1, ChatbotPlatform.SmsTwilio, "", 30); + first.State = ChatbotDialogState.AwaitingConfirmation; + first.PendingIntent = ChatbotIntentType.DispatchCall; + first.Context["description"] = "Structure fire"; + first.LastActivity = DateTime.UtcNow.AddMinutes(-2); + + // Act + var second = await store.GetOrCreateAsync(userId, 1, ChatbotPlatform.SmsTwilio, "", 5); + + // Assert + second.SessionId.Should().Be(first.SessionId); + second.TtlMinutes.Should().Be(5); + second.State.Should().Be(ChatbotDialogState.AwaitingConfirmation); + second.PendingIntent.Should().Be(ChatbotIntentType.DispatchCall); + second.Context["description"].Should().Be("Structure fire"); + } + + [Test] + public async Task SessionStore_PruneWithoutCutoff_UsesEachSessionTtl() + { + // Arrange + ChatbotConfig.UseRedisSessionStore = false; + var store = new RedisSessionStore(Mock.Of()); + var shortSession = await store.GetOrCreateAsync("session-prune-short-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 5); + var longSession = await store.GetOrCreateAsync("session-prune-long-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + shortSession.LastActivity = DateTime.UtcNow.AddMinutes(-10); + longSession.LastActivity = DateTime.UtcNow.AddMinutes(-10); + + // Act + await store.PruneExpiredAsync(); + var recreatedShortSession = await store.GetOrCreateAsync("session-prune-short-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + var retainedLongSession = await store.GetOrCreateAsync("session-prune-long-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + + // Assert + recreatedShortSession.SessionId.Should().NotBe(shortSession.SessionId); + retainedLongSession.SessionId.Should().Be(longSession.SessionId); + } + + [Test] + public async Task SessionStore_PruneWithCutoff_UsesExplicitCutoff() + { + // Arrange + ChatbotConfig.UseRedisSessionStore = false; + var store = new RedisSessionStore(Mock.Of()); + var olderSession = await store.GetOrCreateAsync("session-prune-cutoff-old-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + var newerSession = await store.GetOrCreateAsync("session-prune-cutoff-new-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + olderSession.LastActivity = DateTime.UtcNow.AddMinutes(-10); + newerSession.LastActivity = DateTime.UtcNow.AddMinutes(-2); + + // Act + await store.PruneExpiredAsync(DateTime.UtcNow.AddMinutes(-5)); + var recreatedOlderSession = await store.GetOrCreateAsync("session-prune-cutoff-old-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + var retainedNewerSession = await store.GetOrCreateAsync("session-prune-cutoff-new-user-001", 1, + ChatbotPlatform.SmsTwilio, "", 60); + + // Assert + recreatedOlderSession.SessionId.Should().NotBe(olderSession.SessionId); + retainedNewerSession.SessionId.Should().Be(newerSession.SessionId); + } + // ---- Rate limiting ----------------------------------------------------------------------- [Test] diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs index f2b372b26..bd7c9807e 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotHandlerTests.cs @@ -341,8 +341,9 @@ await handler.HandleAsync(Msg("set my status to available"), } [Test] - public async Task SetStaffing_AvailableShortcut_UsesFirstConfiguredCustomLevel() + public async Task SetStaffing_AvailableShortcut_DoesNotUseCustomLevelPosition() { + // Arrange var userStates = new Mock(); var customStates = new Mock(); customStates.Setup(x => x.GetCustomPersonnelStaffingsOrDefaultsAsync(1)).ReturnsAsync(new List @@ -352,11 +353,15 @@ public async Task SetStaffing_AvailableShortcut_UsesFirstConfiguredCustomLevel() }); var handler = new StaffingActionHandler(userStates.Object, customStates.Object); + + // Act var response = await handler.HandleAsync(Msg("available"), Intent(ChatbotIntentType.SetStaffing, ("staffingType", ((int)UserStateTypes.Available).ToString())), Session()); - response.Text.Should().Contain("On Duty"); - userStates.Verify(x => x.CreateUserState("user-1", 1, 201, It.IsAny()), Times.Once); + // Assert + response.Processed.Should().BeFalse(); + userStates.Verify(x => x.CreateUserState(It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); } [Test] @@ -579,6 +584,110 @@ public async Task RespondToCall_BareResponse_FallsBackToRoleDispatchMembership() It.IsAny(), 11, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); } + [Test] + public async Task RespondToCall_BareResponse_DirectAndNewerGroupDispatch_UsesNewestApplicableTimestamp() + { + // Arrange + var now = DateTime.UtcNow; + var groupDispatchedCall = new Call + { + CallId = 12, + Name = "Group Dispatched Call", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = now.AddMinutes(-20), + Dispatches = new List + { + new CallDispatch { UserId = "user-1", DispatchedOn = now.AddMinutes(-15) } + }, + GroupDispatches = new List + { + new CallDispatchGroup { DepartmentGroupId = 5, DispatchedOn = now.AddMinutes(-1) } + } + }; + var directDispatchedCall = new Call + { + CallId = 13, + Name = "Direct Call", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = now.AddMinutes(-10), + Dispatches = new List + { + new CallDispatch { UserId = "user-1", DispatchedOn = now.AddMinutes(-5) } + } + }; + var calls = CallsWithPopulatedDispatches(directDispatchedCall, groupDispatchedCall); + var groups = new Mock(); + groups.Setup(x => x.GetAllMembersForGroupAsync(5)).ReturnsAsync(new List + { + new DepartmentGroupMember { UserId = "user-1", DepartmentGroupId = 5 } + }); + var actionLogs = new Mock(); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, groups.Object); + + // Act + var response = await handler.HandleAsync(Msg("responding"), + Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); + + // Assert + response.Text.Should().Contain("Call #12"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, + It.IsAny(), 12, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + + [Test] + public async Task RespondToCall_BareResponse_DirectAndNewerRoleDispatch_UsesNewestApplicableTimestamp() + { + // Arrange + var now = DateTime.UtcNow; + var roleDispatchedCall = new Call + { + CallId = 14, + Name = "Role Dispatched Call", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = now.AddMinutes(-20), + Dispatches = new List + { + new CallDispatch { UserId = "user-1", DispatchedOn = now.AddMinutes(-15) } + }, + RoleDispatches = new List + { + new CallDispatchRole { RoleId = 7, DispatchedOn = now.AddMinutes(-1) } + } + }; + var directDispatchedCall = new Call + { + CallId = 15, + Name = "Direct Call", + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = now.AddMinutes(-10), + Dispatches = new List + { + new CallDispatch { UserId = "user-1", DispatchedOn = now.AddMinutes(-5) } + } + }; + var calls = CallsWithPopulatedDispatches(directDispatchedCall, roleDispatchedCall); + var roles = new Mock(); + roles.Setup(x => x.GetRolesForUserAsync("user-1", 1)).ReturnsAsync(new List + { + new PersonnelRole { PersonnelRoleId = 7 } + }); + var actionLogs = new Mock(); + var handler = new RespondToCallHandler(calls.Object, actionLogs.Object, null, null, roles.Object); + + // Act + var response = await handler.HandleAsync(Msg("responding"), + Intent(ChatbotIntentType.RespondToCall, ("response", "yes")), Session()); + + // Assert + response.Text.Should().Contain("Call #14"); + actionLogs.Verify(x => x.SetUserActionAsync("user-1", 1, (int)ActionTypes.Responding, + It.IsAny(), 14, (int)DestinationEntityTypes.Call, It.IsAny()), Times.Once); + } + [Test] public async Task RespondToCall_NotGoing_UsesCustomNotRespondingStatus() { diff --git a/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs b/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs index 77bced7fc..780079957 100644 --- a/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs +++ b/Tests/Resgrid.Tests/Chatbot/ChatbotTextResponseResolverTests.cs @@ -5,10 +5,12 @@ using FluentAssertions; using Moq; using NUnit.Framework; +using Resgrid.Chatbot.Handlers; using Resgrid.Chatbot.Interfaces; using Resgrid.Chatbot.Models; using Resgrid.Chatbot.Services; using Resgrid.Model; +using Resgrid.Model.Identity; using Resgrid.Model.Messages; using Resgrid.Model.Services; using Resgrid.Services; @@ -43,7 +45,12 @@ public async Task GetPendingResponses_ReturnsOnlyRecentUnansweredPolls() new Message { MessageId = 3, Type = (int)MessageTypes.Poll, Subject = "Poll: Answered", SentOn = now.AddHours(-1) } }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(1, "user-1")) - .ReturnsAsync(new MessageRecipient { MessageId = 1, UserId = "user-1" }); + .ReturnsAsync(new MessageRecipient + { + MessageId = 1, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForPoll(10) + }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(3, "user-1")) .ReturnsAsync(new MessageRecipient { MessageId = 3, UserId = "user-1", Response = "Yes" }); @@ -64,7 +71,12 @@ public async Task GetPendingResponses_ReturnsPollAndUnansweredCalendarRsvp() new Message { MessageId = 4, Type = (int)MessageTypes.Poll, Subject = "Poll: Staffing", SentOn = now.AddMinutes(-20) } }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(4, "user-1")) - .ReturnsAsync(new MessageRecipient { MessageId = 4, UserId = "user-1" }); + .ReturnsAsync(new MessageRecipient + { + MessageId = 4, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForPoll(10) + }); _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(5, "user-1")) .ReturnsAsync(new MessageRecipient { @@ -90,6 +102,30 @@ public async Task GetPendingResponses_ReturnsPollAndUnansweredCalendarRsvp() pending.Should().Contain(p => p.Type == PendingTextResponseType.CalendarRsvp && p.SourceId == 77); } + [Test] + public async Task GetPendingResponses_ExcludesPollFromAnotherDepartment() + { + // Arrange + var now = DateTime.UtcNow; + _messages.Setup(m => m.GetInboxMessagesByUserIdAsync("user-1")).ReturnsAsync(new List + { + new Message { MessageId = 4, Type = (int)MessageTypes.Poll, Subject = "Poll: Staffing", SentOn = now } + }); + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(4, "user-1")) + .ReturnsAsync(new MessageRecipient + { + MessageId = 4, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForPoll(20) + }); + + // Act + var pending = await _resolver.GetPendingResponsesAsync("user-1", 10, now.AddDays(-1)); + + // Assert + pending.Should().BeEmpty(); + } + [Test] public async Task GetPendingResponses_ExcludesCalendarEventAlreadyAnswered() { @@ -147,15 +183,89 @@ public async Task RecordResponse_NoForCalendar_RecordsNotAttendingAndRecipientRe (int)CalendarItemAttendeeTypes.NotAttending, It.IsAny()), Times.Once); } + [Test] + public async Task RecordResponse_PollFromAnotherDepartment_DoesNotSaveResponse() + { + // Arrange + var recipient = new MessageRecipient + { + MessageId = 4, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForPoll(20) + }; + _messages.Setup(m => m.GetMessageRecipientByMessageAndUserAsync(4, "user-1")) + .ReturnsAsync(recipient); + + // Act + var response = await _resolver.RecordResponseAsync(new PendingTextResponse + { + Type = PendingTextResponseType.Poll, + SourceId = 4, + MessageId = 4, + Label = "Staffing" + }, "Yes", new ChatbotSession { UserId = "user-1", DepartmentId = 10, Culture = "en" }); + + // Assert + response.Should().BeNull(); + recipient.Response.Should().BeNull(); + _messages.Verify(m => m.SaveMessageRecipientAsync(It.IsAny(), + It.IsAny()), Times.Never); + } + + [Test] + public async Task PollCreateHandler_StoresDepartmentMetadataForEveryRecipient() + { + // Arrange + Message saved = null; + var departments = new Mock(); + var profiles = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(10, true)).ReturnsAsync(new Department()); + departments.Setup(d => d.GetDepartmentMemberAsync("admin", 10, true)) + .ReturnsAsync(new DepartmentMember { UserId = "admin", IsAdmin = true }); + departments.Setup(d => d.GetAllUsersForDepartmentAsync(10, false, false)) + .ReturnsAsync(new List + { + new IdentityUser { UserId = "admin" }, + new IdentityUser { UserId = "user-1" }, + new IdentityUser { UserId = "user-2" } + }); + profiles.Setup(p => p.GetProfileByUserIdAsync("admin", false)).ReturnsAsync((UserProfile)null); + _messages.Setup(m => m.SaveMessageAsync(It.IsAny(), It.IsAny())) + .Callback((Message message, CancellationToken _) => saved = message) + .ReturnsAsync((Message message, CancellationToken _) => message); + _messages.Setup(m => m.SendMessageAsync(It.IsAny(), It.IsAny(), 10, true, + It.IsAny())).ReturnsAsync(true); + var handler = new PollCreateHandler(_messages.Object, departments.Object, profiles.Object); + + // Act + var response = await handler.HandleAsync(new ChatbotMessage(), new ChatbotIntent + { + Type = ChatbotIntentType.CreatePoll, + Parameters = new Dictionary + { + ["question"] = "Available tonight?", + ["__confirmed"] = "true" + } + }, new ChatbotSession { UserId = "admin", DepartmentId = 10, Culture = "en" }); + + // Assert + response.Processed.Should().BeTrue(); + saved.Should().NotBeNull(); + saved.MessageRecipients.Should().HaveCount(2).And.OnlyContain(r => + r.Note == TextResponsePromptMetadata.ForPoll(10)); + } + [Test] public async Task PromptService_StoresCalendarMetadataAndOneDayExpiry() { + // Arrange Message saved = null; _messages.Setup(m => m.SaveMessageAsync(It.IsAny(), It.IsAny())) .Callback((Message message, CancellationToken _) => saved = message) .ReturnsAsync((Message message, CancellationToken _) => message); var service = new TextResponsePromptService(_messages.Object); + // Act await service.RecordCalendarRsvpPromptAsync(new CalendarItem { CalendarItemId = 44, @@ -164,6 +274,7 @@ await service.RecordCalendarRsvpPromptAsync(new CalendarItem CreatorUserId = "creator" }, "user-1"); + // Assert saved.Should().NotBeNull(); saved.Type.Should().Be((int)MessageTypes.CalendarRsvp); saved.ExpireOn.Should().BeCloseTo(saved.SentOn.AddDays(1), TimeSpan.FromSeconds(1)); @@ -172,6 +283,97 @@ await service.RecordCalendarRsvpPromptAsync(new CalendarItem r.UserId == "user-1" && r.Note == TextResponsePromptMetadata.ForCalendarRsvp(44)); } + [Test] + public async Task PromptService_ReusesMatchingUnexpiredCalendarPrompt() + { + // Arrange + var recipient = new MessageRecipient + { + MessageRecipientId = 21, + MessageId = 12, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForCalendarRsvp(44) + }; + var existing = new Message + { + MessageId = 12, + Subject = "Calendar RSVP: Old title", + Body = "Old body", + SentOn = DateTime.UtcNow.AddHours(-1), + ExpireOn = DateTime.UtcNow.AddHours(1), + Type = (int)MessageTypes.CalendarRsvp, + MessageRecipients = new List { recipient } + }; + Message saved = null; + _messages.Setup(m => m.GetInboxMessagesByUserIdAsync("user-1")) + .ReturnsAsync(new List { existing }); + _messages.Setup(m => m.SaveMessageAsync(It.IsAny(), It.IsAny())) + .Callback((Message message, CancellationToken _) => saved = message) + .ReturnsAsync((Message message, CancellationToken _) => message); + var service = new TextResponsePromptService(_messages.Object); + + // Act + await service.RecordCalendarRsvpPromptAsync(new CalendarItem + { + CalendarItemId = 44, + Title = "Updated title", + SignupType = (int)CalendarItemSignupTypes.RSVP, + CreatorUserId = "creator" + }, "user-1"); + + // Assert + saved.Should().BeSameAs(existing); + saved.MessageId.Should().Be(12); + saved.Subject.Should().Be("Calendar RSVP: Updated title"); + saved.MessageRecipients.Should().ContainSingle().Which.Should().BeSameAs(recipient); + saved.ExpireOn.Should().BeCloseTo(DateTime.UtcNow.AddDays(1), TimeSpan.FromSeconds(1)); + } + + [Test] + public async Task PromptService_CreatesNewPromptWhenMatchingPromptIsExpired() + { + // Arrange + var expired = new Message + { + MessageId = 12, + SentOn = DateTime.UtcNow.AddDays(-2), + ExpireOn = DateTime.UtcNow.AddMinutes(-1), + Type = (int)MessageTypes.CalendarRsvp, + MessageRecipients = new List + { + new MessageRecipient + { + MessageRecipientId = 21, + MessageId = 12, + UserId = "user-1", + Note = TextResponsePromptMetadata.ForCalendarRsvp(44) + } + } + }; + Message saved = null; + _messages.Setup(m => m.GetInboxMessagesByUserIdAsync("user-1")) + .ReturnsAsync(new List { expired }); + _messages.Setup(m => m.SaveMessageAsync(It.IsAny(), It.IsAny())) + .Callback((Message message, CancellationToken _) => saved = message) + .ReturnsAsync((Message message, CancellationToken _) => message); + var service = new TextResponsePromptService(_messages.Object); + + // Act + await service.RecordCalendarRsvpPromptAsync(new CalendarItem + { + CalendarItemId = 44, + Title = "Live Fire Training", + SignupType = (int)CalendarItemSignupTypes.RSVP, + CreatorUserId = "creator" + }, "user-1"); + + // Assert + saved.Should().NotBeSameAs(expired); + saved.MessageId.Should().Be(0); + saved.MessageRecipients.Should().ContainSingle(r => + r.UserId == "user-1" && r.Note == TextResponsePromptMetadata.ForCalendarRsvp(44)); + } + [Test] public void ConversationEngine_OnlyCollectsMissingStatusOrStaffingParameters() { @@ -241,7 +443,7 @@ public async Task Ingress_MultipleOutstandingItems_AsksForNumberThenAppliesSelec LastActivity = DateTime.UtcNow }; var sessions = new Mock(); - sessions.Setup(s => s.GetOrCreateSessionAsync("user-1", 10, ChatbotPlatform.WebChat, "web-user")) + sessions.Setup(s => s.GetOrCreateSessionAsync("user-1", 10, ChatbotPlatform.WebChat, "web-user", 12)) .ReturnsAsync(session); var departments = new Mock(); @@ -253,6 +455,14 @@ public async Task Ingress_MultipleOutstandingItems_AsksForNumberThenAppliesSelec authorization.Setup(a => a.IsUserValidWithinLimitsAsync("user-1", 10)).ReturnsAsync(true); var rateLimiter = new Mock(); rateLimiter.Setup(r => r.TryAcquireAsync("user-1", 10, It.IsAny(), It.IsAny())).ReturnsAsync(true); + var departmentConfig = new Mock(); + departmentConfig.Setup(c => c.GetConfigAsync(10, false)).ReturnsAsync(new ChatbotDepartmentConfig + { + DepartmentId = 10, + IsEnabled = true, + AllowedPlatforms = "*", + SessionTtlMinutes = 12 + }); var poll = new PendingTextResponse { @@ -280,7 +490,7 @@ public async Task Ingress_MultipleOutstandingItems_AsksForNumberThenAppliesSelec Mock.Of(), limits.Object, authorization.Object, - Mock.Of(), + departmentConfig.Object, rateLimiter.Object, Mock.Of(), resolver.Object); diff --git a/Tests/Resgrid.Tests/Chatbot/MyCallsActionHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/MyCallsActionHandlerTests.cs new file mode 100644 index 000000000..d556eef65 --- /dev/null +++ b/Tests/Resgrid.Tests/Chatbot/MyCallsActionHandlerTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Chatbot.Handlers; +using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Chatbot +{ + [TestFixture] + public class MyCallsActionHandlerTests + { + [Test] + public async Task HandleAsync_WhenUserDispatchIsOlderThanFormerScanLimit_IncludesCall() + { + // Arrange + var activeCalls = CreateActiveCalls(); + var oldestCall = activeCalls.Last(); + oldestCall.Name = "Older User Call"; + oldestCall.Dispatches.Add(new CallDispatch { UserId = "user-1" }); + var calls = CreateCallsService(activeCalls); + var handler = new MyCallsActionHandler(calls.Object, Mock.Of()); + var intent = new ChatbotIntent { Type = ChatbotIntentType.MyCalls }; + var session = new ChatbotSession { UserId = "user-1", DepartmentId = 1 }; + + // Act + var response = await handler.HandleAsync(new ChatbotMessage { Text = "what calls am I on" }, intent, session); + + // Assert + response.Processed.Should().BeTrue(); + response.Text.Should().Contain("Older User Call"); + calls.Verify(x => x.PopulateCallData(It.IsAny(), true, false, false, false, false, false, false, false, false, false), + Times.Exactly(activeCalls.Count)); + } + + [Test] + public async Task HandleAsync_WhenUnitDispatchIsOlderThanFormerScanLimit_IncludesCall() + { + // Arrange + var activeCalls = CreateActiveCalls(); + var oldestCall = activeCalls.Last(); + oldestCall.Name = "Older Unit Call"; + oldestCall.UnitDispatches.Add(new CallDispatchUnit { UnitId = 7 }); + var calls = CreateCallsService(activeCalls); + var units = new Mock(); + units.Setup(x => x.GetUnitsForDepartmentAsync(1)).ReturnsAsync(new List + { + new Unit { UnitId = 7, DepartmentId = 1, Name = "Rescue 7" } + }); + var handler = new MyCallsActionHandler(calls.Object, units.Object); + var intent = new ChatbotIntent { Type = ChatbotIntentType.UnitCalls }; + intent.Parameters["unitName"] = "Rescue 7"; + var session = new ChatbotSession { UserId = "user-1", DepartmentId = 1 }; + + // Act + var response = await handler.HandleAsync(new ChatbotMessage { Text = "what calls is Rescue 7 on" }, intent, session); + + // Assert + response.Processed.Should().BeTrue(); + response.Text.Should().Contain("Older Unit Call"); + calls.Verify(x => x.PopulateCallData(It.IsAny(), false, false, false, false, true, false, false, false, false, false), + Times.Exactly(activeCalls.Count)); + } + + private static List CreateActiveCalls() + { + var now = DateTime.UtcNow; + return Enumerable.Range(1, 26).Select(index => new Call + { + CallId = index, + DepartmentId = 1, + State = (int)CallStates.Active, + LoggedOn = now.AddMinutes(-index), + Name = $"Call {index}", + Dispatches = new List(), + UnitDispatches = new List() + }).ToList(); + } + + private static Mock CreateCallsService(List activeCalls) + { + var calls = new Mock(); + calls.Setup(x => x.GetActiveCallsByDepartmentAsync(1)).ReturnsAsync(activeCalls); + calls.Setup(x => x.PopulateCallData( + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync((Call call, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _, bool _) => call); + return calls; + } + } +} diff --git a/Tests/Resgrid.Tests/Chatbot/MyScheduleActionHandlerTests.cs b/Tests/Resgrid.Tests/Chatbot/MyScheduleActionHandlerTests.cs new file mode 100644 index 000000000..842a86d18 --- /dev/null +++ b/Tests/Resgrid.Tests/Chatbot/MyScheduleActionHandlerTests.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Chatbot.Handlers; +using Resgrid.Chatbot.Models; +using Resgrid.Model; +using Resgrid.Model.Services; + +namespace Resgrid.Tests.Chatbot +{ + [TestFixture] + public class MyScheduleActionHandlerTests + { + [Test] + public async Task HandleAsync_WithLocalizedDate_ParsesAndFormatsUsingSessionCulture() + { + // Arrange + var shifts = new Mock(); + shifts.Setup(x => x.GetShiftDaysForDayAsync(It.IsAny(), 1)).ReturnsAsync(new List()); + var calendar = new Mock(); + calendar.Setup(x => x.GetAllCalendarItemsForDepartmentInRangeAsync(1, It.IsAny(), It.IsAny())) + .ReturnsAsync(new List()); + var departments = CreateDepartmentsService(); + var handler = new MyScheduleActionHandler(shifts.Object, calendar.Object, departments.Object); + var intent = new ChatbotIntent { Type = ChatbotIntentType.MySchedule }; + intent.Parameters["day"] = "22/7/2030"; + var session = new ChatbotSession { UserId = "user-1", DepartmentId = 1, Culture = "es" }; + var expectedLabel = new DateTime(2030, 7, 22).ToString("ddd M/d", CultureInfo.GetCultureInfo("es")); + + // Act + var response = await handler.HandleAsync(new ChatbotMessage { Text = "mi horario" }, intent, session); + + // Assert + response.Processed.Should().BeTrue(); + response.Text.Should().Contain(expectedLabel); + } + + [Test] + public async Task HandleAsync_WithLocalizedWeekday_FormatsShiftAndEventTimesUsingSessionCulture() + { + // Arrange + var originalCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US"); + try + { + var sessionCulture = CultureInfo.GetCultureInfo("de"); + var shifts = new Mock(); + DateTime targetDate = default; + shifts.Setup(x => x.GetShiftDaysForDayAsync(It.IsAny(), 1)) + .ReturnsAsync((DateTime day, int _) => + { + targetDate = day.Date; + return new List + { + new ShiftDay + { + ShiftId = 5, + Day = targetDate, + Shift = new Shift { ShiftId = 5, Name = "Tagdienst", StartTime = "13:30", EndTime = "15:00" } + } + }; + }); + shifts.Setup(x => x.GetShiftPersonsForUserAsync("user-1")).ReturnsAsync(new List + { + new ShiftPerson { ShiftId = 5, UserId = "user-1" } + }); + shifts.Setup(x => x.GetShiftSignupsForUserAsync("user-1")).ReturnsAsync(new List()); + + var calendar = new Mock(); + calendar.Setup(x => x.GetAllCalendarItemsForDepartmentInRangeAsync(1, It.IsAny(), It.IsAny())) + .ReturnsAsync((int _, DateTime windowStart, DateTime _) => new List + { + new CalendarItem { CalendarItemId = 10, Start = windowStart.AddDays(1).AddHours(16).AddMinutes(45), Title = "Besprechung" } + }); + calendar.Setup(x => x.GetCalendarItemAttendeeByUserAsync(10, "user-1")) + .ReturnsAsync(new CalendarItemAttendee { AttendeeType = (int)CalendarItemAttendeeTypes.RSVP }); + + var handler = new MyScheduleActionHandler(shifts.Object, calendar.Object, CreateDepartmentsService().Object); + var intent = new ChatbotIntent { Type = ChatbotIntentType.MySchedule }; + intent.Parameters["day"] = "Montag"; + var session = new ChatbotSession { UserId = "user-1", DepartmentId = 1, Culture = "de" }; + + // Act + var response = await handler.HandleAsync(new ChatbotMessage { Text = "mein Dienstplan" }, intent, session); + + // Assert + response.Processed.Should().BeTrue(); + response.Text.Should().Contain(targetDate.ToString("ddd M/d", sessionCulture)); + response.Text.Should().Contain(targetDate.AddHours(13).AddMinutes(30).ToString("t", sessionCulture)); + response.Text.Should().Contain(targetDate.AddHours(16).AddMinutes(45).ToString("t", sessionCulture)); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + + private static Mock CreateDepartmentsService() + { + var departments = new Mock(); + departments.Setup(x => x.GetDepartmentByIdAsync(1, true)).ReturnsAsync(new Department + { + DepartmentId = 1, + TimeZone = "UTC" + }); + return departments; + } + } +} diff --git a/Tests/Resgrid.Tests/Framework/FileHelperTests.cs b/Tests/Resgrid.Tests/Framework/FileHelperTests.cs index dbfaa7297..1d6230c61 100644 --- a/Tests/Resgrid.Tests/Framework/FileHelperTests.cs +++ b/Tests/Resgrid.Tests/Framework/FileHelperTests.cs @@ -1,3 +1,7 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; using FluentAssertions; using NUnit.Framework; using Resgrid.Framework; @@ -19,5 +23,81 @@ public void should_return_last_file_extension_without_dot(string fileName, strin extension.Should().Be(expectedExtension); } + + [TestCase(@"C:\fakepath\certificate.pdf", "certificate.pdf")] + [TestCase("/tmp/operations plan.docx", "operations plan.docx")] + [TestCase("document.pdf", "document.pdf")] + [TestCase(null, "")] + public void GetSafeFileName_WithClientPath_ReturnsFileNameOnly(string fileName, string expectedFileName) + { + // Act + var result = FileHelper.GetSafeFileName(fileName); + + // Assert + result.Should().Be(expectedFileName); + } + + [Test] + public async Task ReadAllBytesAsync_WithPartialNonSeekableReads_ReturnsCompleteFile() + { + // Arrange + var expected = new byte[32_769]; + new Random(42).NextBytes(expected); + using var stream = new PartialReadStream(expected, 7); + + // Act + var result = await FileHelper.ReadAllBytesAsync(stream, CancellationToken.None); + + // Assert + result.Should().Equal(expected); + } + + private sealed class PartialReadStream : Stream + { + private readonly MemoryStream _inner; + private readonly int _maximumReadSize; + + public PartialReadStream(byte[] data, int maximumReadSize) + { + _inner = new MemoryStream(data); + _maximumReadSize = maximumReadSize; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override int Read(byte[] buffer, int offset, int count) + { + return _inner.Read(buffer, offset, Math.Min(count, _maximumReadSize)); + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + return _inner.ReadAsync(buffer[..Math.Min(buffer.Length, _maximumReadSize)], cancellationToken); + } + + public override void Flush() + { + } + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + protected override void Dispose(bool disposing) + { + if (disposing) + _inner.Dispose(); + + base.Dispose(disposing); + } + } } } diff --git a/Tests/Resgrid.Tests/Models/CallQueueItemTests.cs b/Tests/Resgrid.Tests/Models/CallQueueItemTests.cs new file mode 100644 index 000000000..7967c0d68 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/CallQueueItemTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using ProtoBuf; +using Resgrid.Model; +using Resgrid.Model.Queue; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class CallQueueItemTests + { + [Test] + public void SetBroadcastDispatches_WithSelectedDispatches_QueuesOnlyNewEntities() + { + // Arrange + var originalCall = CreateCall(); + var queueItem = new CallQueueItem + { + Call = originalCall + }; + + // Act + queueItem.SetBroadcastDispatches(new[] { "new-user" }, new[] { 12 }, new[] { 22 }, new[] { 32 }); + + // Assert + queueItem.Call.Dispatches.Select(x => x.UserId).Should().Equal("new-user"); + queueItem.Call.GroupDispatches.Select(x => x.DepartmentGroupId).Should().Equal(12); + queueItem.Call.UnitDispatches.Select(x => x.UnitId).Should().Equal(22); + queueItem.Call.RoleDispatches.Select(x => x.RoleId).Should().Equal(32); + originalCall.Dispatches.Should().HaveCount(2); + originalCall.GroupDispatches.Should().HaveCount(2); + originalCall.UnitDispatches.Should().HaveCount(2); + originalCall.RoleDispatches.Should().HaveCount(2); + } + + [Test] + public void BroadcastDispatchSelection_AfterQueueSerialization_StillFiltersOldEntities() + { + // Arrange + var queueItem = new CallQueueItem + { + Call = CreateCall() + }; + queueItem.BroadcastOnlySelectedDispatches = true; + queueItem.BroadcastUserIds = new List { "new-user" }; + queueItem.BroadcastGroupIds = new List { 12 }; + queueItem.BroadcastUnitIds = new List { 22 }; + queueItem.BroadcastRoleIds = new List { 32 }; + + // Act + var deserializedQueueItem = Serializer.DeepClone(queueItem); + deserializedQueueItem.ApplyBroadcastDispatchFilter(); + + // Assert + deserializedQueueItem.BroadcastOnlySelectedDispatches.Should().BeTrue(); + deserializedQueueItem.Call.Dispatches.Select(x => x.UserId).Should().Equal("new-user"); + deserializedQueueItem.Call.GroupDispatches.Select(x => x.DepartmentGroupId).Should().Equal(12); + deserializedQueueItem.Call.UnitDispatches.Select(x => x.UnitId).Should().Equal(22); + deserializedQueueItem.Call.RoleDispatches.Select(x => x.RoleId).Should().Equal(32); + } + + [Test] + public void ApplyBroadcastDispatchFilter_WithoutRestriction_KeepsAllEntitiesForRedispatch() + { + // Arrange + var queueItem = new CallQueueItem + { + Call = CreateCall() + }; + + // Act + queueItem.ApplyBroadcastDispatchFilter(); + + // Assert + queueItem.Call.Dispatches.Should().HaveCount(2); + queueItem.Call.GroupDispatches.Should().HaveCount(2); + queueItem.Call.UnitDispatches.Should().HaveCount(2); + queueItem.Call.RoleDispatches.Should().HaveCount(2); + } + + private static Call CreateCall() + { + return new Call + { + Dispatches = new List + { + new CallDispatch { UserId = "existing-user" }, + new CallDispatch { UserId = "new-user" } + }, + GroupDispatches = new List + { + new CallDispatchGroup { DepartmentGroupId = 11 }, + new CallDispatchGroup { DepartmentGroupId = 12 } + }, + UnitDispatches = new List + { + new CallDispatchUnit { UnitId = 21 }, + new CallDispatchUnit { UnitId = 22 } + }, + RoleDispatches = new List + { + new CallDispatchRole { RoleId = 31 }, + new CallDispatchRole { RoleId = 32 } + } + }; + } + } +} diff --git a/Tests/Resgrid.Tests/Models/DocumentTests.cs b/Tests/Resgrid.Tests/Models/DocumentTests.cs new file mode 100644 index 000000000..9cdc09a87 --- /dev/null +++ b/Tests/Resgrid.Tests/Models/DocumentTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq; +using FluentAssertions; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Repositories.DataRepository.Extensions; +using Resgrid.Repositories.DataRepository.Servers.SqlServer; + +namespace Resgrid.Tests.Models +{ + [TestFixture] + public class DocumentTests + { + [Test] + public void Category_WhenPersisted_UsesCategoryDatabaseColumn() + { + // Arrange + var document = new Document { Category = "Operations" }; + + // Act + var columns = document + .GetColumns(new SqlServerConfiguration(), ignoreProperties: document.IgnoredProperties) + .ToList(); + + // Assert + document.Category.Should().Be("Operations"); + columns.Should().Contain(x => x.Contains("Category", StringComparison.OrdinalIgnoreCase)); + columns.Should().NotContain(x => x.Contains("Catery", StringComparison.OrdinalIgnoreCase)); + } + + [Test] + public void Description_WhenPersisted_IsIncludedInDocumentColumns() + { + // Arrange + var document = new Document { Description = "

Pre-incident plan

" }; + + // Act + var columns = document + .GetColumns(new SqlServerConfiguration(), ignoreProperties: document.IgnoredProperties) + .ToList(); + + // Assert + document.Description.Should().Be("

Pre-incident plan

"); + columns.Should().Contain(x => x.Contains("Description", StringComparison.OrdinalIgnoreCase)); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs index 94ee96391..426f9567b 100644 --- a/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalendarServiceTests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -29,6 +30,7 @@ public class with_the_calendar_service : TestBase protected readonly Mock _departmentGroupsServiceMock; protected readonly Mock _departmentSettingsServiceMock; protected readonly Mock _encryptionServiceMock; + protected readonly Mock _textResponsePromptServiceMock; protected with_the_calendar_service() { @@ -41,6 +43,7 @@ protected with_the_calendar_service() _departmentGroupsServiceMock = new Mock(); _departmentSettingsServiceMock = new Mock(); _encryptionServiceMock = new Mock(); + _textResponsePromptServiceMock = new Mock(); #region Departments _testDepartment = new Department() @@ -70,7 +73,70 @@ protected with_the_calendar_service() _calendarService = new CalendarService(_calendarItemRepositoryMock.Object, _calendarItemTypeRepositoryMock.Object, _calendarItemAttendeeRepositoryMock.Object, _departmentsServiceMock.Object, _communicationServiceMock.Object, _userProfileServiceMock.Object, _departmentGroupsServiceMock.Object, _departmentSettingsServiceMock.Object, - _encryptionServiceMock.Object, new Mock().Object); + _encryptionServiceMock.Object, new Mock().Object, + _textResponsePromptServiceMock.Object); + } + } + + [TestFixture] + public class when_sending_calendar_rsvp_prompts : with_the_calendar_service + { + private const string UserId = "user-1"; + + protected override void Before_all_tests() + { + Resgrid.Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Add(_testDepartment.DepartmentId); + _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(999, false)) + .ReturnsAsync(new Dictionary()); + } + + protected override void After_all_tests() + { + Resgrid.Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Remove(_testDepartment.DepartmentId); + } + + [Test] + public async Task should_record_prompt_when_delivery_is_accepted() + { + // Arrange + var calendarItem = CreateRsvpCalendarItem(); + _communicationServiceMock.Setup(x => x.SendCalendarAsync(UserId, 999, It.IsAny(), + It.IsAny(), It.IsAny(), null, _testDepartment)).ReturnsAsync(true); + + // Act + await _calendarService.NotifyUsersAboutCalendarItemAsync(calendarItem, new List { UserId }); + + // Assert + _textResponsePromptServiceMock.Verify(x => x.RecordCalendarRsvpPromptAsync(calendarItem, UserId, + It.IsAny()), Times.Once); + } + + [Test] + public async Task should_not_record_prompt_when_delivery_is_rejected() + { + // Arrange + var calendarItem = CreateRsvpCalendarItem(); + _communicationServiceMock.Setup(x => x.SendCalendarAsync(UserId, 999, It.IsAny(), + It.IsAny(), It.IsAny(), null, _testDepartment)).ReturnsAsync(false); + + // Act + await _calendarService.NotifyUsersAboutCalendarItemAsync(calendarItem, new List { UserId }); + + // Assert + _textResponsePromptServiceMock.Verify(x => x.RecordCalendarRsvpPromptAsync(It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never); + } + + private static CalendarItem CreateRsvpCalendarItem() + { + return new CalendarItem + { + CalendarItemId = 1, + DepartmentId = 999, + Title = "RSVP event", + Start = new DateTime(2026, 7, 18, 12, 0, 0, DateTimeKind.Utc), + SignupType = (int)CalendarItemSignupTypes.RSVP + }; } } @@ -576,6 +642,48 @@ public async Task should_preserve_is_all_day_flag_through_update() [TestFixture] public class when_handling_multi_day_events : with_the_calendar_service { + [Test] + public async Task should_return_events_that_overlap_requested_range() + { + // Arrange + var windowStart = new DateTime(2026, 7, 17, 0, 0, 0, DateTimeKind.Utc); + var windowEnd = windowStart.AddDays(3); + var spansWindow = new CalendarItem + { + CalendarItemId = 1, + DepartmentId = 999, + IsV2Schedule = true, + Start = windowStart.AddDays(-2), + End = windowEnd.AddDays(2) + }; + var endsAtWindowStart = new CalendarItem + { + CalendarItemId = 2, + DepartmentId = 999, + IsV2Schedule = true, + Start = windowStart.AddDays(-1), + End = windowStart + }; + var startsAtWindowEnd = new CalendarItem + { + CalendarItemId = 3, + DepartmentId = 999, + IsV2Schedule = true, + Start = windowEnd, + End = windowEnd.AddHours(1) + }; + _calendarItemRepositoryMock.Setup(x => x.GetAllCalendarItemsByDepartmentIdAsync(999)) + .ReturnsAsync(new List { spansWindow, endsAtWindowStart, startsAtWindowEnd }); + + // Act + var result = await _calendarService.GetAllCalendarItemsForDepartmentInRangeAsync(999, windowStart, windowEnd); + + // Assert + result.Should().Contain(spansWindow); + result.Should().Contain(endsAtWindowStart); + result.Should().NotContain(startsAtWindowEnd); + } + [Test] public void should_detect_multi_day_event() { diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index 6177af128..3b153058b 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -121,20 +121,50 @@ public async Task should_be_able_to_send_call() //_pushServiceMock.Verify(m => m.PushCall(It.IsAny(), Users.TestUser1Id)); } - [Test] - public async Task calendar_notifications_are_sent_by_sms_when_enabled() + [TestCase(true, true)] + [TestCase(null, true)] + [TestCase(false, false)] + public async Task notification_sms_respects_mobile_verification(bool? mobileNumberVerified, bool shouldSend) { + // Arrange var profile = new UserProfile { UserId = TestData.Users.TestUser1Id, - SendNotificationSms = true + SendNotificationSms = true, + MobileNumberVerified = mobileNumberVerified }; + // Act + await _communicationService.SendNotificationAsync(profile.UserId, 1, + "An event is coming up.", "15555550100", new Department(), "Notification", profile); + + // Assert + _smsServiceMock.Verify(m => m.SendNotificationAsync(profile.UserId, 1, + "Notification An event is coming up.", "15555550100", profile), + shouldSend ? Times.Once() : Times.Never()); + } + + [TestCase(true, true)] + [TestCase(null, true)] + [TestCase(false, false)] + public async Task calendar_notification_sms_respects_mobile_verification(bool? mobileNumberVerified, bool shouldSend) + { + // Arrange + var profile = new UserProfile + { + UserId = TestData.Users.TestUser1Id, + SendNotificationSms = true, + MobileNumberVerified = mobileNumberVerified + }; + + // Act await _communicationService.SendCalendarAsync(profile.UserId, 1, "on 7/22 at 18:00. Reply YES or NO.", "15555550100", "New: Drill", profile); + // Assert _smsServiceMock.Verify(m => m.SendNotificationAsync(profile.UserId, 1, - "New: Drill on 7/22 at 18:00. Reply YES or NO.", "15555550100", profile), Times.Once); + "New: Drill on 7/22 at 18:00. Reply YES or NO.", "15555550100", profile), + shouldSend ? Times.Once() : Times.Never()); } } } diff --git a/Tests/Resgrid.Tests/Web/DocumentPageTests.cs b/Tests/Resgrid.Tests/Web/DocumentPageTests.cs new file mode 100644 index 000000000..1f6054d02 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/DocumentPageTests.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Web +{ + [TestFixture] + public class DocumentPageTests + { + [Test] + public void DescriptionEditor_WhenFormIsSubmitted_PostsToBoundDescriptionField() + { + // Arrange + var scriptPath = FindRepositoryFile("Web/Resgrid.Web/wwwroot/js/app/internal/documents/resgrid.documents.newdocument.js"); + + // Act + var script = File.ReadAllText(scriptPath); + + // Assert + script.Should().Contain("$('#Description').val(quill.root.innerHTML)"); + script.Should().NotContain("#Document_Description"); + } + + [Test] + public void ViewDocumentPage_DisplaysMetadataAndPermissionControlledActions() + { + // Arrange + var viewPath = FindRepositoryFile("Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml"); + + // Act + var view = File.ReadAllText(viewPath); + + // Assert + view.Should().Contain("@Model.Document.Name"); + view.Should().Contain("Model.Document.Category"); + view.Should().Contain("@Html.Raw(Model.DescriptionHtml)"); + view.Should().Contain("@Model.UploadedByName"); + view.Should().Contain("@Model.Document.AddedOn.TimeConverterToString(Model.Department)"); + view.Should().Contain("asp-action=\"GetDocument\""); + view.Should().Contain("@if (Model.CanEdit)"); + view.Should().Contain("asp-action=\"EditDocument\""); + view.Should().Contain("@if (Model.CanDelete)"); + view.Should().Contain("asp-action=\"DeleteDocument\""); + view.Should().Contain("@Html.AntiForgeryToken()"); + } + + [Test] + public void DocumentsIndex_OpensDetailsPageAndHasNoTrailingBrace() + { + // Arrange + var viewPath = FindRepositoryFile("Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml"); + + // Act + var view = File.ReadAllText(viewPath); + + // Assert + view.Should().Contain("asp-action=\"ViewDocument\""); + view.TrimEnd().Should().NotEndWith("}"); + } + + private static string FindRepositoryFile(string relativePath) + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + if (directory == null) + throw new InvalidOperationException("Unable to locate the repository root."); + + return Path.Combine(directory.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/UploadFormEncodingTests.cs b/Tests/Resgrid.Tests/Web/UploadFormEncodingTests.cs new file mode 100644 index 000000000..d25b4b3f8 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/UploadFormEncodingTests.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.IO; +using FluentAssertions; +using NUnit.Framework; + +namespace Resgrid.Tests.Web +{ + [TestFixture] + public class UploadFormEncodingTests + { + [TestCaseSource(nameof(UploadViews))] + public void UploadForm_WithFileInput_UsesMultipartEncoding(string relativePath) + { + // Arrange + var viewPath = FindRepositoryFile(relativePath); + + // Act + var view = File.ReadAllText(viewPath); + + // Assert + view.Should().Contain("type=\"file\""); + view.Should().Contain("enctype=\"multipart/form-data\""); + } + + private static IEnumerable UploadViews => new[] + { + "Web/Resgrid.Web/Areas/User/Views/Documents/NewDocument.cshtml", + "Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml", + "Web/Resgrid.Web/Areas/User/Views/Profile/AddCertification.cshtml", + "Web/Resgrid.Web/Areas/User/Views/Profile/EditCertification.cshtml" + }; + + private static string FindRepositoryFile(string relativePath) + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "Resgrid.sln"))) + directory = directory.Parent; + + if (directory == null) + throw new InvalidOperationException("Unable to locate the repository root."); + + return Path.Combine(directory.FullName, relativePath.Replace('/', Path.DirectorySeparatorChar)); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs index 31b0ca643..c24d3bf9c 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CallsController.cs @@ -1223,6 +1223,7 @@ public async Task> EditCall([FromBody] EditCallInpu { var cqi = new CallQueueItem(); cqi.Call = call; + cqi.SetBroadcastDispatches(newUserIds, newGroupIds, newUnitIds, newRoleIds); if (newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId)).Select(x => x.Value).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs index 22dc49675..dc26b2483 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DispatchController.cs @@ -1043,6 +1043,7 @@ public async Task UpdateCall(UpdateCallView model, IFormCollectio { var cqi = new CallQueueItem(); cqi.Call = call; + cqi.SetBroadcastDispatches(newUserIds, newGroupIds, newUnitIds, newRoleIds); if (newGroupIds.Any() || newUnitIds.Any() || newRoleIds.Any()) cqi.Profiles = (await _userProfileService.GetAllProfilesForDepartmentAsync(DepartmentId)).Select(x => x.Value).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs index 1b9a45a01..9062807e4 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DocumentsController.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Mvc.Rendering; using Resgrid.Services; using System.Collections.Generic; +using System.Linq; using NuGet.Packaging; namespace Resgrid.Web.Areas.User.Controllers @@ -41,6 +42,10 @@ public async Task Index(string type, string category) var model = new IndexView(); model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false); model.Documents = await _documentsService.GetFilteredDocumentsByDepartmentIdAsync(DepartmentId, type, category); + + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + model.Documents = model.Documents.Where(x => !x.AdminsOnly).ToList(); + model.Categories = await _documentsService.GetAllCategoriesByDepartmentIdAsync(DepartmentId); model.SelectedCategory = category; @@ -64,6 +69,35 @@ public async Task NewDocument() return View(model); } + [HttpGet] + [Authorize(Policy = ResgridResources.Documents_View)] + public async Task ViewDocument(int documentId) + { + var document = await _documentsService.GetDocumentByIdAsync(documentId); + + if (document == null) + return NotFound(); + + if (document.DepartmentId != DepartmentId) + return Unauthorized(); + + if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + + var canManageDocument = ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || document.UserId == UserId; + var model = new ViewDocumentView + { + Document = document, + Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), + UploadedByName = await UserHelper.GetFullNameForUser(document.UserId), + DescriptionHtml = StringHelpers.SanitizeHtmlInString(document.Description), + CanEdit = canManageDocument && ClaimsAuthorizationHelper.CanUpdateDocument(), + CanDelete = canManageDocument && ClaimsAuthorizationHelper.CanDeleteDocument() + }; + + return View(model); + } + [HttpGet] [Authorize(Policy = ResgridResources.Documents_View)] public async Task GetDepartmentDocumentCategories() @@ -71,6 +105,7 @@ public async Task GetDepartmentDocumentCategories() return Json(await _documentsService.GetDistinctCategoriesByDepartmentIdAsync(DepartmentId)); } + [HttpGet] [Authorize(Policy = ResgridResources.Documents_View)] public async Task GetDocument(int documentId) { @@ -85,9 +120,9 @@ public async Task GetDocument(int documentId) if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); - return new FileContentResult(document.Data, document.Type) + return new FileContentResult(document.Data, String.IsNullOrWhiteSpace(document.Type) ? "application/octet-stream" : document.Type) { - FileDownloadName = document.Filename + FileDownloadName = String.IsNullOrWhiteSpace(document.Filename) ? document.Name : document.Filename }; } @@ -104,6 +139,9 @@ public async Task DeleteDocument(int documentId, CancellationToke if (document.DepartmentId != DepartmentId) return Unauthorized(); + if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) + return Unauthorized(); + if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && document.UserId != UserId) return Unauthorized(); @@ -154,7 +192,7 @@ public async Task NewDocument(NewDocumentView model, IFormFile fi doc.UserId = UserId; doc.AddedOn = DateTime.UtcNow; doc.Name = model.Name; - doc.Description = model.Description; + doc.Description = StringHelpers.SanitizeHtmlInString(model.Description); if (!String.IsNullOrWhiteSpace(model.Category) && model.Category != "None") doc.Category = model.Category; @@ -166,8 +204,8 @@ public async Task NewDocument(NewDocumentView model, IFormFile fi else doc.AdminsOnly = false; - doc.Type = fileToUpload.ContentType; - doc.Filename = fileToUpload.FileName; + doc.Type = String.IsNullOrWhiteSpace(fileToUpload.ContentType) ? "application/octet-stream" : fileToUpload.ContentType; + doc.Filename = FileHelper.GetSafeFileName(fileToUpload.FileName); var auditEvent = new AuditEvent(); auditEvent.DepartmentId = DepartmentId; @@ -180,18 +218,20 @@ public async Task NewDocument(NewDocumentView model, IFormFile fi auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; _eventAggregator.SendMessage(auditEvent); - byte[] uploadedFile = new byte[fileToUpload.OpenReadStream().Length]; - await fileToUpload.OpenReadStream().ReadAsync(uploadedFile, 0, uploadedFile.Length); - - doc.Data = uploadedFile; + using (var uploadStream = fileToUpload.OpenReadStream()) + { + doc.Data = await FileHelper.ReadAllBytesAsync(uploadStream, cancellationToken); + } - await _documentsService.SaveDocumentAsync(doc, cancellationToken); + doc = await _documentsService.SaveDocumentAsync(doc, cancellationToken); _eventAggregator.SendMessage(new DocumentAddedEvent() { DepartmentId = DepartmentId, Document = doc }); - return RedirectToAction("Index", "Documents", new { Area = "User" }); + return RedirectToAction("ViewDocument", "Documents", new { Area = "User", documentId = doc.DocumentId }); } + model.Categories = new SelectList(await _documentsService.GetAllCategoriesByDepartmentIdAsync(DepartmentId), "Name", "Name"); + model.Description = StringHelpers.SanitizeHtmlInString(model.Description); return View(model); } @@ -212,7 +252,7 @@ public async Task EditDocument(int documentId) if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); - if (!ClaimsAuthorizationHelper.CanCreateDocument()) + if (!ClaimsAuthorizationHelper.CanUpdateDocument()) return Unauthorized(); if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && document.UserId != UserId) @@ -224,7 +264,7 @@ public async Task EditDocument(int documentId) model.Categories = new SelectList(noteCategories, "Name", "Name"); model.Name = document.Name; - model.Description = document.Description; + model.Description = StringHelpers.SanitizeHtmlInString(document.Description); model.Category = document.Category; model.AdminOnly = document.AdminsOnly.ToString(); model.Document = document; @@ -249,7 +289,7 @@ public async Task EditDocument(EditDocumentView model, IFormFile if (document.AdminsOnly && !ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) return Unauthorized(); - if (!ClaimsAuthorizationHelper.CanCreateDocument()) + if (!ClaimsAuthorizationHelper.CanUpdateDocument()) return Unauthorized(); if (!ClaimsAuthorizationHelper.IsUserDepartmentAdmin() && document.UserId != UserId) @@ -275,7 +315,7 @@ public async Task EditDocument(EditDocumentView model, IFormFile auditEvent.Before = $"{document.DocumentId} - {document.Name} - {document.Description} - {document.Category} - {document.AdminsOnly}"; document.Name = model.Name; - document.Description = model.Description; + document.Description = StringHelpers.SanitizeHtmlInString(model.Description); if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin()) document.AdminsOnly = bool.Parse(model.AdminOnly); @@ -289,13 +329,13 @@ public async Task EditDocument(EditDocumentView model, IFormFile if (fileToUpload != null && fileToUpload.Length > 0) { - document.Type = fileToUpload.ContentType; - document.Filename = fileToUpload.FileName; - - byte[] uploadedFile = new byte[fileToUpload.OpenReadStream().Length]; - await fileToUpload.OpenReadStream().ReadAsync(uploadedFile, 0, uploadedFile.Length); + document.Type = String.IsNullOrWhiteSpace(fileToUpload.ContentType) ? "application/octet-stream" : fileToUpload.ContentType; + document.Filename = FileHelper.GetSafeFileName(fileToUpload.FileName); - document.Data = uploadedFile; + using (var uploadStream = fileToUpload.OpenReadStream()) + { + document.Data = await FileHelper.ReadAllBytesAsync(uploadStream, cancellationToken); + } } auditEvent.DepartmentId = DepartmentId; @@ -310,13 +350,16 @@ public async Task EditDocument(EditDocumentView model, IFormFile await _documentsService.SaveDocumentAsync(document, cancellationToken); - return RedirectToAction("Index", "Documents", new { Area = "User" }); + return RedirectToAction("ViewDocument", "Documents", new { Area = "User", documentId = document.DocumentId }); } List noteCategories = new List(); noteCategories.Add(new DocumentCategory { Name = "None" }); noteCategories.AddRange(await _documentsService.GetAllCategoriesByDepartmentIdAsync(DepartmentId)); model.Categories = new SelectList(noteCategories, "Name", "Name"); + model.Document = document; + model.UserId = UserId; + model.Description = StringHelpers.SanitizeHtmlInString(model.Description); return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index eb88ab816..a1cc2d649 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -766,8 +766,9 @@ public async Task AddCertification(string userId) } [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Profile_View)] - public async Task AddCertification(AddCertificationView model, IFormFile fileToUpload) + public async Task AddCertification(AddCertificationView model, IFormFile fileToUpload, CancellationToken cancellationToken) { if (fileToUpload != null && fileToUpload.Length > 0) { @@ -800,16 +801,16 @@ public async Task AddCertification(AddCertificationView model, IF if (fileToUpload != null && fileToUpload.Length > 0) { - cert.Filetype = fileToUpload.ContentType; - cert.Filename = fileToUpload.FileName; - - byte[] uploadedFile = new byte[fileToUpload.OpenReadStream().Length]; - fileToUpload.OpenReadStream().Read(uploadedFile, 0, uploadedFile.Length); + cert.Filetype = String.IsNullOrWhiteSpace(fileToUpload.ContentType) ? "application/octet-stream" : fileToUpload.ContentType; + cert.Filename = FileHelper.GetSafeFileName(fileToUpload.FileName); - cert.Data = uploadedFile; + using (var uploadStream = fileToUpload.OpenReadStream()) + { + cert.Data = await FileHelper.ReadAllBytesAsync(uploadStream, cancellationToken); + } } - await _certificationService.SaveCertificationAsync(cert); + await _certificationService.SaveCertificationAsync(cert, cancellationToken); if (model.UserId == UserId) return RedirectToAction("Certifications", "Profile", new { area = "User" }); @@ -840,6 +841,7 @@ public async Task DeleteCertification(int certId, CancellationTok } [HttpPost] + [ValidateAntiForgeryToken] [Authorize(Policy = ResgridResources.Profile_Update)] public async Task EditCertification(EditCertificationView model, IFormFile fileToUpload, CancellationToken cancellationToken) { @@ -874,13 +876,13 @@ public async Task EditCertification(EditCertificationView model, if (fileToUpload != null && fileToUpload.Length > 0) { - cert.Filetype = fileToUpload.ContentType; - cert.Filename = fileToUpload.FileName; - - byte[] uploadedFile = new byte[fileToUpload.OpenReadStream().Length]; - fileToUpload.OpenReadStream().Read(uploadedFile, 0, uploadedFile.Length); + cert.Filetype = String.IsNullOrWhiteSpace(fileToUpload.ContentType) ? "application/octet-stream" : fileToUpload.ContentType; + cert.Filename = FileHelper.GetSafeFileName(fileToUpload.FileName); - cert.Data = uploadedFile; + using (var uploadStream = fileToUpload.OpenReadStream()) + { + cert.Data = await FileHelper.ReadAllBytesAsync(uploadStream, cancellationToken); + } } await _certificationService.SaveCertificationAsync(cert, cancellationToken); @@ -891,6 +893,8 @@ public async Task EditCertification(EditCertificationView model, return RedirectToAction("Certifications", "Profile", new { area = "User", userId = cert.UserId }); } + var certificationTypes = await _certificationService.GetAllCertificationTypesByDepartmentAsync(DepartmentId); + model.CertificationTypes = new SelectList(certificationTypes, "Type", "Type"); return View(model); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs b/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs new file mode 100644 index 000000000..3b3849c85 --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Models/Documents/ViewDocumentView.cs @@ -0,0 +1,14 @@ +using Resgrid.Model; + +namespace Resgrid.Web.Areas.User.Models.Documents +{ + public class ViewDocumentView + { + public Document Document { get; set; } + public Department Department { get; set; } + public string UploadedByName { get; set; } + public string DescriptionHtml { get; set; } + public bool CanEdit { get; set; } + public bool CanDelete { get; set; } + } +} diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml index f47399931..16e881833 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/EditDocument.cshtml @@ -27,11 +27,7 @@
- @localizer["ViewDocumentHeader"] - @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || @Model.Document.UserId == Model.UserId) - { - @localizer["DeleteDocumentButton"] - } + @localizer["ViewDocumentHeader"]
@@ -99,7 +95,7 @@ diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml index ec07d5219..54a511b41 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/Index.cshtml @@ -57,51 +57,61 @@
-
-
- @foreach (var doc in Model.Documents) +
+
+
@commonLocalizer["DocumentsModule"]
+
+
+ @if (Model.Documents != null && Model.Documents.Count > 0) { -
-
- - -
- -
-
-
- @if (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId) +
+ + + + + + + + + + + @foreach (var doc in Model.Documents) { - @doc.Name + + + + + + } - else - { - @doc.Name - } -
- @commonLocalizer["Added"]: @doc.AddedOn.TimeConverterToString(Model.Department) - - @if (ClaimsAuthorizationHelper.CanDeleteDocument() && (ClaimsAuthorizationHelper.IsUserDepartmentAdmin() || doc.UserId == Model.UserId)) - { - - @Html.AntiForgeryToken() - - - - } - + +
@commonLocalizer["Name"]@localizer["Category"]@commonLocalizer["UploadedOn"]@commonLocalizer["Actions"]
+ + + @doc.Name + + @if (doc.AdminsOnly) + { + @localizer["AdminsOnlyLabel"] + } + @(String.IsNullOrWhiteSpace(doc.Category) ? commonLocalizer["None"] : doc.Category)@doc.AddedOn.TimeConverterToString(Model.Department) + + @commonLocalizer["View"] + + + @commonLocalizer["Download"] + +
+
+ } + else + { +
+ No documents match the selected filters.
} - - -
-} diff --git a/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml b/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml new file mode 100644 index 000000000..c7b2efcee --- /dev/null +++ b/Web/Resgrid.Web/Areas/User/Views/Documents/ViewDocument.cshtml @@ -0,0 +1,110 @@ +@using Resgrid.Web.Helpers +@model Resgrid.Web.Areas.User.Models.Documents.ViewDocumentView +@inject IStringLocalizer localizer +@{ + ViewBag.Title = "Resgrid | " + localizer["ViewDocumentHeader"]; +} + +
+
+

@localizer["ViewDocumentHeader"]

+ +
+
+
+ + @commonLocalizer["Download"] + + @if (Model.CanEdit) + { + + @localizer["EditDocumentHeader"] + + } + @if (Model.CanDelete) + { +
+ @Html.AntiForgeryToken() + + +
+ } +
+
+
+ +
+
+
+
+
+
@Model.Document.Name
+
+
+
+
+
+
@commonLocalizer["Name"]
+
@Model.Document.Name
+
@localizer["Category"]
+
@(String.IsNullOrWhiteSpace(Model.Document.Category) ? commonLocalizer["None"] : Model.Document.Category)
+
+
+
+
+
@commonLocalizer["UploadedBy"]
+
@Model.UploadedByName
+
@commonLocalizer["UploadedOn"]
+
@Model.Document.AddedOn.TimeConverterToString(Model.Department)
+
+
+
+ +
+ +
+
+

@commonLocalizer["Description"]

+
+ @if (String.IsNullOrWhiteSpace(Model.DescriptionHtml)) + { + @commonLocalizer["None"] + } + else + { + @Html.Raw(Model.DescriptionHtml) + } +
+
+
+ +
+
+
+ + @(String.IsNullOrWhiteSpace(Model.Document.Filename) ? Model.Document.Name : Model.Document.Filename) + + @commonLocalizer["Download"] + +
+
+
+
+
+
+
+
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/AddCertification.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/AddCertification.cshtml index 00e03f8ba..9c4847ae6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/AddCertification.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/AddCertification.cshtml @@ -38,7 +38,7 @@
-
+ @Html.AntiForgeryToken() @Html.HiddenFor(m => m.UserId) @if (!String.IsNullOrEmpty(Model.Message)) diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/EditCertification.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/EditCertification.cshtml index 1688db1da..e22843388 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/EditCertification.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/EditCertification.cshtml @@ -34,7 +34,7 @@
- + @Html.AntiForgeryToken() @Html.HiddenFor(m => m.CertificationId) @if (!String.IsNullOrEmpty(Model.Message)) diff --git a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs index 88f4302bb..2544e4068 100644 --- a/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs +++ b/Web/Resgrid.Web/Helpers/ClaimsAuthorizationHelper.cs @@ -149,6 +149,11 @@ public static bool CanCreateDocument() return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Documents, ResgridClaimTypes.Actions.Create); } + public static bool CanUpdateDocument() + { + return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Documents, ResgridClaimTypes.Actions.Update); + } + public static bool CanDeleteDocument() { return GetClaimsPrincipal().HasClaim(ResgridClaimTypes.Resources.Documents, ResgridClaimTypes.Actions.Delete); diff --git a/Web/Resgrid.Web/wwwroot/js/app/internal/documents/resgrid.documents.newdocument.js b/Web/Resgrid.Web/wwwroot/js/app/internal/documents/resgrid.documents.newdocument.js index 0e1ae08a0..ea074ec26 100644 --- a/Web/Resgrid.Web/wwwroot/js/app/internal/documents/resgrid.documents.newdocument.js +++ b/Web/Resgrid.Web/wwwroot/js/app/internal/documents/resgrid.documents.newdocument.js @@ -12,7 +12,7 @@ var resgrid; }); $(document).on('submit', '#newDocumentForm', function () { - $('#Document_Description').val(quill.root.innerHTML); + $('#Description').val(quill.root.innerHTML); return true; }); diff --git a/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs index e9a5994e6..363fe30c5 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CalendarNotifierLogic.cs @@ -142,10 +142,10 @@ public async Task> Process(CalendarNotifierQueueItem item) private async Task SendCalendarReminderAsync(CalendarItem calendarItem, string userId, string message, string departmentNumber, Department department, string title, UserProfile profile) { - await _communicationService.SendNotificationAsync(userId, calendarItem.DepartmentId, message, + var sent = await _communicationService.SendNotificationAsync(userId, calendarItem.DepartmentId, message, departmentNumber, department, title, profile); - if (calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) + if (sent && calendarItem.SignupType == (int)CalendarItemSignupTypes.RSVP) { try { diff --git a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs index 492d8b587..4bf35541b 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/CallBroadcast.cs @@ -33,6 +33,7 @@ public static async Task ProcessCallQueueItem(CallQueueItem cqi) _communicationService = Bootstrapper.GetKernel().Resolve(); _callsService = Bootstrapper.GetKernel().Resolve(); _departmentSettingsService = Bootstrapper.GetKernel().Resolve(); + cqi?.ApplyBroadcastDispatchFilter(); if (cqi != null && cqi.Call != null && cqi.Call.HasAnyDispatches()) { diff --git a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs index fb1b757bc..9efa5818a 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/NotificationBroadcastLogic.cs @@ -65,11 +65,18 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri || notification.Type == EventTypes.CalendarEventUpcoming || notification.Type == EventTypes.CalendarEventUpdated) { - var calendarItem = await _calendarService.GetCalendarItemByIdAsync(notification.ItemId); - if (calendarItem?.SignupType == (int)CalendarItemSignupTypes.RSVP) + try { - rsvpItem = calendarItem; - text += " Reply YES or NO."; + var calendarItem = await _calendarService.GetCalendarItemByIdAsync(notification.ItemId); + if (calendarItem?.SignupType == (int)CalendarItemSignupTypes.RSVP) + { + rsvpItem = calendarItem; + text += " Reply YES or NO."; + } + } + catch (Exception ex) + { + Logging.LogException(ex); } } @@ -84,8 +91,8 @@ public static async Task ProcessNotificationItem(NotificationItem ni, stri if (!_notificationService.AllowToSendViaSms(notification.Type)) profile.SendNotificationSms = false; - await _communicationService.SendNotificationAsync(user, notification.DepartmentId, text, queueItem.DepartmentTextNumber, queueItem.Department, "Notification", profile); - if (rsvpItem != null) + var sent = await _communicationService.SendNotificationAsync(user, notification.DepartmentId, text, queueItem.DepartmentTextNumber, queueItem.Department, "Notification", profile); + if (sent && rsvpItem != null) { try {