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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
9 changes: 7 additions & 2 deletions Core/Resgrid.Chatbot/Handlers/CallDetailActionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,12 @@ public class CallDetailActionHandler : IChatbotActionHandler
private readonly ICallsService _callsService;
private readonly IDepartmentsService _departmentsService;
private readonly IAuthorizationService _authorizationService;
private readonly IDispatchScopeService _dispatchScopeService;

public CallDetailActionHandler(ICallsService callsService, IDepartmentsService departmentsService, IAuthorizationService authorizationService)
public CallDetailActionHandler(ICallsService callsService, IDepartmentsService departmentsService, IAuthorizationService authorizationService,
IDispatchScopeService dispatchScopeService)
{
_dispatchScopeService = dispatchScopeService;
_callsService = callsService;
_departmentsService = departmentsService;
_authorizationService = authorizationService;
Expand All @@ -42,7 +45,9 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
return new ChatbotResponse { Text = ChatbotResources.Get("CallDetail_Specify", culture), Processed = false };
}

var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference);
// Scoped so shorthand matches the user's own area rather than a call they'd be refused below.
var call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference,
_dispatchScopeService, session.UserId);
if (call == null)
{
return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true };
Expand Down
13 changes: 10 additions & 3 deletions Core/Resgrid.Chatbot/Handlers/CallDispatchedActionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
Expand All @@ -22,12 +23,15 @@ public class CallDispatchedActionHandler : IChatbotActionHandler
private readonly ICallsService _callsService;
private readonly IUserProfileService _userProfileService;
private readonly IAuthorizationService _authorizationService;
private readonly IDispatchScopeService _dispatchScopeService;

public CallDispatchedActionHandler(
ICallsService callsService,
IUserProfileService userProfileService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IDispatchScopeService dispatchScopeService)
{
_dispatchScopeService = dispatchScopeService;
_callsService = callsService;
_userProfileService = userProfileService;
_authorizationService = authorizationService;
Expand All @@ -47,13 +51,16 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
Call call;
if (!string.IsNullOrWhiteSpace(reference))
{
call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference);
call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference,
_dispatchScopeService, session.UserId);
if (call == null)
return new ChatbotResponse { Text = ChatbotResources.Get("Call_NoMatch", culture, reference), Processed = true };
}
else
{
var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId);
// "The" call means the only one in the user's area, not the only one in the department.
var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(session.DepartmentId, session.UserId,
await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId) ?? new List<Call>());
if (activeCalls?.Count == 1)
call = activeCalls[0];
else
Expand Down
19 changes: 13 additions & 6 deletions Core/Resgrid.Chatbot/Handlers/CallRespondersActionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,18 @@ private enum ResponderBucket
private readonly ICustomStateService _customStateService;
private readonly IUserProfileService _userProfileService;
private readonly IAuthorizationService _authorizationService;
private readonly IDispatchScopeService _dispatchScopeService;

public CallRespondersActionHandler(
ICallsService callsService,
IActionLogsService actionLogsService,
IUnitsService unitsService,
ICustomStateService customStateService,
IUserProfileService userProfileService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IDispatchScopeService dispatchScopeService)
{
_dispatchScopeService = dispatchScopeService;
_callsService = callsService;
_actionLogsService = actionLogsService;
_unitsService = unitsService;
Expand All @@ -66,7 +69,7 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
intent.Parameters.TryGetValue("mode", out var modeValue);
var mode = ParseResponderMode(modeValue);

var call = await ResolveCallAsync(intent, session.DepartmentId);
var call = await ResolveCallAsync(intent, session);
if (call == null)
return new ChatbotResponse { Text = ChatbotResources.Get("CallResp_Specify", culture), Processed = false };

Expand Down Expand Up @@ -166,8 +169,10 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
}
}

