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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions Core/Resgrid.Model/Repositories/IPersonnelRolesRepository.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;

namespace Resgrid.Model.Repositories
Expand Down Expand Up @@ -39,5 +40,16 @@ public interface IPersonnelRolesRepository: IRepository<PersonnelRole>
/// <param name="personnelRoleId">The personnel role identifier.</param>
/// <returns>Task&lt;PersonnelRole&gt;.</returns>
Task<PersonnelRole> GetRoleByRoleIdAsync(int personnelRoleId);

/// <summary>
/// Removes every row in other tables that points at a personnel role, so the role row itself can
/// be deleted without tripping a foreign key (CallDispatchRoles has a non-cascading FK on RoleId).
/// Rows that merely reference the role as an optional qualification (UnitRoles) are nulled out
/// instead of deleted.
/// </summary>
/// <param name="personnelRoleId">The personnel role identifier.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task&lt;bool&gt;.</returns>
Task<bool> DeleteRoleDependenciesAsync(int personnelRoleId, CancellationToken cancellationToken = default(CancellationToken));
}
}
165 changes: 132 additions & 33 deletions Core/Resgrid.Services/CallEmailTemplates/ResgridEmailTemplate.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics.Eventing.Reader;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
Expand All @@ -27,49 +26,47 @@ public async Task<Call> GenerateCall(CallEmail email, string managingUser, List<
Call c = new Call();
c.Notes = email.Body;

// Seed the department default up front so every exit path (including the
// fallback below) leaves a priority the department actually owns on the call.
c.Priority = priority;

string[] data = email.Body.Split(char.Parse("|"));

if (data.Any() && data.Length >= 5)
// ADDRESS and NATURE are the two required values and they sit at index 3 and 5,
// so a body needs at least 6 segments to be a Resgrid format message. NOTES
// (index 6) is optional and anything past it is treated as part of the notes.
if (data.Length >= 6)
{
if (!string.IsNullOrEmpty(data[0]))
c.IncidentNumber = data[0].Trim();

if (!string.IsNullOrEmpty(data[1]))
c.Type = data[1].Trim();

if (string.IsNullOrEmpty(data[2]))
{
int prio;
if (int.TryParse(data[2], out prio))
{
c.Priority = prio;
}
else
{
c.Priority = priority;
}
}
else
c.IncidentNumber = GetValue(data, 0);
c.Type = ParseCallType(GetValue(data, 1), callTypes);
c.Priority = ParseCallPriority(GetValue(data, 2), priority, activePriorities);
c.MapPage = GetValue(data, 4);
c.NatureOfCall = GetValue(data, 5);

// Re-join everything from index 6 on, a pipe inside the notes text shouldn't
// truncate them. When NOTES isn't supplied the raw body stays in Notes, which
// is the behavior imports have always had.
if (data.Length > 6)
{
c.Priority = priority;
}
var notes = String.Join("|", data.Skip(6)).Trim();

if (!string.IsNullOrEmpty(data[4]))
c.MapPage = data[4];
if (!String.IsNullOrWhiteSpace(notes))
c.Notes = notes;
}

c.NatureOfCall = data[5];
var address = GetValue(data, 3);

if (!string.IsNullOrEmpty(data[3]))
if (!String.IsNullOrEmpty(address))
{
c.Address = data[3];
c.Address = address;

try
{
var address = await geolocationProvider.GetLatLonFromAddress(c.Address);
var geolocation = await geolocationProvider.GetLatLonFromAddress(c.Address);


if (address != null)
c.GeoLocationData = address;
if (geolocation != null)
c.GeoLocationData = geolocation;
}
catch (Exception ex)
{
Expand All @@ -80,8 +77,14 @@ public async Task<Call> GenerateCall(CallEmail email, string managingUser, List<
StringBuilder title = new StringBuilder();

title.Append("Email Call ");
title.Append(((CallPriority)c.Priority).ToString());
title.Append(" ");

var priorityName = GetCallPriorityName(c.Priority, activePriorities);

if (!String.IsNullOrEmpty(priorityName))
{
title.Append(priorityName);
title.Append(" ");
}

if (!string.IsNullOrEmpty(c.Type))
{
Expand Down Expand Up @@ -120,5 +123,101 @@ public async Task<Call> GenerateCall(CallEmail email, string managingUser, List<

return c;
}

private static string GetValue(string[] data, int index)
{
if (data == null || index < 0 || index >= data.Length)
return null;

var value = data[index];

if (String.IsNullOrWhiteSpace(value))
return null;

return value.Trim();
}

/// <summary>
/// TYPE is documented as free text so whatever the CAD sends is kept, but when the
/// department has Custom Call Types the value is normalized to the casing of the
/// configured type so protocol triggers, filters and reports match on it.
/// </summary>
private static string ParseCallType(string data, List<CallType> callTypes)
{
if (String.IsNullOrWhiteSpace(data))
return null;

if (callTypes != null && callTypes.Any())
{
var customType = callTypes.FirstOrDefault(x => !String.IsNullOrWhiteSpace(x.Type) &&
String.Equals(x.Type.Trim(), data, StringComparison.OrdinalIgnoreCase));

if (customType != null)
return customType.Type;
}

return data;
}

/// <summary>
/// PRIORITY accepts the priority name or its identifier. Departments on the system
/// priorities keep the documented Low = 0, Medium = 1, High = 2, Emergency = 3
/// integers (those are their identifiers), departments with Custom Call Priorities
/// can send the priority name instead of an internal identifier they can't see.
/// Anything that doesn't resolve falls back to the department default, an identifier
/// the department doesn't own would leave dispatch without a priority to resolve.
/// </summary>
private static int ParseCallPriority(string data, int priority, List<DepartmentCallPriority> activePriorities)
{
if (String.IsNullOrWhiteSpace(data))
return priority;

if (activePriorities != null && activePriorities.Any())
{
var namedPriority = activePriorities.FirstOrDefault(x => !x.IsDeleted && !String.IsNullOrWhiteSpace(x.Name) &&
String.Equals(x.Name.Trim(), data, StringComparison.OrdinalIgnoreCase));

if (namedPriority != null)
return namedPriority.DepartmentCallPriorityId;

int parsedPriorityId;
if (int.TryParse(data, out parsedPriorityId))
{
var idPriority = activePriorities.FirstOrDefault(x => !x.IsDeleted && x.DepartmentCallPriorityId == parsedPriorityId);

if (idPriority != null)
return idPriority.DepartmentCallPriorityId;
}

return priority;
}

// No priority list was supplied by the caller, fall back to the built in priorities.
int parsedPriority;
if (int.TryParse(data, out parsedPriority) && Enum.IsDefined(typeof(CallPriority), parsedPriority))
return parsedPriority;

CallPriority namedSystemPriority;
if (Enum.TryParse<CallPriority>(data, true, out namedSystemPriority) && Enum.IsDefined(typeof(CallPriority), namedSystemPriority))
return (int)namedSystemPriority;

return priority;
}

private static string GetCallPriorityName(int priority, List<DepartmentCallPriority> activePriorities)
{
if (activePriorities != null && activePriorities.Any())
{
var match = activePriorities.FirstOrDefault(x => x.DepartmentCallPriorityId == priority);

if (match != null && !String.IsNullOrWhiteSpace(match.Name))
return match.Name.Trim();
}

if (Enum.IsDefined(typeof(CallPriority), priority))
return ((CallPriority)priority).ToString();

return String.Empty;
}
}
}
32 changes: 29 additions & 3 deletions Core/Resgrid.Services/PersonnelRolesService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Resgrid.Model.Events;
using Resgrid.Model.Providers;
using Resgrid.Model.Repositories;
using Resgrid.Model.Repositories.Queries;
using Resgrid.Model.Services;

namespace Resgrid.Services
Expand All @@ -17,16 +18,18 @@ public class PersonnelRolesService : IPersonnelRolesService
private readonly IDepartmentMembersRepository _departmentMemberRepository;
private readonly ISubscriptionsService _subscriptionsService;
private readonly IEventAggregator _eventAggregator;
private readonly IUnitOfWork _unitOfWork;

public PersonnelRolesService(IPersonnelRolesRepository personnelRolesRepository, IPersonnelRoleUsersRepository personnelRoleUsersRepository,
ISubscriptionsService subscriptionsService, IDepartmentMembersRepository departmentMemberRepository,
IEventAggregator eventAggregator)
IEventAggregator eventAggregator, IUnitOfWork unitOfWork)
{
_personnelRolesRepository = personnelRolesRepository;
_personnelRoleUsersRepository = personnelRoleUsersRepository;
_subscriptionsService = subscriptionsService;
_departmentMemberRepository = departmentMemberRepository;
_eventAggregator = eventAggregator;
_unitOfWork = unitOfWork;
}

/// <summary>
Expand Down Expand Up @@ -89,8 +92,31 @@ public async Task<PersonnelRole> GetRoleByDepartmentAndNameAsync(int departmentI
{
var role = await GetRoleByIdAsync(roleId);

var result = await _personnelRolesRepository.DeleteAsync(role, cancellationToken);
SendRoleVisibilityRefresh(role?.DepartmentId ?? 0);
if (role == null)
return false;

// Call dispatches, shift group requirements, run cards and the rest all point back at the
// role row; CallDispatchRoles has a non-cascading FK, so the delete below fails outright for
// any role that has ever been dispatched unless those rows go first. Both steps share one
// connection and transaction, otherwise a failure on the role delete leaves the dependent
// rows already committed and the role stripped of its dispatches, requirements and members.
bool result;
_unitOfWork.CreateOrGetConnection();
try
{
await _personnelRolesRepository.DeleteRoleDependenciesAsync(roleId, cancellationToken);

result = await _personnelRolesRepository.DeleteAsync(role, cancellationToken);

_unitOfWork.CommitChanges();
}
catch
{
_unitOfWork.DiscardChanges();
throw;
}

SendRoleVisibilityRefresh(role.DepartmentId);

return result;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
using System.Collections.Generic;
using System.Data.Common;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Dapper;
using Resgrid.Config;
using Resgrid.Framework;
using Resgrid.Model;
using Resgrid.Model.Repositories;
Expand Down Expand Up @@ -211,6 +213,65 @@ public async Task<IEnumerable<PersonnelRole>> GetPersonnelRolesByDepartmentIdAsy
}
}

public async Task<bool> DeleteRoleDependenciesAsync(int personnelRoleId, CancellationToken cancellationToken = default(CancellationToken))
{
try
{
var dynamicParameters = new DynamicParametersExtension();
dynamicParameters.Add("RoleId", personnelRoleId);

var notation = _sqlConfiguration.ParameterNotation;
var schema = _sqlConfiguration.SchemaName;

// Every table below points at PersonnelRoles. CallDispatchRoles is the one with a
// non-cascading FK, so leaving any of these behind blocks the role delete outright.
// UnitRoles only names the role as an optional qualification for a seat, so the
// requirement is cleared rather than the seat being deleted.
var sql = DataConfig.DatabaseType == DatabaseTypes.Postgres
? $@"DELETE FROM {schema}.personnelroleusers WHERE personnelroleid = {notation}RoleId;
DELETE FROM {schema}.calldispatchroles WHERE roleid = {notation}RoleId;
DELETE FROM {schema}.shiftgrouproles WHERE personnelroleid = {notation}RoleId;
DELETE FROM {schema}.commanddefinitionrolepersonnelroles WHERE personnelroleid = {notation}RoleId;
DELETE FROM {schema}.runcardrolerequirements WHERE personnelroleid = {notation}RoleId;
DELETE FROM {schema}.stationcoveragerequirements WHERE personnelroleid = {notation}RoleId;
DELETE FROM {schema}.chatchannelaccessrules WHERE personnelroleid = {notation}RoleId;
UPDATE {schema}.unitroles SET personnelroleid = NULL, personnelrolerequired = false WHERE personnelroleid = {notation}RoleId;"
: $@"DELETE FROM {schema}.[PersonnelRoleUsers] WHERE [PersonnelRoleId] = {notation}RoleId;
DELETE FROM {schema}.[CallDispatchRoles] WHERE [RoleId] = {notation}RoleId;
DELETE FROM {schema}.[ShiftGroupRoles] WHERE [PersonnelRoleId] = {notation}RoleId;
DELETE FROM {schema}.[CommandDefinitionRolePersonnelRoles] WHERE [PersonnelRoleId] = {notation}RoleId;
DELETE FROM {schema}.[RunCardRoleRequirements] WHERE [PersonnelRoleId] = {notation}RoleId;
DELETE FROM {schema}.[StationCoverageRequirements] WHERE [PersonnelRoleId] = {notation}RoleId;
DELETE FROM {schema}.[ChatChannelAccessRules] WHERE [PersonnelRoleId] = {notation}RoleId;
UPDATE {schema}.[UnitRoles] SET [PersonnelRoleId] = NULL, [PersonnelRoleRequired] = 0 WHERE [PersonnelRoleId] = {notation}RoleId;";

var executeFunction = new Func<DbConnection, Task<bool>>(async x =>
{
await x.ExecuteAsync(sql, dynamicParameters, _unitOfWork.Transaction);

return true;
});

if (_unitOfWork?.Connection == null)
{
using (var conn = _connectionProvider.Create())
{
await conn.OpenAsync(cancellationToken);

return await executeFunction(conn);
}
}

return await executeFunction(_unitOfWork.CreateOrGetConnection());
}
catch (Exception ex)
{
Logging.LogException(ex);

throw;
}
}

private static Func<PersonnelRole, PersonnelRoleUser, PersonnelRole> PersonnelRoleUserMapping(Dictionary<int, PersonnelRole> dictionary)
{
return new Func<PersonnelRole, PersonnelRoleUser, PersonnelRole>((role, roleUser) =>
Expand Down
Loading
Loading