diff --git a/.coderabbit.yaml b/.coderabbit.yaml
index 2d5c0aeaa..6b4aeb317 100644
--- a/.coderabbit.yaml
+++ b/.coderabbit.yaml
@@ -39,6 +39,7 @@ reviews:
- "!**/obj/**"
- "!**/Tests/**"
- "!**/.claude/**"
+ - "!**/.agent/**"
- "!**/*.md"
path_instructions: []
abort_on_close: true
diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml
index 1cbf1d54b..d1df76efb 100644
--- a/.github/workflows/dotnet.yml
+++ b/.github/workflows/dotnet.yml
@@ -143,7 +143,8 @@ jobs:
if (pr) {
const body = (pr.body || '')
.replace(/##\s*Summary by CodeRabbit[\s\S]*/i, '')
- .replace(/Pull Request Description/gi, 'Summary')
+ .replace(/^(#+\s*)(Pull Request Description|PR Description)\s*$/gim, '$1Summary')
+ .replace(/Pull Request Description|PR Description/gi, 'Summary')
.trim() || 'No release notes provided.';
fs.writeFileSync('release_notes.md', body);
} else {
diff --git a/Core/Resgrid.Config/SecurityConfig.cs b/Core/Resgrid.Config/SecurityConfig.cs
index 177c0f41a..eee8f771d 100644
--- a/Core/Resgrid.Config/SecurityConfig.cs
+++ b/Core/Resgrid.Config/SecurityConfig.cs
@@ -25,6 +25,13 @@ public static class SecurityConfig
///
public static string SystemApiKey = "";
+ ///
+ /// Shared secret required on the internal scheduled-report endpoint (User/Reports/InternalRunReport).
+ /// The report-delivery worker supplies this value as the "key" query parameter; requests without a
+ /// matching key are rejected. Must be set (non-empty) for scheduled report delivery to function.
+ ///
+ public static string InternalReportsToken = "";
+
// ── Encryption ───────────────────────────────────────────────────────────────
/// AES-256 master key used by IEncryptionService for system-wide encryption.
diff --git a/Core/Resgrid.Config/ServiceBusConfig.cs b/Core/Resgrid.Config/ServiceBusConfig.cs
index 7aaf97ec0..75e40d62c 100644
--- a/Core/Resgrid.Config/ServiceBusConfig.cs
+++ b/Core/Resgrid.Config/ServiceBusConfig.cs
@@ -15,6 +15,9 @@ public static class ServiceBusConfig
public static string PaymentQueueName = "paymenttest";
public static string AuditQueueName = "audittest";
public static string UnitLoactionQueueName = "unitlocationtest";
+ public static string UnitLocationQueueV2Name = "unitlocation-v2test";
+ public static string UnitLocationRetryQueueV2Name = "unitlocation-v2test.retry";
+ public static string UnitLocationDeadQueueV2Name = "unitlocation-v2test.dead";
public static string PersonnelLoactionQueueName = "personnellocationtest";
public static string SecurityRefreshQueueName = "securityrefreshtest";
public static string WorkflowQueueName = "workflowqueuetest";
@@ -29,6 +32,9 @@ public static class ServiceBusConfig
public static string PaymentQueueName = "payment";
public static string AuditQueueName = "audit";
public static string UnitLoactionQueueName = "unitlocation";
+ public static string UnitLocationQueueV2Name = "unitlocation-v2";
+ public static string UnitLocationRetryQueueV2Name = "unitlocation-v2.retry";
+ public static string UnitLocationDeadQueueV2Name = "unitlocation-v2.dead";
public static string PersonnelLoactionQueueName = "personnellocation";
public static string SecurityRefreshQueueName = "securityrefresh";
public static string WorkflowQueueName = "workflowqueue";
diff --git a/Core/Resgrid.Config/UnitTrackingConfig.cs b/Core/Resgrid.Config/UnitTrackingConfig.cs
new file mode 100644
index 000000000..dbc8ad946
--- /dev/null
+++ b/Core/Resgrid.Config/UnitTrackingConfig.cs
@@ -0,0 +1,46 @@
+namespace Resgrid.Config
+{
+ public static class UnitTrackingConfig
+ {
+ public static bool Enabled = false;
+ public static bool HttpsIngressEnabled = false;
+ public static bool NativeGatewayEnabled = false;
+ public static string CredentialPepper = "";
+ public static string PublicHttpsBaseUrl = "";
+ public static int MaxRequestBytes = 262144;
+ public static int MaxBatchPositions = 100;
+ public static int MaxJsonDepth = 16;
+ public static int MaxFutureSkewSeconds = 300;
+ public static int DefaultLocationRetentionDays = 90;
+ public static int MinimumLocationRetentionDays = 1;
+ public static int MaximumLocationRetentionDays = 3650;
+ public static int PerDeviceRequestsPerMinute = 120;
+ public static int PerDeviceRecordsPerMinute = 1200;
+ public static int UnknownEndpointRequestsPerMinute = 30;
+ public static int CredentialCacheSeconds = 60;
+ public static int CredentialRotationOverlapHours = 24;
+ public static int DeviceMappingCacheSeconds = 300;
+ public static int UnknownDeviceCacheSeconds = 30;
+ public static int QueueMessageTtlSeconds = 86400;
+ public static int QueuePublishTimeoutSeconds = 5;
+ public static int UnitLocationQueuePrefetchCount = 25;
+ public static int UnitLocationRetryDelaySeconds = 30;
+ public static int UnitLocationMaxRetryAttempts = 3;
+ public static int TcpIdleTimeoutSeconds = 300;
+ public static int MaxFrameBytes = 65536;
+ public static int MaxConnections = 5000;
+ public static int MaxConnectionsPerIp = 100;
+ public static int GracefulShutdownSeconds = 30;
+ public static int InternalHealthPort = 8080;
+ public static int QueclinkTcpPort = 5004;
+ public static int QueclinkUdpPort = 5004;
+ public static int Gt06TcpPort = 5023;
+ public static int Gt06UdpPort = 5023;
+ public static int TeltonikaTcpPort = 5027;
+ public static int TeltonikaUdpPort = 5027;
+ public static bool EnableQueclink = false;
+ public static bool EnableGt06 = false;
+ public static bool EnableTeltonika = false;
+ public static bool RawDiagnosticCaptureEnabled = false;
+ }
+}
diff --git a/Core/Resgrid.Framework/SentryTransactionFilter.cs b/Core/Resgrid.Framework/SentryTransactionFilter.cs
index d0e1ede9e..08c7d2e05 100644
--- a/Core/Resgrid.Framework/SentryTransactionFilter.cs
+++ b/Core/Resgrid.Framework/SentryTransactionFilter.cs
@@ -61,7 +61,15 @@ public static class SentryTransactionFilter
///
public static SentryTransaction Filter(SentryTransaction transaction)
{
- if (transaction == null || transaction.Status != SpanStatus.NotFound)
+ if (transaction == null)
+ return null;
+
+ if (transaction.Request != null)
+ transaction.Request.Url = RedactCapabilityPath(transaction.Request.Url);
+
+ ((IEventLike)transaction).TransactionName = RedactCapabilityPath(transaction.Name);
+
+ if (transaction.Status != SpanStatus.NotFound)
return transaction;
var requestTarget = transaction.Request?.Url;
@@ -71,6 +79,26 @@ public static SentryTransaction Filter(SentryTransaction transaction)
return ShouldDrop(transaction.Status, requestTarget) ? null : transaction;
}
+ public static string RedactCapabilityPath(string value)
+ {
+ const string prefix = "/api/v4/unit-trackers/c/";
+ if (string.IsNullOrWhiteSpace(value))
+ return value;
+
+ var start = value.IndexOf(prefix, StringComparison.OrdinalIgnoreCase);
+ if (start < 0)
+ return value;
+
+ var tokenStart = start + prefix.Length;
+ var tokenEnd = value.IndexOfAny(new[] { '?', '#', '/' }, tokenStart);
+ if (tokenEnd < 0)
+ tokenEnd = value.Length;
+
+ return value.Substring(0, tokenStart) +
+ "[REDACTED]" +
+ value.Substring(tokenEnd);
+ }
+
public static bool ShouldDrop(SpanStatus? status, string requestTarget)
{
return status == SpanStatus.NotFound && IsKnownScannerPath(requestTarget);
diff --git a/Core/Resgrid.Localization/Areas/User/Units/Units.resx b/Core/Resgrid.Localization/Areas/User/Units/Units.resx
new file mode 100644
index 000000000..972a6edb4
--- /dev/null
+++ b/Core/Resgrid.Localization/Areas/User/Units/Units.resx
@@ -0,0 +1,237 @@
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ Add tracking binding
+
+
+ Allowed source networks
+
+
+ Optional comma-separated IPv4 or IPv6 CIDR ranges permitted to send positions for this tracker.
+
+
+ Authentication mode
+
+
+ Basic authentication username
+
+
+ A Basic authentication username is required.
+
+
+ Certification status
+
+
+ Copy
+
+
+ Create credential
+
+
+ Credential prefix
+
+
+ I have saved this credential
+
+
+ Credentials
+
+
+ Credential token
+
+
+ A custom header name is required.
+
+
+ Remove this tracking binding? Its credentials will be revoked and it will stop accepting positions.
+
+
+ Device identifier
+
+
+ Use the identifier emitted by the device or forwarding service. It is normalized before storage.
+
+
+ Disable tracking
+
+
+ Disable this binding and revoke all of its credentials?
+
+
+ Edit tracking binding
+
+
+ HTTPS endpoint
+
+
+ Expires
+
+
+ Firmware version
+
+
+ Hardware GPS tracking
+
+
+ Bind a hardware tracker or forwarding service to this Unit, issue credentials, and monitor delivery health.
+
+
+ Header name
+
+
+ Header value
+
+
+ Last error code
+
+
+ Last received
+
+
+ Last seen
+
+
+ Last used
+
+
+ Last valid fix
+
+
+ JSON payload
+
+
+ Never
+
+
+ No credentials have been issued for this binding.
+
+
+ No hardware tracking bindings are configured for this Unit.
+
+
+ Save tracking credential
+
+
+ This credential is shown only once. Copy the endpoint and secret into the tracker configuration before leaving this page.
+
+
+ Protocol
+
+
+ Validate test JSON
+
+
+ The JSON payload is malformed, contains duplicate fields, or exceeds the nesting limit.
+
+
+ Each position requires a non-empty eventId and valid latitude and longitude values.
+
+
+ A batch must contain at least one JSON position object.
+
+
+ Enter a JSON payload to validate.
+
+
+ The preview parser accepted {0} position(s). Nothing was queued or stored.
+
+
+ The JSON payload exceeds the configured request size limit.
+
+
+ The JSON batch exceeds the configured position limit.
+
+
+ Revoke
+
+
+ Revoke this credential? The sender will no longer be able to use it.
+
+
+ Retry expectation:
+
+
+ Revoked
+
+
+ Rotate
+
+
+ Save tracking binding
+
+
+ Send test JSON
+
+
+ Validate a generic Resgrid JSON payload without authenticating, queueing, or storing it. This preview is available only to department administrators outside production.
+
+
+ Setup instructions
+
+
+ Secondary identifier
+
+
+ Select a certified tracking profile
+
+
+ Select a certified tracking profile.
+
+
+ Source priority
+
+
+ Tracking binding actions
+
+
+ Tracking binding created. Generate a credential to finish setup.
+
+
+ Tracking binding removed.
+
+
+ Tracking binding disabled and its credentials revoked.
+
+
+ Tracking binding updated.
+
+
+ Tracking credential revoked.
+
+
+ Display name
+
+
+ Enabled
+
+
+ A device identifier is required for the selected profile.
+
+
+ Tracking profile
+
+
+ Tracking status
+
+
+ Transport
+
+
+ The selected authentication mode is not supported by this tracking profile.
+
+
+ View tracking status
+
+
diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs
index 20ee009e3..e365d80da 100644
--- a/Core/Resgrid.Model/AuditLogTypes.cs
+++ b/Core/Resgrid.Model/AuditLogTypes.cs
@@ -163,6 +163,16 @@ public enum AuditLogTypes
WeatherAlertSettingsChanged,
// Feature Toggles
FeatureFlagChanged,
- FeatureFlagOverrideChanged
+ FeatureFlagOverrideChanged,
+ // Unit hardware tracking
+ UnitTrackingDeviceCreated,
+ UnitTrackingDeviceUpdated,
+ UnitTrackingDeviceDisabled,
+ UnitTrackingDeviceDeleted,
+ UnitTrackingCredentialCreated,
+ UnitTrackingCredentialRotated,
+ UnitTrackingCredentialRevoked,
+ // Department deletion lifecycle
+ DeleteDepartmentRequestExecuted
}
}
diff --git a/Core/Resgrid.Model/DepartmentSettingTypes.cs b/Core/Resgrid.Model/DepartmentSettingTypes.cs
index d747393f8..8fdfe3522 100644
--- a/Core/Resgrid.Model/DepartmentSettingTypes.cs
+++ b/Core/Resgrid.Model/DepartmentSettingTypes.cs
@@ -56,5 +56,8 @@ public enum DepartmentSettingTypes
UnitCallStatusOverridesByUnitType = 52,
EnableModernNotifications = 53,
ForceChatbotSecurityPin = 54,
+ HardwareTrackingStaleAfterSeconds = 55,
+ HardwareTrackingMobileFallbackEnabled = 56,
+ HardwareTrackingLocationRetentionDays = 57,
}
}
diff --git a/Core/Resgrid.Model/Events/UnitLocationEvent.cs b/Core/Resgrid.Model/Events/UnitLocationEvent.cs
index fff6d0b50..d1f8d91c7 100644
--- a/Core/Resgrid.Model/Events/UnitLocationEvent.cs
+++ b/Core/Resgrid.Model/Events/UnitLocationEvent.cs
@@ -42,6 +42,53 @@ public class UnitLocationEvent
[ProtoMember(12)]
public decimal? Heading { get; set; }
+ [ProtoMember(13)]
+ public DateTime? ReceivedOn { get; set; }
+
+ [ProtoMember(14)]
+ public int SourceType { get; set; }
+
+ [ProtoMember(15)]
+ public string SourceId { get; set; }
+
+ [ProtoMember(16)]
+ public int SourcePriority { get; set; }
+
+ [ProtoMember(17)]
+ public int? TransportType { get; set; }
+
+ [ProtoMember(18)]
+ public string ProtocolKey { get; set; }
+
+ [ProtoMember(19)]
+ public bool? IsValidFix { get; set; }
+
+ [ProtoMember(20)]
+ public int? Satellites { get; set; }
+
+ [ProtoMember(21)]
+ public decimal? Hdop { get; set; }
+
+ [ProtoMember(22)]
+ public decimal? BatteryPercent { get; set; }
+
+ [ProtoMember(23)]
+ public decimal? ExternalPowerVolts { get; set; }
+
+ [ProtoMember(24)]
+ public int? SignalPercent { get; set; }
+
+ [ProtoMember(25)]
+ public bool? Ignition { get; set; }
+
+ [ProtoMember(26)]
+ public bool? IsMoving { get; set; }
+
+ [ProtoMember(27)]
+ public string AlarmCode { get; set; }
+
+ [ProtoMember(28)]
+ public int? TimestampSource { get; set; }
public UnitLocationEvent()
{
diff --git a/Core/Resgrid.Model/Providers/IEventAggregator.cs b/Core/Resgrid.Model/Providers/IEventAggregator.cs
index 0aaad5d4f..bcbd30158 100644
--- a/Core/Resgrid.Model/Providers/IEventAggregator.cs
+++ b/Core/Resgrid.Model/Providers/IEventAggregator.cs
@@ -26,6 +26,8 @@ public interface IEventSubscriptionManager
/// Returns the current IEventSubscriptionManager to allow for easy fluent additions.
Guid AddListener(Action listener);
+ Guid AddAsyncListener(Func listener, Action onError = null);
+
///
/// Removes the listener object from the EventAggregator
///
@@ -37,6 +39,7 @@ public interface IEventSubscriptionManager
public interface IEventPublisher
{
void SendMessage(TMessage message);
+ Task SendMessageAsync(TMessage message);
}
public interface IEventAggregator : IEventPublisher, IEventSubscriptionManager
diff --git a/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs b/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs
index df79f60ef..088b9b754 100644
--- a/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs
+++ b/Core/Resgrid.Model/Providers/IRabbitOutboundQueueProvider.cs
@@ -1,5 +1,7 @@
using Resgrid.Model.Events;
using Resgrid.Model.Queue;
+using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
namespace Resgrid.Model.Providers
@@ -14,6 +16,9 @@ public interface IRabbitOutboundQueueProvider
Task EnqueueCqrsEvent(CqrsEvent cqrsEvent);
Task EnqueueAuditEvent(AuditEvent auditEvent);
Task EnqueueUnitLocationEvent(UnitLocationEvent unitLocationEvent);
+ Task EnqueueUnitLocationEvents(
+ IReadOnlyCollection unitLocationEvents,
+ CancellationToken cancellationToken = default);
Task EnqueuePersonnelLocationEvent(PersonnelLocationEvent personnelLocationEvent);
Task EnqueueWorkflowEvent(WorkflowQueueItem item);
Task EnqueueChatbotMessage(ChatbotMessageQueueItem chatbotMessageQueue);
diff --git a/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs b/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs
index 833dee520..e85de421d 100644
--- a/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs
+++ b/Core/Resgrid.Model/Providers/IUnitLocationEventProvider.cs
@@ -1,4 +1,6 @@
using Resgrid.Model.Events;
+using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
namespace Resgrid.Model.Providers
@@ -6,5 +8,8 @@ namespace Resgrid.Model.Providers
public interface IUnitLocationEventProvider
{
Task EnqueueUnitLocationEventAsync(UnitLocationEvent unitLocationEvent);
+ Task EnqueueUnitLocationEventsAsync(
+ IReadOnlyCollection unitLocationEvents,
+ CancellationToken cancellationToken = default);
}
}
diff --git a/Core/Resgrid.Model/QueueItem.cs b/Core/Resgrid.Model/QueueItem.cs
index 7038c2d41..421fc8e77 100644
--- a/Core/Resgrid.Model/QueueItem.cs
+++ b/Core/Resgrid.Model/QueueItem.cs
@@ -50,6 +50,9 @@ public class QueueItem : IEntity
[ProtoMember(12)]
public int ReminderCount { get; set; }
+ [ProtoMember(13)]
+ public int AttemptCount { get; set; }
+
[NotMapped]
public int DequeueCount { get; set; }
diff --git a/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs b/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs
index 72ff7cb5d..fd96383db 100644
--- a/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IDocumentDbRepository.cs
@@ -8,7 +8,7 @@ namespace Resgrid.Model.Repositories
public interface IDocumentDbRepository
{
///
- /// Updates the Postgres document database schema.
+ /// Updates the configured document database schema and indexes.
///
/// If the operation was successful
Task UpdateDocumentDatabaseAsync();
diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs
index 0e30eba6b..ba3dd0cc6 100644
--- a/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs
+++ b/Core/Resgrid.Model/Repositories/IUnitLocationsDocRepository.cs
@@ -10,7 +10,7 @@ public interface IUnitLocationsDocRepository
Task> GetLatestLocationsByDepartmentIdAsync(int departmentId);
Task GetByIdAsync(string id);
Task GetByOldIdAsync(string id);
- Task InsertAsync(UnitsLocation location);
- Task UpdateAsync(UnitsLocation location);
+ Task InsertAsync(UnitsLocation location);
+ Task UpdateAsync(UnitsLocation location);
}
}
diff --git a/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs
new file mode 100644
index 000000000..94d537f6c
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IUnitLocationsMongoRepository.cs
@@ -0,0 +1,11 @@
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IUnitLocationsMongoRepository
+ {
+ Task EnsureIndexesAsync();
+ Task InsertAsync(UnitsLocation location);
+ Task UpdateAsync(UnitsLocation location);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs b/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs
new file mode 100644
index 000000000..5e28fe8c8
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IUnitTrackingCredentialsRepository.cs
@@ -0,0 +1,13 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IUnitTrackingCredentialsRepository : IRepository
+ {
+ Task> GetAllByDeviceIdAsync(string unitTrackingDeviceId);
+ Task GetBySecretHashAsync(string secretHash);
+ Task RevokeAllByDeviceIdAsync(string unitTrackingDeviceId, DateTime revokedOn);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs b/Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs
new file mode 100644
index 000000000..e673db92c
--- /dev/null
+++ b/Core/Resgrid.Model/Repositories/IUnitTrackingDevicesRepository.cs
@@ -0,0 +1,11 @@
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Repositories
+{
+ public interface IUnitTrackingDevicesRepository : IRepository
+ {
+ Task> GetAllByUnitIdAsync(int departmentId, int unitId);
+ Task GetByProtocolIdentifierAsync(string protocolKey, string deviceIdentifier);
+ }
+}
diff --git a/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs b/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs
index 859e0623a..bb995a068 100644
--- a/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs
+++ b/Core/Resgrid.Model/Repositories/Queries/IUnitOfWork.cs
@@ -1,5 +1,7 @@
using System;
using System.Data.Common;
+using System.Threading;
+using System.Threading.Tasks;
namespace Resgrid.Model.Repositories.Queries
{
@@ -8,6 +10,7 @@ public interface IUnitOfWork : IDisposable
DbTransaction Transaction { get; }
DbConnection Connection { get; }
DbConnection CreateOrGetConnection();
+ Task CreateOrGetConnectionAsync(CancellationToken cancellationToken = default(CancellationToken));
void DiscardChanges();
void CommitChanges();
}
diff --git a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
index d0054b6f4..c216563f0 100644
--- a/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
+++ b/Core/Resgrid.Model/Services/IDepartmentSettingsService.cs
@@ -320,5 +320,11 @@ Task SetUnitCallStatusOverridesByUnitTypeAsync(int department
/// chatbot/SMS actions (overrides the per-user opt-in).
///
Task GetForceChatbotSecurityPinAsync(int departmentId, bool bypassCache = false);
+
+ Task GetHardwareTrackingStaleAfterSecondsAsync(int departmentId, bool bypassCache = false);
+
+ Task GetHardwareTrackingMobileFallbackEnabledAsync(int departmentId, bool bypassCache = false);
+
+ Task GetHardwareTrackingLocationRetentionDaysAsync(int departmentId, bool bypassCache = false);
}
}
diff --git a/Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs b/Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs
new file mode 100644
index 000000000..c31a33069
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitLocationSourceResolver.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IUnitLocationSourceResolver
+ {
+ Task ResolveAsync(
+ int departmentId,
+ IReadOnlyCollection locations,
+ DateTime? utcNow = null);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs b/Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs
new file mode 100644
index 000000000..64f455583
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingAuthenticationService.cs
@@ -0,0 +1,31 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingAuthenticationService
+ {
+ UnitTrackingGeneratedCredential GenerateCredential();
+ string ComputeSecretHash(string token);
+ bool VerifySecret(string token, string storedHash);
+ Task AuthenticateAsync(
+ string token,
+ DateTime? utcNow = null,
+ CancellationToken cancellationToken = default);
+ Task GetEnabledDeviceByEndpointIdAsync(
+ string deviceId,
+ CancellationToken cancellationToken = default);
+ Task GetEnabledDeviceByProtocolIdentifierAsync(
+ string protocolKey,
+ string deviceIdentifier,
+ CancellationToken cancellationToken = default);
+ Task> GetActiveCredentialsForDeviceAsync(
+ string deviceId,
+ DateTime? utcNow = null,
+ CancellationToken cancellationToken = default);
+ Task InvalidateCredentialAsync(string secretHash);
+ Task InvalidateDeviceAsync(UnitTrackingDevice device);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs b/Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs
new file mode 100644
index 000000000..f45c61abc
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingCatalogService.cs
@@ -0,0 +1,17 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingCatalogService
+ {
+ Task> GetProfilesAsync(
+ CancellationToken cancellationToken = default);
+
+ Task GetProfileAsync(
+ string profileKey,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs b/Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs
new file mode 100644
index 000000000..56fb56a39
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingEventIdService.cs
@@ -0,0 +1,7 @@
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingEventIdService
+ {
+ string CreateForHttps(string unitTrackingDeviceId, string callerEventId);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs b/Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs
new file mode 100644
index 000000000..7ed8c2efa
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingIdentifierService.cs
@@ -0,0 +1,8 @@
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingIdentifierService
+ {
+ string Normalize(string identifier);
+ string Mask(string identifier);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs
new file mode 100644
index 000000000..fcbd37ede
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingIngressService.cs
@@ -0,0 +1,15 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingIngressService
+ {
+ Task AcceptAsync(
+ AuthenticatedTrackingSource source,
+ IReadOnlyCollection positions,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingService.cs b/Core/Resgrid.Model/Services/IUnitTrackingService.cs
new file mode 100644
index 000000000..cfe053451
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingService.cs
@@ -0,0 +1,62 @@
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingService
+ {
+ Task GetDeviceByIdAsync(string deviceId, int departmentId);
+ Task> GetDevicesForDepartmentAsync(int departmentId);
+ Task> GetDevicesForUnitAsync(int departmentId, int unitId);
+ Task> GetCredentialsForDeviceAsync(string deviceId, int departmentId);
+ Task CreateDeviceAsync(
+ UnitTrackingDevice device,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default);
+ Task UpdateDeviceAsync(
+ UnitTrackingDevice device,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default);
+ Task DisableDeviceAsync(
+ string deviceId,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default);
+ Task DeleteDeviceAsync(
+ string deviceId,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default);
+ Task RebindDeviceAsync(
+ string deviceId,
+ int departmentId,
+ int newUnitId,
+ string userId,
+ CancellationToken cancellationToken = default);
+ Task CreateCredentialAsync(
+ string deviceId,
+ int departmentId,
+ UnitTrackingAuthMode authMode,
+ string userId,
+ string headerName = null,
+ string basicUsername = null,
+ CancellationToken cancellationToken = default);
+ Task RotateCredentialAsync(
+ string deviceId,
+ string credentialId,
+ int departmentId,
+ string userId,
+ TimeSpan? overlap = null,
+ CancellationToken cancellationToken = default);
+ Task RevokeCredentialAsync(
+ string deviceId,
+ string credentialId,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs b/Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs
new file mode 100644
index 000000000..0c5e9ada1
--- /dev/null
+++ b/Core/Resgrid.Model/Services/IUnitTrackingStatusService.cs
@@ -0,0 +1,14 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Resgrid.Model.Services
+{
+ public interface IUnitTrackingStatusService
+ {
+ Task GetEffectiveStatusAsync(
+ UnitTrackingDevice device,
+ DateTime? utcNow = null,
+ CancellationToken cancellationToken = default);
+ }
+}
diff --git a/Core/Resgrid.Model/Services/IUnitsService.cs b/Core/Resgrid.Model/Services/IUnitsService.cs
index effbefbcb..e98d3031f 100644
--- a/Core/Resgrid.Model/Services/IUnitsService.cs
+++ b/Core/Resgrid.Model/Services/IUnitsService.cs
@@ -304,8 +304,8 @@ Task DeleteStatesForUnitAsync(int unitId,
///
/// The location.
/// The cancellation token that can be used by other objects or threads to receive notice of cancellation.
- /// Task<UnitLocation>.
- Task AddUnitLocationAsync(UnitsLocation location, int departmentId,
+ /// The idempotent document-store write result.
+ Task AddUnitLocationAsync(UnitsLocation location, int departmentId,
CancellationToken cancellationToken = default(CancellationToken));
///
diff --git a/Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs b/Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs
new file mode 100644
index 000000000..08662801e
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/AuthenticatedTrackingSource.cs
@@ -0,0 +1,9 @@
+namespace Resgrid.Model.Tracking
+{
+ public sealed class AuthenticatedTrackingSource
+ {
+ public UnitTrackingDevice Device { get; set; }
+ public UnitTrackingCredential Credential { get; set; }
+ public string ReportedDeviceIdentifier { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs b/Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs
new file mode 100644
index 000000000..5d5b06e90
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/CanonicalTrackingPosition.cs
@@ -0,0 +1,27 @@
+using System;
+
+namespace Resgrid.Model.Tracking
+{
+ public sealed class CanonicalTrackingPosition
+ {
+ public string EventId { get; set; }
+ public DateTime TimestampUtc { get; set; }
+ public DateTime ReceivedOnUtc { get; set; }
+ public decimal Latitude { get; set; }
+ public decimal Longitude { get; set; }
+ public decimal? AccuracyMeters { get; set; }
+ public decimal? AltitudeMeters { get; set; }
+ public decimal? SpeedMetersPerSecond { get; set; }
+ public decimal? HeadingDegrees { get; set; }
+ public int? Satellites { get; set; }
+ public decimal? Hdop { get; set; }
+ public decimal? BatteryPercent { get; set; }
+ public decimal? ExternalPowerVolts { get; set; }
+ public int? SignalPercent { get; set; }
+ public bool? Ignition { get; set; }
+ public bool? IsMoving { get; set; }
+ public string AlarmCode { get; set; }
+ public TrackingTimestampSource TimestampSource { get; set; }
+ public bool IsValidFix { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/Tracking/TrackingIngressResult.cs b/Core/Resgrid.Model/Tracking/TrackingIngressResult.cs
new file mode 100644
index 000000000..a5a249593
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/TrackingIngressResult.cs
@@ -0,0 +1,21 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Tracking
+{
+ public enum TrackingIngressStatus
+ {
+ Accepted = 0,
+ Invalid = 1,
+ Unavailable = 2
+ }
+
+ public sealed class TrackingIngressResult
+ {
+ public TrackingIngressStatus Status { get; set; }
+ public int Accepted { get; set; }
+ public bool DuplicatesPossible { get; set; }
+ public DateTime ReceivedOn { get; set; }
+ public IReadOnlyCollection Errors { get; set; } = Array.Empty();
+ }
+}
diff --git a/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs
new file mode 100644
index 000000000..f53abe061
--- /dev/null
+++ b/Core/Resgrid.Model/Tracking/UnitTrackingCatalogProfile.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+
+namespace Resgrid.Model.Tracking
+{
+ public sealed class UnitTrackingCatalogProfile
+ {
+ public string Key { get; set; }
+ public string ManufacturerKey { get; set; }
+ public string ManufacturerName { get; set; }
+ public string Model { get; set; }
+ public UnitTrackingTransportType TransportType { get; set; }
+ public string ProtocolKey { get; set; }
+ public string PayloadAdapterKey { get; set; }
+ public UnitTrackingCertificationStatus CertificationStatus { get; set; }
+ public bool IdentifierRequired { get; set; }
+ public bool IsSelectable { get; set; }
+ public IReadOnlyCollection SupportedAuthModes { get; set; } =
+ Array.Empty();
+ public string SetupSummary { get; set; }
+ public string RetryExpectation { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/UnitLocationWriteResult.cs b/Core/Resgrid.Model/UnitLocationWriteResult.cs
new file mode 100644
index 000000000..21c57025a
--- /dev/null
+++ b/Core/Resgrid.Model/UnitLocationWriteResult.cs
@@ -0,0 +1,29 @@
+namespace Resgrid.Model
+{
+ public enum UnitLocationWriteStatus
+ {
+ Inserted = 0,
+ Duplicate = 1
+ }
+
+ public class UnitLocationWriteResult
+ {
+ public UnitLocationWriteStatus Status { get; set; }
+ public UnitsLocation Location { get; set; }
+
+ public static UnitLocationWriteResult Inserted(UnitsLocation location) =>
+ Create(UnitLocationWriteStatus.Inserted, location);
+
+ public static UnitLocationWriteResult Duplicate(UnitsLocation location) =>
+ Create(UnitLocationWriteStatus.Duplicate, location);
+
+ private static UnitLocationWriteResult Create(UnitLocationWriteStatus status, UnitsLocation location)
+ {
+ return new UnitLocationWriteResult
+ {
+ Status = status,
+ Location = location
+ };
+ }
+ }
+}
diff --git a/Core/Resgrid.Model/UnitTrackingCredential.cs b/Core/Resgrid.Model/UnitTrackingCredential.cs
new file mode 100644
index 000000000..1f5eb2fc9
--- /dev/null
+++ b/Core/Resgrid.Model/UnitTrackingCredential.cs
@@ -0,0 +1,75 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ public class UnitTrackingCredential : IEntity
+ {
+ [Key]
+ [Required]
+ [MaxLength(128)]
+ public string UnitTrackingCredentialId { get; set; }
+
+ [Required]
+ [MaxLength(128)]
+ public string UnitTrackingDeviceId { get; set; }
+
+ [ForeignKey(nameof(UnitTrackingDeviceId))]
+ public virtual UnitTrackingDevice UnitTrackingDevice { get; set; }
+
+ [Required]
+ public int AuthMode { get; set; }
+
+ [MaxLength(128)]
+ public string HeaderName { get; set; }
+
+ [MaxLength(128)]
+ public string BasicUsername { get; set; }
+
+ [Required]
+ [MaxLength(20)]
+ public string KeyPrefix { get; set; }
+
+ [Required]
+ [MaxLength(64)]
+ [JsonIgnore]
+ public string SecretHash { get; set; }
+
+ public DateTime ValidFrom { get; set; }
+
+ public DateTime? ExpiresOn { get; set; }
+
+ public DateTime? RevokedOn { get; set; }
+
+ public DateTime? LastUsedOn { get; set; }
+
+ [Required]
+ public string CreatedByUserId { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get => UnitTrackingCredentialId;
+ set => UnitTrackingCredentialId = (string)value;
+ }
+
+ [NotMapped]
+ public string TableName => "UnitTrackingCredentials";
+
+ [NotMapped]
+ public string IdName => "UnitTrackingCredentialId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties =>
+ new[] { "IdValue", "IdType", "TableName", "IdName", "UnitTrackingDevice" };
+ }
+}
diff --git a/Core/Resgrid.Model/UnitTrackingDevice.cs b/Core/Resgrid.Model/UnitTrackingDevice.cs
new file mode 100644
index 000000000..58a62ba7b
--- /dev/null
+++ b/Core/Resgrid.Model/UnitTrackingDevice.cs
@@ -0,0 +1,104 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+using Newtonsoft.Json;
+
+namespace Resgrid.Model
+{
+ public class UnitTrackingDevice : IEntity
+ {
+ [Key]
+ [Required]
+ [MaxLength(128)]
+ public string UnitTrackingDeviceId { get; set; }
+
+ [Required]
+ public int DepartmentId { get; set; }
+
+ [ForeignKey(nameof(DepartmentId))]
+ public virtual Department Department { get; set; }
+
+ [Required]
+ public int UnitId { get; set; }
+
+ [ForeignKey(nameof(UnitId))]
+ public virtual Unit Unit { get; set; }
+
+ [MaxLength(200)]
+ public string DisplayName { get; set; }
+
+ [MaxLength(64)]
+ public string ManufacturerKey { get; set; }
+
+ [MaxLength(64)]
+ public string ModelKey { get; set; }
+
+ [Required]
+ public int TransportType { get; set; }
+
+ [MaxLength(64)]
+ public string ProtocolKey { get; set; }
+
+ [MaxLength(64)]
+ public string PayloadAdapterKey { get; set; }
+
+ [MaxLength(128)]
+ public string DeviceIdentifier { get; set; }
+
+ [MaxLength(128)]
+ public string SecondaryIdentifier { get; set; }
+
+ public bool IsEnabled { get; set; } = true;
+
+ public bool IsDeleted { get; set; }
+
+ public int SourcePriority { get; set; } = 100;
+
+ public string AllowedSourceCidrs { get; set; }
+
+ public DateTime? LastSeenOn { get; set; }
+
+ public DateTime? LastPositionOn { get; set; }
+
+ public DateTime? LastReceivedOn { get; set; }
+
+ public int LastStatus { get; set; } = (int)UnitTrackingDeviceStatus.NeverSeen;
+
+ [MaxLength(64)]
+ public string LastErrorCode { get; set; }
+
+ [MaxLength(128)]
+ public string FirmwareVersion { get; set; }
+
+ [Required]
+ public string CreatedByUserId { get; set; }
+
+ public DateTime CreatedOn { get; set; }
+
+ public string UpdatedByUserId { get; set; }
+
+ public DateTime? UpdatedOn { get; set; }
+
+ [NotMapped]
+ [JsonIgnore]
+ public object IdValue
+ {
+ get => UnitTrackingDeviceId;
+ set => UnitTrackingDeviceId = (string)value;
+ }
+
+ [NotMapped]
+ public string TableName => "UnitTrackingDevices";
+
+ [NotMapped]
+ public string IdName => "UnitTrackingDeviceId";
+
+ [NotMapped]
+ public int IdType => 1;
+
+ [NotMapped]
+ public IEnumerable IgnoredProperties =>
+ new[] { "IdValue", "IdType", "TableName", "IdName", "Department", "Unit" };
+ }
+}
diff --git a/Core/Resgrid.Model/UnitTrackingEnums.cs b/Core/Resgrid.Model/UnitTrackingEnums.cs
new file mode 100644
index 000000000..497de104c
--- /dev/null
+++ b/Core/Resgrid.Model/UnitTrackingEnums.cs
@@ -0,0 +1,53 @@
+namespace Resgrid.Model
+{
+ public enum UnitLocationSourceType
+ {
+ UnknownLegacy = 0,
+ UnitApp = 1,
+ HardwareTracker = 2
+ }
+
+ public enum UnitTrackingTransportType
+ {
+ Unknown = 0,
+ NativeHttps = 1,
+ ManagedHttpsJson = 2,
+ NativeTcpUdp = 3,
+ ProtocolGateway = 4
+ }
+
+ public enum UnitTrackingAuthMode
+ {
+ Unknown = 0,
+ Bearer = 1,
+ Basic = 2,
+ CustomHeader = 3,
+ CapabilityPath = 4
+ }
+
+ public enum UnitTrackingDeviceStatus
+ {
+ NeverSeen = 0,
+ Online = 1,
+ Stale = 2,
+ Error = 3,
+ Disabled = 4
+ }
+
+ public enum TrackingTimestampSource
+ {
+ Unknown = 0,
+ Device = 1,
+ Server = 2
+ }
+
+ public enum UnitTrackingCertificationStatus
+ {
+ Unknown = 0,
+ Candidate = 1,
+ FixtureVerified = 2,
+ HardwareVerified = 3,
+ Certified = 4,
+ Deprecated = 5
+ }
+}
diff --git a/Core/Resgrid.Model/UnitTrackingResults.cs b/Core/Resgrid.Model/UnitTrackingResults.cs
new file mode 100644
index 000000000..22c065ce1
--- /dev/null
+++ b/Core/Resgrid.Model/UnitTrackingResults.cs
@@ -0,0 +1,31 @@
+namespace Resgrid.Model
+{
+ public sealed class UnitTrackingGeneratedCredential
+ {
+ public string Token { get; set; }
+ public string KeyPrefix { get; set; }
+ public string SecretHash { get; set; }
+ }
+
+ public sealed class UnitTrackingCredentialProvisionResult
+ {
+ public UnitTrackingCredential Credential { get; set; }
+ public string Token { get; set; }
+ public string EndpointUrl { get; set; }
+ public string HeaderName { get; set; }
+ public string HeaderValue { get; set; }
+ public string BasicUsername { get; set; }
+ }
+
+ public sealed class UnitTrackingAuthenticationResult
+ {
+ public UnitTrackingDevice Device { get; set; }
+ public UnitTrackingCredential Credential { get; set; }
+ }
+
+ public sealed class ResolvedUnitLocation
+ {
+ public UnitsLocation Location { get; set; }
+ public bool IsStale { get; set; }
+ }
+}
diff --git a/Core/Resgrid.Model/UnitsLocation.cs b/Core/Resgrid.Model/UnitsLocation.cs
index b4b4cf791..226b6b273 100644
--- a/Core/Resgrid.Model/UnitsLocation.cs
+++ b/Core/Resgrid.Model/UnitsLocation.cs
@@ -9,6 +9,10 @@ namespace Resgrid.Model
[BsonCollection("unitLocations")]
public class UnitsLocation : NoSqlDocument
{
+ [BsonElement("eventId")]
+ [BsonIgnoreIfNull]
+ public string EventId { get; set; }
+
[BsonElement("departmentId")]
public int DepartmentId { get; set; }
@@ -19,6 +23,27 @@ public class UnitsLocation : NoSqlDocument
[BsonElement("timestamp")]
public DateTime Timestamp { get; set; }
+ [BsonElement("receivedOn")]
+ public DateTime? ReceivedOn { get; set; }
+
+ [BsonElement("sourceType")]
+ public int SourceType { get; set; }
+
+ [BsonElement("sourceId")]
+ public string SourceId { get; set; }
+
+ [BsonElement("sourcePriority")]
+ public int SourcePriority { get; set; }
+
+ [BsonElement("transportType")]
+ public int? TransportType { get; set; }
+
+ [BsonElement("protocolKey")]
+ public string ProtocolKey { get; set; }
+
+ [BsonElement("isValidFix")]
+ public bool? IsValidFix { get; set; }
+
[BsonElement("latitude")]
public decimal Latitude { get; set; }
@@ -40,6 +65,33 @@ public class UnitsLocation : NoSqlDocument
[BsonElement("heading")]
public decimal? Heading { get; set; }
+ [BsonElement("satellites")]
+ public int? Satellites { get; set; }
+
+ [BsonElement("hdop")]
+ public decimal? Hdop { get; set; }
+
+ [BsonElement("batteryPercent")]
+ public decimal? BatteryPercent { get; set; }
+
+ [BsonElement("externalPowerVolts")]
+ public decimal? ExternalPowerVolts { get; set; }
+
+ [BsonElement("signalPercent")]
+ public int? SignalPercent { get; set; }
+
+ [BsonElement("ignition")]
+ public bool? Ignition { get; set; }
+
+ [BsonElement("isMoving")]
+ public bool? IsMoving { get; set; }
+
+ [BsonElement("alarmCode")]
+ public string AlarmCode { get; set; }
+
+ [BsonElement("timestampSource")]
+ public int? TimestampSource { get; set; }
+
[BsonIgnore()]
public string PgId { get; set; }
diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs
index 545fcee97..f6d9708d6 100644
--- a/Core/Resgrid.Services/AuditService.cs
+++ b/Core/Resgrid.Services/AuditService.cs
@@ -178,7 +178,27 @@ public string GetAuditLogTypeString(AuditLogTypes logType)
return "Calendar Check-In Deleted";
case AuditLogTypes.CalendarAdminCheckInPerformed:
return "Admin Calendar Check-In";
- }
+ case AuditLogTypes.UnitTrackingDeviceCreated:
+ return "Unit Tracking Device Created";
+ case AuditLogTypes.UnitTrackingDeviceUpdated:
+ return "Unit Tracking Device Updated";
+ case AuditLogTypes.UnitTrackingDeviceDisabled:
+ return "Unit Tracking Device Disabled";
+ case AuditLogTypes.UnitTrackingDeviceDeleted:
+ return "Unit Tracking Device Deleted";
+ case AuditLogTypes.UnitTrackingCredentialCreated:
+ return "Unit Tracking Credential Created";
+ case AuditLogTypes.UnitTrackingCredentialRotated:
+ return "Unit Tracking Credential Rotated";
+ case AuditLogTypes.UnitTrackingCredentialRevoked:
+ return "Unit Tracking Credential Revoked";
+ case AuditLogTypes.DeleteDepartmentRequested:
+ return "Department Deletion Requested";
+ case AuditLogTypes.DeleteDepartmentRequestedCancelled:
+ return "Department Deletion Request Cancelled";
+ case AuditLogTypes.DeleteDepartmentRequestExecuted:
+ return "Department Deletion Executed";
+ }
return $"Unknown ({logType})";
}
diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs
index a1346c966..6393dd053 100644
--- a/Core/Resgrid.Services/DeleteService.cs
+++ b/Core/Resgrid.Services/DeleteService.cs
@@ -1,4 +1,5 @@
using System;
+using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -36,6 +37,7 @@ public class DeleteService : IDeleteService
private readonly IQueueService _queueService;
private readonly IEmailService _emailService;
private readonly IDeleteRepository _deleteRepository;
+ private readonly IAuditLogsRepository _auditLogsRepository;
public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService,
ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService,
@@ -44,7 +46,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
IDistributionListsService distributionListsService, IShiftsService shiftsService, IUnitsService unitsService,
ICertificationService certificationService, ILogService logService, IInventoryService inventoryService,
IEventAggregator eventAggregator, IAddressService addressService, IQueueService queueService, IEmailService emailService,
- IDeleteRepository deleteRepository)
+ IDeleteRepository deleteRepository, IAuditLogsRepository auditLogsRepository)
{
_authorizationService = authorizationService;
_departmentsService = departmentsService;
@@ -68,6 +70,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer
_queueService = queueService;
_emailService = emailService;
_deleteRepository = deleteRepository;
+ _auditLogsRepository = auditLogsRepository;
}
public async Task DeleteUserAsync(int departmentId, string authorizingUserId, string userIdToDelete)
@@ -201,32 +204,29 @@ public async Task DeleteUserAsync(int departmentId, string au
if (!await _authorizationService.CanUserDeleteDepartmentAsync(authorizingUserId, departmentId))
return DeleteDepartmentResults.UnAuthorized;
+ // Only one pending deletion request per department; don't stack duplicates (and their emails).
+ var existingRequest = await _queueService.GetPendingDeleteDepartmentQueueItemAsync(departmentId);
+ if (existingRequest != null)
+ return DeleteDepartmentResults.NoFailure;
+
+ var result = await _queueService.EnqueuePendingDeleteDepartmentAsync(departmentId, authorizingUserId, cancellationToken);
+
var auditEvent = new AuditEvent();
auditEvent.Before = null;
auditEvent.DepartmentId = departmentId;
auditEvent.UserId = authorizingUserId;
auditEvent.Type = AuditLogTypes.DeleteDepartmentRequested;
- auditEvent.After = null;
+ auditEvent.After = result?.CloneJsonToString();
auditEvent.Successful = true;
auditEvent.IpAddress = ipAddress;
auditEvent.ServerName = Environment.MachineName;
auditEvent.UserAgent = userAgent;
_eventAggregator.SendMessage(auditEvent);
- var result = await _queueService.EnqueuePendingDeleteDepartmentAsync(departmentId, authorizingUserId, cancellationToken);
-
if (result != null)
{
var department = await _departmentsService.GetDepartmentByIdAsync(departmentId);
-
- var ownerUserProfile = await _userProfileService.GetProfileByUserIdAsync(authorizingUserId);
- var result2 = await _emailService.SendDeleteDepartmentEmail(ownerUserProfile.User.Email, ownerUserProfile.FullName.AsFirstNameLastName, result);
-
- foreach (var adminUser in department.AdminUsers)
- {
- var adminUserProfile = await _userProfileService.GetProfileByUserIdAsync(adminUser);
- var result1 = await _emailService.SendDeleteDepartmentEmail(adminUserProfile.User.Email, adminUserProfile.FullName.AsFirstNameLastName, result);
- }
+ await SendDeleteDepartmentEmailToAllAdminsAsync(department, result);
}
return DeleteDepartmentResults.NoFailure;
@@ -234,30 +234,31 @@ public async Task DeleteUserAsync(int departmentId, string au
public async Task HandlePendingDepartmentDeletionRequestAsync(QueueItem item, CancellationToken cancellationToken = default(CancellationToken))
{
- if (!await _authorizationService.CanUserDeleteDepartmentAsync(item.QueuedByUserId, int.Parse(item.SourceId)))
- return DeleteDepartmentResults.UnAuthorized;
-
- if (item.ToBeCompletedOn.HasValue && DateTime.UtcNow >= item.ToBeCompletedOn.Value.AddDays(-10) && item.ReminderCount == 0)
+ if (!int.TryParse(item.SourceId, out var departmentId))
{
- /*
- * You have a pending department deletion request, it is within 10 days out and we have no yet sent a reminder.
- */
+ Logging.LogError($"DeleteService::Pending department deletion QueueItemId {item.QueueItemId} has a malformed SourceId '{item.SourceId}'; setting a terminal state so it is not retried.");
+ await SetTerminalQueueItemStateAsync(item, $"Department deletion failed: malformed SourceId '{item.SourceId}'.", cancellationToken);
- var department = await _departmentsService.GetDepartmentByIdAsync(int.Parse(item.SourceId));
+ return DeleteDepartmentResults.Failure;
+ }
- var ownerUserProfile = await _userProfileService.GetProfileByUserIdAsync(item.QueuedByUserId);
- var result2 = await _emailService.SendDeleteDepartmentEmail(ownerUserProfile.User.Email, ownerUserProfile.FullName.AsFirstNameLastName, item);
+ if (!item.ToBeCompletedOn.HasValue)
+ return DeleteDepartmentResults.Failure;
- foreach (var adminUser in department.AdminUsers)
- {
- var adminUserProfile = await _userProfileService.GetProfileByUserIdAsync(adminUser);
- var result1 = await _emailService.SendDeleteDepartmentEmail(adminUserProfile.User.Email, adminUserProfile.FullName.AsFirstNameLastName, item);
- }
+ var now = DateTime.UtcNow;
+
+ if (!await _authorizationService.CanUserDeleteDepartmentAsync(item.QueuedByUserId, departmentId))
+ {
+ // A past-due item whose requester is no longer the managing user can never execute;
+ // finalize it so it stops looping in the pending set (a current admin can still
+ // cancel a not-yet-due item from department settings instead).
+ if (now >= item.ToBeCompletedOn.Value)
+ await SetTerminalQueueItemStateAsync(item, $"Department deletion abandoned: requesting user {item.QueuedByUserId} is no longer the managing user.", cancellationToken);
- item.ReminderCount += 1;
- var result = await _queueService.UpdateQueueItem(item, cancellationToken);
+ return DeleteDepartmentResults.UnAuthorized;
}
- else if (item.ToBeCompletedOn.HasValue && DateTime.UtcNow >= item.ToBeCompletedOn.Value)
+
+ if (now >= item.ToBeCompletedOn.Value)
{
/*
* You have a pending department deletion request and it can be executed now.
@@ -265,21 +266,166 @@ public async Task DeleteUserAsync(int departmentId, string au
try
{
- var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(int.Parse(item.SourceId));
+ Logging.LogInfo($"DeleteService::Executing pending department deletion for DepartmentId {item.SourceId}, requested by UserId {item.QueuedByUserId} on {item.QueuedOn:u}, scheduled for {item.ToBeCompletedOn:u}");
+
+ var result = await _deleteRepository.DeleteDepartmentAndUsersAsync(departmentId);
+
+ // Write the execution audit record only after the delete succeeds: retried
+ // attempts must not each leave an "executed" row. The row is written directly
+ // (not via the queued audit events, which resolve the now-deleted user profile
+ // when processed), and AuditLogs are not deleted with the department, so it
+ // survives as the durable trail for the actual deletion.
+ await WriteDepartmentDeletionExecutedAuditLogAsync(item, departmentId, cancellationToken);
item.CompletedOn = DateTime.UtcNow;
+ item.Data = "Department deletion executed by the system.";
var result2 = await _queueService.UpdateQueueItem(item, cancellationToken);
}
catch (Exception e)
{
Logging.LogException(e);
- Logging.SendExceptionEmail(e, "DeleteDepartment", int.Parse(item.SourceId));
+ Logging.SendExceptionEmail(e, "DeleteDepartment", departmentId);
+
+ // Bounded retry: a transient failure (DB timeout/deadlock) must not permanently
+ // abort a scheduled deletion. The item stays pending (CompletedOn null) and is
+ // retried on the next poll until the attempt budget is exhausted; only then set
+ // a terminal state so it stops re-queuing.
+ const int maxAttempts = 5;
+ item.AttemptCount += 1;
+
+ if (item.AttemptCount >= maxAttempts)
+ {
+ await SetTerminalQueueItemStateAsync(item, $"Department deletion permanently failed after {item.AttemptCount} attempts: {e.Message}", cancellationToken);
+ }
+ else
+ {
+ item.Data = $"Department deletion attempt {item.AttemptCount} failed: {e.Message}";
+
+ try
+ {
+ await _queueService.UpdateQueueItem(item, cancellationToken);
+ }
+ catch (Exception updateEx)
+ {
+ // Persisting the retry state failed; the item stays pending and is retried
+ // next poll regardless, but don't let this mask the original exception or
+ // abort the worker's processing of other pending items.
+ Logging.LogException(updateEx, $"DeleteService::Failed to persist retry state for department deletion QueueItemId {item.QueueItemId}");
+ }
+ }
return DeleteDepartmentResults.Failure;
}
}
+ else if (now.Date >= item.ToBeCompletedOn.Value.Date && item.ReminderCount < 3)
+ {
+ /*
+ * Deletion is scheduled for today (final reminder).
+ */
+ await SendDeleteDepartmentReminderAndMarkAsync(item, departmentId, 3, cancellationToken);
+ }
+ else if (now >= item.ToBeCompletedOn.Value.AddDays(-5) && item.ReminderCount < 2)
+ {
+ /*
+ * Deletion is within 5 days and the 5-day reminder has not been sent yet.
+ */
+ await SendDeleteDepartmentReminderAndMarkAsync(item, departmentId, 2, cancellationToken);
+ }
+ else if (now >= item.ToBeCompletedOn.Value.AddDays(-14) && item.ReminderCount < 1)
+ {
+ /*
+ * Deletion is within 14 days and the 14-day reminder has not been sent yet.
+ */
+ await SendDeleteDepartmentReminderAndMarkAsync(item, departmentId, 1, cancellationToken);
+ }
return DeleteDepartmentResults.NoFailure;
}
+
+ private async Task SendDeleteDepartmentReminderAndMarkAsync(QueueItem item, int departmentId, int reminderLevel, CancellationToken cancellationToken)
+ {
+ await SendDeleteDepartmentReminderToAllAdminsAsync(item, departmentId);
+ item.ReminderCount = reminderLevel;
+ await _queueService.UpdateQueueItem(item, cancellationToken);
+ }
+
+ private async Task SendDeleteDepartmentReminderToAllAdminsAsync(QueueItem item, int departmentId)
+ {
+ var department = await _departmentsService.GetDepartmentByIdAsync(departmentId);
+ await SendDeleteDepartmentEmailToAllAdminsAsync(department, item);
+ }
+
+ private async Task SetTerminalQueueItemStateAsync(QueueItem item, string data, CancellationToken cancellationToken)
+ {
+ try
+ {
+ item.CompletedOn = DateTime.UtcNow;
+ item.Data = data;
+ await _queueService.UpdateQueueItem(item, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, $"DeleteService::Failed to set terminal state on failed department deletion QueueItemId {item.QueueItemId}");
+ }
+ }
+
+ private async Task SendDeleteDepartmentEmailToAllAdminsAsync(Department department, QueueItem item)
+ {
+ if (department == null || item == null)
+ return;
+
+ // Notify the managing member AND every department admin, deduped, so no single
+ // person is the only one who knows a deletion is pending.
+ var adminUserIds = new List();
+
+ if (!String.IsNullOrWhiteSpace(department.ManagingUserId))
+ adminUserIds.Add(department.ManagingUserId);
+
+ if (department.AdminUsers != null)
+ adminUserIds.AddRange(department.AdminUsers);
+
+ foreach (var adminUserId in adminUserIds.Distinct())
+ {
+ try
+ {
+ var adminUserProfile = await _userProfileService.GetProfileByUserIdAsync(adminUserId);
+
+ if (adminUserProfile?.User == null || String.IsNullOrWhiteSpace(adminUserProfile.User.Email))
+ continue;
+
+ await _emailService.SendDeleteDepartmentEmail(adminUserProfile.User.Email, adminUserProfile.FullName.AsFirstNameLastName, item);
+ }
+ catch (Exception ex)
+ {
+ // A single bad recipient must not block notifications to the remaining admins.
+ Logging.LogException(ex, $"DeleteService::Failed to send department deletion email to UserId {adminUserId} for DepartmentId {item.SourceId}");
+ }
+ }
+ }
+
+ private async Task WriteDepartmentDeletionExecutedAuditLogAsync(QueueItem item, int departmentId, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var auditLog = new AuditLog();
+ auditLog.DepartmentId = departmentId;
+ auditLog.UserId = item.QueuedByUserId;
+ auditLog.LogType = (int)AuditLogTypes.DeleteDepartmentRequestExecuted;
+ auditLog.Message = $"The system executed the pending department deletion request for department id {item.SourceId}";
+ auditLog.Data = $"QueueItemId: {item.QueueItemId}; RequestedByUserId: {item.QueuedByUserId}; QueuedOn (UTC): {item.QueuedOn:u}; ScheduledFor (UTC): {item.ToBeCompletedOn:u}; RemindersSent: {item.ReminderCount}";
+ auditLog.Successful = true;
+ auditLog.IpAddress = null;
+ auditLog.UserAgent = null;
+ auditLog.ServerName = Environment.MachineName;
+ auditLog.LoggedOn = DateTime.UtcNow;
+
+ await _auditLogsRepository.SaveOrUpdateAsync(auditLog, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ // Auditing must never block the deletion itself.
+ Logging.LogException(ex, $"DeleteService::Failed to write department deletion executed audit log for DepartmentId {item.SourceId}");
+ }
+ }
}
}
diff --git a/Core/Resgrid.Services/DepartmentSettingsService.cs b/Core/Resgrid.Services/DepartmentSettingsService.cs
index df62825f7..83aa5601c 100644
--- a/Core/Resgrid.Services/DepartmentSettingsService.cs
+++ b/Core/Resgrid.Services/DepartmentSettingsService.cs
@@ -24,6 +24,9 @@ public class DepartmentSettingsService : IDepartmentSettingsService
private static string PersonnelOnUnitSetUnitStatusCacheKey = "DSetPersonnelOnUnitSetUnitStatus_{0}";
private static string ModernNotificationsCacheKey = "DSetModernNotifications_{0}";
private static string ForceChatbotSecurityPinCacheKey = "DSetForceChatbotSecurityPin_{0}";
+ private static string HardwareTrackingStaleAfterSecondsCacheKey = "DSetHardwareTrackingStale_{0}";
+ private static string HardwareTrackingMobileFallbackCacheKey = "DSetHardwareTrackingFallback_{0}";
+ private static string HardwareTrackingRetentionDaysCacheKey = "DSetHardwareTrackingRetention_{0}";
private static TimeSpan LongCacheLength = TimeSpan.FromDays(14);
private static TimeSpan ThatsNotLongThisIsLongCacheLength = TimeSpan.FromDays(365);
private static TimeSpan TwoYearCacheLength = TimeSpan.FromDays(730);
@@ -45,14 +48,10 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting
public async Task SaveOrUpdateSettingAsync(int departmentId, string setting, DepartmentSettingTypes type, CancellationToken cancellationToken = default(CancellationToken))
{
var savedSetting = await GetSettingByDepartmentIdType(departmentId, type);
+ await InvalidateSettingCacheAsync(departmentId, type);
if (savedSetting == null)
{
- // First-time write still needs to clear any cache populated by the fallback
- // "new DepartmentModuleSettings()" in GetDepartmentModuleSettingsAsync (cached 365 days).
- if (type == DepartmentSettingTypes.ModuleSettings)
- await _cacheProvider.RemoveAsync(string.Format(ModuleSettingsCacheKey, departmentId));
-
DepartmentSetting newSetting = new DepartmentSetting();
newSetting.DepartmentId = departmentId;
newSetting.Setting = setting;
@@ -62,35 +61,6 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting
}
else
{
- // Clear out Cache
- switch (type)
- {
- case DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates:
- await _cacheProvider.RemoveAsync(string.Format(BigBoardCenterGps, departmentId));
- break;
- case DepartmentSettingTypes.DisabledAutoAvailable:
- await _cacheProvider.RemoveAsync(string.Format(DisableAutoAvailableCacheKey, departmentId));
- break;
- case DepartmentSettingTypes.StaffingSuppressStaffingLevels:
- await _cacheProvider.RemoveAsync(string.Format(StaffingSupressInfo, departmentId));
- break;
- case DepartmentSettingTypes.ModuleSettings:
- await _cacheProvider.RemoveAsync(string.Format(ModuleSettingsCacheKey, departmentId));
- break;
- case DepartmentSettingTypes.TtsLanguage:
- await _cacheProvider.RemoveAsync(string.Format(TtsLanguageCacheKey, departmentId));
- break;
- case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus:
- await _cacheProvider.RemoveAsync(string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId));
- break;
- case DepartmentSettingTypes.EnableModernNotifications:
- await _cacheProvider.RemoveAsync(string.Format(ModernNotificationsCacheKey, departmentId));
- break;
- case DepartmentSettingTypes.ForceChatbotSecurityPin:
- await _cacheProvider.RemoveAsync(string.Format(ForceChatbotSecurityPinCacheKey, departmentId));
- break;
- }
-
savedSetting.Setting = setting;
return await _departmentSettingsRepository.SaveOrUpdateAsync(savedSetting, cancellationToken);
}
@@ -103,7 +73,13 @@ public DepartmentSettingsService(IDepartmentSettingsRepository departmentSetting
var savedSetting = await GetSettingByDepartmentIdType(departmentId, type);
if (savedSetting != null)
- return await _departmentSettingsRepository.DeleteAsync(savedSetting, cancellationToken);
+ {
+ var deleted = await _departmentSettingsRepository.DeleteAsync(savedSetting, cancellationToken);
+ if (deleted)
+ await InvalidateSettingCacheAsync(departmentId, type);
+
+ return deleted;
+ }
return false;
}
@@ -978,6 +954,117 @@ async Task getSetting()
return bool.Parse(await getSetting());
}
+ public async Task GetHardwareTrackingStaleAfterSecondsAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getSetting()
+ {
+ var setting = await GetSettingByDepartmentIdType(
+ departmentId,
+ DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds);
+ return setting?.Setting ?? "180";
+ }
+
+ var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache
+ ? await _cacheProvider.RetrieveAsync(
+ string.Format(HardwareTrackingStaleAfterSecondsCacheKey, departmentId),
+ getSetting,
+ LongCacheLength)
+ : await getSetting();
+
+ return int.TryParse(value, out var seconds) ? Math.Max(1, seconds) : 180;
+ }
+
+ public async Task GetHardwareTrackingMobileFallbackEnabledAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getSetting()
+ {
+ var setting = await GetSettingByDepartmentIdType(
+ departmentId,
+ DepartmentSettingTypes.HardwareTrackingMobileFallbackEnabled);
+ return setting?.Setting ?? "true";
+ }
+
+ var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache
+ ? await _cacheProvider.RetrieveAsync(
+ string.Format(HardwareTrackingMobileFallbackCacheKey, departmentId),
+ getSetting,
+ LongCacheLength)
+ : await getSetting();
+
+ return !bool.TryParse(value, out var enabled) || enabled;
+ }
+
+ public async Task GetHardwareTrackingLocationRetentionDaysAsync(int departmentId, bool bypassCache = false)
+ {
+ async Task getSetting()
+ {
+ var setting = await GetSettingByDepartmentIdType(
+ departmentId,
+ DepartmentSettingTypes.HardwareTrackingLocationRetentionDays);
+ return setting?.Setting ?? UnitTrackingConfig.DefaultLocationRetentionDays.ToString();
+ }
+
+ var value = Config.SystemBehaviorConfig.CacheEnabled && !bypassCache
+ ? await _cacheProvider.RetrieveAsync(
+ string.Format(HardwareTrackingRetentionDaysCacheKey, departmentId),
+ getSetting,
+ LongCacheLength)
+ : await getSetting();
+
+ var retentionDays = int.TryParse(value, out var parsed)
+ ? parsed
+ : UnitTrackingConfig.DefaultLocationRetentionDays;
+
+ return Math.Min(
+ UnitTrackingConfig.MaximumLocationRetentionDays,
+ Math.Max(UnitTrackingConfig.MinimumLocationRetentionDays, retentionDays));
+ }
+
+ private async Task InvalidateSettingCacheAsync(int departmentId, DepartmentSettingTypes type)
+ {
+ string cacheKey = null;
+
+ switch (type)
+ {
+ case DepartmentSettingTypes.BigBoardMapCenterGpsCoordinates:
+ cacheKey = string.Format(BigBoardCenterGps, departmentId);
+ break;
+ case DepartmentSettingTypes.DisabledAutoAvailable:
+ cacheKey = string.Format(DisableAutoAvailableCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.StaffingSuppressStaffingLevels:
+ cacheKey = string.Format(StaffingSupressInfo, departmentId);
+ break;
+ case DepartmentSettingTypes.ModuleSettings:
+ cacheKey = string.Format(ModuleSettingsCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.TtsLanguage:
+ cacheKey = string.Format(TtsLanguageCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.PersonnelOnUnitSetUnitStatus:
+ cacheKey = string.Format(PersonnelOnUnitSetUnitStatusCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.EnableModernNotifications:
+ cacheKey = string.Format(ModernNotificationsCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.ForceChatbotSecurityPin:
+ cacheKey = string.Format(ForceChatbotSecurityPinCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.HardwareTrackingStaleAfterSeconds:
+ cacheKey = string.Format(HardwareTrackingStaleAfterSecondsCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.HardwareTrackingMobileFallbackEnabled:
+ cacheKey = string.Format(HardwareTrackingMobileFallbackCacheKey, departmentId);
+ break;
+ case DepartmentSettingTypes.HardwareTrackingLocationRetentionDays:
+ cacheKey = string.Format(HardwareTrackingRetentionDaysCacheKey, departmentId);
+ break;
+ }
+
+ if (!string.IsNullOrWhiteSpace(cacheKey))
+ await _cacheProvider.RemoveAsync(cacheKey);
+ }
+
private static string GetDefaultTtsLanguage()
{
if (EspeakVoiceCatalog.TryNormalizeIdentifier(TtsConfig.DefaultVoice, out var normalizedVoice))
diff --git a/Core/Resgrid.Services/QueueService.cs b/Core/Resgrid.Services/QueueService.cs
index a6bd6d006..b90457a2b 100644
--- a/Core/Resgrid.Services/QueueService.cs
+++ b/Core/Resgrid.Services/QueueService.cs
@@ -13,6 +13,13 @@ namespace Resgrid.Services
{
public class QueueService : IQueueService
{
+ // Shared predicate for pending department-deletion queue items. No ToBeCompletedOn filter:
+ // a past-due request that hasn't executed yet is still pending and must remain visible
+ // (and cancellable) until the worker completes it; the handler decides between reminders
+ // and execution. Keep both queries below on this single predicate so they can't drift.
+ private static readonly Func IsPendingDepartmentDeletion =
+ x => x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null;
+
private readonly IQueueItemsRepository _queueItemsRepository;
private readonly IOutboundQueueProvider _outboundQueueProvider;
private readonly IDepartmentSettingsService _departmentSettingsService;
@@ -47,8 +54,11 @@ public async Task GetQueueItemByIdAsync(int queueItemId)
public async Task GetPendingDeleteDepartmentQueueItemAsync(int departmentId)
{
var allItems = await _queueItemsRepository.GetAllAsync();
- var depItem = allItems.FirstOrDefault(x =>
- x.SourceId == departmentId.ToString() && x.ToBeCompletedOn > DateTime.UtcNow && x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null);
+
+ var depItem = allItems.Where(x =>
+ x.SourceId == departmentId.ToString() && IsPendingDepartmentDeletion(x))
+ .OrderByDescending(x => x.QueuedOn)
+ .FirstOrDefault();
return depItem;
}
@@ -56,8 +66,8 @@ public async Task GetPendingDeleteDepartmentQueueItemAsync(int depart
public async Task> GetAllPendingDeleteDepartmentQueueItemsAsync()
{
var allItems = await _queueItemsRepository.GetAllAsync();
- var depItems = allItems.Where(x =>
- x.ToBeCompletedOn > DateTime.UtcNow && x.QueueType == (int)QueueTypes.DeleteDepartment && x.CompletedOn == null).ToList();
+
+ var depItems = allItems.Where(IsPendingDepartmentDeletion).ToList();
return depItems;
}
diff --git a/Core/Resgrid.Services/ServicesModule.cs b/Core/Resgrid.Services/ServicesModule.cs
index 1f20f7da2..a6e620892 100644
--- a/Core/Resgrid.Services/ServicesModule.cs
+++ b/Core/Resgrid.Services/ServicesModule.cs
@@ -56,6 +56,14 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().SingleInstance();
+ builder.RegisterType().As().SingleInstance();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().SingleInstance();
+ builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
diff --git a/Core/Resgrid.Services/UnitLocationSourceResolver.cs b/Core/Resgrid.Services/UnitLocationSourceResolver.cs
new file mode 100644
index 000000000..0fce5c1db
--- /dev/null
+++ b/Core/Resgrid.Services/UnitLocationSourceResolver.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class UnitLocationSourceResolver : IUnitLocationSourceResolver
+ {
+ private readonly IDepartmentSettingsService _departmentSettingsService;
+
+ public UnitLocationSourceResolver(IDepartmentSettingsService departmentSettingsService)
+ {
+ _departmentSettingsService = departmentSettingsService;
+ }
+
+ public async Task ResolveAsync(
+ int departmentId,
+ IReadOnlyCollection locations,
+ DateTime? utcNow = null)
+ {
+ if (departmentId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(departmentId));
+
+ if (locations == null || locations.Count == 0)
+ return null;
+
+ var latestPerSource = locations
+ .Where(location =>
+ location != null &&
+ location.DepartmentId == departmentId &&
+ location.IsValidFix != false)
+ .GroupBy(location => new
+ {
+ location.SourceType,
+ SourceId = location.SourceId ?? string.Empty
+ })
+ .Select(group => group
+ .OrderByDescending(location => location.Timestamp)
+ .ThenByDescending(location => location.ReceivedOn ?? location.Timestamp)
+ .First())
+ .ToList();
+
+ if (latestPerSource.Count == 0)
+ return null;
+
+ var staleAfterSeconds =
+ await _departmentSettingsService.GetHardwareTrackingStaleAfterSecondsAsync(departmentId);
+ var mobileFallbackEnabled =
+ await _departmentSettingsService.GetHardwareTrackingMobileFallbackEnabledAsync(departmentId);
+ var now = utcNow ?? DateTime.UtcNow;
+ var freshThreshold = now.AddSeconds(-Math.Max(1, staleAfterSeconds));
+ var fresh = latestPerSource
+ .Where(location => (location.ReceivedOn ?? location.Timestamp) >= freshThreshold)
+ .ToList();
+
+ var hasHardwareHistory = latestPerSource.Any(location =>
+ location.SourceType == (int)UnitLocationSourceType.HardwareTracker);
+ var hasFreshHardware = fresh.Any(location =>
+ location.SourceType == (int)UnitLocationSourceType.HardwareTracker);
+
+ if (!mobileFallbackEnabled && hasHardwareHistory && !hasFreshHardware)
+ return null;
+
+ var selected = fresh
+ .OrderByDescending(location => location.SourcePriority)
+ .ThenByDescending(location => location.Timestamp)
+ .ThenByDescending(location => location.ReceivedOn ?? location.Timestamp)
+ .FirstOrDefault();
+
+ if (selected != null)
+ {
+ return new ResolvedUnitLocation
+ {
+ Location = selected,
+ IsStale = false
+ };
+ }
+
+ if (!mobileFallbackEnabled)
+ return null;
+
+ selected = latestPerSource
+ .OrderByDescending(location => location.Timestamp)
+ .ThenByDescending(location => location.ReceivedOn ?? location.Timestamp)
+ .First();
+
+ return new ResolvedUnitLocation
+ {
+ Location = selected,
+ IsStale = true
+ };
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs b/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs
new file mode 100644
index 000000000..6c5654a63
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingAuthenticationService.cs
@@ -0,0 +1,288 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Security.Cryptography;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Model;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingAuthenticationService : IUnitTrackingAuthenticationService
+ {
+ private readonly IUnitTrackingCredentialsRepository _credentialsRepository;
+ private readonly IUnitTrackingDevicesRepository _devicesRepository;
+ private readonly IUnitTrackingIdentifierService _identifierService;
+ private readonly ICacheProvider _cacheProvider;
+
+ public UnitTrackingAuthenticationService(
+ IUnitTrackingCredentialsRepository credentialsRepository,
+ IUnitTrackingDevicesRepository devicesRepository,
+ IUnitTrackingIdentifierService identifierService,
+ ICacheProvider cacheProvider)
+ {
+ _credentialsRepository = credentialsRepository;
+ _devicesRepository = devicesRepository;
+ _identifierService = identifierService;
+ _cacheProvider = cacheProvider;
+ }
+
+ public UnitTrackingGeneratedCredential GenerateCredential()
+ {
+ EnsurePepperConfigured();
+
+ var secretBytes = RandomNumberGenerator.GetBytes(32);
+ var prefixBytes = RandomNumberGenerator.GetBytes(6);
+ var encodedSecret = EncodeBase64Url(secretBytes);
+ var keyPrefix = EncodeBase64Url(prefixBytes);
+ var token = $"rgtrk_{keyPrefix}_{encodedSecret}";
+
+ return new UnitTrackingGeneratedCredential
+ {
+ Token = token,
+ KeyPrefix = keyPrefix,
+ SecretHash = ComputeSecretHash(token)
+ };
+ }
+
+ public string ComputeSecretHash(string token)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ throw new ArgumentNullException(nameof(token));
+
+ EnsurePepperConfigured();
+
+ using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(UnitTrackingConfig.CredentialPepper));
+ return Convert.ToHexString(hmac.ComputeHash(Encoding.UTF8.GetBytes(token))).ToLowerInvariant();
+ }
+
+ public bool VerifySecret(string token, string storedHash)
+ {
+ if (string.IsNullOrWhiteSpace(token) ||
+ string.IsNullOrWhiteSpace(storedHash) ||
+ storedHash.Length != 64)
+ return false;
+
+ try
+ {
+ var computed = Convert.FromHexString(ComputeSecretHash(token));
+ var stored = Convert.FromHexString(storedHash);
+ return CryptographicOperations.FixedTimeEquals(computed, stored);
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ }
+
+ public async Task AuthenticateAsync(
+ string token,
+ DateTime? utcNow = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(token))
+ return null;
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var secretHash = ComputeSecretHash(token);
+
+ async Task load()
+ {
+ var credential = await _credentialsRepository.GetBySecretHashAsync(secretHash);
+ if (credential == null || !VerifySecret(token, credential.SecretHash))
+ return null;
+
+ var device = await _devicesRepository.GetByIdAsync(credential.UnitTrackingDeviceId);
+ if (device == null)
+ return null;
+
+ return new UnitTrackingAuthenticationResult
+ {
+ Device = device,
+ Credential = credential
+ };
+ }
+
+ var result = SystemBehaviorConfig.CacheEnabled
+ ? await _cacheProvider.RetrieveAsync(
+ UnitTrackingCacheKeys.Credential(secretHash),
+ load,
+ TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, UnitTrackingConfig.CredentialCacheSeconds))))
+ : await load();
+
+ return IsActive(result, utcNow ?? DateTime.UtcNow) ? result : null;
+ }
+
+ public async Task GetEnabledDeviceByEndpointIdAsync(
+ string deviceId,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ return null;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ async Task load()
+ {
+ var device = await _devicesRepository.GetByIdAsync(deviceId);
+ return IsEnabled(device) ? device : null;
+ }
+
+ return SystemBehaviorConfig.CacheEnabled
+ ? await _cacheProvider.RetrieveAsync(
+ UnitTrackingCacheKeys.Endpoint(deviceId),
+ load,
+ TimeSpan.FromSeconds(Math.Max(1, Math.Min(300, UnitTrackingConfig.DeviceMappingCacheSeconds))))
+ : await load();
+ }
+
+ public async Task GetEnabledDeviceByProtocolIdentifierAsync(
+ string protocolKey,
+ string deviceIdentifier,
+ CancellationToken cancellationToken = default)
+ {
+ var normalizedProtocol = NormalizeKey(protocolKey);
+ var normalizedIdentifier = _identifierService.Normalize(deviceIdentifier);
+ if (normalizedProtocol == null || normalizedIdentifier == null)
+ return null;
+
+ cancellationToken.ThrowIfCancellationRequested();
+
+ async Task load()
+ {
+ var device = await _devicesRepository.GetByProtocolIdentifierAsync(
+ normalizedProtocol,
+ normalizedIdentifier);
+ return IsEnabled(device) ? device : null;
+ }
+
+ return SystemBehaviorConfig.CacheEnabled
+ ? await _cacheProvider.RetrieveAsync(
+ UnitTrackingCacheKeys.ProtocolIdentifier(normalizedProtocol, normalizedIdentifier),
+ load,
+ TimeSpan.FromSeconds(Math.Max(1, Math.Min(300, UnitTrackingConfig.DeviceMappingCacheSeconds))))
+ : await load();
+ }
+
+ public async Task> GetActiveCredentialsForDeviceAsync(
+ string deviceId,
+ DateTime? utcNow = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ return Array.Empty();
+
+ cancellationToken.ThrowIfCancellationRequested();
+ var device = await GetEnabledDeviceByEndpointIdAsync(deviceId, cancellationToken);
+ if (device == null)
+ return Array.Empty();
+
+ async Task> load()
+ {
+ var credentials = await _credentialsRepository.GetAllByDeviceIdAsync(deviceId);
+ return credentials?
+ .Select(SanitizeCredential)
+ .ToList() ?? new List();
+ }
+
+ var cached = SystemBehaviorConfig.CacheEnabled
+ ? await _cacheProvider.RetrieveAsync(
+ UnitTrackingCacheKeys.DeviceCredentials(deviceId),
+ load,
+ TimeSpan.FromSeconds(Math.Max(1, Math.Min(60, UnitTrackingConfig.CredentialCacheSeconds))))
+ : await load();
+ cached ??= new List();
+ var now = utcNow ?? DateTime.UtcNow;
+
+ return cached
+ .Where(credential => IsActive(credential, now))
+ .ToList();
+ }
+
+ public async Task InvalidateCredentialAsync(string secretHash)
+ {
+ if (!string.IsNullOrWhiteSpace(secretHash))
+ await _cacheProvider.RemoveAsync(UnitTrackingCacheKeys.Credential(secretHash));
+ }
+
+ public async Task InvalidateDeviceAsync(UnitTrackingDevice device)
+ {
+ if (device == null || string.IsNullOrWhiteSpace(device.UnitTrackingDeviceId))
+ return;
+
+ await _cacheProvider.RemoveAsync(UnitTrackingCacheKeys.Endpoint(device.UnitTrackingDeviceId));
+ await _cacheProvider.RemoveAsync(UnitTrackingCacheKeys.DeviceCredentials(device.UnitTrackingDeviceId));
+
+ var protocolKey = NormalizeKey(device.ProtocolKey);
+ var identifier = _identifierService.Normalize(device.DeviceIdentifier);
+ if (protocolKey != null && identifier != null)
+ {
+ await _cacheProvider.RemoveAsync(
+ UnitTrackingCacheKeys.ProtocolIdentifier(protocolKey, identifier));
+ }
+ }
+
+ private static bool IsActive(UnitTrackingAuthenticationResult result, DateTime utcNow)
+ {
+ if (result?.Credential == null || !IsEnabled(result.Device))
+ return false;
+
+ return result.Credential.ValidFrom <= utcNow &&
+ !result.Credential.RevokedOn.HasValue &&
+ (!result.Credential.ExpiresOn.HasValue || result.Credential.ExpiresOn > utcNow);
+ }
+
+ private static bool IsActive(UnitTrackingCredential credential, DateTime utcNow)
+ {
+ return credential != null &&
+ credential.ValidFrom <= utcNow &&
+ !credential.RevokedOn.HasValue &&
+ (!credential.ExpiresOn.HasValue || credential.ExpiresOn > utcNow);
+ }
+
+ private static bool IsEnabled(UnitTrackingDevice device) =>
+ device != null && device.IsEnabled && !device.IsDeleted;
+
+ private static string NormalizeKey(string value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
+
+ private static string EncodeBase64Url(byte[] value) =>
+ Convert.ToBase64String(value)
+ .TrimEnd('=')
+ .Replace('+', '-')
+ .Replace('/', '_');
+
+ private static UnitTrackingCredential SanitizeCredential(UnitTrackingCredential credential)
+ {
+ return new UnitTrackingCredential
+ {
+ UnitTrackingCredentialId = credential.UnitTrackingCredentialId,
+ UnitTrackingDeviceId = credential.UnitTrackingDeviceId,
+ AuthMode = credential.AuthMode,
+ HeaderName = credential.HeaderName,
+ BasicUsername = credential.BasicUsername,
+ KeyPrefix = credential.KeyPrefix,
+ ValidFrom = credential.ValidFrom,
+ ExpiresOn = credential.ExpiresOn,
+ RevokedOn = credential.RevokedOn,
+ LastUsedOn = credential.LastUsedOn,
+ CreatedByUserId = credential.CreatedByUserId,
+ CreatedOn = credential.CreatedOn
+ };
+ }
+
+ private static void EnsurePepperConfigured()
+ {
+ if (string.IsNullOrWhiteSpace(UnitTrackingConfig.CredentialPepper))
+ {
+ throw new InvalidOperationException(
+ "UnitTrackingConfig.CredentialPepper must be configured before using tracking credentials.");
+ }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingCacheKeys.cs b/Core/Resgrid.Services/UnitTrackingCacheKeys.cs
new file mode 100644
index 000000000..58623ea43
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingCacheKeys.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+
+namespace Resgrid.Services
+{
+ internal static class UnitTrackingCacheKeys
+ {
+ public static string Endpoint(string deviceId) =>
+ $"UnitTracking:Endpoint:{Digest(deviceId)}";
+
+ public static string Credential(string secretHash) =>
+ $"UnitTracking:Credential:{secretHash?.ToLowerInvariant()}";
+
+ public static string DeviceCredentials(string deviceId) =>
+ $"UnitTracking:DeviceCredentials:{Digest(deviceId)}";
+
+ public static string ProtocolIdentifier(string protocolKey, string deviceIdentifier) =>
+ $"UnitTracking:Protocol:{Digest($"{protocolKey}|{deviceIdentifier}")}";
+
+ private static string Digest(string value)
+ {
+ if (string.IsNullOrWhiteSpace(value))
+ throw new ArgumentNullException(nameof(value));
+
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value)))
+ .ToLowerInvariant();
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingCatalogService.cs b/Core/Resgrid.Services/UnitTrackingCatalogService.cs
new file mode 100644
index 000000000..4e3441948
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingCatalogService.cs
@@ -0,0 +1,84 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingCatalogService : IUnitTrackingCatalogService
+ {
+ private static readonly IReadOnlyCollection Profiles =
+ new[]
+ {
+ new UnitTrackingCatalogProfile
+ {
+ Key = "generic-https",
+ ManufacturerKey = "generic",
+ ManufacturerName = "Generic",
+ Model = "Resgrid JSON",
+ TransportType = UnitTrackingTransportType.NativeHttps,
+ ProtocolKey = "resgrid-json",
+ PayloadAdapterKey = "resgrid-json-v1",
+ CertificationStatus = UnitTrackingCertificationStatus.Certified,
+ IdentifierRequired = false,
+ IsSelectable = true,
+ SupportedAuthModes = new[]
+ {
+ UnitTrackingAuthMode.Bearer,
+ UnitTrackingAuthMode.Basic,
+ UnitTrackingAuthMode.CustomHeader,
+ UnitTrackingAuthMode.CapabilityPath
+ },
+ SetupSummary =
+ "POST the documented Resgrid JSON position payload to the generated HTTPS endpoint.",
+ RetryExpectation =
+ "Retry on 429 and 503. Treat 200, 201, 202, and 204 as successful delivery."
+ },
+ new UnitTrackingCatalogProfile
+ {
+ Key = "traccar-forwarder",
+ ManufacturerKey = "traccar",
+ ManufacturerName = "Traccar",
+ Model = "Position Forwarding",
+ TransportType = UnitTrackingTransportType.ProtocolGateway,
+ ProtocolKey = "traccar",
+ PayloadAdapterKey = "traccar-json-v1",
+ CertificationStatus = UnitTrackingCertificationStatus.Candidate,
+ IdentifierRequired = true,
+ IsSelectable = false,
+ SupportedAuthModes = new[]
+ {
+ UnitTrackingAuthMode.Bearer,
+ UnitTrackingAuthMode.Basic,
+ UnitTrackingAuthMode.CustomHeader,
+ UnitTrackingAuthMode.CapabilityPath
+ },
+ SetupSummary =
+ "Configure Traccar v6.14.5 JSON position forwarding. Use a per-device capability URL when one Traccar server forwards multiple devices.",
+ RetryExpectation =
+ "Enable Traccar position-forwarding retries. Resgrid returns 202 after durable queue acceptance and 429 or 503 for retryable failures."
+ }
+ };
+
+ public Task> GetProfilesAsync(
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(Profiles);
+ }
+
+ public Task GetProfileAsync(
+ string profileKey,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var profile = Profiles.FirstOrDefault(item =>
+ string.Equals(item.Key, profileKey?.Trim(), StringComparison.OrdinalIgnoreCase));
+ return Task.FromResult(profile);
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingEventIdService.cs b/Core/Resgrid.Services/UnitTrackingEventIdService.cs
new file mode 100644
index 000000000..c22c34fab
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingEventIdService.cs
@@ -0,0 +1,32 @@
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingEventIdService : IUnitTrackingEventIdService
+ {
+ private const int MaximumCallerEventIdLength = 256;
+
+ public string CreateForHttps(string unitTrackingDeviceId, string callerEventId)
+ {
+ if (string.IsNullOrWhiteSpace(unitTrackingDeviceId))
+ throw new ArgumentNullException(nameof(unitTrackingDeviceId));
+ if (string.IsNullOrWhiteSpace(callerEventId))
+ throw new ArgumentNullException(nameof(callerEventId));
+
+ var normalizedCallerEventId = callerEventId.Trim();
+ if (normalizedCallerEventId.Length > MaximumCallerEventIdLength)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(callerEventId),
+ $"Tracking event identifiers cannot exceed {MaximumCallerEventIdLength} characters.");
+ }
+
+ var input = $"https|{unitTrackingDeviceId.Trim()}|{normalizedCallerEventId}";
+ return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(input)))
+ .ToLowerInvariant();
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingIdentifierService.cs b/Core/Resgrid.Services/UnitTrackingIdentifierService.cs
new file mode 100644
index 000000000..20db90a0b
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingIdentifierService.cs
@@ -0,0 +1,41 @@
+using System;
+using System.Text;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingIdentifierService : IUnitTrackingIdentifierService
+ {
+ private const int MaximumIdentifierLength = 128;
+
+ public string Normalize(string identifier)
+ {
+ if (string.IsNullOrWhiteSpace(identifier))
+ return null;
+
+ var normalized = identifier
+ .Trim()
+ .Normalize(NormalizationForm.FormKC)
+ .ToUpperInvariant();
+
+ if (normalized.Length > MaximumIdentifierLength)
+ throw new ArgumentOutOfRangeException(
+ nameof(identifier),
+ $"Tracking identifiers cannot exceed {MaximumIdentifierLength} characters.");
+
+ return normalized;
+ }
+
+ public string Mask(string identifier)
+ {
+ var normalized = Normalize(identifier);
+ if (normalized == null)
+ return null;
+
+ if (normalized.Length <= 4)
+ return new string('*', normalized.Length);
+
+ return new string('*', normalized.Length - 4) + normalized.Substring(normalized.Length - 4);
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingIngressService.cs b/Core/Resgrid.Services/UnitTrackingIngressService.cs
new file mode 100644
index 000000000..01d7e8b5f
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingIngressService.cs
@@ -0,0 +1,380 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Config;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Services;
+using Resgrid.Model.Tracking;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingIngressService : IUnitTrackingIngressService
+ {
+ private const int MaximumAlarmCodeLength = 64;
+ private const int MaximumEventIdLength = 256;
+
+ private readonly IUnitLocationEventProvider _unitLocationEventProvider;
+ private readonly IUnitTrackingDevicesRepository _devicesRepository;
+ private readonly IUnitTrackingEventIdService _eventIdService;
+ private readonly IUnitTrackingIdentifierService _identifierService;
+ private readonly IDepartmentSettingsService _departmentSettingsService;
+ private readonly IUnitsService _unitsService;
+
+ public UnitTrackingIngressService(
+ IUnitLocationEventProvider unitLocationEventProvider,
+ IUnitTrackingDevicesRepository devicesRepository,
+ IUnitTrackingEventIdService eventIdService,
+ IUnitTrackingIdentifierService identifierService,
+ IDepartmentSettingsService departmentSettingsService,
+ IUnitsService unitsService)
+ {
+ _unitLocationEventProvider = unitLocationEventProvider;
+ _devicesRepository = devicesRepository;
+ _eventIdService = eventIdService;
+ _identifierService = identifierService;
+ _departmentSettingsService = departmentSettingsService;
+ _unitsService = unitsService;
+ }
+
+ public async Task AcceptAsync(
+ AuthenticatedTrackingSource source,
+ IReadOnlyCollection positions,
+ CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var receivedOn = GetReceivedOn(positions);
+
+ if (!IsEnabled(source?.Device))
+ return Invalid(receivedOn, "The tracking binding is not enabled.");
+
+ var device = source.Device;
+
+ if (positions == null || positions.Count == 0)
+ {
+ await TryUpdateDeviceStatusAsync(device, receivedOn, null, "empty-payload", cancellationToken);
+ return Invalid(receivedOn, "At least one position is required.");
+ }
+
+ if (positions.Count > Math.Max(1, UnitTrackingConfig.MaxBatchPositions))
+ {
+ await TryUpdateDeviceStatusAsync(device, receivedOn, null, "batch-limit", cancellationToken);
+ return Invalid(receivedOn, "The position batch exceeds the configured limit.");
+ }
+
+ var unit = await _unitsService.GetUnitByIdAsync(device.UnitId);
+ if (unit == null || unit.DepartmentId != device.DepartmentId)
+ {
+ await TryUpdateDeviceStatusAsync(device, receivedOn, null, "tenant-binding-invalid", cancellationToken);
+ return Invalid(receivedOn, "The tracking binding is invalid.");
+ }
+
+ if (!IdentifierMatches(device.DeviceIdentifier, source.ReportedDeviceIdentifier))
+ {
+ await TryUpdateDeviceStatusAsync(device, receivedOn, null, "identifier-mismatch", cancellationToken);
+ return Invalid(receivedOn, "The reported device identifier does not match the binding.");
+ }
+
+ var retentionDays =
+ await _departmentSettingsService.GetHardwareTrackingLocationRetentionDaysAsync(device.DepartmentId);
+ var normalization = NormalizeAll(positions, retentionDays);
+ if (normalization.Errors.Count > 0)
+ {
+ await TryUpdateDeviceStatusAsync(device, receivedOn, null, "invalid-payload", cancellationToken);
+ return new TrackingIngressResult
+ {
+ Status = TrackingIngressStatus.Invalid,
+ ReceivedOn = receivedOn,
+ Errors = normalization.Errors
+ };
+ }
+
+ var events = normalization.Positions
+ .Where(position => position.IsValidFix)
+ .Select(position => BuildEvent(device, position))
+ .ToList();
+
+ if (events.Count > 0)
+ {
+ var published = await _unitLocationEventProvider.EnqueueUnitLocationEventsAsync(events, cancellationToken);
+ if (!published)
+ {
+ await TryUpdateDeviceStatusAsync(
+ device,
+ receivedOn,
+ normalization.Positions,
+ "queue-unavailable",
+ cancellationToken);
+ return new TrackingIngressResult
+ {
+ Status = TrackingIngressStatus.Unavailable,
+ ReceivedOn = receivedOn
+ };
+ }
+ }
+
+ await TryUpdateDeviceStatusAsync(
+ device,
+ receivedOn,
+ normalization.Positions,
+ null,
+ cancellationToken);
+
+ return new TrackingIngressResult
+ {
+ Status = TrackingIngressStatus.Accepted,
+ Accepted = events.Count,
+ DuplicatesPossible = false,
+ ReceivedOn = receivedOn
+ };
+ }
+
+ private NormalizationResult NormalizeAll(
+ IReadOnlyCollection positions,
+ int retentionDays)
+ {
+ var normalized = new List(positions.Count);
+ var errors = new List();
+ var index = 0;
+
+ foreach (var position in positions)
+ {
+ var itemErrors = new List();
+ var item = Normalize(position, Math.Max(1, retentionDays), itemErrors);
+ if (itemErrors.Count > 0)
+ errors.AddRange(itemErrors.Select(error => $"positions[{index}]: {error}"));
+ else
+ normalized.Add(item);
+
+ index++;
+ }
+
+ return new NormalizationResult(normalized, errors);
+ }
+
+ private CanonicalTrackingPosition Normalize(
+ CanonicalTrackingPosition position,
+ int retentionDays,
+ ICollection errors)
+ {
+ if (position == null)
+ {
+ errors.Add("Position is required.");
+ return null;
+ }
+
+ if (string.IsNullOrWhiteSpace(position.EventId))
+ errors.Add("eventId is required.");
+ else if (position.EventId.Trim().Length > MaximumEventIdLength)
+ errors.Add($"eventId cannot exceed {MaximumEventIdLength} characters.");
+
+ if (position.Latitude < -90m || position.Latitude > 90m)
+ errors.Add("latitude must be between -90 and 90.");
+ if (position.Longitude < -180m || position.Longitude > 180m)
+ errors.Add("longitude must be between -180 and 180.");
+
+ var receivedOn = EnsureUtc(
+ position.ReceivedOnUtc == default ? DateTime.UtcNow : position.ReceivedOnUtc);
+ var timestampSource = position.TimestampSource;
+ var timestamp = position.TimestampUtc;
+ if (timestamp == default)
+ {
+ timestamp = receivedOn;
+ timestampSource = TrackingTimestampSource.Server;
+ }
+ else
+ {
+ timestamp = EnsureUtc(timestamp);
+ if (timestampSource == TrackingTimestampSource.Unknown)
+ timestampSource = TrackingTimestampSource.Device;
+ }
+
+ if (timestamp > receivedOn.AddSeconds(Math.Max(0, UnitTrackingConfig.MaxFutureSkewSeconds)))
+ errors.Add("timestamp is too far in the future.");
+ if (timestamp < receivedOn.AddDays(-retentionDays))
+ errors.Add("timestamp is outside the configured retention window.");
+
+ var alarmCode = string.IsNullOrWhiteSpace(position.AlarmCode)
+ ? null
+ : position.AlarmCode.Trim();
+ if (alarmCode?.Length > MaximumAlarmCodeLength)
+ errors.Add($"alarmCode cannot exceed {MaximumAlarmCodeLength} characters.");
+
+ return new CanonicalTrackingPosition
+ {
+ EventId = position.EventId?.Trim(),
+ TimestampUtc = timestamp,
+ ReceivedOnUtc = receivedOn,
+ Latitude = position.Latitude,
+ Longitude = position.Longitude,
+ AccuracyMeters = NonNegative(position.AccuracyMeters),
+ AltitudeMeters = position.AltitudeMeters,
+ SpeedMetersPerSecond = NonNegative(position.SpeedMetersPerSecond),
+ HeadingDegrees = NormalizeHeading(position.HeadingDegrees),
+ Satellites = NonNegative(position.Satellites),
+ Hdop = NonNegative(position.Hdop),
+ BatteryPercent = Percentage(position.BatteryPercent),
+ ExternalPowerVolts = NonNegative(position.ExternalPowerVolts),
+ SignalPercent = Percentage(position.SignalPercent),
+ Ignition = position.Ignition,
+ IsMoving = position.IsMoving,
+ AlarmCode = alarmCode,
+ TimestampSource = timestampSource,
+ IsValidFix = position.IsValidFix
+ };
+ }
+
+ private UnitLocationEvent BuildEvent(
+ UnitTrackingDevice device,
+ CanonicalTrackingPosition position)
+ {
+ return new UnitLocationEvent
+ {
+ EventId = _eventIdService.CreateForHttps(device.UnitTrackingDeviceId, position.EventId),
+ DepartmentId = device.DepartmentId,
+ UnitId = device.UnitId,
+ Timestamp = position.TimestampUtc,
+ ReceivedOn = position.ReceivedOnUtc,
+ Latitude = position.Latitude,
+ Longitude = position.Longitude,
+ Accuracy = position.AccuracyMeters,
+ Altitude = position.AltitudeMeters,
+ Speed = position.SpeedMetersPerSecond,
+ Heading = position.HeadingDegrees,
+ SourceType = (int)UnitLocationSourceType.HardwareTracker,
+ SourceId = device.UnitTrackingDeviceId,
+ SourcePriority = device.SourcePriority,
+ TransportType = device.TransportType,
+ ProtocolKey = device.ProtocolKey,
+ IsValidFix = position.IsValidFix,
+ Satellites = position.Satellites,
+ Hdop = position.Hdop,
+ BatteryPercent = position.BatteryPercent,
+ ExternalPowerVolts = position.ExternalPowerVolts,
+ SignalPercent = position.SignalPercent,
+ Ignition = position.Ignition,
+ IsMoving = position.IsMoving,
+ AlarmCode = position.AlarmCode,
+ TimestampSource = (int)position.TimestampSource
+ };
+ }
+
+ private async Task TryUpdateDeviceStatusAsync(
+ UnitTrackingDevice device,
+ DateTime receivedOn,
+ IReadOnlyCollection positions,
+ string errorCode,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ device.LastSeenOn = receivedOn;
+ device.LastErrorCode = errorCode;
+ device.LastStatus = errorCode == null
+ ? (int)UnitTrackingDeviceStatus.Online
+ : (int)UnitTrackingDeviceStatus.Error;
+
+ var validPositions = positions?
+ .Where(position => position.IsValidFix)
+ .ToList();
+ if (validPositions?.Count > 0)
+ {
+ device.LastPositionOn = validPositions.Max(position => position.TimestampUtc);
+ device.LastReceivedOn = validPositions.Max(position => position.ReceivedOnUtc);
+ }
+
+ await _devicesRepository.UpdateAsync(device, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex, "Unable to update tracking device status metadata.");
+ }
+ }
+
+ private bool IdentifierMatches(string configured, string reported)
+ {
+ if (string.IsNullOrWhiteSpace(reported))
+ return true;
+
+ try
+ {
+ var normalizedReported = _identifierService.Normalize(reported);
+ var normalizedConfigured = _identifierService.Normalize(configured);
+ return normalizedConfigured != null &&
+ string.Equals(normalizedConfigured, normalizedReported, StringComparison.Ordinal);
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ private static bool IsEnabled(UnitTrackingDevice device) =>
+ device != null && device.IsEnabled && !device.IsDeleted;
+
+ private static TrackingIngressResult Invalid(DateTime receivedOn, string error) =>
+ new()
+ {
+ Status = TrackingIngressStatus.Invalid,
+ ReceivedOn = receivedOn,
+ Errors = new[] { error }
+ };
+
+ private static DateTime GetReceivedOn(IReadOnlyCollection positions)
+ {
+ var receivedOn = positions?
+ .Where(position => position != null && position.ReceivedOnUtc != default)
+ .Select(position => EnsureUtc(position.ReceivedOnUtc))
+ .DefaultIfEmpty(DateTime.UtcNow)
+ .Min() ?? DateTime.UtcNow;
+ return receivedOn;
+ }
+
+ private static DateTime EnsureUtc(DateTime value) =>
+ value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+
+ private static decimal? NonNegative(decimal? value) =>
+ value.HasValue && value.Value >= 0m ? value : null;
+
+ private static int? NonNegative(int? value) =>
+ value.HasValue && value.Value >= 0 ? value : null;
+
+ private static decimal? Percentage(decimal? value) =>
+ value.HasValue && value.Value >= 0m && value.Value <= 100m ? value : null;
+
+ private static int? Percentage(int? value) =>
+ value.HasValue && value.Value >= 0 && value.Value <= 100 ? value : null;
+
+ private static decimal? NormalizeHeading(decimal? heading)
+ {
+ if (!heading.HasValue)
+ return null;
+
+ return ((heading.Value % 360m) + 360m) % 360m;
+ }
+
+ private sealed class NormalizationResult
+ {
+ public NormalizationResult(
+ IReadOnlyCollection positions,
+ IReadOnlyCollection errors)
+ {
+ Positions = positions;
+ Errors = errors;
+ }
+
+ public IReadOnlyCollection Positions { get; }
+ public IReadOnlyCollection Errors { get; }
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingService.cs b/Core/Resgrid.Services/UnitTrackingService.cs
new file mode 100644
index 000000000..e7705bddd
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingService.cs
@@ -0,0 +1,789 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Resgrid.Config;
+using Resgrid.Framework;
+using Resgrid.Model;
+using Resgrid.Model.Events;
+using Resgrid.Model.Providers;
+using Resgrid.Model.Repositories;
+using Resgrid.Model.Repositories.Queries;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingService : IUnitTrackingService
+ {
+ private readonly IUnitTrackingDevicesRepository _devicesRepository;
+ private readonly IUnitTrackingCredentialsRepository _credentialsRepository;
+ private readonly IUnitTrackingAuthenticationService _authenticationService;
+ private readonly IUnitTrackingIdentifierService _identifierService;
+ private readonly IUnitsService _unitsService;
+ private readonly IEventAggregator _eventAggregator;
+ private readonly IUnitOfWork _unitOfWork;
+
+ public UnitTrackingService(
+ IUnitTrackingDevicesRepository devicesRepository,
+ IUnitTrackingCredentialsRepository credentialsRepository,
+ IUnitTrackingAuthenticationService authenticationService,
+ IUnitTrackingIdentifierService identifierService,
+ IUnitsService unitsService,
+ IEventAggregator eventAggregator,
+ IUnitOfWork unitOfWork)
+ {
+ _devicesRepository = devicesRepository;
+ _credentialsRepository = credentialsRepository;
+ _authenticationService = authenticationService;
+ _identifierService = identifierService;
+ _unitsService = unitsService;
+ _eventAggregator = eventAggregator;
+ _unitOfWork = unitOfWork;
+ }
+
+ public async Task GetDeviceByIdAsync(string deviceId, int departmentId)
+ {
+ var device = await _devicesRepository.GetByIdAsync(deviceId);
+ return device != null && device.DepartmentId == departmentId && !device.IsDeleted
+ ? device
+ : null;
+ }
+
+ public async Task> GetDevicesForDepartmentAsync(int departmentId)
+ {
+ var devices = await _devicesRepository.GetAllByDepartmentIdAsync(departmentId);
+ return devices?.Where(device => !device.IsDeleted).ToList() ?? new List();
+ }
+
+ public async Task> GetDevicesForUnitAsync(int departmentId, int unitId)
+ {
+ var devices = await _devicesRepository.GetAllByUnitIdAsync(departmentId, unitId);
+ return devices?.Where(device => !device.IsDeleted).ToList() ?? new List();
+ }
+
+ public async Task> GetCredentialsForDeviceAsync(string deviceId, int departmentId)
+ {
+ await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false);
+ var credentials = await _credentialsRepository.GetAllByDeviceIdAsync(deviceId);
+ return credentials?.Select(SanitizeCredential).ToList() ?? new List();
+ }
+
+ public async Task CreateDeviceAsync(
+ UnitTrackingDevice device,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default)
+ {
+ if (device == null)
+ throw new ArgumentNullException(nameof(device));
+
+ ValidateUserAndDepartment(userId, departmentId);
+ await ValidateUnitOwnershipAsync(device.UnitId, departmentId);
+
+ var now = DateTime.UtcNow;
+ var created = new UnitTrackingDevice
+ {
+ UnitTrackingDeviceId = Guid.NewGuid().ToString(),
+ DepartmentId = departmentId,
+ UnitId = device.UnitId,
+ DisplayName = TrimToNull(device.DisplayName),
+ ManufacturerKey = NormalizeKey(device.ManufacturerKey),
+ ModelKey = NormalizeKey(device.ModelKey),
+ TransportType = ValidateTransportType(device.TransportType),
+ ProtocolKey = NormalizeKey(device.ProtocolKey),
+ PayloadAdapterKey = NormalizeKey(device.PayloadAdapterKey),
+ DeviceIdentifier = _identifierService.Normalize(device.DeviceIdentifier),
+ SecondaryIdentifier = _identifierService.Normalize(device.SecondaryIdentifier),
+ IsEnabled = device.IsEnabled,
+ IsDeleted = false,
+ SourcePriority = device.SourcePriority,
+ AllowedSourceCidrs = ValidateAllowedSourceCidrs(device.AllowedSourceCidrs),
+ LastStatus = device.IsEnabled
+ ? (int)UnitTrackingDeviceStatus.NeverSeen
+ : (int)UnitTrackingDeviceStatus.Disabled,
+ FirmwareVersion = TrimToNull(device.FirmwareVersion),
+ CreatedByUserId = userId,
+ CreatedOn = now
+ };
+
+ var saved = await _devicesRepository.InsertAsync(created, cancellationToken);
+ await _authenticationService.InvalidateDeviceAsync(saved);
+ PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceCreated, null, DeviceAuditSnapshot(saved));
+ return saved;
+ }
+
+ public async Task UpdateDeviceAsync(
+ UnitTrackingDevice device,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default)
+ {
+ if (device == null)
+ throw new ArgumentNullException(nameof(device));
+
+ ValidateUserAndDepartment(userId, departmentId);
+ var existing = await GetOwnedDeviceAsync(device.UnitTrackingDeviceId, departmentId, includeDeleted: false);
+
+ if (device.UnitId != existing.UnitId)
+ {
+ throw new InvalidOperationException(
+ "A physical tracker must be rebound by creating a new binding; UnitId cannot be edited in place.");
+ }
+
+ var disableRequested = existing.IsEnabled && !device.IsEnabled;
+ await ValidateUnitOwnershipAsync(existing.UnitId, departmentId);
+ var credentials = await GetCredentialEntitiesAsync(existing.UnitTrackingDeviceId);
+ var before = DeviceAuditSnapshot(existing);
+ var oldCacheIdentity = CopyCacheIdentity(existing);
+
+ existing.DisplayName = TrimToNull(device.DisplayName);
+ existing.ManufacturerKey = NormalizeKey(device.ManufacturerKey);
+ existing.ModelKey = NormalizeKey(device.ModelKey);
+ existing.TransportType = ValidateTransportType(device.TransportType);
+ existing.ProtocolKey = NormalizeKey(device.ProtocolKey);
+ existing.PayloadAdapterKey = NormalizeKey(device.PayloadAdapterKey);
+ existing.DeviceIdentifier = _identifierService.Normalize(device.DeviceIdentifier);
+ existing.SecondaryIdentifier = _identifierService.Normalize(device.SecondaryIdentifier);
+ existing.IsEnabled = device.IsEnabled;
+ existing.SourcePriority = device.SourcePriority;
+ existing.AllowedSourceCidrs = ValidateAllowedSourceCidrs(device.AllowedSourceCidrs);
+ existing.FirmwareVersion = TrimToNull(device.FirmwareVersion);
+ existing.UpdatedByUserId = userId;
+ existing.UpdatedOn = DateTime.UtcNow;
+ if (disableRequested)
+ {
+ return await PersistDisabledOrDeletedDeviceAsync(
+ existing,
+ oldCacheIdentity,
+ credentials,
+ before,
+ departmentId,
+ userId,
+ false,
+ cancellationToken);
+ }
+
+ if (device.IsEnabled && existing.LastStatus == (int)UnitTrackingDeviceStatus.Disabled)
+ existing.LastStatus = (int)UnitTrackingDeviceStatus.NeverSeen;
+
+ var saved = await _devicesRepository.UpdateAsync(existing, cancellationToken);
+ await InvalidateDeviceAndCredentialsAsync(oldCacheIdentity, saved, credentials);
+ PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceUpdated, before, DeviceAuditSnapshot(saved));
+ return saved;
+ }
+
+ public Task DisableDeviceAsync(
+ string deviceId,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default)
+ {
+ return DisableOrDeleteDeviceAsync(deviceId, departmentId, userId, false, cancellationToken);
+ }
+
+ public Task DeleteDeviceAsync(
+ string deviceId,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default)
+ {
+ return DisableOrDeleteDeviceAsync(deviceId, departmentId, userId, true, cancellationToken);
+ }
+
+ public async Task RebindDeviceAsync(
+ string deviceId,
+ int departmentId,
+ int newUnitId,
+ string userId,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateUserAndDepartment(userId, departmentId);
+ var existing = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false);
+ if (existing.UnitId == newUnitId)
+ throw new InvalidOperationException("The tracking device is already bound to the requested Unit.");
+
+ await ValidateUnitOwnershipAsync(newUnitId, departmentId);
+ var credentials = await GetCredentialEntitiesAsync(deviceId);
+ var before = DeviceAuditSnapshot(existing);
+ var oldCacheIdentity = CopyCacheIdentity(existing);
+ var now = DateTime.UtcNow;
+
+ await _unitOfWork.CreateOrGetConnectionAsync(cancellationToken);
+ UnitTrackingDevice replacement;
+ try
+ {
+ replacement = CopyForRebind(existing, newUnitId, userId, now);
+ existing.IsEnabled = false;
+ existing.IsDeleted = true;
+ existing.LastStatus = (int)UnitTrackingDeviceStatus.Disabled;
+ existing.UpdatedByUserId = userId;
+ existing.UpdatedOn = now;
+ await _devicesRepository.UpdateAsync(existing, cancellationToken);
+ await RevokeCredentialsAsync(credentials, now, cancellationToken);
+
+ await _devicesRepository.InsertAsync(replacement, cancellationToken);
+ _unitOfWork.CommitChanges();
+ }
+ catch (Exception ex)
+ {
+ _unitOfWork.DiscardChanges();
+ Logging.LogException(ex, $"Unit tracking device rebind failed for device {deviceId} in department {departmentId}.");
+ throw;
+ }
+
+ await InvalidateDeviceAndCredentialsAsync(oldCacheIdentity, replacement, credentials);
+ PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceDeleted, before, DeviceAuditSnapshot(existing));
+ PublishAudit(departmentId, userId, AuditLogTypes.UnitTrackingDeviceCreated, null, DeviceAuditSnapshot(replacement));
+ return replacement;
+ }
+
+ public async Task CreateCredentialAsync(
+ string deviceId,
+ int departmentId,
+ UnitTrackingAuthMode authMode,
+ string userId,
+ string headerName = null,
+ string basicUsername = null,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateUserAndDepartment(userId, departmentId);
+ var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false);
+ if (!device.IsEnabled)
+ throw new InvalidOperationException("Credentials cannot be created for a disabled tracking device.");
+
+ ValidateAuthMode(authMode, headerName, basicUsername);
+ var generated = _authenticationService.GenerateCredential();
+ var now = DateTime.UtcNow;
+ var credential = new UnitTrackingCredential
+ {
+ UnitTrackingCredentialId = Guid.NewGuid().ToString(),
+ UnitTrackingDeviceId = deviceId,
+ AuthMode = (int)authMode,
+ HeaderName = authMode == UnitTrackingAuthMode.CustomHeader ? headerName.Trim() : null,
+ BasicUsername = authMode == UnitTrackingAuthMode.Basic ? basicUsername.Trim() : null,
+ KeyPrefix = generated.KeyPrefix,
+ SecretHash = generated.SecretHash,
+ ValidFrom = now,
+ CreatedByUserId = userId,
+ CreatedOn = now
+ };
+
+ var saved = await _credentialsRepository.InsertAsync(credential, cancellationToken);
+ await _authenticationService.InvalidateCredentialAsync(saved.SecretHash);
+ await _authenticationService.InvalidateDeviceAsync(device);
+ PublishAudit(
+ departmentId,
+ userId,
+ AuditLogTypes.UnitTrackingCredentialCreated,
+ null,
+ CredentialAuditSnapshot(saved));
+
+ return BuildProvisioningResult(device, saved, generated.Token);
+ }
+
+ public async Task RotateCredentialAsync(
+ string deviceId,
+ string credentialId,
+ int departmentId,
+ string userId,
+ TimeSpan? overlap = null,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateUserAndDepartment(userId, departmentId);
+ var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false);
+ if (!device.IsEnabled)
+ throw new InvalidOperationException("Credentials cannot be rotated for a disabled tracking device.");
+
+ var existing = await GetOwnedCredentialAsync(credentialId, deviceId);
+ if (existing.RevokedOn.HasValue)
+ throw new InvalidOperationException("A revoked tracking credential cannot be rotated.");
+
+ var rotationOverlap = overlap ??
+ TimeSpan.FromHours(Math.Max(0, UnitTrackingConfig.CredentialRotationOverlapHours));
+ if (rotationOverlap < TimeSpan.Zero)
+ throw new ArgumentOutOfRangeException(nameof(overlap));
+
+ var generated = _authenticationService.GenerateCredential();
+ var now = DateTime.UtcNow;
+ var replacement = new UnitTrackingCredential
+ {
+ UnitTrackingCredentialId = Guid.NewGuid().ToString(),
+ UnitTrackingDeviceId = deviceId,
+ AuthMode = existing.AuthMode,
+ HeaderName = existing.HeaderName,
+ BasicUsername = existing.BasicUsername,
+ KeyPrefix = generated.KeyPrefix,
+ SecretHash = generated.SecretHash,
+ ValidFrom = now,
+ CreatedByUserId = userId,
+ CreatedOn = now
+ };
+ var before = CredentialAuditSnapshot(existing);
+
+ _unitOfWork.CreateOrGetConnection();
+ try
+ {
+ existing.ExpiresOn = now.Add(rotationOverlap);
+ await _credentialsRepository.UpdateAsync(existing, cancellationToken);
+ await _credentialsRepository.InsertAsync(replacement, cancellationToken);
+ _unitOfWork.CommitChanges();
+ }
+ catch
+ {
+ _unitOfWork.DiscardChanges();
+ throw;
+ }
+
+ await _authenticationService.InvalidateCredentialAsync(existing.SecretHash);
+ await _authenticationService.InvalidateCredentialAsync(replacement.SecretHash);
+ await _authenticationService.InvalidateDeviceAsync(device);
+ PublishAudit(
+ departmentId,
+ userId,
+ AuditLogTypes.UnitTrackingCredentialRotated,
+ before,
+ new
+ {
+ Previous = CredentialAuditSnapshot(existing),
+ Current = CredentialAuditSnapshot(replacement)
+ });
+
+ return BuildProvisioningResult(device, replacement, generated.Token);
+ }
+
+ public async Task RevokeCredentialAsync(
+ string deviceId,
+ string credentialId,
+ int departmentId,
+ string userId,
+ CancellationToken cancellationToken = default)
+ {
+ ValidateUserAndDepartment(userId, departmentId);
+ var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false);
+ var credential = await GetOwnedCredentialAsync(credentialId, deviceId);
+ var before = CredentialAuditSnapshot(credential);
+
+ if (!credential.RevokedOn.HasValue)
+ {
+ credential.RevokedOn = DateTime.UtcNow;
+ await _credentialsRepository.UpdateAsync(credential, cancellationToken);
+ }
+
+ await _authenticationService.InvalidateCredentialAsync(credential.SecretHash);
+ await _authenticationService.InvalidateDeviceAsync(device);
+ PublishAudit(
+ departmentId,
+ userId,
+ AuditLogTypes.UnitTrackingCredentialRevoked,
+ before,
+ CredentialAuditSnapshot(credential));
+
+ return SanitizeCredential(credential);
+ }
+
+ private async Task DisableOrDeleteDeviceAsync(
+ string deviceId,
+ int departmentId,
+ string userId,
+ bool delete,
+ CancellationToken cancellationToken)
+ {
+ ValidateUserAndDepartment(userId, departmentId);
+ var device = await GetOwnedDeviceAsync(deviceId, departmentId, includeDeleted: false);
+ var before = DeviceAuditSnapshot(device);
+ var oldCacheIdentity = CopyCacheIdentity(device);
+ var credentials = await GetCredentialEntitiesAsync(deviceId);
+ return await PersistDisabledOrDeletedDeviceAsync(
+ device,
+ oldCacheIdentity,
+ credentials,
+ before,
+ departmentId,
+ userId,
+ delete,
+ cancellationToken);
+ }
+
+ private async Task PersistDisabledOrDeletedDeviceAsync(
+ UnitTrackingDevice device,
+ UnitTrackingDevice oldCacheIdentity,
+ IEnumerable credentials,
+ object before,
+ int departmentId,
+ string userId,
+ bool delete,
+ CancellationToken cancellationToken)
+ {
+ var now = DateTime.UtcNow;
+
+ _unitOfWork.CreateOrGetConnection();
+ try
+ {
+ device.IsEnabled = false;
+ device.IsDeleted = delete;
+ device.LastStatus = (int)UnitTrackingDeviceStatus.Disabled;
+ device.UpdatedByUserId = userId;
+ device.UpdatedOn = now;
+ await _devicesRepository.UpdateAsync(device, cancellationToken);
+ await RevokeCredentialsAsync(credentials, now, cancellationToken);
+ _unitOfWork.CommitChanges();
+ }
+ catch
+ {
+ _unitOfWork.DiscardChanges();
+ throw;
+ }
+
+ await InvalidateDeviceAndCredentialsAsync(oldCacheIdentity, device, credentials);
+ PublishAudit(
+ departmentId,
+ userId,
+ delete ? AuditLogTypes.UnitTrackingDeviceDeleted : AuditLogTypes.UnitTrackingDeviceDisabled,
+ before,
+ DeviceAuditSnapshot(device));
+
+ return device;
+ }
+
+ private async Task GetOwnedDeviceAsync(
+ string deviceId,
+ int departmentId,
+ bool includeDeleted)
+ {
+ if (string.IsNullOrWhiteSpace(deviceId))
+ throw new ArgumentNullException(nameof(deviceId));
+
+ var device = await _devicesRepository.GetByIdAsync(deviceId);
+ if (device == null || device.DepartmentId != departmentId || (!includeDeleted && device.IsDeleted))
+ throw new InvalidOperationException("The tracking device was not found for this department.");
+
+ return device;
+ }
+
+ private async Task GetOwnedCredentialAsync(string credentialId, string deviceId)
+ {
+ if (string.IsNullOrWhiteSpace(credentialId))
+ throw new ArgumentNullException(nameof(credentialId));
+
+ var credential = await _credentialsRepository.GetByIdAsync(credentialId);
+ if (credential == null ||
+ !string.Equals(
+ credential.UnitTrackingDeviceId,
+ deviceId,
+ StringComparison.OrdinalIgnoreCase))
+ throw new InvalidOperationException("The tracking credential was not found for this device.");
+
+ return credential;
+ }
+
+ private async Task> GetCredentialEntitiesAsync(string deviceId)
+ {
+ var credentials = await _credentialsRepository.GetAllByDeviceIdAsync(deviceId);
+ return credentials?.ToList() ?? new List();
+ }
+
+ private async Task ValidateUnitOwnershipAsync(int unitId, int departmentId)
+ {
+ if (unitId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(unitId));
+
+ var unit = await _unitsService.GetUnitByIdAsync(unitId);
+ if (unit == null || unit.DepartmentId != departmentId)
+ throw new InvalidOperationException("The selected Unit was not found for this department.");
+ }
+
+ private async Task RevokeCredentialsAsync(
+ IEnumerable credentials,
+ DateTime revokedOn,
+ CancellationToken cancellationToken)
+ {
+ var pending = credentials.Where(item => !item.RevokedOn.HasValue).ToList();
+ if (pending.Count == 0)
+ return;
+
+ await _credentialsRepository.RevokeAllByDeviceIdAsync(pending[0].UnitTrackingDeviceId, revokedOn);
+ foreach (var credential in pending)
+ credential.RevokedOn = revokedOn;
+ }
+
+ private async Task InvalidateDeviceAndCredentialsAsync(
+ UnitTrackingDevice oldIdentity,
+ UnitTrackingDevice currentIdentity,
+ IEnumerable credentials)
+ {
+ await _authenticationService.InvalidateDeviceAsync(oldIdentity);
+ await _authenticationService.InvalidateDeviceAsync(currentIdentity);
+
+ foreach (var credential in credentials)
+ await _authenticationService.InvalidateCredentialAsync(credential.SecretHash);
+ }
+
+ private void PublishAudit(
+ int departmentId,
+ string userId,
+ AuditLogTypes type,
+ object before,
+ object after)
+ {
+ _eventAggregator.SendMessage(new AuditEvent
+ {
+ DepartmentId = departmentId,
+ UserId = userId,
+ Type = type,
+ Before = before == null ? null : JsonConvert.SerializeObject(before),
+ After = after == null ? null : JsonConvert.SerializeObject(after),
+ Successful = true,
+ ServerName = Environment.MachineName
+ });
+ }
+
+ private object DeviceAuditSnapshot(UnitTrackingDevice device)
+ {
+ if (device == null)
+ return null;
+
+ return new
+ {
+ device.UnitTrackingDeviceId,
+ device.DepartmentId,
+ device.UnitId,
+ device.DisplayName,
+ device.ManufacturerKey,
+ device.ModelKey,
+ device.TransportType,
+ device.ProtocolKey,
+ device.PayloadAdapterKey,
+ DeviceIdentifier = _identifierService.Mask(device.DeviceIdentifier),
+ SecondaryIdentifier = _identifierService.Mask(device.SecondaryIdentifier),
+ device.IsEnabled,
+ device.IsDeleted,
+ device.SourcePriority,
+ device.LastStatus,
+ device.LastErrorCode,
+ device.FirmwareVersion,
+ device.CreatedByUserId,
+ device.CreatedOn,
+ device.UpdatedByUserId,
+ device.UpdatedOn
+ };
+ }
+
+ private static object CredentialAuditSnapshot(UnitTrackingCredential credential)
+ {
+ if (credential == null)
+ return null;
+
+ return new
+ {
+ credential.UnitTrackingCredentialId,
+ credential.UnitTrackingDeviceId,
+ credential.AuthMode,
+ credential.HeaderName,
+ credential.BasicUsername,
+ credential.KeyPrefix,
+ credential.ValidFrom,
+ credential.ExpiresOn,
+ credential.RevokedOn,
+ credential.LastUsedOn,
+ credential.CreatedByUserId,
+ credential.CreatedOn
+ };
+ }
+
+ private static UnitTrackingCredential SanitizeCredential(UnitTrackingCredential credential)
+ {
+ return new UnitTrackingCredential
+ {
+ UnitTrackingCredentialId = credential.UnitTrackingCredentialId,
+ UnitTrackingDeviceId = credential.UnitTrackingDeviceId,
+ AuthMode = credential.AuthMode,
+ HeaderName = credential.HeaderName,
+ BasicUsername = credential.BasicUsername,
+ KeyPrefix = credential.KeyPrefix,
+ ValidFrom = credential.ValidFrom,
+ ExpiresOn = credential.ExpiresOn,
+ RevokedOn = credential.RevokedOn,
+ LastUsedOn = credential.LastUsedOn,
+ CreatedByUserId = credential.CreatedByUserId,
+ CreatedOn = credential.CreatedOn
+ };
+ }
+
+ private static UnitTrackingCredentialProvisionResult BuildProvisioningResult(
+ UnitTrackingDevice device,
+ UnitTrackingCredential credential,
+ string token)
+ {
+ var mode = (UnitTrackingAuthMode)credential.AuthMode;
+ var relativeEndpoint = mode == UnitTrackingAuthMode.CapabilityPath
+ ? $"/api/v4/unit-trackers/c/{Uri.EscapeDataString(token)}"
+ : $"/api/v4/unit-trackers/{Uri.EscapeDataString(device.UnitTrackingDeviceId)}/positions";
+ var configuredBaseUrl = UnitTrackingConfig.PublicHttpsBaseUrl?.Trim().TrimEnd('/');
+ var baseUrl = Uri.TryCreate(configuredBaseUrl, UriKind.Absolute, out var publicUri) &&
+ string.Equals(publicUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)
+ ? configuredBaseUrl
+ : null;
+ var result = new UnitTrackingCredentialProvisionResult
+ {
+ Credential = SanitizeCredential(credential),
+ Token = token,
+ EndpointUrl = string.IsNullOrWhiteSpace(baseUrl)
+ ? relativeEndpoint
+ : baseUrl + relativeEndpoint
+ };
+
+ switch (mode)
+ {
+ case UnitTrackingAuthMode.Bearer:
+ result.HeaderName = "Authorization";
+ result.HeaderValue = $"Bearer {token}";
+ break;
+ case UnitTrackingAuthMode.Basic:
+ result.HeaderName = "Authorization";
+ result.BasicUsername = credential.BasicUsername;
+ result.HeaderValue = "Basic " + Convert.ToBase64String(
+ Encoding.UTF8.GetBytes($"{credential.BasicUsername}:{token}"));
+ break;
+ case UnitTrackingAuthMode.CustomHeader:
+ result.HeaderName = credential.HeaderName;
+ result.HeaderValue = token;
+ break;
+ }
+
+ return result;
+ }
+
+ private static UnitTrackingDevice CopyCacheIdentity(UnitTrackingDevice device)
+ {
+ return new UnitTrackingDevice
+ {
+ UnitTrackingDeviceId = device.UnitTrackingDeviceId,
+ ProtocolKey = device.ProtocolKey,
+ DeviceIdentifier = device.DeviceIdentifier
+ };
+ }
+
+ private static UnitTrackingDevice CopyForRebind(
+ UnitTrackingDevice device,
+ int newUnitId,
+ string userId,
+ DateTime now)
+ {
+ return new UnitTrackingDevice
+ {
+ UnitTrackingDeviceId = Guid.NewGuid().ToString(),
+ DepartmentId = device.DepartmentId,
+ UnitId = newUnitId,
+ DisplayName = device.DisplayName,
+ ManufacturerKey = device.ManufacturerKey,
+ ModelKey = device.ModelKey,
+ TransportType = device.TransportType,
+ ProtocolKey = device.ProtocolKey,
+ PayloadAdapterKey = device.PayloadAdapterKey,
+ DeviceIdentifier = device.DeviceIdentifier,
+ SecondaryIdentifier = device.SecondaryIdentifier,
+ IsEnabled = device.IsEnabled,
+ IsDeleted = false,
+ SourcePriority = device.SourcePriority,
+ AllowedSourceCidrs = device.AllowedSourceCidrs,
+ LastStatus = device.IsEnabled
+ ? (int)UnitTrackingDeviceStatus.NeverSeen
+ : (int)UnitTrackingDeviceStatus.Disabled,
+ FirmwareVersion = device.FirmwareVersion,
+ CreatedByUserId = userId,
+ CreatedOn = now
+ };
+ }
+
+ private static int ValidateTransportType(int transportType)
+ {
+ if (!Enum.IsDefined(typeof(UnitTrackingTransportType), transportType) ||
+ transportType == (int)UnitTrackingTransportType.Unknown)
+ throw new ArgumentOutOfRangeException(nameof(transportType));
+
+ return transportType;
+ }
+
+ private static void ValidateAuthMode(
+ UnitTrackingAuthMode authMode,
+ string headerName,
+ string basicUsername)
+ {
+ if (!Enum.IsDefined(typeof(UnitTrackingAuthMode), authMode) ||
+ authMode == UnitTrackingAuthMode.Unknown)
+ throw new ArgumentOutOfRangeException(nameof(authMode));
+
+ if (authMode == UnitTrackingAuthMode.CustomHeader && string.IsNullOrWhiteSpace(headerName))
+ throw new ArgumentNullException(nameof(headerName));
+ if (authMode == UnitTrackingAuthMode.CustomHeader &&
+ (headerName.Length > 128 || !headerName.All(IsHttpTokenCharacter)))
+ throw new ArgumentException("The custom credential header name is invalid.", nameof(headerName));
+
+ if (authMode == UnitTrackingAuthMode.Basic && string.IsNullOrWhiteSpace(basicUsername))
+ throw new ArgumentNullException(nameof(basicUsername));
+ if (authMode == UnitTrackingAuthMode.Basic && basicUsername.Trim().Length > 128)
+ throw new ArgumentOutOfRangeException(
+ nameof(basicUsername),
+ "Basic authentication usernames cannot exceed 128 characters.");
+ }
+
+ private static bool IsHttpTokenCharacter(char character) =>
+ (character >= 'a' && character <= 'z') ||
+ (character >= 'A' && character <= 'Z') ||
+ (character >= '0' && character <= '9') ||
+ "!#$%&'*+-.^_`|~".Contains(character);
+
+ private static void ValidateUserAndDepartment(string userId, int departmentId)
+ {
+ if (departmentId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(departmentId));
+ if (string.IsNullOrWhiteSpace(userId))
+ throw new ArgumentNullException(nameof(userId));
+ }
+
+ private static string NormalizeKey(string value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim().ToLowerInvariant();
+
+ private static string ValidateAllowedSourceCidrs(string allowedSourceCidrs)
+ {
+ if (string.IsNullOrWhiteSpace(allowedSourceCidrs))
+ return null;
+
+ var entries = allowedSourceCidrs.Split(
+ ',',
+ StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
+ if (entries.Length == 0)
+ {
+ throw new ArgumentException(
+ "Allowed source CIDRs must contain at least one canonical CIDR entry.",
+ nameof(allowedSourceCidrs));
+ }
+
+ foreach (var entry in entries)
+ {
+ var parts = entry.Split('/', 2, StringSplitOptions.None);
+ if (parts.Length != 2 ||
+ !IPAddress.TryParse(parts[0], out var address) ||
+ !int.TryParse(parts[1], out var prefixLength) ||
+ prefixLength < 0 ||
+ prefixLength > address.GetAddressBytes().Length * 8 ||
+ !IPNetwork.TryParse(entry, out var network) ||
+ !string.Equals(entry, network.ToString(), StringComparison.Ordinal))
+ {
+ throw new ArgumentException(
+ "Allowed source CIDRs must contain only canonical IPv4 or IPv6 CIDR entries.",
+ nameof(allowedSourceCidrs));
+ }
+ }
+
+ return string.Join(",", entries);
+ }
+
+ private static string TrimToNull(string value) =>
+ string.IsNullOrWhiteSpace(value) ? null : value.Trim();
+ }
+}
diff --git a/Core/Resgrid.Services/UnitTrackingStatusService.cs b/Core/Resgrid.Services/UnitTrackingStatusService.cs
new file mode 100644
index 000000000..5cd4c7ca0
--- /dev/null
+++ b/Core/Resgrid.Services/UnitTrackingStatusService.cs
@@ -0,0 +1,50 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Resgrid.Model;
+using Resgrid.Model.Services;
+
+namespace Resgrid.Services
+{
+ public class UnitTrackingStatusService : IUnitTrackingStatusService
+ {
+ private readonly IDepartmentSettingsService _departmentSettingsService;
+
+ public UnitTrackingStatusService(IDepartmentSettingsService departmentSettingsService)
+ {
+ _departmentSettingsService = departmentSettingsService;
+ }
+
+ public async Task GetEffectiveStatusAsync(
+ UnitTrackingDevice device,
+ DateTime? utcNow = null,
+ CancellationToken cancellationToken = default)
+ {
+ if (device == null)
+ throw new ArgumentNullException(nameof(device));
+
+ cancellationToken.ThrowIfCancellationRequested();
+ if (!device.IsEnabled || device.IsDeleted)
+ return UnitTrackingDeviceStatus.Disabled;
+ if (device.LastStatus == (int)UnitTrackingDeviceStatus.Error)
+ return UnitTrackingDeviceStatus.Error;
+
+ var lastConnectivity =
+ device.LastReceivedOn.HasValue && device.LastSeenOn.HasValue
+ ? (device.LastReceivedOn.Value > device.LastSeenOn.Value
+ ? device.LastReceivedOn
+ : device.LastSeenOn)
+ : device.LastReceivedOn ?? device.LastSeenOn;
+ if (!lastConnectivity.HasValue)
+ return UnitTrackingDeviceStatus.NeverSeen;
+
+ var staleAfterSeconds =
+ await _departmentSettingsService.GetHardwareTrackingStaleAfterSecondsAsync(
+ device.DepartmentId);
+ var now = utcNow ?? DateTime.UtcNow;
+ return lastConnectivity.Value < now.AddSeconds(-Math.Max(1, staleAfterSeconds))
+ ? UnitTrackingDeviceStatus.Stale
+ : UnitTrackingDeviceStatus.Online;
+ }
+ }
+}
diff --git a/Core/Resgrid.Services/UnitsService.cs b/Core/Resgrid.Services/UnitsService.cs
index 363aa0713..9e9beb36a 100644
--- a/Core/Resgrid.Services/UnitsService.cs
+++ b/Core/Resgrid.Services/UnitsService.cs
@@ -22,6 +22,7 @@ public class UnitsService : IUnitsService
private readonly IUnitStateRoleRepository _unitStateRoleRepository;
private readonly Lazy> _unitLocationRepository;
private readonly IUnitLocationsDocRepository _unitLocationsDocRepository;
+ private readonly Lazy _unitLocationsMongoRepository;
private readonly ISubscriptionsService _subscriptionsService;
private readonly IUserStateService _userStateService;
private readonly IEventAggregator _eventAggregator;
@@ -35,7 +36,8 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit
IUnitLogsRepository unitLogsRepository, IUnitTypesRepository unitTypesRepository, ISubscriptionsService subscriptionsService,
IUnitRolesRepository unitRolesRepository, IUnitStateRoleRepository unitStateRoleRepository, IUserStateService userStateService,
IEventAggregator eventAggregator, ICustomStateService customStateService, Lazy> unitLocationRepository,
- IUnitLocationsDocRepository unitLocationsDocRepository, IUnitActiveRolesRepository unitActiveRolesRepository,
+ IUnitLocationsDocRepository unitLocationsDocRepository, Lazy unitLocationsMongoRepository,
+ IUnitActiveRolesRepository unitActiveRolesRepository,
IDepartmentGroupsService departmentGroupsService, ILimitsService limitsService, IPersonnelRolesService personnelRolesService)
{
_unitsRepository = unitsRepository;
@@ -50,6 +52,7 @@ public UnitsService(IUnitsRepository unitsRepository, IUnitStatesRepository unit
_customStateService = customStateService;
_unitLocationRepository = unitLocationRepository;
_unitLocationsDocRepository = unitLocationsDocRepository;
+ _unitLocationsMongoRepository = unitLocationsMongoRepository;
_unitActiveRolesRepository = unitActiveRolesRepository;
_departmentGroupsService = departmentGroupsService;
_limitsService = limitsService;
@@ -539,40 +542,41 @@ where callEnabledStates.Contains(us.State)
return unitStates;
}
- public async Task AddUnitLocationAsync(UnitsLocation location, int departmentId, CancellationToken cancellationToken = default(CancellationToken))
+ public async Task AddUnitLocationAsync(UnitsLocation location, int departmentId, CancellationToken cancellationToken = default(CancellationToken))
{
- try
+ if (location == null)
+ throw new ArgumentNullException(nameof(location));
+
+ UnitLocationWriteResult result;
+
+ if (Config.DataConfig.DocDatabaseType == Config.DatabaseTypes.Postgres)
{
- if (Config.DataConfig.DocDatabaseType == Config.DatabaseTypes.Postgres)
- {
- if (String.IsNullOrWhiteSpace(location.PgId))
- location = await _unitLocationsDocRepository.InsertAsync(location);
- else
- location = await _unitLocationsDocRepository.UpdateAsync(location);
- }
+ if (String.IsNullOrWhiteSpace(location.PgId))
+ result = await _unitLocationsDocRepository.InsertAsync(location);
else
- {
- if (location.Id.Timestamp == 0)
- await _unitLocationRepository.Value.InsertOneAsync(location);
- else
- await _unitLocationRepository.Value.ReplaceOneAsync(location);
- }
-
- _eventAggregator.SendMessage(new UnitLocationUpdatedEvent()
- {
- DepartmentId = departmentId,
- UnitId = location.UnitId.ToString(),
- Latitude = double.Parse(location.Latitude.ToString()),
- Longitude = double.Parse(location.Longitude.ToString()),
- RecordId = location.GetId(),
- });
+ result = await _unitLocationsDocRepository.UpdateAsync(location);
}
- catch (Exception ex)
+ else
+ {
+ if (location.Id.Timestamp == 0)
+ result = await _unitLocationsMongoRepository.Value.InsertAsync(location);
+ else
+ result = await _unitLocationsMongoRepository.Value.UpdateAsync(location);
+ }
+
+ if (result.Status == UnitLocationWriteStatus.Inserted)
{
- Framework.Logging.LogException(ex);
+ await _eventAggregator.SendMessageAsync(new UnitLocationUpdatedEvent
+ {
+ DepartmentId = departmentId,
+ UnitId = result.Location.UnitId.ToString(),
+ Latitude = (double)result.Location.Latitude,
+ Longitude = (double)result.Location.Longitude,
+ RecordId = result.Location.GetId(),
+ });
}
- return location;
+ return result;
}
public async Task GetLatestUnitLocationAsync(int unitId, DateTime? timestamp = null)
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
index d8e0a0856..ef5e17219 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitConnection.cs
@@ -133,13 +133,13 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Audit
autoDelete: false,
arguments: null);
- await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.UnitLoactionQueueName),
- durable: false,
- exclusive: false,
- autoDelete: false,
- arguments: null);
+ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.UnitLoactionQueueName),
+ durable: false,
+ exclusive: false,
+ autoDelete: false,
+ arguments: null);
- await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.PersonnelLoactionQueueName),
+ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.PersonnelLoactionQueueName),
durable: false,
exclusive: false,
autoDelete: false,
@@ -163,13 +163,15 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Workf
autoDelete: false,
arguments: null);
- await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.ChatbotProcessingQueueName),
- durable: true,
- exclusive: false,
- autoDelete: false,
- arguments: null);
+ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.ChatbotProcessingQueueName),
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: null);
+
+ await DeclareUnitLocationV2QueuesAsync(channel);
- return true;
+ return true;
}
catch (Exception ex)
{
@@ -182,6 +184,61 @@ await channel.QueueDeclareAsync(queue: SetQueueNameForEnv(ServiceBusConfig.Chatb
return false;
}
+ private static async Task DeclareUnitLocationV2QueuesAsync(IChannel channel)
+ {
+ try
+ {
+ var unitLocationQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationQueueV2Name);
+ var unitLocationRetryQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationRetryQueueV2Name);
+ var unitLocationDeadQueueV2Name = SetQueueNameForEnv(ServiceBusConfig.UnitLocationDeadQueueV2Name);
+ var queueExchange = ServiceBusConfig.RabbbitExchange ?? string.Empty;
+ var queueTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L;
+ var retryTtlMilliseconds = (long)Math.Max(1, UnitTrackingConfig.UnitLocationRetryDelaySeconds) * 1000L;
+
+ await channel.QueueDeclareAsync(
+ queue: unitLocationDeadQueueV2Name,
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: null);
+
+ await channel.QueueDeclareAsync(
+ queue: unitLocationRetryQueueV2Name,
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: new System.Collections.Generic.Dictionary
+ {
+ ["x-message-ttl"] = retryTtlMilliseconds,
+ ["x-dead-letter-exchange"] = queueExchange,
+ ["x-dead-letter-routing-key"] = unitLocationQueueV2Name
+ });
+
+ await channel.QueueDeclareAsync(
+ queue: unitLocationQueueV2Name,
+ durable: true,
+ exclusive: false,
+ autoDelete: false,
+ arguments: new System.Collections.Generic.Dictionary
+ {
+ ["x-message-ttl"] = queueTtlMilliseconds,
+ ["x-dead-letter-exchange"] = queueExchange,
+ ["x-dead-letter-routing-key"] = unitLocationDeadQueueV2Name
+ });
+
+ if (!string.IsNullOrWhiteSpace(queueExchange))
+ {
+ await channel.QueueBindAsync(unitLocationQueueV2Name, queueExchange, unitLocationQueueV2Name);
+ await channel.QueueBindAsync(unitLocationRetryQueueV2Name, queueExchange, unitLocationRetryQueueV2Name);
+ await channel.QueueBindAsync(unitLocationDeadQueueV2Name, queueExchange, unitLocationDeadQueueV2Name);
+ }
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ }
+ }
+
public static async Task CreateConnection(string clientName)
{
if (_connection == null)
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs
index c294638eb..f883e3400 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitInboundQueueProvider.cs
@@ -16,6 +16,7 @@ public class RabbitInboundQueueProvider
{
private string _clientName;
private IChannel _channel;
+ private IChannel _unitLocationChannel;
public Func CallQueueReceived;
public Func MessageQueueReceived;
public Func DistributionListQueueReceived;
@@ -43,6 +44,16 @@ public async Task Start(string clientName)
if (connection != null)
{
_channel = await connection.CreateChannelAsync();
+
+ if (UnitLocationEventQueueReceived != null)
+ {
+ _unitLocationChannel = await connection.CreateChannelAsync();
+ var prefetchCount = (ushort)Math.Min(
+ ushort.MaxValue,
+ Math.Max(1, UnitTrackingConfig.UnitLocationQueuePrefetchCount));
+ await _unitLocationChannel.BasicQosAsync(0, prefetchCount, false);
+ }
+
await StartMonitoring();
}
}
@@ -438,44 +449,8 @@ private async Task StartMonitoring()
if (UnitLocationEventQueueReceived != null)
{
- var unitLocationQueueReceivedConsumer = new AsyncEventingBasicConsumer(_channel);
- unitLocationQueueReceivedConsumer.ReceivedAsync += async (model, ea) =>
- {
- if (ea != null && ea.Body.Length > 0)
- {
- UnitLocationEvent unitLocation = null;
- try
- {
- var body = ea.Body;
- var message = Encoding.UTF8.GetString(body.ToArray());
- unitLocation = ObjectSerialization.Deserialize(message);
- }
- catch (Exception ex)
- {
- Logging.LogException(ex, Encoding.UTF8.GetString(ea.Body.ToArray()));
- }
-
- try
- {
- if (unitLocation != null)
- {
- if (UnitLocationEventQueueReceived != null)
- {
- await UnitLocationEventQueueReceived.Invoke(unitLocation);
- }
- }
- }
- catch (Exception ex)
- {
- Logging.LogException(ex);
- }
- }
- };
-
- String unitLocationEventQueueReceivedConsumerTag = await _channel.BasicConsumeAsync(
- queue: RabbitConnection.SetQueueNameForEnv(ServiceBusConfig.UnitLoactionQueueName),
- autoAck: true,
- consumer: unitLocationQueueReceivedConsumer);
+ await StartUnitLocationConsumer(ServiceBusConfig.UnitLoactionQueueName);
+ await StartUnitLocationConsumer(ServiceBusConfig.UnitLocationQueueV2Name);
}
if (PersonnelLocationEventQueueReceived != null)
@@ -659,10 +634,118 @@ await _channel.BasicConsumeAsync(
public bool IsConnected()
{
- if (_channel == null)
+ if (_channel == null || (UnitLocationEventQueueReceived != null && _unitLocationChannel == null))
return false;
- return _channel.IsOpen;
+ return _channel.IsOpen && (_unitLocationChannel?.IsOpen ?? true);
+ }
+
+ private async Task StartUnitLocationConsumer(string queueName)
+ {
+ var consumer = new AsyncEventingBasicConsumer(_unitLocationChannel);
+ consumer.ReceivedAsync += async (model, ea) =>
+ {
+ if (ea == null)
+ return;
+
+ try
+ {
+ if (ea.Body.Length == 0)
+ throw new InvalidOperationException("Unit location queue message body is empty.");
+
+ var message = Encoding.UTF8.GetString(ea.Body.ToArray());
+ var unitLocation = ObjectSerialization.Deserialize(message)
+ ?? throw new InvalidOperationException("Unit location queue message could not be deserialized.");
+
+ await UnitLocationEventQueueReceived.Invoke(unitLocation);
+ await _unitLocationChannel.BasicAckAsync(ea.DeliveryTag, false);
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+
+ if (await RetryOrDeadLetterUnitLocationAsync(ea, ex))
+ await _unitLocationChannel.BasicAckAsync(ea.DeliveryTag, false);
+ else
+ await _unitLocationChannel.BasicNackAsync(ea.DeliveryTag, false, true);
+ }
+ };
+
+ await _unitLocationChannel.BasicConsumeAsync(
+ queue: RabbitConnection.SetQueueNameForEnv(queueName),
+ autoAck: false,
+ consumer: consumer);
+ }
+
+ private async Task RetryOrDeadLetterUnitLocationAsync(BasicDeliverEventArgs ea, Exception exception)
+ {
+ var retryCount = GetRetryCount(ea.BasicProperties?.Headers);
+ var maxRetryAttempts = Math.Max(0, UnitTrackingConfig.UnitLocationMaxRetryAttempts);
+ var targetQueue = retryCount >= maxRetryAttempts
+ ? ServiceBusConfig.UnitLocationDeadQueueV2Name
+ : ServiceBusConfig.UnitLocationRetryQueueV2Name;
+
+ try
+ {
+ var connection = await RabbitConnection.CreateConnection(_clientName);
+ if (connection == null)
+ return false;
+
+ var channelOptions = new CreateChannelOptions(true, true);
+ await using var channel = await connection.CreateChannelAsync(channelOptions);
+ var properties = new BasicProperties
+ {
+ DeliveryMode = DeliveryModes.Persistent,
+ MessageId = ea.BasicProperties?.MessageId,
+ Headers = new Dictionary
+ {
+ ["x-unitlocation-retry-count"] = retryCount + 1,
+ ["x-previous-error"] = exception.GetType().Name
+ }
+ };
+
+ using var publishTimeout = new System.Threading.CancellationTokenSource(
+ TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds)));
+
+ await channel.BasicPublishAsync(
+ exchange: ServiceBusConfig.RabbbitExchange,
+ routingKey: RabbitConnection.SetQueueNameForEnv(targetQueue),
+ mandatory: true,
+ basicProperties: properties,
+ body: ea.Body,
+ cancellationToken: publishTimeout.Token);
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ return false;
+ }
+ }
+
+ private static int GetRetryCount(IDictionary headers)
+ {
+ if (headers == null || !headers.TryGetValue("x-unitlocation-retry-count", out var value))
+ return 0;
+
+ switch (value)
+ {
+ case byte byteValue:
+ return byteValue;
+ case sbyte signedByteValue:
+ return Math.Max(0, (int)signedByteValue);
+ case short shortValue:
+ return Math.Max(0, (int)shortValue);
+ case int intValue:
+ return Math.Max(0, intValue);
+ case long longValue:
+ return (int)Math.Max(0, Math.Min(int.MaxValue, longValue));
+ case byte[] bytes when int.TryParse(Encoding.UTF8.GetString(bytes), out var parsed):
+ return Math.Max(0, parsed);
+ default:
+ return int.TryParse(value.ToString(), out var converted) ? Math.Max(0, converted) : 0;
+ }
}
private async Task RetryQueueItem(BasicDeliverEventArgs ea, Exception mex)
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
index 6e240813b..0e58a54c2 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitOutboundQueueProvider.cs
@@ -1,4 +1,4 @@
-using System.Text;
+using System.Text;
using Resgrid.Config;
using Resgrid.Model.Queue;
using Resgrid.Framework;
@@ -7,7 +7,9 @@
using Resgrid.Model;
using Resgrid.Model.Providers;
using System.Collections.Generic;
+using System.Linq;
using Resgrid.Model.Events;
+using System.Threading;
using System.Threading.Tasks;
namespace Resgrid.Providers.Bus.Rabbit
@@ -90,7 +92,30 @@ public async Task EnqueueUnitLocationEvent(UnitLocationEvent unitLocationE
{
var serializedObject = ObjectSerialization.Serialize(unitLocationEvent);
- return await SendMessage(ServiceBusConfig.UnitLoactionQueueName, serializedObject, false, "300000");
+ var expiration = ((long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L).ToString();
+ return await SendMessage(ServiceBusConfig.UnitLocationQueueV2Name, serializedObject, true, expiration, true);
+ }
+
+ public async Task EnqueueUnitLocationEvents(
+ IReadOnlyCollection unitLocationEvents,
+ CancellationToken cancellationToken = default)
+ {
+ if (unitLocationEvents == null)
+ throw new ArgumentNullException(nameof(unitLocationEvents));
+ if (unitLocationEvents.Count == 0)
+ return true;
+
+ var serializedMessages = unitLocationEvents
+ .Select(ObjectSerialization.Serialize)
+ .ToList();
+ var expiration =
+ ((long)Math.Max(1, UnitTrackingConfig.QueueMessageTtlSeconds) * 1000L).ToString();
+
+ return await SendMessagesWithConfirmation(
+ ServiceBusConfig.UnitLocationQueueV2Name,
+ serializedMessages,
+ expiration,
+ cancellationToken);
}
public async Task EnqueuePersonnelLocationEvent(PersonnelLocationEvent personnelLocationEvent)
@@ -114,7 +139,8 @@ public async Task EnqueueWorkflowEvent(Resgrid.Model.Queue.WorkflowQueueIt
return await SendMessage(ServiceBusConfig.WorkflowQueueName, serializedObject);
}
- private async Task SendMessage(string queueName, string message, bool durable = true, string expiration = "36000000")
+ private async Task SendMessage(string queueName, string message, bool durable = true, string expiration = "36000000",
+ bool requirePublisherConfirmation = false)
{
if (String.IsNullOrWhiteSpace(queueName))
throw new ArgumentNullException("queueName");
@@ -131,7 +157,13 @@ private async Task SendMessage(string queueName, string message, bool dura
// v7 IChannel skips the async Channel.Close/CloseOk handshake that releases the channel
// number back to the SessionManager, leaking channels until the connection hits its limit
// (ChannelAllocationException: "The connection cannot support any more channels").
- await using (var channel = await connection.CreateChannelAsync())
+ var channelOptions = requirePublisherConfirmation
+ ? new CreateChannelOptions(true, true)
+ : null;
+
+ await using (var channel = channelOptions == null
+ ? await connection.CreateChannelAsync()
+ : await connection.CreateChannelAsync(channelOptions))
{
if (channel != null)
{
@@ -148,11 +180,18 @@ private async Task SendMessage(string queueName, string message, bool dura
props.Expiration = expiration;
- await channel.BasicPublishAsync(exchange: ServiceBusConfig.RabbbitExchange,
- routingKey: RabbitConnection.SetQueueNameForEnv(queueName),
- mandatory: true,
- basicProperties: props,
- body: Encoding.ASCII.GetBytes(message));
+ using var publishTimeout = requirePublisherConfirmation
+ ? new System.Threading.CancellationTokenSource(
+ TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds)))
+ : null;
+
+ await channel.BasicPublishAsync(
+ exchange: ServiceBusConfig.RabbbitExchange,
+ routingKey: RabbitConnection.SetQueueNameForEnv(queueName),
+ mandatory: true,
+ basicProperties: props,
+ body: Encoding.UTF8.GetBytes(message),
+ cancellationToken: publishTimeout?.Token ?? default);
return true;
}
@@ -176,6 +215,65 @@ await channel.BasicPublishAsync(exchange: ServiceBusConfig.RabbbitExchange,
}
}
+ private async Task SendMessagesWithConfirmation(
+ string queueName,
+ IReadOnlyCollection messages,
+ string expiration,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(queueName))
+ throw new ArgumentNullException(nameof(queueName));
+ if (messages == null || messages.Count == 0)
+ return true;
+ if (messages.Any(string.IsNullOrWhiteSpace))
+ throw new ArgumentException("Queue messages cannot be empty.", nameof(messages));
+
+ try
+ {
+ var connection = await RabbitConnection.CreateConnection(_clientName);
+ if (connection == null)
+ {
+ Logging.LogError("RabbitOutboundQueueProvider->SendMessagesWithConfirmation connection is null.");
+ return false;
+ }
+
+ await using var channel =
+ await connection.CreateChannelAsync(new CreateChannelOptions(true, true), cancellationToken);
+ var props = new BasicProperties
+ {
+ DeliveryMode = DeliveryModes.Persistent,
+ Expiration = expiration,
+ Headers = new Dictionary
+ {
+ ["x-redelivered-count"] = 0
+ }
+ };
+
+ using var publishTimeout =
+ CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ publishTimeout.CancelAfter(
+ TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds)));
+
+ foreach (var message in messages)
+ {
+ await channel.BasicPublishAsync(
+ exchange: ServiceBusConfig.RabbbitExchange,
+ routingKey: RabbitConnection.SetQueueNameForEnv(queueName),
+ mandatory: true,
+ basicProperties: props,
+ body: Encoding.UTF8.GetBytes(message),
+ cancellationToken: publishTimeout.Token);
+ }
+
+ return true;
+ }
+ catch (Exception ex)
+ {
+ Logging.LogException(ex);
+ return false;
+ }
+ }
+
public async Task VerifyAndCreateClients()
{
return await RabbitConnection.VerifyAndCreateClients(_clientName);
diff --git a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
index 9285e949d..360c28f01 100644
--- a/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
+++ b/Providers/Resgrid.Providers.Bus.Rabbit/RabbitTopicProvider.cs
@@ -128,7 +128,7 @@ public async Task UnitLocationUpdatedChanged(UnitLocationUpdatedEvent mess
DepartmentId = message.DepartmentId,
ItemId = message.RecordId,
Payload = JsonConvert.SerializeObject(message)
- }.SerializeJson());
+ }.SerializeJson(), true);
}
private static async Task VerifyAndCreateClients(string clientName)
@@ -161,27 +161,40 @@ private static async Task VerifyAndCreateClients(string clientName)
return true;
}
- private async Task SendMessage(string topicName, string message)
+ private async Task SendMessage(string topicName, string message, bool requirePublisherConfirmation = false)
{
- await VerifyAndCreateClients(_clientName);
+ if (!await VerifyAndCreateClients(_clientName))
+ return false;
try
{
var connection = await RabbitConnection.CreateConnection(_clientName);
- if (connection != null)
+ if (connection == null)
+ return false;
+
+ var channelOptions = requirePublisherConfirmation
+ ? new CreateChannelOptions(true, true)
+ : null;
+ await using (var channel = channelOptions == null
+ ? await connection.CreateChannelAsync()
+ : await connection.CreateChannelAsync(channelOptions))
{
- // await using so the channel is closed via DisposeAsync(): the synchronous Dispose() on a
- // v7 IChannel skips the async Channel.Close/CloseOk handshake that releases the channel
- // number back to the SessionManager, leaking channels until the connection hits its limit
- // (ChannelAllocationException: "The connection cannot support any more channels").
- await using (var channel = await connection.CreateChannelAsync())
- {
- await channel.BasicPublishAsync(exchange: RabbitConnection.SetQueueNameForEnv(topicName),
- routingKey: "",
- //mandatory: false, //TODO: Not sure here. -SJ
- //basicProperties: null,
- body: Encoding.ASCII.GetBytes(message));
- }
+ using var publishTimeout = requirePublisherConfirmation
+ ? new System.Threading.CancellationTokenSource(
+ TimeSpan.FromSeconds(Math.Max(1, UnitTrackingConfig.QueuePublishTimeoutSeconds)))
+ : null;
+ await channel.BasicPublishAsync(
+ exchange: RabbitConnection.SetQueueNameForEnv(topicName),
+ routingKey: "",
+ mandatory: false,
+ basicProperties: new BasicProperties
+ {
+ DeliveryMode = requirePublisherConfirmation
+ ? DeliveryModes.Persistent
+ : DeliveryModes.Transient
+ },
+ body: Encoding.ASCII.GetBytes(message),
+ cancellationToken: publishTimeout?.Token ?? default);
}
return true;
diff --git a/Providers/Resgrid.Providers.Bus/BusModule.cs b/Providers/Resgrid.Providers.Bus/BusModule.cs
index a435589a4..ada6ceb05 100644
--- a/Providers/Resgrid.Providers.Bus/BusModule.cs
+++ b/Providers/Resgrid.Providers.Bus/BusModule.cs
@@ -14,6 +14,7 @@ protected override void Load(ContainerBuilder builder)
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
builder.RegisterType().As().InstancePerLifetimeScope();
+ builder.RegisterType().As().InstancePerLifetimeScope();
// Singletons
builder.RegisterType().As().SingleInstance();
diff --git a/Providers/Resgrid.Providers.Bus/EventAggregator.cs b/Providers/Resgrid.Providers.Bus/EventAggregator.cs
index 1c4d55da3..80ef56c9f 100644
--- a/Providers/Resgrid.Providers.Bus/EventAggregator.cs
+++ b/Providers/Resgrid.Providers.Bus/EventAggregator.cs
@@ -1,16 +1,21 @@
using System;
using Easy.MessageHub;
using Resgrid.Model.Providers;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Threading.Tasks;
namespace Resgrid.Providers.Bus
{
public class EventAggregator: IEventAggregator
{
private readonly IMessageHub _hub;
+ private readonly ConcurrentDictionary>> _asyncListeners;
public EventAggregator()
{
_hub = new MessageHub();
+ _asyncListeners = new ConcurrentDictionary>>();
}
public void SendMessage(TMessage message)
@@ -18,13 +23,54 @@ public void SendMessage(TMessage message)
_hub.Publish(message);
}
+ public async Task SendMessageAsync(TMessage message)
+ {
+ if (!_asyncListeners.TryGetValue(typeof(TMessage), out var listeners))
+ return;
+
+ await Task.WhenAll(listeners.Values.Select(listener => listener(message)));
+ }
+
public Guid AddListener(Action listener)
{
return _hub.Subscribe(listener);
}
+ public Guid AddAsyncListener(Func listener, Action onError = null)
+ {
+ if (listener == null)
+ throw new ArgumentNullException(nameof(listener));
+
+ var token = Guid.NewGuid();
+ var listeners = _asyncListeners.GetOrAdd(
+ typeof(T),
+ _ => new ConcurrentDictionary>());
+ listeners[token] = async message =>
+ {
+ try
+ {
+ await listener((T)message);
+ }
+ catch (Exception ex)
+ {
+ if (onError == null)
+ throw;
+
+ onError(ex);
+ }
+ };
+
+ return token;
+ }
+
public void RemoveListener(Guid token)
{
+ foreach (var listeners in _asyncListeners.Values)
+ {
+ if (listeners.TryRemove(token, out _))
+ return;
+ }
+
if (_hub.IsSubscribed(token))
_hub.Unsubscribe(token);
}
diff --git a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
index 805e11366..a4fce4184 100644
--- a/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
+++ b/Providers/Resgrid.Providers.Bus/OutboundEventProvider.cs
@@ -58,7 +58,7 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
_eventAggregator.AddListener(callClosedTopicHandler);
_eventAggregator.AddListener(incidentCommandUpdatedTopicHandler);
_eventAggregator.AddListener(personnelLocationUpdatedTopicHandler);
- _eventAggregator.AddListener(unitLocationUpdatedTopicHandler);
+ _eventAggregator.AddAsyncListener(unitLocationUpdatedTopicHandler);
}
public Action unitStatusHandler = async delegate (UnitStatusEvent message)
@@ -610,12 +610,13 @@ public OutboundEventProvider(IEventAggregator eventAggregator, IOutboundQueuePro
_rabbitTopicProvider.PersonnelLocationUnidatedChanged(message);
};
- public Action unitLocationUpdatedTopicHandler = async delegate (UnitLocationUpdatedEvent message)
+ public Func unitLocationUpdatedTopicHandler = async delegate (UnitLocationUpdatedEvent message)
{
if (_rabbitTopicProvider == null)
_rabbitTopicProvider = new RabbitTopicProvider();
- _rabbitTopicProvider.UnitLocationUpdatedChanged(message);
+ if (!await _rabbitTopicProvider.UnitLocationUpdatedChanged(message))
+ Framework.Logging.LogError($"Unable to publish the Unit location realtime update for department {message.DepartmentId}.");
};
#endregion Topic Based Events
}
diff --git a/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs b/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs
index b80013209..880cef6d6 100644
--- a/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs
+++ b/Providers/Resgrid.Providers.Bus/UnitLocationEventProvider.cs
@@ -1,27 +1,30 @@
using System.Threading.Tasks;
using Resgrid.Model.Events;
using Resgrid.Model.Providers;
-using Resgrid.Providers.Bus.Rabbit;
namespace Resgrid.Providers.Bus
{
public class UnitLocationEventProvider : IUnitLocationEventProvider
{
- private RabbitOutboundQueueProvider _rabbitOutboundQueueProvider;
+ private readonly IRabbitOutboundQueueProvider _rabbitOutboundQueueProvider;
- public UnitLocationEventProvider()
+ public UnitLocationEventProvider(IRabbitOutboundQueueProvider rabbitOutboundQueueProvider)
{
-
+ _rabbitOutboundQueueProvider = rabbitOutboundQueueProvider;
}
public async Task EnqueueUnitLocationEventAsync(UnitLocationEvent unitLocationEvent)
{
- if (_rabbitOutboundQueueProvider == null)
- _rabbitOutboundQueueProvider = new RabbitOutboundQueueProvider();
-
- _rabbitOutboundQueueProvider.EnqueueUnitLocationEvent(unitLocationEvent);
-
- return true;
+ return await _rabbitOutboundQueueProvider.EnqueueUnitLocationEvent(unitLocationEvent);
+ }
+
+ public async Task EnqueueUnitLocationEventsAsync(
+ System.Collections.Generic.IReadOnlyCollection unitLocationEvents,
+ System.Threading.CancellationToken cancellationToken = default)
+ {
+ return await _rabbitOutboundQueueProvider.EnqueueUnitLocationEvents(
+ unitLocationEvents,
+ cancellationToken);
}
}
}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs
new file mode 100644
index 000000000..4f2838719
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0101_AddUnitTracking.cs
@@ -0,0 +1,150 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(101)]
+ public class M0101_AddUnitTracking : Migration
+ {
+ private const string DevicesTable = "UnitTrackingDevices";
+ private const string CredentialsTable = "UnitTrackingCredentials";
+
+ public override void Up()
+ {
+ if (!Schema.Table(DevicesTable).Exists())
+ {
+ Create.Table(DevicesTable)
+ .WithColumn("UnitTrackingDeviceId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("DepartmentId").AsInt32().NotNullable()
+ .WithColumn("UnitId").AsInt32().NotNullable()
+ .WithColumn("DisplayName").AsString(200).Nullable()
+ .WithColumn("ManufacturerKey").AsString(64).Nullable()
+ .WithColumn("ModelKey").AsString(64).Nullable()
+ .WithColumn("TransportType").AsInt32().NotNullable()
+ .WithColumn("ProtocolKey").AsString(64).Nullable()
+ .WithColumn("PayloadAdapterKey").AsString(64).Nullable()
+ .WithColumn("DeviceIdentifier").AsString(128).Nullable()
+ .WithColumn("SecondaryIdentifier").AsString(128).Nullable()
+ .WithColumn("IsEnabled").AsBoolean().NotNullable().WithDefaultValue(true)
+ .WithColumn("IsDeleted").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("SourcePriority").AsInt32().NotNullable().WithDefaultValue(100)
+ .WithColumn("AllowedSourceCidrs").AsString(int.MaxValue).Nullable()
+ .WithColumn("LastSeenOn").AsDateTime().Nullable()
+ .WithColumn("LastPositionOn").AsDateTime().Nullable()
+ .WithColumn("LastReceivedOn").AsDateTime().Nullable()
+ .WithColumn("LastStatus").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("LastErrorCode").AsString(64).Nullable()
+ .WithColumn("FirmwareVersion").AsString(128).Nullable()
+ .WithColumn("CreatedByUserId").AsString(128).NotNullable()
+ .WithColumn("CreatedOn").AsDateTime().NotNullable()
+ .WithColumn("UpdatedByUserId").AsString(128).Nullable()
+ .WithColumn("UpdatedOn").AsDateTime().Nullable();
+
+ if (!Schema.Table("Units").Constraint("UQ_Units_DepartmentId_UnitId").Exists())
+ {
+ Create.UniqueConstraint("UQ_Units_DepartmentId_UnitId")
+ .OnTable("Units")
+ .Columns("DepartmentId", "UnitId");
+ }
+
+ Create.ForeignKey("FK_UnitTrackingDevices_Units_Department_Unit")
+ .FromTable(DevicesTable).ForeignColumns("DepartmentId", "UnitId")
+ .ToTable("Units").PrimaryColumns("DepartmentId", "UnitId");
+ }
+
+ if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_Department_Unit_Deleted").Exists())
+ {
+ Create.Index("IX_UnitTrackingDevices_Department_Unit_Deleted")
+ .OnTable(DevicesTable)
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("UnitId").Ascending()
+ .OnColumn("IsDeleted").Ascending();
+ }
+
+ if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_Department_Enabled_Deleted").Exists())
+ {
+ Create.Index("IX_UnitTrackingDevices_Department_Enabled_Deleted")
+ .OnTable(DevicesTable)
+ .OnColumn("DepartmentId").Ascending()
+ .OnColumn("IsEnabled").Ascending()
+ .OnColumn("IsDeleted").Ascending();
+ }
+
+ if (!Schema.Table(DevicesTable).Index("IX_UnitTrackingDevices_LastSeenOn").Exists())
+ {
+ Create.Index("IX_UnitTrackingDevices_LastSeenOn")
+ .OnTable(DevicesTable)
+ .OnColumn("LastSeenOn").Ascending();
+ }
+
+ Execute.Sql(@"
+ IF NOT EXISTS (
+ SELECT 1
+ FROM sys.indexes
+ WHERE name = 'UX_UnitTrackingDevices_Protocol_DeviceIdentifier'
+ AND object_id = OBJECT_ID(N'UnitTrackingDevices'))
+ BEGIN
+ CREATE UNIQUE NONCLUSTERED INDEX UX_UnitTrackingDevices_Protocol_DeviceIdentifier
+ ON UnitTrackingDevices (ProtocolKey, DeviceIdentifier)
+ WHERE DeviceIdentifier IS NOT NULL AND IsDeleted = 0;
+ END");
+
+ if (!Schema.Table(CredentialsTable).Exists())
+ {
+ Create.Table(CredentialsTable)
+ .WithColumn("UnitTrackingCredentialId").AsString(128).NotNullable().PrimaryKey()
+ .WithColumn("UnitTrackingDeviceId").AsString(128).NotNullable()
+ .WithColumn("AuthMode").AsInt32().NotNullable()
+ .WithColumn("HeaderName").AsString(128).Nullable()
+ .WithColumn("BasicUsername").AsString(128).Nullable()
+ .WithColumn("KeyPrefix").AsString(20).NotNullable()
+ .WithColumn("SecretHash").AsString(64).NotNullable()
+ .WithColumn("ValidFrom").AsDateTime().NotNullable()
+ .WithColumn("ExpiresOn").AsDateTime().Nullable()
+ .WithColumn("RevokedOn").AsDateTime().Nullable()
+ .WithColumn("LastUsedOn").AsDateTime().Nullable()
+ .WithColumn("CreatedByUserId").AsString(128).NotNullable()
+ .WithColumn("CreatedOn").AsDateTime().NotNullable();
+
+ Create.ForeignKey("FK_UnitTrackingCredentials_Devices")
+ .FromTable(CredentialsTable).ForeignColumn("UnitTrackingDeviceId")
+ .ToTable(DevicesTable).PrimaryColumn("UnitTrackingDeviceId");
+ }
+
+ if (!Schema.Table(CredentialsTable).Index("UX_UnitTrackingCredentials_SecretHash").Exists())
+ {
+ Create.Index("UX_UnitTrackingCredentials_SecretHash")
+ .OnTable(CredentialsTable)
+ .OnColumn("SecretHash").Ascending()
+ .WithOptions().Unique();
+ }
+
+ if (!Schema.Table(CredentialsTable).Index("IX_UnitTrackingCredentials_Device_Revoked_Expires").Exists())
+ {
+ Create.Index("IX_UnitTrackingCredentials_Device_Revoked_Expires")
+ .OnTable(CredentialsTable)
+ .OnColumn("UnitTrackingDeviceId").Ascending()
+ .OnColumn("RevokedOn").Ascending()
+ .OnColumn("ExpiresOn").Ascending();
+ }
+
+ if (!Schema.Table(CredentialsTable).Index("IX_UnitTrackingCredentials_KeyPrefix").Exists())
+ {
+ Create.Index("IX_UnitTrackingCredentials_KeyPrefix")
+ .OnTable(CredentialsTable)
+ .OnColumn("KeyPrefix").Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table(CredentialsTable).Exists())
+ Delete.Table(CredentialsTable);
+
+ if (Schema.Table(DevicesTable).Exists())
+ Delete.Table(DevicesTable);
+
+ if (Schema.Table("Units").Constraint("UQ_Units_DepartmentId_UnitId").Exists())
+ Delete.UniqueConstraint("UQ_Units_DepartmentId_UnitId").FromTable("Units");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs
new file mode 100644
index 000000000..a14edb011
--- /dev/null
+++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0102_AddAttemptCountToQueueItems.cs
@@ -0,0 +1,20 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.Migrations.Migrations
+{
+ [Migration(102)]
+ public class M0102_AddAttemptCountToQueueItems : Migration
+ {
+ public override void Up()
+ {
+ // Bounded-retry counter for system queue items (e.g. department deletion) so a
+ // transient execution failure can be retried without looping forever.
+ Alter.Table("QueueItems").AddColumn("AttemptCount").AsInt32().NotNullable().WithDefaultValue(0);
+ }
+
+ public override void Down()
+ {
+
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs
new file mode 100644
index 000000000..efc6c4595
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0101_AddUnitTrackingPg.cs
@@ -0,0 +1,190 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ // TransactionBehavior.None is required for CREATE UNIQUE INDEX CONCURRENTLY on the live units
+ // table (it cannot run inside a transaction). Statements self-commit, so all creates below are
+ // guarded with existence checks to stay safe on a re-run after a partial apply.
+ [Migration(101, TransactionBehavior.None)]
+ public class M0101_AddUnitTrackingPg : Migration
+ {
+ private const string DevicesTable = "unittrackingdevices";
+ private const string CredentialsTable = "unittrackingcredentials";
+
+ public override void Up()
+ {
+ // The units unique constraint is independent of the devices table, so it runs
+ // unconditionally (each statement is self-guarded). With TransactionBehavior.None a
+ // re-run after a partial apply (e.g. the duplicate check failed after the devices
+ // table self-committed) would otherwise skip it because the devices table exists.
+ // ALTER TABLE ADD CONSTRAINT UNIQUE takes a SHARE ROW EXCLUSIVE lock on the live units
+ // table and fails if duplicates exist. Pre-validate duplicates, build the index online
+ // with CONCURRENTLY, then attach it as a constraint (brief lock) so it can still serve
+ // as the foreign key target below.
+ Execute.Sql(@"
+ DO $$
+ BEGIN
+ IF EXISTS (SELECT 1 FROM units GROUP BY departmentid, unitid HAVING COUNT(*) > 1) THEN
+ RAISE EXCEPTION 'uq_units_departmentid_unitid: duplicate (departmentid, unitid) rows exist in units; remove them before rerunning this migration';
+ END IF;
+ END $$;");
+
+ // Drop a leftover INVALID index from a previously-failed CREATE INDEX CONCURRENTLY
+ // build before the existence check below. The check only tests the index name, so an
+ // invalid index would skip the create and then fail the ADD CONSTRAINT ... USING INDEX
+ // attach. Only invalid indexes are dropped: a valid one may already back the constraint
+ // (which cannot be dropped independently) on a re-run after a later step failed.
+ Execute.Sql(@"
+ DO $$
+ BEGIN
+ IF EXISTS (
+ SELECT 1
+ FROM pg_class c
+ JOIN pg_index i ON i.indexrelid = c.oid
+ JOIN pg_namespace n ON n.oid = c.relnamespace
+ WHERE c.relname = 'uq_units_departmentid_unitid'
+ AND n.nspname = current_schema()
+ AND NOT i.indisvalid
+ ) THEN
+ DROP INDEX uq_units_departmentid_unitid;
+ END IF;
+ END $$;");
+
+ if (!Schema.Table("units").Index("uq_units_departmentid_unitid").Exists())
+ {
+ Execute.Sql(@"
+ CREATE UNIQUE INDEX CONCURRENTLY IF NOT EXISTS uq_units_departmentid_unitid
+ ON units (departmentid, unitid);");
+ }
+
+ if (!Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists())
+ {
+ Execute.Sql(@"
+ ALTER TABLE units
+ ADD CONSTRAINT uq_units_departmentid_unitid UNIQUE USING INDEX uq_units_departmentid_unitid;");
+ }
+
+ if (!Schema.Table(DevicesTable).Exists())
+ {
+ Create.Table(DevicesTable)
+ .WithColumn("unittrackingdeviceid").AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("departmentid").AsInt32().NotNullable()
+ .WithColumn("unitid").AsInt32().NotNullable()
+ .WithColumn("displayname").AsCustom("citext").Nullable()
+ .WithColumn("manufacturerkey").AsCustom("citext").Nullable()
+ .WithColumn("modelkey").AsCustom("citext").Nullable()
+ .WithColumn("transporttype").AsInt32().NotNullable()
+ .WithColumn("protocolkey").AsCustom("citext").Nullable()
+ .WithColumn("payloadadapterkey").AsCustom("citext").Nullable()
+ .WithColumn("deviceidentifier").AsCustom("citext").Nullable()
+ .WithColumn("secondaryidentifier").AsCustom("citext").Nullable()
+ .WithColumn("isenabled").AsBoolean().NotNullable().WithDefaultValue(true)
+ .WithColumn("isdeleted").AsBoolean().NotNullable().WithDefaultValue(false)
+ .WithColumn("sourcepriority").AsInt32().NotNullable().WithDefaultValue(100)
+ .WithColumn("allowedsourcecidrs").AsCustom("text").Nullable()
+ .WithColumn("lastseenon").AsDateTime2().Nullable()
+ .WithColumn("lastpositionon").AsDateTime2().Nullable()
+ .WithColumn("lastreceivedon").AsDateTime2().Nullable()
+ .WithColumn("laststatus").AsInt32().NotNullable().WithDefaultValue(0)
+ .WithColumn("lasterrorcode").AsCustom("citext").Nullable()
+ .WithColumn("firmwareversion").AsCustom("citext").Nullable()
+ .WithColumn("createdbyuserid").AsCustom("citext").NotNullable()
+ .WithColumn("createdon").AsDateTime2().NotNullable()
+ .WithColumn("updatedbyuserid").AsCustom("citext").Nullable()
+ .WithColumn("updatedon").AsDateTime2().Nullable();
+
+ Create.ForeignKey("fk_unittrackingdevices_units_department_unit")
+ .FromTable(DevicesTable).ForeignColumns("departmentid", "unitid")
+ .ToTable("units").PrimaryColumns("departmentid", "unitid");
+ }
+
+ if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_department_unit_deleted").Exists())
+ {
+ Create.Index("ix_unittrackingdevices_department_unit_deleted")
+ .OnTable(DevicesTable)
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("unitid").Ascending()
+ .OnColumn("isdeleted").Ascending();
+ }
+
+ if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_department_enabled_deleted").Exists())
+ {
+ Create.Index("ix_unittrackingdevices_department_enabled_deleted")
+ .OnTable(DevicesTable)
+ .OnColumn("departmentid").Ascending()
+ .OnColumn("isenabled").Ascending()
+ .OnColumn("isdeleted").Ascending();
+ }
+
+ if (!Schema.Table(DevicesTable).Index("ix_unittrackingdevices_lastseenon").Exists())
+ {
+ Create.Index("ix_unittrackingdevices_lastseenon")
+ .OnTable(DevicesTable)
+ .OnColumn("lastseenon").Ascending();
+ }
+
+ Execute.Sql(@"
+ CREATE UNIQUE INDEX IF NOT EXISTS ux_unittrackingdevices_protocol_deviceidentifier
+ ON unittrackingdevices (COALESCE(protocolkey, ''), deviceidentifier)
+ WHERE deviceidentifier IS NOT NULL AND isdeleted = false;");
+
+ if (!Schema.Table(CredentialsTable).Exists())
+ {
+ Create.Table(CredentialsTable)
+ .WithColumn("unittrackingcredentialid").AsCustom("citext").NotNullable().PrimaryKey()
+ .WithColumn("unittrackingdeviceid").AsCustom("citext").NotNullable()
+ .WithColumn("authmode").AsInt32().NotNullable()
+ .WithColumn("headername").AsCustom("citext").Nullable()
+ .WithColumn("basicusername").AsCustom("citext").Nullable()
+ .WithColumn("keyprefix").AsCustom("citext").NotNullable()
+ .WithColumn("secrethash").AsString(64).NotNullable()
+ .WithColumn("validfrom").AsDateTime2().NotNullable()
+ .WithColumn("expireson").AsDateTime2().Nullable()
+ .WithColumn("revokedon").AsDateTime2().Nullable()
+ .WithColumn("lastusedon").AsDateTime2().Nullable()
+ .WithColumn("createdbyuserid").AsCustom("citext").NotNullable()
+ .WithColumn("createdon").AsDateTime2().NotNullable();
+
+ Create.ForeignKey("fk_unittrackingcredentials_devices")
+ .FromTable(CredentialsTable).ForeignColumn("unittrackingdeviceid")
+ .ToTable(DevicesTable).PrimaryColumn("unittrackingdeviceid");
+ }
+
+ if (!Schema.Table(CredentialsTable).Index("ux_unittrackingcredentials_secrethash").Exists())
+ {
+ Create.Index("ux_unittrackingcredentials_secrethash")
+ .OnTable(CredentialsTable)
+ .OnColumn("secrethash").Ascending()
+ .WithOptions().Unique();
+ }
+
+ if (!Schema.Table(CredentialsTable).Index("ix_unittrackingcredentials_device_revoked_expires").Exists())
+ {
+ Create.Index("ix_unittrackingcredentials_device_revoked_expires")
+ .OnTable(CredentialsTable)
+ .OnColumn("unittrackingdeviceid").Ascending()
+ .OnColumn("revokedon").Ascending()
+ .OnColumn("expireson").Ascending();
+ }
+
+ if (!Schema.Table(CredentialsTable).Index("ix_unittrackingcredentials_keyprefix").Exists())
+ {
+ Create.Index("ix_unittrackingcredentials_keyprefix")
+ .OnTable(CredentialsTable)
+ .OnColumn("keyprefix").Ascending();
+ }
+ }
+
+ public override void Down()
+ {
+ if (Schema.Table(CredentialsTable).Exists())
+ Delete.Table(CredentialsTable);
+
+ if (Schema.Table(DevicesTable).Exists())
+ Delete.Table(DevicesTable);
+
+ if (Schema.Table("units").Constraint("uq_units_departmentid_unitid").Exists())
+ Delete.UniqueConstraint("uq_units_departmentid_unitid").FromTable("units");
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs
new file mode 100644
index 000000000..967e561fb
--- /dev/null
+++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0102_AddAttemptCountToQueueItemsPg.cs
@@ -0,0 +1,20 @@
+using FluentMigrator;
+
+namespace Resgrid.Providers.MigrationsPg.Migrations
+{
+ [Migration(102)]
+ public class M0102_AddAttemptCountToQueueItemsPg : Migration
+ {
+ public override void Up()
+ {
+ // Bounded-retry counter for system queue items (e.g. department deletion) so a
+ // transient execution failure can be retried without looping forever.
+ Alter.Table("QueueItems".ToLower()).AddColumn("AttemptCount".ToLower()).AsInt32().NotNullable().WithDefaultValue(0);
+ }
+
+ public override void Down()
+ {
+
+ }
+ }
+}
diff --git a/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql b/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql
index 9ace3f60e..c9463177b 100644
--- a/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql
+++ b/Providers/Resgrid.Providers.MigrationsPg/Sql/EF0003_PopulateDocDb.sql
@@ -8,14 +8,38 @@ CREATE TABLE IF NOT EXISTS public.unitlocations(
unitid integer NOT NULL,
oid text,
"timestamp" timestamp without time zone NOT NULL DEFAULT (now() AT TIME ZONE 'utc'::text),
+ eventid text,
+ receivedon timestamp without time zone,
+ sourcetype integer,
+ sourceid text,
+ sourcepriority integer NOT NULL DEFAULT 0,
data jsonb NOT NULL
);
+ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS eventid text;
+ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS receivedon timestamp without time zone;
+ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS sourcetype integer;
+ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS sourceid text;
+ALTER TABLE public.unitlocations ADD COLUMN IF NOT EXISTS sourcepriority integer NOT NULL DEFAULT 0;
+
IF NOT exists (select constraint_name from information_schema.table_constraints where table_name = 'unitlocations' and constraint_type = 'PRIMARY KEY') then
ALTER TABLE public.unitlocations
ADD PRIMARY KEY (id);
END IF;
+CREATE UNIQUE INDEX IF NOT EXISTS ux_unitlocations_eventid
+ ON public.unitlocations (eventid)
+ WHERE eventid IS NOT NULL;
+
+CREATE INDEX IF NOT EXISTS ix_unitlocations_department_unit_timestamp
+ ON public.unitlocations (departmentid, unitid, "timestamp" DESC);
+
+CREATE INDEX IF NOT EXISTS ix_unitlocations_department_unit_source_timestamp
+ ON public.unitlocations (departmentid, unitid, sourcetype, sourceid, "timestamp" DESC);
+
+CREATE INDEX IF NOT EXISTS ix_unitlocations_retention
+ ON public.unitlocations (departmentid, sourcetype, "timestamp");
+
--
-- CREATE TABLE IF NOT EXISTS "public"."personnellocations"
diff --git a/Providers/Resgrid.Providers.Tracking/NOTICE.md b/Providers/Resgrid.Providers.Tracking/NOTICE.md
new file mode 100644
index 000000000..c494ba6ed
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/NOTICE.md
@@ -0,0 +1,16 @@
+# Third-party notices
+
+## Traccar
+
+The Resgrid `traccar-json-v1` interoperability adapter was independently implemented
+against Traccar's public JSON position-forwarding contract.
+
+- Project: Traccar GPS Tracking System
+- Repository: https://github.com/traccar/traccar
+- Pinned version: `v6.14.5`
+- Pinned commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9`
+- License: Apache License 2.0
+- Copyright: Anton Tananaev and Traccar contributors
+
+No Traccar Java source is compiled into or redistributed with Resgrid. See
+`PROVENANCE.md` for the exact files reviewed and the adaptation record.
diff --git a/Providers/Resgrid.Providers.Tracking/PROVENANCE.md b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md
new file mode 100644
index 000000000..50e382171
--- /dev/null
+++ b/Providers/Resgrid.Providers.Tracking/PROVENANCE.md
@@ -0,0 +1,45 @@
+# Tracking adapter provenance
+
+## `traccar-json-v1`
+
+### Pin
+
+- Upstream repository: https://github.com/traccar/traccar
+- Release: `v6.14.5`
+- Commit: `5c5e710d5e357912f1b30561ed54bfd07a5d42f9`
+- Commit date: 2026-06-18
+- License: Apache License 2.0
+
+### Public material reviewed
+
+- Position-forwarding configuration: https://www.traccar.org/forward/
+- `src/main/java/org/traccar/forward/PositionData.java`
+- `src/main/java/org/traccar/forward/PositionForwarderJson.java`
+- `src/main/java/org/traccar/model/Position.java`
+- `src/main/java/org/traccar/model/Device.java`
+- `src/main/java/org/traccar/model/Message.java`
+- `src/main/java/org/traccar/model/ExtendedModel.java`
+- `src/main/java/org/traccar/model/BaseModel.java`
+
+All source-file references above are pinned to commit
+`5c5e710d5e357912f1b30561ed54bfd07a5d42f9`.
+
+### Adaptation record
+
+The Resgrid adapter is an independent C# implementation of the serialized forwarding
+contract. No Java implementation code was copied or ported. The mapping intentionally:
+
+- accepts the `PositionData` envelope's `position` and `device` objects;
+- uses `device.uniqueId` only for defense-in-depth binding validation;
+- cross-checks Traccar's internal device IDs;
+- converts `Position.speed` from knots to meters per second;
+- maps only allowlisted health and alarm attributes;
+- ignores unrecognized fields;
+- derives a deterministic SHA-256 retry fingerprint when `Position.id` is zero.
+
+### Fixtures
+
+`Tests/Resgrid.Tests/Data/UnitTracking/Fixtures/traccar/v6.14.5/` contains an
+independently generated, sanitized fixture representing a long-tail SinoTrack ST-901
+decoded by Traccar's `h02` protocol. It contains no upstream or customer packet data and
+does not constitute physical-hardware certification.
diff --git a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
index d48a9cd00..347f7090c 100644
--- a/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
+++ b/Repositories/Resgrid.Repositories.DataRepository/Modules/DataModule.cs
@@ -62,6 +62,8 @@ 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