private async Task<Call> ResolveCallAsync(ChatbotIntent intent, int departmentId)
private async Task<Call> ResolveCallAsync(ChatbotIntent intent, ChatbotSession session)
{
var departmentId = session.DepartmentId;

intent.Parameters.TryGetValue("callRef", out var reference);
if (string.IsNullOrWhiteSpace(reference))
intent.Parameters.TryGetValue("callId", out reference);
Expand All @@ -180,10 +185,12 @@ private async Task<Call> ResolveCallAsync(ChatbotIntent intent, int departmentId
cleaned = null;

if (!string.IsNullOrWhiteSpace(cleaned))
return await Services.CallReferenceResolver.ResolveAsync(_callsService, departmentId, cleaned);
return await Services.CallReferenceResolver.ResolveAsync(_callsService, departmentId, cleaned,
_dispatchScopeService, session.UserId);

// No reference: when exactly one call is active it is unambiguous.
var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(departmentId);
// No reference: when exactly one call is active in the user's area it is unambiguous.
var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(departmentId, session.UserId,
await _callsService.GetActiveCallsByDepartmentAsync(departmentId) ?? new List<Call>());
return activeCalls?.Count == 1 ? activeCalls[0] : null;
}

Expand Down
10 changes: 8 additions & 2 deletions Core/Resgrid.Chatbot/Handlers/CallsActionHandler.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
Expand All @@ -18,14 +19,17 @@ public class CallsActionHandler : IChatbotActionHandler
private readonly ICustomStateService _customStateService;
private readonly IUserProfileService _userProfileService;
private readonly IAuthorizationService _authorizationService;
private readonly IDispatchScopeService _dispatchScopeService;

public CallsActionHandler(
ICallsService callsService,
IDepartmentsService departmentsService,
ICustomStateService customStateService,
IUserProfileService userProfileService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IDispatchScopeService dispatchScopeService)
{
_dispatchScopeService = dispatchScopeService;
_callsService = callsService;
_departmentsService = departmentsService;
_customStateService = customStateService;
Expand All @@ -48,7 +52,9 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn

var department = await _departmentsService.GetDepartmentByIdAsync(session.DepartmentId);
var departmentName = department?.Name ?? ChatbotResources.Get("Common_YourDepartment", culture);
var activeCalls = await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId);
// Group-scoped dispatch (off by default): list only the calls in the user's area or that they're on.
var activeCalls = await _dispatchScopeService.FilterCallsForUserAsync(session.DepartmentId, session.UserId,
await _callsService.GetActiveCallsByDepartmentAsync(session.DepartmentId) ?? new List<Resgrid.Model.Call>());

// The department boundary is the per-row rule (the unread-messages list applies the same one).
var callList = (activeCalls ?? Enumerable.Empty<Resgrid.Model.Call>())
Expand Down
9 changes: 7 additions & 2 deletions Core/Resgrid.Chatbot/Handlers/RespondToCallHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,15 @@ public class RespondToCallHandler : IChatbotActionHandler
private readonly ICustomStateService _customStateService;
private readonly IDepartmentGroupsService _departmentGroupsService;
private readonly IPersonnelRolesService _personnelRolesService;
private readonly IDispatchScopeService _dispatchScopeService;

public RespondToCallHandler(ICallsService callsService, IActionLogsService actionLogsService,
public RespondToCallHandler(ICallsService callsService, IActionLogsService actionLogsService, IDispatchScopeService dispatchScopeService,
ICustomStateService customStateService = null, IDepartmentGroupsService departmentGroupsService = null,
IPersonnelRolesService personnelRolesService = null)
{
_callsService = callsService;
_actionLogsService = actionLogsService;
_dispatchScopeService = dispatchScopeService;
_customStateService = customStateService;
_departmentGroupsService = departmentGroupsService;
_personnelRolesService = personnelRolesService;
Expand All @@ -55,7 +57,10 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
call = await ResolveMostRecentDispatchAsync(session.UserId, session.DepartmentId);
else
{
call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference);
// Group-scoped dispatch (off by default): only a call in the user's area, or one they're on, resolves.
// The no-reference path above only ever picks a call the user was dispatched to, which is always in scope.
call = await Services.CallReferenceResolver.ResolveAsync(_callsService, session.DepartmentId, reference,
_dispatchScopeService, session.UserId);
}
if (call == null)
{
Expand Down
38 changes: 36 additions & 2 deletions Core/Resgrid.Chatbot/Services/CallReferenceResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,36 @@ public static class CallReferenceResolver
{
private static readonly Regex CallNumberRegex = new Regex(@"^\d{2,4}-\d+$", RegexOptions.Compiled, TimeSpan.FromMilliseconds(200));

public static async Task<Call> ResolveAsync(ICallsService callsService, int departmentId, string reference)
public static Task<Call> ResolveAsync(ICallsService callsService, int departmentId, string reference)
{
return ResolveAsync(callsService, departmentId, reference, null, null);
}

/// <summary>
/// As above, limited to the user's dispatch scope (group-scoped dispatch, off by default): a call
/// outside it resolves to null like a foreign one, and shorthand ("fire") matches only among the
/// in-scope active calls, so an area supervisor gets their own area's fire rather than a more
/// recent one elsewhere. A null <paramref name="dispatchScopeService"/> means unscoped.
/// </summary>
public static async Task<Call> ResolveAsync(ICallsService callsService, int departmentId, string reference,
IDispatchScopeService dispatchScopeService, string userId)
{
if (string.IsNullOrWhiteSpace(reference))
return null;

DispatchScope scope = null;
if (dispatchScopeService != null)
{
scope = await dispatchScopeService.GetScopeForUserAsync(departmentId, userId);

// Never null by contract; if it ever is, resolve nothing rather than everything.
if (scope == null)
return null;

if (scope.IsDepartmentWide)
scope = null;
}

// Trailing punctuation is never part of a call reference ("omw to 26-1.", "respond to fire?").
var text = reference.Trim().TrimEnd('?', '!', '.', ',');
if (text.Length == 0)
Expand All @@ -37,10 +62,19 @@ public static async Task<Call> ResolveAsync(ICallsService callsService, int depa
if (int.TryParse(text, out var callId))
{
var call = await callsService.GetCallByIdAsync(callId);
return (call != null && call.DepartmentId == departmentId) ? call : null;
if (call == null || call.DepartmentId != departmentId)
return null;

if (scope != null && !await dispatchScopeService.IsCallInScopeAsync(scope, call))
return null;

return call;
}

var activeCalls = await callsService.GetActiveCallsByDepartmentAsync(departmentId);
if (scope != null && activeCalls != null)
activeCalls = await dispatchScopeService.FilterCallsAsync(scope, activeCalls);

if (activeCalls == null || activeCalls.Count == 0)
return null;

Expand Down
8 changes: 6 additions & 2 deletions Core/Resgrid.Chatbot/Services/IncidentContextResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,16 @@ public class IncidentContextResolver : IIncidentContextResolver
private readonly IIncidentCommandService _incidentCommandService;
private readonly IIncidentResourcesService _incidentResourcesService;
private readonly IAuthorizationService _authorizationService;
private readonly IDispatchScopeService _dispatchScopeService;

public IncidentContextResolver(
ICallsService callsService,
IIncidentCommandService incidentCommandService,
IIncidentResourcesService incidentResourcesService,
IAuthorizationService authorizationService)
IAuthorizationService authorizationService,
IDispatchScopeService dispatchScopeService)
{
_dispatchScopeService = dispatchScopeService;
_callsService = callsService;
_incidentCommandService = incidentCommandService;
_incidentResourcesService = incidentResourcesService;
Expand All @@ -50,7 +53,8 @@ public async Task<IncidentContext> ResolveAsync(ChatbotIntent intent, ChatbotSes
var reference = GetParameter(intent, "callRef") ?? GetParameter(intent, "callId");
if (!string.IsNullOrWhiteSpace(reference))
{
var referenced = await CallReferenceResolver.ResolveAsync(_callsService, departmentId, reference);
var referenced = await CallReferenceResolver.ResolveAsync(_callsService, departmentId, reference,
_dispatchScopeService, session.UserId);
if (referenced == null)
return context;

Expand Down
43 changes: 40 additions & 3 deletions Core/Resgrid.Config/DataProtectionConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,47 @@ public static class DataProtectionConfig
/// separated (RMS plan section 5.9.4). Each purpose is an egress the department acknowledged in the
/// application before the caller reaches the broker: neris-submission (worker 41), records-export
/// (worker 45 / Workflow renders) and invoicing (invoice delivery, pay links and deployment finance: the
/// Workforce &amp; Business Operations plan's document renders and the DTR void-reason append). Empty
/// disables the lane; callers fail closed with workload_purpose_denied.
/// Workforce &amp; Business Operations plan's document renders and the DTR void-reason append) and
/// protected-workflow (an approved Protected Workflow release sending its allow-listed fields to its pinned
/// destination). Empty disables the lane; callers fail closed with workload_purpose_denied.
/// </summary>
public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting";
public static string BrokerWorkloadPurposes = "neris-submission,records-export,invoicing,workforce-costing,pay-data-reporting,protected-workflow";

/// <summary>
/// Days an approved Protected Workflow release stays Active before it expires and must be renewed with a
/// fresh step-up and re-attestation. Expiry is checked at run time and by the daily sweep (worker 71).
/// </summary>
public static int ProtectedWorkflowReleaseLifetimeDays = 365;

/// <summary>Hard HTTP timeout, in seconds, for a protected workflow step's request (and its OAuth2 token request).</summary>
public static int ProtectedWorkflowHttpTimeoutSeconds = 30;

/// <summary>Ceiling on the number of catalog fields one Protected Workflow release may allow-list.</summary>
public static int ProtectedWorkflowMaxFieldsPerRelease = 16;

/// <summary>
/// How recent, in minutes, the approving administrator's step-up MFA must be for a release request, approval,
/// renewal or the department toggle. Older proofs are refused with step_up_required.
/// </summary>
public static int ProtectedWorkflowStepUpFreshnessMinutes = 10;

/// <summary>
/// Whether a protected step may authenticate with an HttpBasic credential. Off by default: Bearer, API key and
/// OAuth2 client credentials are allowed; Basic sends a long-lived password on every request.
/// </summary>
public static bool ProtectedWorkflowAllowHttpBasicCredentials = false;

/// <summary>Days before ExpiresOn at which department administrators are emailed an expiry notice, comma separated.</summary>
public static string ProtectedWorkflowExpiryNoticeDays = "30,7";

/// <summary>Largest response body a protected step reads for its success rule or capture (larger is failed_response_too_large).</summary>
public static int ProtectedWorkflowMaxResponseBytes = 1048576;

/// <summary>Most ResponseCapture entries one protected step may declare.</summary>
public static int ProtectedWorkflowMaxCaptureKeys = 5;

/// <summary>Days a rotated private_key_jwt signing key stays published in the credential's JWKS.</summary>
public static int WorkflowJwksOverlapDays = 7;

/// <summary>True on the broker host to run the ADP migration coordinator sweep there (the only
/// host with a real KMS adapter). Workers.Console keeps its sweep for liveness/offboarding
Expand Down
2 changes: 1 addition & 1 deletion Core/Resgrid.Localization/Account/Login.ar.resx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@
<value>إذا لم يسجّل قسمك في Resgrid بعد، يمكنك إنشاء حساب جديد سيؤدي إلى إنشاء القسم تلقائيًا.</value>
</data>
<data name="LoginMessage4" xml:space="preserve">
<value>تُجرى الصيانة أسبوعيًا كل سبت ابتداءً من الساعة 20:00 بتوقيت المحيط الهادئ.</value>
<value>تُجرى الصيانة المجدولة ضمن نافذة أسبوعية ثابتة، أيام الثلاثاء من الساعة 10 مساءً حتى منتصف الليل بتوقيت المحيط الهادئ، ويُعلَن عنها مسبقًا على صفحة حالة اتفاقية مستوى الخدمة (SLA). أما نوافذ الصيانة الممتدة العرضية (حتى 4 ساعات، وبحد أقصى مرة واحدة كل ربع سنة) فيُعلَن عنها قبل 5 أيام عمل على الأقل.</value>
</data>
<data name="LogOnHeader" xml:space="preserve">
<value>تسجيل الدخول</value>
Expand Down
2 changes: 1 addition & 1 deletion Core/Resgrid.Localization/Account/Login.de.resx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@
<value>If your department has not yet signed up for Resgrid you can create a new account which will create the department for you.</value>
</data>
<data name="LoginMessage4" xml:space="preserve">
<value>Maintenance is performed weekly on Saturday starting at 2000 hours Pacific.</value>
<value>Scheduled maintenance runs in a standing weekly window, Tuesdays 10 p.m.–midnight Pacific, and is announced in advance on the SLA status page. Occasional extended windows (up to 4 hours, no more than once per quarter) are announced at least 5 business days ahead.</value>
</data>
<data name="LogOnHeader" xml:space="preserve">
<value>Anmelden</value>
Expand Down
2 changes: 1 addition & 1 deletion Core/Resgrid.Localization/Account/Login.el.resx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@
<value>Αν το τμήμα σας δεν έχει εγγραφεί ακόμη στο Resgrid, μπορείτε να δημιουργήσετε νέο λογαριασμό, ο οποίος θα δημιουργήσει το τμήμα για εσάς.</value>
</data>
<data name="LoginMessage4" xml:space="preserve">
<value>Η συντήρηση πραγματοποιείται εβδομαδιαία, το Σάββατο, με έναρξη στις 2000 ώρα Ειρηνικού.</value>
<value>Η προγραμματισμένη συντήρηση πραγματοποιείται σε σταθερό εβδομαδιαίο παράθυρο, κάθε Τρίτη 10 μ.μ.–μεσάνυχτα ώρα Ειρηνικού, και ανακοινώνεται εκ των προτέρων στη σελίδα κατάστασης SLA. Περιστασιακά εκτεταμένα παράθυρα (έως 4 ώρες, όχι συχνότερα από μία φορά ανά τρίμηνο) ανακοινώνονται τουλάχιστον 5 εργάσιμες ημέρες νωρίτερα.</value>
</data>
<data name="LogOnHeader" xml:space="preserve">
<value>Σύνδεση</value>
Expand Down
Loading
Loading