From 118524e9fd2166cba1c4d0fba5de433f55c539e8 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 24 Jun 2026 14:15:02 -0500 Subject: [PATCH 1/2] RIC-T39 Backend IC work --- Core/Resgrid.Model/CheckInRecord.cs | 6 + Core/Resgrid.Model/IChangeTracked.cs | 14 + .../IncidentCommand/CommandStructureNode.cs | 13 +- .../IncidentCommand/IncidentAdHocResources.cs | 10 +- .../IncidentCommand/IncidentCommand.cs | 5 +- .../IncidentCommand/IncidentCommandChanges.cs | 38 ++ .../IncidentCommand/IncidentRole.cs | 5 +- .../IncidentCommand/IncidentTacticals.cs | 15 +- .../Services/IIncidentCommandService.cs | 7 + .../Services/IIncidentResourcesService.cs | 7 + Core/Resgrid.Services/CheckInTimerService.cs | 11 + .../IncidentCommandService.cs | 275 ++++++++---- .../IncidentResourcesService.cs | 60 ++- Core/Resgrid.Services/IncidentVoiceService.cs | 3 +- .../M0081_AddIncidentCommandChangeTracking.cs | 58 +++ .../M0082_AddCheckInRecordIdempotencyKey.cs | 28 ++ .../M0083_AddAdHocResourceChangeTracking.cs | 36 ++ ...0081_AddIncidentCommandChangeTrackingPg.cs | 59 +++ .../M0082_AddCheckInRecordIdempotencyKeyPg.cs | 28 ++ .../M0083_AddAdHocResourceChangeTrackingPg.cs | 38 ++ .../Services/CheckInTimerServiceTests.cs | 28 ++ .../IncidentCommandServiceParTests.cs | 163 ++++++- .../Services/IncidentResourcesServiceTests.cs | 180 ++++++++ .../Services/IncidentVoiceServiceTests.cs | 6 +- .../Controllers/v4/CheckInTimersController.cs | 3 +- .../Controllers/v4/SyncController.cs | 66 +++ .../v4/CheckInTimers/CheckInTimerModels.cs | 3 + .../Models/v4/Sync/SyncModels.cs | 13 + .../Resgrid.Web.Services.xml | 24 + .../offline-first-architecture.md | 416 ++++++++++++++++++ 30 files changed, 1494 insertions(+), 124 deletions(-) create mode 100644 Core/Resgrid.Model/IChangeTracked.cs create mode 100644 Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs create mode 100644 Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs create mode 100644 Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs create mode 100644 Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs create mode 100644 docs/architecture/offline-first-architecture.md diff --git a/Core/Resgrid.Model/CheckInRecord.cs b/Core/Resgrid.Model/CheckInRecord.cs index ccea2fdb3..56f8f9ba2 100644 --- a/Core/Resgrid.Model/CheckInRecord.cs +++ b/Core/Resgrid.Model/CheckInRecord.cs @@ -27,6 +27,12 @@ public class CheckInRecord : IEntity public string Note { get; set; } + /// + /// Client-supplied idempotency key (the offline outbox event id). When set, a replayed check-in carrying the + /// same key returns the original record instead of inserting a duplicate. See offline-first-architecture.md. + /// + public string IdempotencyKey { get; set; } + [NotMapped] public string TableName => "CheckInRecords"; diff --git a/Core/Resgrid.Model/IChangeTracked.cs b/Core/Resgrid.Model/IChangeTracked.cs new file mode 100644 index 000000000..a809a0e61 --- /dev/null +++ b/Core/Resgrid.Model/IChangeTracked.cs @@ -0,0 +1,14 @@ +using System; + +namespace Resgrid.Model +{ + /// + /// Entities that carry a change cursor. It is stamped on every insert and update + /// and drives two offline-first concerns: the delta-sync "changed since" query (pull) and last-write-wins + /// conflict resolution on reconnect. See docs/architecture/offline-first-architecture.md. + /// + public interface IChangeTracked + { + DateTime? ModifiedOn { get; set; } + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs index 5b705a742..485dd235f 100644 --- a/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs +++ b/Core/Resgrid.Model/IncidentCommand/CommandStructureNode.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model /// A live lane / span-of-control node on the command board (Division, Group, Branch, Staging, ...). /// Initially seeded from a CommandDefinitionRole then per-incident editable. /// - public class CommandStructureNode : IEntity + public class CommandStructureNode : IEntity, IChangeTracked { public string CommandStructureNodeId { get; set; } @@ -36,6 +36,12 @@ public class CommandStructureNode : IEntity /// The CommandDefinitionRole this node was seeded from, if any. public int? SourceRoleId { get; set; } + /// Soft-delete tombstone so a lane removed offline propagates on delta sync (null = live). + public DateTime? DeletedOn { get; set; } + + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "CommandStructureNodes"; @@ -61,7 +67,7 @@ public object IdValue /// Assigns a resource to a command structure node. Polymorphic: the resource may be an own-department /// unit/person, a linked (mutual-aid) department unit/person, or an incident ad-hoc unit/person. /// - public class ResourceAssignment : IEntity + public class ResourceAssignment : IEntity, IChangeTracked { public string ResourceAssignmentId { get; set; } @@ -85,6 +91,9 @@ public class ResourceAssignment : IEntity public DateTime? ReleasedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "ResourceAssignments"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs b/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs index 816f0703b..5895ff7aa 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentAdHocResources.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model /// An incident-scoped, ad-hoc unit created on the fly for resources not in Resgrid (e.g. a mutual-aid /// crew from a non-Resgrid agency, or a unit formed from on-scene personnel). Not a real department Unit. /// - public class IncidentAdHocUnit : IEntity + public class IncidentAdHocUnit : IEntity, IChangeTracked { public string IncidentAdHocUnitId { get; set; } @@ -34,6 +34,9 @@ public class IncidentAdHocUnit : IEntity public DateTime? ReleasedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentAdHocUnits"; @@ -59,7 +62,7 @@ public object IdValue /// An incident-scoped, ad-hoc person created on the fly for resources not in Resgrid. May ride an ad-hoc /// (or real) unit for accountability via + . /// - public class IncidentAdHocPersonnel : IEntity + public class IncidentAdHocPersonnel : IEntity, IChangeTracked { public string IncidentAdHocPersonnelId { get; set; } @@ -88,6 +91,9 @@ public class IncidentAdHocPersonnel : IEntity public DateTime? ReleasedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentAdHocPersonnel"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs index 698815472..c9fa8c0df 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommand.cs @@ -9,7 +9,7 @@ namespace Resgrid.Model /// A live incident-command instance established on a specific Call. Seeded (optionally) from a /// CommandDefinition template and then freely editable by the Commander for the life of the incident. /// - public class IncidentCommand : IEntity + public class IncidentCommand : IEntity, IChangeTracked { public string IncidentCommandId { get; set; } @@ -40,6 +40,9 @@ public class IncidentCommand : IEntity public DateTime? ClosedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentCommands"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs new file mode 100644 index 000000000..fbc33ef8b --- /dev/null +++ b/Core/Resgrid.Model/IncidentCommand/IncidentCommandChanges.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; + +namespace Resgrid.Model +{ + /// + /// Delta payload for offline sync: every change-tracked incident-command row whose + /// (or, for the append-only timeline, OccurredOn) is newer than the client's last sync cursor, scoped to a department. + /// Soft-deleted / closed / released rows ARE included (with their state columns set) so the client can remove or + /// update them locally. The client stores and passes it back as the next `since`. + /// Ad-hoc resources are not change-tracked and are pulled separately (full refetch). See + /// docs/architecture/offline-first-architecture.md. + /// + public class IncidentCommandChanges + { + /// Server clock (Unix epoch ms) captured at the start of the read; the client's next-sync cursor. + public long ServerTimestampMs { get; set; } + + public List Commands { get; set; } = new List(); + + public List Nodes { get; set; } = new List(); + + public List Assignments { get; set; } = new List(); + + public List Objectives { get; set; } = new List(); + + public List Timers { get; set; } = new List(); + + public List Annotations { get; set; } = new List(); + + public List Roles { get; set; } = new List(); + + public List AdHocUnits { get; set; } = new List(); + + public List AdHocPersonnel { get; set; } = new List(); + + public List TimelineEntries { get; set; } = new List(); + } +} diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs index c12583610..37c6d83c1 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentRole.cs @@ -149,7 +149,7 @@ public static IncidentCapabilities GetCapabilities(IncidentRoleType role) /// Assigns a Resgrid user to a functional incident-command role for a specific incident (Call). Incident-scoped, /// not a department-wide claim. Optionally scoped to a structure node for supervisors. /// - public class IncidentRoleAssignment : IEntity + public class IncidentRoleAssignment : IEntity, IChangeTracked { public string IncidentRoleAssignmentId { get; set; } @@ -174,6 +174,9 @@ public class IncidentRoleAssignment : IEntity public DateTime? RemovedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentRoleAssignments"; diff --git a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs index deeada03f..6c70f361c 100644 --- a/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs +++ b/Core/Resgrid.Model/IncidentCommand/IncidentTacticals.cs @@ -6,7 +6,7 @@ namespace Resgrid.Model { /// A tactical objective / benchmark for an incident (e.g. "Primary search complete"). - public class TacticalObjective : IEntity + public class TacticalObjective : IEntity, IChangeTracked { public string TacticalObjectiveId { get; set; } @@ -32,6 +32,9 @@ public class TacticalObjective : IEntity public int SortOrder { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "TacticalObjectives"; @@ -57,7 +60,7 @@ public object IdValue /// A scene / benchmark / role timer for an incident. Personnel accountability (PAR) is handled by the /// Checkin feature, not by these timers. /// - public class IncidentTimer : IEntity + public class IncidentTimer : IEntity, IChangeTracked { public string IncidentTimerId { get; set; } @@ -89,6 +92,9 @@ public class IncidentTimer : IEntity public DateTime? AcknowledgedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentTimers"; @@ -111,7 +117,7 @@ public object IdValue } /// A real-time map annotation (markup) on the tactical map, synced across devices. - public class IncidentMapAnnotation : IEntity + public class IncidentMapAnnotation : IEntity, IChangeTracked { public string IncidentMapAnnotationId { get; set; } @@ -138,6 +144,9 @@ public class IncidentMapAnnotation : IEntity public DateTime? DeletedOn { get; set; } + /// Change cursor for offline delta sync + last-write-wins; stamped on every write. + public DateTime? ModifiedOn { get; set; } + [NotMapped] public string TableName => "IncidentMapAnnotations"; diff --git a/Core/Resgrid.Model/Services/IIncidentCommandService.cs b/Core/Resgrid.Model/Services/IIncidentCommandService.cs index eef68d477..5f899093a 100644 --- a/Core/Resgrid.Model/Services/IIncidentCommandService.cs +++ b/Core/Resgrid.Model/Services/IIncidentCommandService.cs @@ -19,6 +19,13 @@ public interface IIncidentCommandService Task GetCommandBoardAsync(int departmentId, int callId); Task> GetAccountabilityForCallAsync(int departmentId, int callId); + /// + /// Offline-first delta pull: returns every change-tracked incident-command row (and append-only timeline entry) + /// for the department whose ModifiedOn/OccurredOn is newer than . Includes + /// soft-deleted/closed/released rows so a reconnecting client can reconcile removals. See the offline-first doc. + /// + Task GetChangesSinceAsync(int departmentId, System.DateTime sinceUtc); + /// /// Sweeps personnel accountability (PAR) for the call and raises CriticalParDetectedEvent once per /// member each time they transition into the Critical (overdue) state. Idempotent via a timeline marker — diff --git a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs index 2d3a6d2f0..b99bfffc0 100644 --- a/Core/Resgrid.Model/Services/IIncidentResourcesService.cs +++ b/Core/Resgrid.Model/Services/IIncidentResourcesService.cs @@ -26,5 +26,12 @@ public interface IIncidentResourcesService /// Forms a new ad-hoc unit and attaches the given ad-hoc personnel to it as its roster. Task FormUnitFromPersonnelAsync(IncidentAdHocUnit unit, List adHocPersonnelIds, string userId, CancellationToken cancellationToken = default(CancellationToken)); + + /// + /// Offline-first delta pull for ad-hoc resources: returns the department's ad-hoc units and personnel whose + /// ModifiedOn is newer than (released rows included so the client reconciles them). + /// Aggregated into the unified /Sync/Changes payload by SyncController. See offline-first-architecture.md. + /// + Task<(List Units, List Personnel)> GetAdHocChangesSinceAsync(int departmentId, System.DateTime sinceUtc); } } diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index 96d33c0d4..f81807166 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -320,6 +320,17 @@ public async Task> ResolveAllTimersForCallAsync(Call public async Task PerformCheckInAsync(CheckInRecord record, CancellationToken cancellationToken = default) { + // Offline idempotency: when the client supplies its outbox event id, a replayed check-in returns the + // original record instead of inserting a duplicate. Dedup is in-memory over the call's (small) check-in + // set, so only replayed events — which carry a key — pay the extra read; live UI check-ins skip it. + if (!string.IsNullOrWhiteSpace(record.IdempotencyKey)) + { + var existing = await _recordRepository.GetByCallIdAsync(record.CallId); + var duplicate = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey); + if (duplicate != null) + return duplicate; + } + record.Timestamp = DateTime.UtcNow; var saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken); diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 01749caa1..57c0dc426 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -98,7 +98,9 @@ public IncidentCommandService( try { - command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + // Explicit insert: the GUID is pre-set, and SaveOrUpdateAsync would treat a non-empty IdType-1 id + // as an UPDATE (zero rows) instead of an insert. + command = await _incidentCommandRepository.InsertAsync(Touch(command), cancellationToken); } catch (Exception) { @@ -141,7 +143,7 @@ public IncidentCommandService( SourceRoleId = role.CommandDefinitionRoleId }; - await _commandStructureNodeRepository.SaveOrUpdateAsync(node, cancellationToken); + await _commandStructureNodeRepository.InsertAsync(Touch(node), cancellationToken); } } } @@ -259,23 +261,20 @@ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int c return null; assignment.CallId = command.CallId; - if (string.IsNullOrWhiteSpace(assignment.IncidentRoleAssignmentId)) - { - assignment.IncidentRoleAssignmentId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _incidentRoleAssignmentRepository.GetByIdAsync(assignment.IncidentRoleAssignmentId); - if (existing == null || existing.DepartmentId != assignment.DepartmentId) - return null; - } - assignment.AssignedByUserId = userId; if (assignment.AssignedOn == default(DateTime)) assignment.AssignedOn = DateTime.UtcNow; - assignment = await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + var (saved, _, rejected) = await UpsertOwnedAsync(_incidentRoleAssignmentRepository, assignment, assignment.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.AssignedOn = stored.AssignedOn; + incoming.AssignedByUserId = stored.AssignedByUserId; + incoming.RemovedOn = stored.RemovedOn; + }, cancellationToken); + if (rejected) + return null; + assignment = saved; await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleAssigned, $"Role {(IncidentRoleType)assignment.RoleType} assigned", userId, cancellationToken); @@ -290,7 +289,7 @@ private async Task EnableAccountabilityIfConfiguredAsync(int departmentId, int c return false; assignment.RemovedOn = DateTime.UtcNow; - await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + await _incidentRoleAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.RoleRemoved, $"Role {(IncidentRoleType)assignment.RoleType} removed", userId, cancellationToken); return true; @@ -352,6 +351,53 @@ public async Task GetCommandBoardAsync(int departmentId, i return board; } + public async Task GetChangesSinceAsync(int departmentId, DateTime sinceUtc) + { + // Capture the cursor before reading so a row committed during the read is not missed next time (it may be + // returned again on the next sync — harmless, the client upserts idempotently). + var changes = new IncidentCommandChanges { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; + + // Method-group conversion is contravariance-aware, so this Func binds to each + // entity-typed Where(). Soft-deleted/closed/released rows are intentionally NOT filtered out here — the + // delta must surface them (with their state columns) so the client removes/updates them locally. + bool Changed(IChangeTracked e) => e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc; + + var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); + if (commands != null) + changes.Commands = commands.Where(Changed).ToList(); + + var nodes = await _commandStructureNodeRepository.GetAllByDepartmentIdAsync(departmentId); + if (nodes != null) + changes.Nodes = nodes.Where(Changed).ToList(); + + var assignments = await _resourceAssignmentRepository.GetAllByDepartmentIdAsync(departmentId); + if (assignments != null) + changes.Assignments = assignments.Where(Changed).ToList(); + + var objectives = await _tacticalObjectiveRepository.GetAllByDepartmentIdAsync(departmentId); + if (objectives != null) + changes.Objectives = objectives.Where(Changed).ToList(); + + var timers = await _incidentTimerRepository.GetAllByDepartmentIdAsync(departmentId); + if (timers != null) + changes.Timers = timers.Where(Changed).ToList(); + + var annotations = await _incidentMapAnnotationRepository.GetAllByDepartmentIdAsync(departmentId); + if (annotations != null) + changes.Annotations = annotations.Where(Changed).ToList(); + + var roles = await _incidentRoleAssignmentRepository.GetAllByDepartmentIdAsync(departmentId); + if (roles != null) + changes.Roles = roles.Where(Changed).ToList(); + + // The timeline is append-only (no ModifiedOn); its natural cursor is OccurredOn. + var timeline = await _commandLogEntryRepository.GetAllByDepartmentIdAsync(departmentId); + if (timeline != null) + changes.TimelineEntries = timeline.Where(x => x.OccurredOn > sinceUtc).ToList(); + + return changes; + } + public async Task CloseCommandAsync(int departmentId, string incidentCommandId, string userId, CancellationToken cancellationToken = default(CancellationToken)) { var command = await _incidentCommandRepository.GetByIdAsync(incidentCommandId); @@ -360,7 +406,7 @@ public async Task GetCommandBoardAsync(int departmentId, i command.Status = (int)IncidentCommandStatus.Closed; command.ClosedOn = DateTime.UtcNow; - command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandClosed, "Command closed", userId, cancellationToken); @@ -378,7 +424,7 @@ public async Task GetCommandBoardAsync(int departmentId, i return null; command.CurrentCommanderUserId = toUserId; - await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); var transfer = new CommandTransfer { @@ -391,7 +437,7 @@ public async Task GetCommandBoardAsync(int departmentId, i TransferredOn = DateTime.UtcNow, Notes = notes }; - transfer = await _commandTransferRepository.SaveOrUpdateAsync(transfer, cancellationToken); + transfer = await _commandTransferRepository.InsertAsync(transfer, cancellationToken); await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.CommandTransferred, "Command transferred", fromUserId, cancellationToken); @@ -406,7 +452,7 @@ public async Task GetCommandBoardAsync(int departmentId, i return null; command.IncidentActionPlan = actionPlan; - command = await _incidentCommandRepository.SaveOrUpdateAsync(command, cancellationToken); + command = await _incidentCommandRepository.SaveOrUpdateAsync(Touch(command), cancellationToken); await WriteLogAsync(command.IncidentCommandId, command.DepartmentId, command.CallId, CommandLogEntryType.Note, "Incident action plan updated", userId, cancellationToken); return command; @@ -426,20 +472,11 @@ public async Task GetCommandBoardAsync(int departmentId, i return null; node.CallId = command.CallId; - var isNew = string.IsNullOrWhiteSpace(node.CommandStructureNodeId); - if (isNew) - { - node.CommandStructureNodeId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department (no foreign-row takeover). - var existing = await _commandStructureNodeRepository.GetByIdAsync(node.CommandStructureNodeId); - if (existing == null || existing.DepartmentId != node.DepartmentId) - return null; - } - - node = await _commandStructureNodeRepository.SaveOrUpdateAsync(node, cancellationToken); + var (saved, isNew, rejected) = await UpsertOwnedAsync(_commandStructureNodeRepository, node, node.DepartmentId, + e => e.DepartmentId, (stored, incoming) => incoming.DeletedOn = stored.DeletedOn, cancellationToken); + if (rejected) + return null; + node = saved; await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, isNew ? CommandLogEntryType.NodeAdded : CommandLogEntryType.NodeUpdated, @@ -453,10 +490,13 @@ await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, if (node == null || node.DepartmentId != departmentId) return false; - var result = await _commandStructureNodeRepository.DeleteAsync(node, cancellationToken); + // Soft-delete (tombstone) rather than hard-delete so the removal propagates to offline clients on the + // next delta sync; ModifiedOn is stamped so the change is picked up by the "changed since" query. + node.DeletedOn = DateTime.UtcNow; + await _commandStructureNodeRepository.SaveOrUpdateAsync(Touch(node), cancellationToken); await WriteLogAsync(node.IncidentCommandId, node.DepartmentId, node.CallId, CommandLogEntryType.NodeRemoved, $"Lane '{node.Name}' removed", userId, cancellationToken); - return result; + return true; } public async Task> GetNodesForCallAsync(int departmentId, int callId) @@ -465,7 +505,7 @@ public async Task> GetNodesForCallAsync(int departmen if (items == null) return new List(); - return items.Where(x => x.CallId == callId).OrderBy(x => x.SortOrder).ToList(); + return items.Where(x => x.CallId == callId && x.DeletedOn == null).OrderBy(x => x.SortOrder).ToList(); } #endregion Structure (lanes) @@ -481,23 +521,20 @@ public async Task> GetNodesForCallAsync(int departmen return null; assignment.CallId = command.CallId; - if (string.IsNullOrWhiteSpace(assignment.ResourceAssignmentId)) - { - assignment.ResourceAssignmentId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _resourceAssignmentRepository.GetByIdAsync(assignment.ResourceAssignmentId); - if (existing == null || existing.DepartmentId != assignment.DepartmentId) - return null; - } - if (assignment.AssignedOn == default(DateTime)) assignment.AssignedOn = DateTime.UtcNow; - assignment.AssignedByUserId = userId; - assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + + var (saved, _, rejected) = await UpsertOwnedAsync(_resourceAssignmentRepository, assignment, assignment.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.AssignedOn = stored.AssignedOn; + incoming.AssignedByUserId = stored.AssignedByUserId; + incoming.ReleasedOn = stored.ReleasedOn; + }, cancellationToken); + if (rejected) + return null; + assignment = saved; await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceAssigned, "Resource assigned", userId, cancellationToken); @@ -518,7 +555,7 @@ public async Task> GetNodesForCallAsync(int departmen return null; assignment.CommandStructureNodeId = targetNodeId; - assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + assignment = await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceMoved, "Resource moved", userId, cancellationToken); return assignment; @@ -531,7 +568,7 @@ public async Task> GetNodesForCallAsync(int departmen return false; assignment.ReleasedOn = DateTime.UtcNow; - await _resourceAssignmentRepository.SaveOrUpdateAsync(assignment, cancellationToken); + await _resourceAssignmentRepository.SaveOrUpdateAsync(Touch(assignment), cancellationToken); await WriteLogAsync(assignment.IncidentCommandId, assignment.DepartmentId, assignment.CallId, CommandLogEntryType.ResourceReleased, "Resource released", userId, cancellationToken); @@ -561,20 +598,17 @@ public async Task> GetAssignmentsForCallAsync(int depar return null; objective.CallId = command.CallId; - var isNew = string.IsNullOrWhiteSpace(objective.TacticalObjectiveId); - if (isNew) - { - objective.TacticalObjectiveId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _tacticalObjectiveRepository.GetByIdAsync(objective.TacticalObjectiveId); - if (existing == null || existing.DepartmentId != objective.DepartmentId) - return null; - } - - objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(objective, cancellationToken); + var (saved, isNew, rejected) = await UpsertOwnedAsync(_tacticalObjectiveRepository, objective, objective.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + // Completion is owned by CompleteObjectiveAsync; a Save (edit/replay) must not reset it. + incoming.Status = stored.Status; + incoming.CompletedByUserId = stored.CompletedByUserId; + incoming.CompletedOn = stored.CompletedOn; + }, cancellationToken); + if (rejected) + return null; + objective = saved; if (isNew) await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveAdded, $"Objective '{objective.Name}' added", userId, cancellationToken); @@ -591,7 +625,7 @@ public async Task> GetAssignmentsForCallAsync(int depar objective.Status = (int)TacticalObjectiveStatus.Complete; objective.CompletedByUserId = userId; objective.CompletedOn = DateTime.UtcNow; - objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(objective, cancellationToken); + objective = await _tacticalObjectiveRepository.SaveOrUpdateAsync(Touch(objective), cancellationToken); await WriteLogAsync(objective.IncidentCommandId, objective.DepartmentId, objective.CallId, CommandLogEntryType.ObjectiveCompleted, $"Objective '{objective.Name}' completed", userId, cancellationToken); @@ -621,24 +655,23 @@ public async Task> GetObjectivesForCallAsync(int departm return null; timer.CallId = command.CallId; - if (string.IsNullOrWhiteSpace(timer.IncidentTimerId)) - { - timer.IncidentTimerId = Guid.NewGuid().ToString(); - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _incidentTimerRepository.GetByIdAsync(timer.IncidentTimerId); - if (existing == null || existing.DepartmentId != timer.DepartmentId) - return null; - } - timer.StartedOn = DateTime.UtcNow; timer.Status = (int)IncidentTimerStatus.Running; if (timer.IntervalSeconds > 0) timer.NextDueOn = timer.StartedOn.AddSeconds(timer.IntervalSeconds); - timer = await _incidentTimerRepository.SaveOrUpdateAsync(timer, cancellationToken); + var (saved, _, rejected) = await UpsertOwnedAsync(_incidentTimerRepository, timer, timer.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + // Existing id => a replayed start; keep the original run state rather than restarting the timer. + incoming.StartedOn = stored.StartedOn; + incoming.Status = stored.Status; + incoming.NextDueOn = stored.NextDueOn; + incoming.AcknowledgedOn = stored.AcknowledgedOn; + }, cancellationToken); + if (rejected) + return null; + timer = saved; await WriteLogAsync(timer.IncidentCommandId, timer.DepartmentId, timer.CallId, CommandLogEntryType.TimerStarted, $"Timer '{timer.Name}' started", userId, cancellationToken); return timer; @@ -655,7 +688,7 @@ public async Task> GetObjectivesForCallAsync(int departm if (timer.IntervalSeconds > 0) timer.NextDueOn = timer.AcknowledgedOn.Value.AddSeconds(timer.IntervalSeconds); - timer = await _incidentTimerRepository.SaveOrUpdateAsync(timer, cancellationToken); + timer = await _incidentTimerRepository.SaveOrUpdateAsync(Touch(timer), cancellationToken); await WriteLogAsync(timer.IncidentCommandId, timer.DepartmentId, timer.CallId, CommandLogEntryType.TimerAcknowledged, $"Timer '{timer.Name}' acknowledged", userId, cancellationToken); return timer; @@ -683,22 +716,21 @@ public async Task> GetActiveTimersForCallAsync(int departmen return null; annotation.CallId = command.CallId; - var isNew = string.IsNullOrWhiteSpace(annotation.IncidentMapAnnotationId); - if (isNew) - { - annotation.IncidentMapAnnotationId = Guid.NewGuid().ToString(); + if (annotation.CreatedOn == default(DateTime)) annotation.CreatedOn = DateTime.UtcNow; + if (string.IsNullOrWhiteSpace(annotation.CreatedByUserId)) annotation.CreatedByUserId = userId; - } - else - { - // On update, the existing row must belong to the caller's department. - var existing = await _incidentMapAnnotationRepository.GetByIdAsync(annotation.IncidentMapAnnotationId); - if (existing == null || existing.DepartmentId != annotation.DepartmentId) - return null; - } - annotation = await _incidentMapAnnotationRepository.SaveOrUpdateAsync(annotation, cancellationToken); + var (saved, isNew, rejected) = await UpsertOwnedAsync(_incidentMapAnnotationRepository, annotation, annotation.DepartmentId, + e => e.DepartmentId, (stored, incoming) => + { + incoming.CreatedOn = stored.CreatedOn; + incoming.CreatedByUserId = stored.CreatedByUserId; + incoming.DeletedOn = stored.DeletedOn; + }, cancellationToken); + if (rejected) + return null; + annotation = saved; if (isNew) await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationAdded, "Map annotation added", userId, cancellationToken); @@ -713,7 +745,7 @@ public async Task> GetActiveTimersForCallAsync(int departmen return false; annotation.DeletedOn = DateTime.UtcNow; - await _incidentMapAnnotationRepository.SaveOrUpdateAsync(annotation, cancellationToken); + await _incidentMapAnnotationRepository.SaveOrUpdateAsync(Touch(annotation), cancellationToken); await WriteLogAsync(annotation.IncidentCommandId, annotation.DepartmentId, annotation.CallId, CommandLogEntryType.AnnotationRemoved, "Map annotation removed", userId, cancellationToken); return true; @@ -750,6 +782,55 @@ public async Task> GetTimelineForCallAsync(int departmentI #region Private helpers + /// + /// Stamps the offline-sync change cursor on an entity. Called on every insert and update so the delta + /// endpoint can surface the row as "changed since" and reconnect conflict resolution can compare write + /// times (last-write-wins). See docs/architecture/offline-first-architecture.md. + /// + private static T Touch(T entity) where T : IChangeTracked + { + entity.ModifiedOn = DateTime.UtcNow; + return entity; + } + + /// + /// Idempotent upsert for an owned child entity. Create-vs-update is resolved by the entity id's EXISTENCE + /// (not merely whether an id was supplied), which is what makes offline replay safe: + /// • no id, or a client-supplied id that does not exist yet -> INSERT with that (or a generated) GUID, so + /// an offline-created row replays without duplicating; + /// • id already present -> it must belong to (else rejected) and is + /// UPDATED, with copying server-owned fields off the stored row so a + /// replayed create payload cannot clobber them. + /// Returns rejected=true only for a foreign-department row. A plain SaveOrUpdateAsync cannot do the create + /// here: for string-GUID (IdType 1) entities it only inserts when the id is blank and otherwise issues a + /// blind UPDATE, so a client-supplied PK would silently update zero rows. See offline-first-architecture.md. + /// + private static async Task<(T entity, bool isNew, bool rejected)> UpsertOwnedAsync( + IRepository repository, T entity, int departmentId, Func departmentOf, + Action preserve, CancellationToken cancellationToken) where T : class, IEntity, IChangeTracked + { + var id = entity.IdValue?.ToString(); + + T stored = null; + if (!string.IsNullOrWhiteSpace(id)) + { + stored = await repository.GetByIdAsync(id); + if (stored != null && departmentOf(stored) != departmentId) + return (null, false, true); + } + + if (stored == null) + { + if (string.IsNullOrWhiteSpace(id)) + entity.IdValue = Guid.NewGuid().ToString(); + + return (await repository.InsertAsync(Touch(entity), cancellationToken), true, false); + } + + preserve?.Invoke(stored, entity); + return (await repository.SaveOrUpdateAsync(Touch(entity), cancellationToken), false, false); + } + /// /// Loads the parent incident command and returns it only if it belongs to the given department (else null). /// Gates create/update of child entities AND supplies the authoritative CallId to stamp onto them — a caller @@ -778,7 +859,7 @@ private async Task WriteLogAsync(string incidentCommandId, int OccurredOn = DateTime.UtcNow }; - var saved = await _commandLogEntryRepository.SaveOrUpdateAsync(entry, cancellationToken); + var saved = await _commandLogEntryRepository.InsertAsync(entry, cancellationToken); // Real-time: every command mutation flows through here, so push one board-changed signal. await _coreEventService.IncidentCommandUpdatedAsync(departmentId, callId); diff --git a/Core/Resgrid.Services/IncidentResourcesService.cs b/Core/Resgrid.Services/IncidentResourcesService.cs index ab7f0f352..aa487f744 100644 --- a/Core/Resgrid.Services/IncidentResourcesService.cs +++ b/Core/Resgrid.Services/IncidentResourcesService.cs @@ -44,14 +44,25 @@ public IncidentResourcesService( if (command == null) return null; - if (string.IsNullOrWhiteSpace(unit.IncidentAdHocUnitId)) + // Idempotent create: the client may generate the GUID PK offline. If a row with that id already exists + // for this department the create was already applied (replay) — return it without duplicating. Otherwise + // INSERT explicitly; SaveOrUpdateAsync would treat the pre-set GUID as a 0-row UPDATE, not an insert. + if (!string.IsNullOrWhiteSpace(unit.IncidentAdHocUnitId)) + { + var stored = await _adHocUnitRepository.GetByIdAsync(unit.IncidentAdHocUnitId); + if (stored != null) + return stored.DepartmentId == unit.DepartmentId ? stored : null; + } + else + { unit.IncidentAdHocUnitId = Guid.NewGuid().ToString(); + } unit.CreatedByUserId = userId; if (unit.CreatedOn == default(DateTime)) unit.CreatedOn = DateTime.UtcNow; - unit = await _adHocUnitRepository.SaveOrUpdateAsync(unit, cancellationToken); + unit = await _adHocUnitRepository.InsertAsync(Touch(unit), cancellationToken); await LogAsync(unit.DepartmentId, unit.CallId, $"Ad-hoc unit '{unit.Name}' created", userId, cancellationToken); @@ -80,7 +91,7 @@ public async Task> GetAdHocUnitsForCallAsync(int departm return false; unit.ReleasedOn = DateTime.UtcNow; - await _adHocUnitRepository.SaveOrUpdateAsync(unit, cancellationToken); + await _adHocUnitRepository.SaveOrUpdateAsync(Touch(unit), cancellationToken); await LogAsync(unit.DepartmentId, unit.CallId, $"Ad-hoc unit '{unit.Name}' released", userId, cancellationToken); return true; @@ -97,14 +108,24 @@ public async Task> GetAdHocUnitsForCallAsync(int departm if (command == null) return null; - if (string.IsNullOrWhiteSpace(personnel.IncidentAdHocPersonnelId)) + // Idempotent create (see CreateAdHocUnitAsync): replay of an existing id returns the stored row; a new id + // is inserted explicitly (a pre-set GUID + SaveOrUpdateAsync would be a 0-row UPDATE, not an insert). + if (!string.IsNullOrWhiteSpace(personnel.IncidentAdHocPersonnelId)) + { + var stored = await _adHocPersonnelRepository.GetByIdAsync(personnel.IncidentAdHocPersonnelId); + if (stored != null) + return stored.DepartmentId == personnel.DepartmentId ? stored : null; + } + else + { personnel.IncidentAdHocPersonnelId = Guid.NewGuid().ToString(); + } personnel.CreatedByUserId = userId; if (personnel.CreatedOn == default(DateTime)) personnel.CreatedOn = DateTime.UtcNow; - personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + personnel = await _adHocPersonnelRepository.InsertAsync(Touch(personnel), cancellationToken); await LogAsync(personnel.DepartmentId, personnel.CallId, $"Ad-hoc personnel '{personnel.Name}' created", userId, cancellationToken); @@ -128,7 +149,7 @@ public async Task> GetAdHocPersonnelForCallAsync(in return false; personnel.ReleasedOn = DateTime.UtcNow; - await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + await _adHocPersonnelRepository.SaveOrUpdateAsync(Touch(personnel), cancellationToken); await LogAsync(personnel.DepartmentId, personnel.CallId, $"Ad-hoc personnel '{personnel.Name}' released", userId, cancellationToken); return true; @@ -146,7 +167,7 @@ public async Task> GetAdHocPersonnelForCallAsync(in personnel.RidingResourceKind = ridingResourceKind; personnel.RidingResourceId = ridingResourceId; - personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + personnel = await _adHocPersonnelRepository.SaveOrUpdateAsync(Touch(personnel), cancellationToken); await LogAsync(personnel.DepartmentId, personnel.CallId, $"'{personnel.Name}' added to unit roster", userId, cancellationToken); return personnel; @@ -170,7 +191,7 @@ public async Task> GetAdHocPersonnelForCallAsync(in personnel.RidingResourceKind = (int)ResourceAssignmentKind.AdHocUnit; personnel.RidingResourceId = createdUnit.IncidentAdHocUnitId; - await _adHocPersonnelRepository.SaveOrUpdateAsync(personnel, cancellationToken); + await _adHocPersonnelRepository.SaveOrUpdateAsync(Touch(personnel), cancellationToken); } } @@ -180,8 +201,31 @@ public async Task> GetAdHocPersonnelForCallAsync(in #endregion Roster building + #region Offline sync + + public async Task<(List Units, List Personnel)> GetAdHocChangesSinceAsync(int departmentId, DateTime sinceUtc) + { + bool Changed(IChangeTracked e) => e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc; + + var units = await _adHocUnitRepository.GetAllByDepartmentIdAsync(departmentId); + var personnel = await _adHocPersonnelRepository.GetAllByDepartmentIdAsync(departmentId); + + return ( + units?.Where(Changed).ToList() ?? new List(), + personnel?.Where(Changed).ToList() ?? new List()); + } + + #endregion Offline sync + #region Private helpers + /// Stamps the offline-sync change cursor (ModifiedOn) on every insert/update. See offline-first-architecture.md. + private static T Touch(T entity) where T : IChangeTracked + { + entity.ModifiedOn = DateTime.UtcNow; + return entity; + } + private async Task LogAsync(int departmentId, int callId, string description, string userId, CancellationToken cancellationToken) { var command = await _incidentCommandService.GetActiveCommandForCallAsync(departmentId, callId); diff --git a/Core/Resgrid.Services/IncidentVoiceService.cs b/Core/Resgrid.Services/IncidentVoiceService.cs index 4d6106a80..85d501c5a 100644 --- a/Core/Resgrid.Services/IncidentVoiceService.cs +++ b/Core/Resgrid.Services/IncidentVoiceService.cs @@ -126,7 +126,8 @@ private async Task WriteLogAsync(int departmentId, int callId, CommandLogEntryTy OccurredOn = DateTime.UtcNow }; - await _commandLogEntryRepository.SaveOrUpdateAsync(entry, cancellationToken); + // Append-only insert: a pre-set GUID would make SaveOrUpdateAsync issue a 0-row UPDATE instead of inserting. + await _commandLogEntryRepository.InsertAsync(entry, cancellationToken); // Real-time: channel open/close is a board change. await _coreEventService.IncidentCommandUpdatedAsync(departmentId, callId); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs new file mode 100644 index 000000000..e6e23382a --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0081_AddIncidentCommandChangeTracking.cs @@ -0,0 +1,58 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Offline-first sync foundation: adds a ModifiedOn change cursor to the mutable incident-command tables + /// (delta "changed since" + last-write-wins) and a DeletedOn soft-delete tombstone to CommandStructureNodes + /// so a lane removed offline propagates on delta sync. Append-only tables (CommandLogEntries / CommandTransfers) + /// already carry a natural creation timestamp and are intentionally excluded. + /// See docs/architecture/offline-first-architecture.md. + /// + [Migration(81)] + public class M0081_AddIncidentCommandChangeTracking : Migration + { + private static readonly string[] ChangeTrackedTables = + { + "IncidentCommands", + "CommandStructureNodes", + "ResourceAssignments", + "TacticalObjectives", + "IncidentTimers", + "IncidentMapAnnotations", + "IncidentRoleAssignments" + }; + + public override void Up() + { + foreach (var table in ChangeTrackedTables) + { + if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists()) + { + Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable(); + } + } + + if (Schema.Table("CommandStructureNodes").Exists() && !Schema.Table("CommandStructureNodes").Column("DeletedOn").Exists()) + { + Alter.Table("CommandStructureNodes").AddColumn("DeletedOn").AsDateTime2().Nullable(); + } + } + + public override void Down() + { + foreach (var table in ChangeTrackedTables) + { + if (Schema.Table(table).Exists() && Schema.Table(table).Column("ModifiedOn").Exists()) + { + Delete.Column("ModifiedOn").FromTable(table); + } + } + + if (Schema.Table("CommandStructureNodes").Exists() && Schema.Table("CommandStructureNodes").Column("DeletedOn").Exists()) + { + Delete.Column("DeletedOn").FromTable("CommandStructureNodes"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs new file mode 100644 index 000000000..496b6f2cb --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Offline-first action idempotency: a client-supplied key on check-in records so a replayed offline check-in + /// dedups instead of creating a duplicate row. See docs/architecture/offline-first-architecture.md. + /// + [Migration(82)] + public class M0082_AddCheckInRecordIdempotencyKey : Migration + { + public override void Up() + { + if (Schema.Table("CheckInRecords").Exists() && !Schema.Table("CheckInRecords").Column("IdempotencyKey").Exists()) + { + Alter.Table("CheckInRecords").AddColumn("IdempotencyKey").AsString(128).Nullable(); + } + } + + public override void Down() + { + if (Schema.Table("CheckInRecords").Exists() && Schema.Table("CheckInRecords").Column("IdempotencyKey").Exists()) + { + Delete.Column("IdempotencyKey").FromTable("CheckInRecords"); + } + } + } +} diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs new file mode 100644 index 000000000..70a8fc16d --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0083_AddAdHocResourceChangeTracking.cs @@ -0,0 +1,36 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Offline-first: adds a ModifiedOn change cursor to the ad-hoc incident resource tables so they participate in + /// the /Sync/Changes delta pull (previously they were full-refetched). See docs/architecture/offline-first-architecture.md. + /// + [Migration(83)] + public class M0083_AddAdHocResourceChangeTracking : Migration + { + private static readonly string[] Tables = { "IncidentAdHocUnits", "IncidentAdHocPersonnel" }; + + public override void Up() + { + foreach (var table in Tables) + { + if (Schema.Table(table).Exists() && !Schema.Table(table).Column("ModifiedOn").Exists()) + { + Alter.Table(table).AddColumn("ModifiedOn").AsDateTime2().Nullable(); + } + } + } + + public override void Down() + { + foreach (var table in Tables) + { + if (Schema.Table(table).Exists() && Schema.Table(table).Column("ModifiedOn").Exists()) + { + Delete.Column("ModifiedOn").FromTable(table); + } + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs new file mode 100644 index 000000000..390bf19a3 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0081_AddIncidentCommandChangeTrackingPg.cs @@ -0,0 +1,59 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Offline-first sync foundation (PostgreSQL): adds a ModifiedOn change cursor to the mutable incident-command + /// tables (delta "changed since" + last-write-wins) and a DeletedOn soft-delete tombstone to commandstructurenodes. + /// Append-only tables already carry a natural creation timestamp and are intentionally excluded. + /// See docs/architecture/offline-first-architecture.md. + /// + [Migration(81)] + public class M0081_AddIncidentCommandChangeTrackingPg : Migration + { + private static readonly string[] ChangeTrackedTables = + { + "IncidentCommands", + "CommandStructureNodes", + "ResourceAssignments", + "TacticalObjectives", + "IncidentTimers", + "IncidentMapAnnotations", + "IncidentRoleAssignments" + }; + + public override void Up() + { + foreach (var table in ChangeTrackedTables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && !Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Alter.Table(t).AddColumn("ModifiedOn".ToLower()).AsDateTime2().Nullable(); + } + } + + if (Schema.Table("CommandStructureNodes".ToLower()).Exists() && !Schema.Table("CommandStructureNodes".ToLower()).Column("DeletedOn".ToLower()).Exists()) + { + Alter.Table("CommandStructureNodes".ToLower()).AddColumn("DeletedOn".ToLower()).AsDateTime2().Nullable(); + } + } + + public override void Down() + { + foreach (var table in ChangeTrackedTables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Delete.Column("ModifiedOn".ToLower()).FromTable(t); + } + } + + if (Schema.Table("CommandStructureNodes".ToLower()).Exists() && Schema.Table("CommandStructureNodes".ToLower()).Column("DeletedOn".ToLower()).Exists()) + { + Delete.Column("DeletedOn".ToLower()).FromTable("CommandStructureNodes".ToLower()); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs new file mode 100644 index 000000000..d708a16cb --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs @@ -0,0 +1,28 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Offline-first action idempotency (PostgreSQL): a client-supplied key on check-in records so a replayed offline + /// check-in dedups instead of creating a duplicate row. See docs/architecture/offline-first-architecture.md. + /// + [Migration(82)] + public class M0082_AddCheckInRecordIdempotencyKeyPg : Migration + { + public override void Up() + { + if (Schema.Table("CheckInRecords".ToLower()).Exists() && !Schema.Table("CheckInRecords".ToLower()).Column("IdempotencyKey".ToLower()).Exists()) + { + Alter.Table("CheckInRecords".ToLower()).AddColumn("IdempotencyKey".ToLower()).AsCustom("citext").Nullable(); + } + } + + public override void Down() + { + if (Schema.Table("CheckInRecords".ToLower()).Exists() && Schema.Table("CheckInRecords".ToLower()).Column("IdempotencyKey".ToLower()).Exists()) + { + Delete.Column("IdempotencyKey".ToLower()).FromTable("CheckInRecords".ToLower()); + } + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs new file mode 100644 index 000000000..d63c63d00 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0083_AddAdHocResourceChangeTrackingPg.cs @@ -0,0 +1,38 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Offline-first (PostgreSQL): adds a ModifiedOn change cursor to the ad-hoc incident resource tables so they + /// participate in the /Sync/Changes delta pull. See docs/architecture/offline-first-architecture.md. + /// + [Migration(83)] + public class M0083_AddAdHocResourceChangeTrackingPg : Migration + { + private static readonly string[] Tables = { "IncidentAdHocUnits", "IncidentAdHocPersonnel" }; + + public override void Up() + { + foreach (var table in Tables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && !Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Alter.Table(t).AddColumn("ModifiedOn".ToLower()).AsDateTime2().Nullable(); + } + } + } + + public override void Down() + { + foreach (var table in Tables) + { + var t = table.ToLower(); + if (Schema.Table(t).Exists() && Schema.Table(t).Column("ModifiedOn".ToLower()).Exists()) + { + Delete.Column("ModifiedOn".ToLower()).FromTable(t); + } + } + } + } +} diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index 22b9f7bee..a7389f74e 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -270,6 +270,34 @@ public async Task PerformCheckInAsync_SavesRecordWithTimestamp() _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task PerformCheckInAsync_WithIdempotencyKey_ReturnsExistingRecord_WithoutDuplicateInsert() + { + // A check-in with this key was already recorded for the call (the client's outbox is replaying it). + var existing = new CheckInRecord { CheckInRecordId = "existing-1", DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1", Timestamp = DateTime.UtcNow.AddMinutes(-1) }; + _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List { existing }); + + var replay = new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }; + var result = await _service.PerformCheckInAsync(replay); + + result.Should().BeSameAs(existing); + _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task PerformCheckInAsync_WithNewIdempotencyKey_Inserts() + { + _recordRepo.Setup(x => x.GetByCallIdAsync(1)).ReturnsAsync(new List()); // key not seen yet + _recordRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CheckInRecord r, CancellationToken ct, bool b) => { r.CheckInRecordId = "new-id"; return r; }); + + var record = new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-2" }; + var result = await _service.PerformCheckInAsync(record); + + result.CheckInRecordId.Should().Be("new-id"); + _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + [Test] public async Task GetLastCheckInAsync_ReturnsUserCheckIn_WhenNoUnitId() { diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index b859a0e9d..d866728b3 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -61,8 +61,8 @@ public void SetUp() _eventAggregator = new Mock(); _coreEventService = new Mock(); - // The marker write echoes back the entry so WriteLogAsync resolves a non-null result. - _logRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + // Timeline entries are append-only inserts; echo back the entry so WriteLogAsync resolves a non-null result. + _logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); _service = new IncidentCommandService(_commandRepo.Object, _nodeRepo.Object, _assignmentRepo.Object, @@ -126,7 +126,7 @@ public async Task EvaluateCriticalParAsync_RaisesEventAndWritesMarker_OnFirstTra result.Should().ContainSingle().Which.Should().Be("user1"); _eventAggregator.Verify(x => x.SendMessage(It.Is( e => e.UserId == "user1" && e.CallId == CallId && e.DepartmentId == Dept)), Times.Once); - _logRepo.Verify(x => x.SaveOrUpdateAsync( + _logRepo.Verify(x => x.InsertAsync( It.Is(e => e.EntryType == (int)CommandLogEntryType.ParCritical && e.UserId == "user1"), It.IsAny(), It.IsAny()), Times.Once); } @@ -150,7 +150,7 @@ public async Task EvaluateCriticalParAsync_Deduped_WhenMarkerAlreadyExistsForEpi result.Should().BeEmpty(); _eventAggregator.Verify(x => x.SendMessage(It.IsAny()), Times.Never); - _logRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _logRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } [Test] @@ -254,7 +254,7 @@ public async Task SaveNodeAsync_StampsCallId_FromParentCommand_NotCallerSupplied { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active }); - _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); var node = new CommandStructureNode { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = 999, Name = "Staging" }; @@ -330,5 +330,158 @@ public async Task MoveResourceAsync_ReturnsNull_WhenTargetNodeMissing() result.Should().BeNull(); } + + // Offline-first change tracking: every mutation stamps ModifiedOn (the delta "changed since" cursor) and a + // lane removal is a soft-delete tombstone (DeletedOn), so removals propagate to offline clients on delta sync. + + [Test] + public async Task SaveNodeAsync_StampsModifiedOn_OnSave() + { + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + saved.ModifiedOn.Should().NotBeNull(); + } + + // Idempotent creates (offline replay): the client generates the GUID PK offline. A brand-new client id must + // INSERT (not be rejected as a missing-row update); a replayed id must UPDATE the same row (no duplicate); + // an id owned by another department must be rejected. + + [Test] + public async Task SaveNodeAsync_WithClientSuppliedId_NotYetPersisted_InsertsWithThatId() + { + const string clientId = "client-guid-1"; + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + _nodeRepo.Setup(x => x.GetByIdAsync(clientId)).ReturnsAsync((CommandStructureNode)null); // not persisted yet + _nodeRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + saved.CommandStructureNodeId.Should().Be(clientId); // honored the client GUID, did not generate a new one + _nodeRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _nodeRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveNodeAsync_WithClientSuppliedId_AlreadyPersisted_Updates_WithoutDuplicateInsert() + { + const string clientId = "client-guid-1"; + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + // Replay: the row already exists for this department. + _nodeRepo.Setup(x => x.GetByIdAsync(clientId)).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Name = "Staging" + }); + _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var node = new CommandStructureNode { CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, Name = "Staging (edited)" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().NotBeNull(); + _nodeRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _nodeRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task SaveNodeAsync_WithClientSuppliedId_OwnedByAnotherDepartment_ReturnsNull() + { + const string clientId = "client-guid-1"; + _commandRepo.Setup(x => x.GetByIdAsync("ic1")).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + // A row with that id exists but belongs to another department — reject, never take it over. + _nodeRepo.Setup(x => x.GetByIdAsync(clientId)).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = clientId, DepartmentId = 99, CallId = CallId + }); + + var node = new CommandStructureNode { CommandStructureNodeId = clientId, IncidentCommandId = "ic1", DepartmentId = Dept, Name = "Staging" }; + var saved = await _service.SaveNodeAsync(node, "user1"); + + saved.Should().BeNull(); + _nodeRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _nodeRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task DeleteNodeAsync_SoftDeletes_SetsDeletedOnAndModifiedOn_AndPersistsInsteadOfHardDeleting() + { + CommandStructureNode persisted = null; + _nodeRepo.Setup(x => x.GetByIdAsync("node-1")).ReturnsAsync(new CommandStructureNode + { + CommandStructureNodeId = "node-1", DepartmentId = Dept, CallId = CallId, IncidentCommandId = "ic1", Name = "Staging" + }); + // A hard delete would never call SaveOrUpdate; capturing it here proves the removal is a soft-delete. + _nodeRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((CommandStructureNode n, CancellationToken ct, bool b) => persisted = n) + .ReturnsAsync((CommandStructureNode n, CancellationToken ct, bool b) => n); + + var result = await _service.DeleteNodeAsync(Dept, "node-1", "user1"); + + result.Should().BeTrue(); + persisted.Should().NotBeNull(); + persisted.DeletedOn.Should().NotBeNull(); + persisted.ModifiedOn.Should().NotBeNull(); + } + + [Test] + public async Task GetNodesForCallAsync_ExcludesSoftDeletedNodes() + { + _nodeRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new CommandStructureNode { CommandStructureNodeId = "n1", DepartmentId = Dept, CallId = CallId, Name = "Live", SortOrder = 1 }, + new CommandStructureNode { CommandStructureNodeId = "n2", DepartmentId = Dept, CallId = CallId, Name = "Removed", SortOrder = 2, DeletedOn = DateTime.UtcNow } + }); + + var nodes = await _service.GetNodesForCallAsync(Dept, CallId); + + nodes.Should().ContainSingle().Which.CommandStructureNodeId.Should().Be("n1"); + } + + // Delta pull (offline reconnect): returns only rows changed after the cursor — INCLUDING soft-deleted rows so + // the client can remove them locally; rows with no ModifiedOn or an older ModifiedOn are excluded. + + [Test] + public async Task GetChangesSinceAsync_ReturnsOnlyRowsChangedAfterCursor_IncludingSoftDeleted() + { + var since = DateTime.UtcNow.AddMinutes(-10); + + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "c-new", DepartmentId = Dept, CallId = CallId, ModifiedOn = DateTime.UtcNow }, + new IncidentCommand { IncidentCommandId = "c-old", DepartmentId = Dept, CallId = CallId, ModifiedOn = since.AddMinutes(-5) }, + new IncidentCommand { IncidentCommandId = "c-null", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } + }); + _nodeRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + // A lane soft-deleted after the cursor must be INCLUDED (with DeletedOn) so the client removes it. + new CommandStructureNode { CommandStructureNodeId = "n-del", DepartmentId = Dept, CallId = CallId, DeletedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow } + }); + + var changes = await _service.GetChangesSinceAsync(Dept, since); + + changes.ServerTimestampMs.Should().BeGreaterThan(0); + changes.Commands.Should().ContainSingle().Which.IncidentCommandId.Should().Be("c-new"); + changes.Nodes.Should().ContainSingle().Which.DeletedOn.Should().NotBeNull(); + } } } diff --git a/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs new file mode 100644 index 000000000..8a4e1fa82 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Covers idempotent ad-hoc resource creation: the create must actually INSERT (a pre-set GUID + SaveOrUpdateAsync + /// would be a silent 0-row UPDATE), honor a client-supplied GUID, treat a replayed id as a no-op (return the stored + /// row, no duplicate), and reject an id owned by another department. + /// + [TestFixture] + public class IncidentResourcesServiceTests + { + private const int Dept = 10; + private const int CallId = 7; + + private Mock _unitRepo; + private Mock _personnelRepo; + private Mock _commandService; + private Mock _eventAggregator; + private IncidentResourcesService _service; + + [SetUp] + public void SetUp() + { + _unitRepo = new Mock(); + _personnelRepo = new Mock(); + _commandService = new Mock(); + _eventAggregator = new Mock(); + + _service = new IncidentResourcesService(_unitRepo.Object, _personnelRepo.Object, _commandService.Object, _eventAggregator.Object); + } + + private void ArrangeActiveCommand() + { + _commandService.Setup(x => x.GetActiveCommandForCallAsync(Dept, CallId)).ReturnsAsync(new IncidentCommand + { + IncidentCommandId = "ic1", DepartmentId = Dept, CallId = CallId, Status = (int)IncidentCommandStatus.Active + }); + } + + [Test] + public async Task CreateAdHocUnitAsync_ReturnsNull_WhenNoActiveCommandForCall() + { + _commandService.Setup(x => x.GetActiveCommandForCallAsync(Dept, CallId)).ReturnsAsync((IncidentCommand)null); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().BeNull(); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocUnitAsync_NoId_GeneratesIdAndInserts() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAdHocUnit u, CancellationToken ct, bool b) => u); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocUnitId.Should().NotBeNullOrEmpty(); + result.CreatedOn.Should().NotBe(default(DateTime)); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _unitRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocUnitAsync_ClientSuppliedId_NotPersisted_InsertsWithThatId() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.GetByIdAsync("client-1")).ReturnsAsync((IncidentAdHocUnit)null); + _unitRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAdHocUnit u, CancellationToken ct, bool b) => u); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocUnitId.Should().Be("client-1"); // honored the client GUID + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task CreateAdHocUnitAsync_ReplayOfExistingId_ReturnsStored_WithoutDuplicate() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.GetByIdAsync("client-1")).ReturnsAsync(new IncidentAdHocUnit + { + IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" + }); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocUnitId.Should().Be("client-1"); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _unitRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocUnitAsync_ClientSuppliedId_OwnedByAnotherDepartment_ReturnsNull() + { + ArrangeActiveCommand(); + _unitRepo.Setup(x => x.GetByIdAsync("client-1")).ReturnsAsync(new IncidentAdHocUnit + { + IncidentAdHocUnitId = "client-1", DepartmentId = 99, CallId = CallId, Name = "Engine 1" + }); + + var result = await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { IncidentAdHocUnitId = "client-1", DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + result.Should().BeNull(); + _unitRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task CreateAdHocPersonnelAsync_NoId_GeneratesIdAndInserts() + { + ArrangeActiveCommand(); + _personnelRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((IncidentAdHocPersonnel p, CancellationToken ct, bool b) => p); + + var result = await _service.CreateAdHocPersonnelAsync(new IncidentAdHocPersonnel { DepartmentId = Dept, CallId = CallId, Name = "J. Doe" }, "user1"); + + result.Should().NotBeNull(); + result.IncidentAdHocPersonnelId.Should().NotBeNullOrEmpty(); + _personnelRepo.Verify(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + _personnelRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + // Change tracking: ad-hoc resources now carry ModifiedOn so they ride the /Sync/Changes delta. + + [Test] + public async Task CreateAdHocUnitAsync_StampsModifiedOn() + { + ArrangeActiveCommand(); + IncidentAdHocUnit inserted = null; + _unitRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Callback((IncidentAdHocUnit u, CancellationToken ct, bool b) => inserted = u) + .ReturnsAsync((IncidentAdHocUnit u, CancellationToken ct, bool b) => u); + + await _service.CreateAdHocUnitAsync(new IncidentAdHocUnit { DepartmentId = Dept, CallId = CallId, Name = "Engine 1" }, "user1"); + + inserted.Should().NotBeNull(); + inserted.ModifiedOn.Should().NotBeNull(); + } + + [Test] + public async Task GetAdHocChangesSinceAsync_ReturnsOnlyRowsChangedAfterCursor() + { + var since = DateTime.UtcNow.AddMinutes(-10); + _unitRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-new", DepartmentId = Dept, CallId = CallId, ModifiedOn = DateTime.UtcNow }, + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-old", DepartmentId = Dept, CallId = CallId, ModifiedOn = since.AddMinutes(-5) }, + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-null", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } + }); + _personnelRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + // A released person changed after the cursor must be included so the client reconciles the removal. + new IncidentAdHocPersonnel { IncidentAdHocPersonnelId = "p-rel", DepartmentId = Dept, CallId = CallId, ReleasedOn = DateTime.UtcNow, ModifiedOn = DateTime.UtcNow } + }); + + var (units, personnel) = await _service.GetAdHocChangesSinceAsync(Dept, since); + + units.Should().ContainSingle().Which.IncidentAdHocUnitId.Should().Be("u-new"); + personnel.Should().ContainSingle().Which.IncidentAdHocPersonnelId.Should().Be("p-rel"); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs index 46756504a..e5a45c691 100644 --- a/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentVoiceServiceTests.cs @@ -46,7 +46,7 @@ public async Task CloseIncidentChannelsForCallAsync_WritesChannelClosedLog_EvenW Status = (int)IncidentCommandStatus.Closed, EstablishedOn = DateTime.UtcNow.AddHours(-1) } }); - logRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + logRepo.Setup(x => x.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((CommandLogEntry e, CancellationToken ct, bool b) => e); var service = new IncidentVoiceService(voiceService.Object, departmentsService.Object, logRepo.Object, @@ -55,8 +55,8 @@ public async Task CloseIncidentChannelsForCallAsync_WritesChannelClosedLog_EvenW var result = await service.CloseIncidentChannelsForCallAsync(10, 7, "user1"); result.Should().BeTrue(); - // The channel-closed entry is logged against the (now Closed) command, not silently dropped. - logRepo.Verify(x => x.SaveOrUpdateAsync( + // The channel-closed entry is logged (inserted) against the (now Closed) command, not silently dropped. + logRepo.Verify(x => x.InsertAsync( It.Is(e => e.EntryType == (int)CommandLogEntryType.ChannelClosed && e.IncidentCommandId == "ic1"), It.IsAny(), It.IsAny()), Times.Once); coreEventService.Verify(x => x.IncidentCommandUpdatedAsync(10, 7), Times.Once); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs index 8b9fded21..87615db43 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CheckInTimersController.cs @@ -391,7 +391,8 @@ public async Task> PerformCheckIn([FromBody] UnitId = input.UnitId, Latitude = input.Latitude, Longitude = input.Longitude, - Note = input.Note + Note = input.Note, + IdempotencyKey = input.IdempotencyKey }; var saved = await _checkInTimerService.PerformCheckInAsync(record, cancellationToken); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs b/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs new file mode 100644 index 000000000..00121e58c --- /dev/null +++ b/Web/Resgrid.Web.Services/Controllers/v4/SyncController.cs @@ -0,0 +1,66 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Resgrid.Model; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Services.Helpers; +using Resgrid.Web.Services.Models.v4.Sync; +using System; +using System.Threading.Tasks; + +namespace Resgrid.Web.Services.Controllers.v4 +{ + /// + /// Offline-first delta sync. A reconnecting client calls Changes with its last cursor to pull everything that + /// changed while it was offline (created / updated / removed incident-command rows), reconciles its local store, + /// then replays its outbox. See docs/architecture/offline-first-architecture.md. + /// + [Route("api/v{VersionId:apiVersion}/[controller]")] + [ApiVersion("4.0")] + [ApiExplorerSettings(GroupName = "v4")] + public class SyncController : V4AuthenticatedApiControllerbase + { + #region Members and Constructors + private readonly IIncidentCommandService _incidentCommandService; + private readonly IIncidentResourcesService _incidentResourcesService; + + public SyncController(IIncidentCommandService incidentCommandService, IIncidentResourcesService incidentResourcesService) + { + _incidentCommandService = incidentCommandService; + _incidentResourcesService = incidentResourcesService; + } + #endregion Members and Constructors + + /// + /// Returns incident-command rows changed since (Unix epoch milliseconds; 0 or omitted + /// = full pull), scoped to the caller's department. Soft-deleted / closed / released rows are included so the + /// client can remove them locally. Persist the returned Data.ServerTimestampMs and pass it as the next + /// . + /// + [HttpGet("Changes")] + [ProducesResponseType(StatusCodes.Status200OK)] + [Authorize(Policy = ResgridResources.Command_View)] + public async Task> Changes(long since = 0) + { + var sinceUtc = since <= 0 + ? DateTime.MinValue + : DateTimeOffset.FromUnixTimeMilliseconds(since).UtcDateTime; + + var changes = await _incidentCommandService.GetChangesSinceAsync(DepartmentId, sinceUtc); + + // Ad-hoc resources live in IncidentResourcesService; aggregate them into the unified delta payload. + var adHoc = await _incidentResourcesService.GetAdHocChangesSinceAsync(DepartmentId, sinceUtc); + changes.AdHocUnits = adHoc.Units; + changes.AdHocPersonnel = adHoc.Personnel; + + var result = new SyncChangesResult { Data = changes }; + result.PageSize = changes.Commands.Count + changes.Nodes.Count + changes.Assignments.Count + + changes.Objectives.Count + changes.Timers.Count + changes.Annotations.Count + + changes.Roles.Count + changes.AdHocUnits.Count + changes.AdHocPersonnel.Count + changes.TimelineEntries.Count; + result.Status = ResponseHelper.Success; + ResponseHelper.PopulateV4ResponseData(result); + return result; + } + } +} diff --git a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs index 8583faef1..7e29c20d9 100644 --- a/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/CheckInTimers/CheckInTimerModels.cs @@ -145,6 +145,9 @@ public class PerformCheckInInput public string Longitude { get; set; } public int? UnitId { get; set; } public string Note { get; set; } + + /// Optional offline idempotency key (the client's outbox event id); a replayed check-in dedups on it. + public string IdempotencyKey { get; set; } } public class PerformCheckInResult : StandardApiResponseV4Base diff --git a/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs b/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs new file mode 100644 index 000000000..4d4b85a6e --- /dev/null +++ b/Web/Resgrid.Web.Services/Models/v4/Sync/SyncModels.cs @@ -0,0 +1,13 @@ +using Resgrid.Web.Services.Models.v4; + +namespace Resgrid.Web.Services.Models.v4.Sync +{ + /// + /// Delta sync payload for offline clients: incident-command rows changed since the client's cursor. The client + /// stores Data.ServerTimestampMs and passes it back as the next `since`. + /// + public class SyncChangesResult : StandardApiResponseV4Base + { + public Resgrid.Model.IncidentCommandChanges Data { get; set; } + } +} diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 4cb874c91..2695276af 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1922,6 +1922,21 @@ + + + Offline-first delta sync. A reconnecting client calls Changes with its last cursor to pull everything that + changed while it was offline (created / updated / removed incident-command rows), reconciles its local store, + then replays its outbox. See docs/architecture/offline-first-architecture.md. + + + + + Returns incident-command rows changed since (Unix epoch milliseconds; 0 or omitted + = full pull), scoped to the caller's department. Soft-deleted / closed / released rows are included so the + client can remove them locally. Persist the returned Data.ServerTimestampMs and pass it as the next + . + + Templates in the system. Templates can be call Templates, Autofills (i.e. Call Notes) @@ -6303,6 +6318,9 @@ User Defined Field values for this contact + + Optional offline idempotency key (the client's outbox event id); a replayed check-in dedups on it. + Response wrapper for . @@ -9614,6 +9632,12 @@ Default constructor + + + Delta sync payload for offline clients: incident-command rows changed since the client's cursor. The client + stores Data.ServerTimestampMs and passes it back as the next `since`. + + Multiple call note template result diff --git a/docs/architecture/offline-first-architecture.md b/docs/architecture/offline-first-architecture.md new file mode 100644 index 000000000..cc30385b4 --- /dev/null +++ b/docs/architecture/offline-first-architecture.md @@ -0,0 +1,416 @@ +# Resgrid Offline-First Architecture + +**Status:** Design / proposal +**Author:** Resgrid IC backend work (RIC-T39 follow-on) +**Last updated:** 2026-06-24 +**Applies to:** Resgrid **IC** app (new, `../IC`), Resgrid **Unit** app (existing, `../Unit`), Resgrid **Core** backend (this repo) + +> This document is the single source of truth for how the Resgrid mobile apps work +> **fully offline** and **sync back** when reconnected. It is written so the **Unit** +> app team can implement the identical pattern — both apps share one design. + +--- + +## 1. Goal & scenario + +A responder logs in and **syncs at the start of a shift** (online, usually on station +WiFi) and pulls down everything they need. They then drive to an incident where there is +**no cell or WiFi**. The app must remain **useful offline** — they can view calls / the +command board / roster / maps, and they can **take actions** (set status, set location, +check in, assign resources, complete objectives, add annotations, etc.). Those actions are +**captured locally** and **synced back** to Resgrid automatically when the device returns +to connectivity (at the incident if cell returns, or back at station/office). + +**Hard requirements** + +1. Full read functionality offline for the shift's working data set. +2. Full write capture offline, replayed on reconnect, with no lost or duplicated work. +3. **Offline mapping** — map tiles for the operational area must be available with no network. +4. One design, documented, implementable in **both IC and Unit**. + +**Non-goals (v1)** + +- Real-time multi-device collaborative editing while offline (no CRDTs). Resgrid writes are + overwhelmingly *additive, timestamped intent-events*, not concurrent edits to a shared document. +- Offline geocoding / turn-by-turn routing (vector search + routing need network; we cache + tiles and known POIs/destinations only). Documented as a limitation. + +--- + +## 2. Why this design (decision record) + +**Decision:** *Evolve* the persistence + outbox stack the Unit app already uses +(**Zustand + MMKV + an offline event queue**) into a complete sync layer. Do **not** +introduce a new on-device database or a managed sync engine for v1. + +The Unit app is already ~40% of the way here. State of the world today (`../Unit`): + +| Capability | Today | Gap to close | +|---|---|---| +| Persistence | Zustand 4.5.7 + **MMKV 3.1.0** (encrypted), `zustandStorage` adapter | Most domain stores (e.g. `calls`) are **not persisted** → nothing to read offline | +| Write outbox | **Exists**: `src/stores/offline-queue/store.ts` (4 event types, retry/backoff) drained by `src/services/offline-event-manager.service.ts` | Only 4 event types; **no ordering, no idempotency, no dependencies**; relies on backend being idempotent | +| Read cache | `src/api/common/cached-client.ts` + `cache-manager.ts`, 5-min TTL in MMKV | **Does not serve stale data when offline** — returns only *fresh* entries. Biggest single blocker | +| Maps | **@rnmapbox/maps 10.2.10** (Mapbox, native v11.16.2) | `offlineManager.createPack()` supported but unused | +| Realtime | `@microsoft/signalr 8.0.17`, 2 hubs (update + geo) | Needs reconnect reconciliation | +| Auth offline | Tokens in MMKV, `offline_access` scope, network-vs-401 already distinguished in `client.tsx` | Mostly fine | + +**Alternatives considered and rejected for v1:** + +- **Embedded SQLite (op-sqlite + Drizzle).** More robust at scale, but the per-shift/per-incident + working set is hundreds–low-thousands of records, not millions; SQL's query/row-write edge is + marginal here, and retrofitting both apps' domain stores onto a new persistence layer is a large + migration. **Kept as a documented upgrade path** for individual hot datasets (see §11). +- **Managed sync engine (PowerSync / ElectricSQL / WatermelonDB / RxDB).** Least custom sync code, + but PowerSync/Electric sync from the *database* via Postgres CDC and route writes around your + service layer — that fights Resgrid's heavy server-side business logic (billing, workflows, + accountability) and its dual SQL Server + PostgreSQL backends. WatermelonDB/RxDB still require a + full persistence-layer rewrite in both apps. Rejected. + +**Consequences:** lowest risk, fastest to ship in both apps, one mental model. The cost is that we +own the sync logic (orchestrator, conflict rules, idempotency) — which this document specifies. + +--- + +## 3. Architecture overview + +``` +┌──────────────────────────── Device (IC app / Unit app) ────────────────────────────┐ +│ │ +│ UI (React / Expo Router) │ +│ │ reads (reactive) ▲ optimistic apply │ +│ ▼ │ │ +│ Zustand stores ──── persist ────► MMKV (encrypted) ◄── stale-while-offline reads │ +│ │ │ +│ │ write intent │ +│ ▼ │ +│ Outbox (offline-queue store, MMKV) ── ordered, idempotent ──┐ │ +│ │ │ +│ Sync Orchestrator ◄─────────────────────────────────────────┘ │ +│ • fullSync() (shift start) │ +│ • drain() (replay outbox on reconnect) │ +│ • pullDelta() (reconcile server-authoritative truth) │ +│ • map pre-download (offlineManager.createPack) │ +│ │ │ +│ NetInfo (online/offline) SignalR (live updates when online) │ +└─────┼────────────────────────────────────┼─────────────────────────────────────────┘ + │ REST v4 │ CQRS push (callsUpdated, incidentCommandUpdated…) + ▼ ▼ +┌──────────────────────────── Resgrid Core backend (this repo) ───────────────────────┐ +│ v4 REST controllers + Services + Repos CQRS/SignalR (ICoreEventService) │ +│ NEW: delta endpoints, idempotent creates, tombstones, sync bundle (see §9) │ +└──────────────────────────────────────────────────────────────────────────────────┘ +``` + +Three sync phases, described in §6–§8: **(1) shift-start hydration**, **(2) offline operation**, +**(3) reconnect sync-back**. + +--- + +## 4. Local persistence model + +**Keep MMKV + Zustand `persist`.** Every store that holds data needed offline must: + +1. Wrap its store in `persist(...)` using the existing `zustandStorage` adapter + (`src/lib/storage/index.tsx` → `createJSONStorage(() => zustandStorage)`). +2. Use `partialize` to persist only durable server data + sync metadata (exclude transient flags + like `isLoading`). +3. Carry per-store sync metadata: `lastSyncAt: number`, and where applicable a server `syncToken`/ + high-water timestamp for delta pulls. + +> **Action item:** the `calls` store (and any other currently-ephemeral domain store) must be +> converted to `persist`. Today `src/stores/calls/store.ts` re-fetches on launch and has nothing to +> show offline. This is the first concrete code change in the Unit app and the default for every IC +> store. + +**Stale-while-offline (critical fix).** Upgrade `cached-client.ts` so that when +`NetInfo.isConnected === false` (or a request fails with a network error), it **returns the last +cached value even if its TTL expired**, flagged as stale. Today it only returns *fresh* entries, so +offline reads return nothing. The cache becomes the read fallback; persisted Zustand stores are the +primary offline read source. + +Read precedence offline: **persisted store → stale cache → empty/typed-default** (never throw). + +**Storage budget.** MMKV holds JSON; keep persisted payloads to the working set. Prune on incident +close (drop closed-incident command boards, their map packs, completed/uploaded outbox entries). + +--- + +## 5. The outbox (generalized write model) + +Generalize the existing `offline-queue` from 4 hard-coded event types to a **typed intent-event** +that can represent any mutation in either app. + +### 5.1 Event contract + +```ts +interface QueuedEvent { + id: string; // client UUID — ALSO the idempotency key sent to the server + type: QueuedEventType; // discriminator → handler + endpoint (see registry below) + entityType: string; // 'IncidentCommand' | 'UnitStatus' | 'CheckIn' | 'Annotation' | … + op: 'create' | 'update' | 'delete' | 'action'; + payload: Record; // request body; for creates, INCLUDES the client-generated GUID PK + dependsOn?: string; // id of a prior queued event that must COMPLETE first (FK ordering) + clientCreatedAt: number; // ms — authoritative timestamp for last-write-wins + // existing fields retained: + status: 'pending' | 'processing' | 'failed' | 'completed'; + retryCount: number; maxRetries: number; + createdAt: number; lastAttemptAt?: number; nextRetryAt?: number; error?: string; +} +``` + +This is a superset of today's shape — existing events migrate by adding `entityType`/`op`/ +`clientCreatedAt`/`dependsOn`. Persistence (MMKV key `offline-queue-storage`, `partialize`) is unchanged. + +### 5.2 Handler registry + +Replace the per-type `switch` in `offline-event-manager.service.ts` with a registry mapping +`QueuedEventType → { apiCall, onSuccess(serverResult), isIdempotent }`. Adding a new offline +mutation = one registry entry, in either app. Today's 4 handlers (UNIT_STATUS, LOCATION_UPDATE, +CALL_IMAGE_UPLOAD, CHECK_IN) become registry entries. + +### 5.3 Drainer changes (correctness) + +The current drainer (`Promise.allSettled`, 3 concurrent, **no ordering**) is unsafe once events have +dependencies (e.g. *assign resource* offline before *establish command* has synced). Required changes: + +- **Order by `createdAt`** and **honor `dependsOn`**: an event whose dependency is not yet + `completed` is skipped this pass (stays pending). Independent events may still run concurrently. +- **Idempotency on replay** (see §5.4) so a partial success + retry never double-applies. +- Keep: netinfo gate, AppState trigger + 10 s interval, exponential backoff, max-retries → `failed` + with manual retry. Add a **dead-letter** surfacing UI for permanently failed events (today they + silently sit in `failed`). + +### 5.4 Idempotency (the key to safe replay) + +Two mechanisms, chosen per entity: + +- **Client-generated GUID PK for creates.** Resgrid entities already use string-GUID PKs that the + *service* sets via `Guid.NewGuid().ToString()`. Change create endpoints to **honor a client-supplied + id when present** and **upsert by PK**. The client generates the GUID offline, stores it locally, + and includes it in the payload → replaying the create is a no-op the second time. This makes + additive records (annotations, objectives, resource/role assignments, ad-hoc resources) safe. +- **Idempotency key for actions.** Append/telemetry actions (check-in, set status, set location) + send `id` (the event UUID) as an idempotency key; the server dedups on + `(DepartmentId, UserId, IdempotencyKey)` within a window, or simply accepts last-write-wins by + `clientCreatedAt` (a replayed status with the same timestamp is harmless). + +### 5.5 Optimistic apply + +On a write the app: (1) generates the GUID/event, (2) **applies the change to the Zustand store +immediately** (so the UI updates offline) with a `_pendingSync` flag, (3) enqueues the outbox event. +On successful drain, clear `_pendingSync` and reconcile with the server result. On permanent failure, +mark the local record `_syncError` and surface it (do **not** silently roll back field work). + +--- + +## 6. Phase 1 — Shift-start hydration (online) + +`SyncOrchestrator.fullSync()`, run on login / "Sync now": + +1. **Bulk pull** all working data in parallel and persist to stores. For IC: active calls + call + metadata, command boards for active incidents, lanes/resources/objectives/timers/roles/annotations, + accountability/check-in status, mutual-aid + ad-hoc resources, department units/personnel/statuses, + groups, POIs/destinations, protocols, contacts, config. For Unit: its existing `init()` set, now + persisted, plus routes/protocols/weather. +2. **Download offline map packs** (see §10) for the operational area and any active-incident bounds. +3. Record `lastSyncAt` / per-store high-water timestamps; show progress + a "Synced ✓ (time)" state. +4. Pre-warm SignalR so live updates keep the cache hot while still online. + +Optional backend optimization: a single `/Sync/Bundle` aggregate endpoint (§9) to cut round-trips. +v1 can fan out existing `GetAll*` calls. + +--- + +## 7. Phase 2 — Offline operation + +- **Reads**: served from persisted Zustand stores; `cached-client` returns stale-when-offline. + NetInfo drives a global "Offline" banner + per-record "pending sync" chips. +- **Writes**: optimistic apply (§5.5) → outbox enqueue. The UI never blocks on the network. +- **Maps**: render from the downloaded offline pack; user location from GPS (works offline); + annotations/markers are local store data. +- **Auth**: tokens already persist in MMKV; refresh fails gracefully offline (existing + network-vs-401 logic in `client.tsx`). Ensure the access token's absence/expiry while offline does + **not** force logout — gate logout on a *non-network* 401 only (already the case) and allow the app + to operate read/write-to-outbox with an expired token; the outbox drains after a successful refresh + on reconnect. + +--- + +## 8. Phase 3 — Reconnect sync-back + +NetInfo offline→online transition triggers, in order: + +1. **Refresh auth** if needed (existing flow). +2. **Drain the outbox** (§5.3) — ordered, dependency-aware, idempotent. Each success updates the + local record with the server's canonical result and clears `_pendingSync`. +3. **Pull delta / reconcile** (§9): fetch server changes since `lastSyncAt` and merge using the + conflict policy (§8.1). This catches edits made by *others* (dispatch, other responders) while + this device was offline. +4. **Reconnect SignalR** and resume live updates; set `lastSyncAt = now`. + +### 8.1 Conflict-resolution policy (per entity class) + +| Class | Examples | Policy | +|---|---|---| +| **Telemetry / time-series** | unit/personnel status, location, check-in | **Server-authoritative, accept by timestamp (LWW).** Replays harmless. | +| **Additive records** | annotations, timeline/log entries, objectives, resource & role assignments, ad-hoc resources, call notes/images | **Client GUID PK → idempotent create.** No real conflict; duplicates impossible via PK. | +| **Mutable singletons** | the IncidentCommand, lane structure, action plan, call fields | **LWW by `ModifiedOn`**; last writer wins, prior value recorded to the timeline. The one-active-command unique index (M0077) already prevents the worst split-brain. | +| **Deletes** | removed lanes/assignments/resources | **Tombstones** (soft-delete `DeletedOn`) so delta pull propagates the removal to all clients. | + +Field-ops reality: contention is rare because each responder mostly writes *their own* status/location/ +check-in and *adds* records. LWW + idempotent-additive covers the overwhelming majority; the timeline +preserves an audit trail when a singleton is overwritten. + +--- + +## 9. Backend changes required (Core repo) + +These land in this repo as the next backend chunk (migrations start at **M0081**, never renumber — +see the IC backend state doc). All are additive and apply to **both** SQL Server and PostgreSQL. + +1. **Consistent change-tracking columns.** Ensure offline-relevant entities have + `CreatedOn`, `ModifiedOn` (set on every write), and `DeletedOn` (soft-delete/tombstone). Many IC + entities have `CreatedOn`/`Timestamp` already; audit and backfill the rest. +2. **Idempotent creates.** v4 create endpoints honor a **client-supplied GUID PK** and upsert by PK + (today the services overwrite with `Guid.NewGuid()`). Guard for cross-department ownership exactly + as the existing IC create paths do. +3. **Action idempotency keys.** Check-in / status / location endpoints accept an `IdempotencyKey` + (the outbox event id) and dedup on `(DepartmentId, UserId, IdempotencyKey)` within a window — or + rely on LWW-by-timestamp where natural. +4. **Delta endpoint(s).** `GET /api/v4/Sync/Changes?since={utcIso}&types=Calls,IncidentCommand,…` + returning, per type, `created/updated` rows and `deleted` ids (from tombstones) with a new + server high-water `syncToken`. v1 may implement per-domain `GetChangedSince` and full-refetch the + small reference sets; build true deltas first for the large sets (messages, call/audit history). +5. **(Optional) `GET /api/v4/Sync/Bundle`** — one aggregate shift-start pull to reduce round-trips. + +> CQRS/SignalR already publishes live updates including `IncidentCommandUpdated = 22` +> (`ICoreEventService.IncidentCommandUpdatedAsync`). The delta endpoints are the *catch-up* path for +> what was missed while offline; SignalR is the *live* path while online. Keep both. + +--- + +## 10. Offline mapping (@rnmapbox/maps) + +Mapbox is already the map engine (`@rnmapbox/maps 10.2.10`, native v11). Use its **offline pack** +API — no map-library change. + +```ts +await offlineManager.createPack({ + name: `incident-${callId}`, + styleURL: StyleURL.Street, // the style already in use + minZoom: 10, + maxZoom: 16, // cap to bound size; see caveats + bounds: [[swLng, swLat], [neLng, neLat]], +}, onProgress, onError); +``` + +**What to pre-download at shift start (§6):** + +- **Operational area pack** — wide bounds (department/station AO), lower max zoom (~13–14) for + context. +- **Per-incident pack(s)** — tight bounds around each active call location, higher max zoom (~16) for + tactical detail. Created on incident establish; deleted on incident close to reclaim space. + +**Caveats (from the rnmapbox issue tracker — design around these):** + +- The **Mapbox access token must be set before `createPack`** or iOS can crash. Sequence map init + before any offline download. +- **Tile-count limits / Mapbox ToS** apply (`setTileCountLimit`); do not bypass. Cap `maxZoom` and + bounds so a pack stays within budget; surface download size to the user. +- Historical Android bugs around zoom > 15 and pack usability — validate on target devices; keep + `maxZoom ≤ 16` unless verified. +- All `offlineManager` methods are async (SQLite-backed); show progress and handle partial downloads + / resume. + +**Limitations (documented):** offline geocoding/search and routing are **not** available without +network — cache known POIs/destinations/routes as store data instead. Satellite/imagery packs are +large; default to the vector street style offline. + +--- + +## 11. Reuse across IC and Unit (shared module) + +Both apps must run the *same* implementation. Neither app is a monorepo today. + +**Recommended:** extract the sync layer into a single shared TypeScript module with a stable public +API, consumed by both apps: + +- `SyncOrchestrator` (`fullSync`, `drain`, `pullDelta`, map pre-download) +- the generalized outbox store + handler-registry types +- the `zustandStorage`/MMKV persist helpers (already identical in spirit) +- the stale-while-offline `cached-client` wrapper +- the conflict-resolution helpers + entity policy table +- the Mapbox offline-pack helper + +Packaging options, in order of preference: +1. **Private package** `@resgrid/offline-sync` (local `file:`/workspace link or private registry) — + one source of truth, versioned. +2. A shared folder consumed by both via path alias / git submodule. +3. **Copy with a documented contract** (this doc) — acceptable for v1 if packaging is deferred, as + long as both copies stay in lockstep. + +App-specific pieces stay in each app: the **entity registry** (which stores/endpoints exist), the +**data scope** to hydrate, and the **map bounds** logic. The orchestrator, outbox, conflict engine, +and map helper are shared verbatim. + +--- + +## 12. Per-app offline data scope + +**IC app** — "useful offline at an incident": the command board for active incidents (lanes, +resources, objectives, timers, roles, annotations, timeline), accountability/PAR status, mutual-aid + +ad-hoc resources, department units/personnel/statuses, call detail + metadata, POIs/destinations, +protocols, maps for the incident area. Offline writes: establish/transfer/close command, lane CRUD, +resource assign/move/release, role assign/remove, objective complete, timer start/ack, annotations, +check-ins, status/location. + +**Unit app** — its current `init()` data made durable: active calls + metadata, units, personnel + +statuses + staffing, dispatches, contacts, groups, POIs, routes, protocols, weather. Offline writes: +the existing 4 (status, location, call image, check-in) plus call notes/files/close as needed. + +--- + +## 13. Implementation sequencing + +1. **Backend (this repo):** change-tracking columns + idempotent creates + action idempotency keys + (migrations M0081+), then delta endpoint(s). Each is additive and independently shippable. +2. **Shared sync module:** generalize the outbox + handler registry; build `SyncOrchestrator`; + upgrade `cached-client` to stale-while-offline; Mapbox offline-pack helper. +3. **Unit app adoption (lower risk, proves the pattern):** persist the ephemeral stores; wire the + orchestrator; pre-download maps; move the 4 existing events onto the generalized outbox. +4. **IC app (Part B):** built offline-first from the start on the shared module. +5. **Reconnect reconciliation + SignalR catch-up:** delta pull on reconnect; conflict policy; dead-letter UI. + +--- + +## 14. Risks & open questions + +- **Backfilling `ModifiedOn`/tombstones** across legacy Resgrid entities is the largest backend + effort; scope delta sync to offline-relevant entities first. +- **Idempotent creates** require auditing every targeted v4 create endpoint to honor a client PK + safely (ownership guards must hold). +- **Mapbox tile budget / ToS** for fleet-wide offline downloads — confirm licensing/limits with + Mapbox for the expected device count and AO sizes. +- **MMKV size ceiling** for very large datasets (long message/call history) — the documented upgrade + path is op-sqlite for *that dataset only*, behind the same store interface. +- **Clock skew** affects LWW — prefer server `ModifiedOn` for singletons; use `clientCreatedAt` only + for the device's own telemetry. +- **Long-offline token expiry** — verify the refresh-token lifetime (`offline_access`) exceeds a + realistic offline shift; if not, allow a longer-lived refresh for the mobile scope. + +--- + +## 15. References + +- Unit app stack: Zustand+MMKV stores (`src/stores/*`), outbox (`src/stores/offline-queue/store.ts`, + `src/services/offline-event-manager.service.ts`), cache (`src/api/common/cached-client.ts`, + `src/lib/cache/cache-manager.ts`), client (`src/api/common/client.tsx`), maps + (`src/components/maps/*`), SignalR (`src/services/signalr.service.ts`). +- IC backend (this repo): `Core/Resgrid.Model/IncidentCommand/*`, `Core/Resgrid.Services/IncidentCommandService.cs`, + v4 controllers under `Web/Resgrid.Web.Services/Controllers/v4/`, CQRS `CqrsEventTypes.IncidentCommandUpdated = 22`. +- Mapbox offline: rnmapbox `offlineManager` docs — https://rnmapbox.github.io/docs/components/offlineManager +- Local-first / sync background: PowerSync RN local-DB options + (https://powersync.com/blog/react-native-local-database-options), Expo local-first guide + (https://docs.expo.dev/guides/local-first/), RxDB RN (https://rxdb.info/react-native-database.html). +- Sync patterns: outbox + idempotency (https://microservices.io/patterns/data/transactional-outbox.html), + offline conflict resolution (https://www.sachith.co.uk/offline-sync-conflict-resolution-patterns-architecture-trade%E2%80%91offs-practical-guide-feb-19-2026/). From d70e9a164885a8c71d3ea3adb86b1112c6d8e32e Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Wed, 24 Jun 2026 20:17:48 -0500 Subject: [PATCH 2/2] RIC-T39 PR#417 fixes --- Core/Resgrid.Services/CheckInTimerService.cs | 19 ++++++++++++++++++- .../IncidentCommandService.cs | 11 +++++++---- .../IncidentResourcesService.cs | 4 +++- .../M0082_AddCheckInRecordIdempotencyKey.cs | 6 ++++++ .../M0082_AddCheckInRecordIdempotencyKeyPg.cs | 5 +++++ .../Services/CheckInTimerServiceTests.cs | 17 +++++++++++++++++ .../IncidentCommandServiceParTests.cs | 16 ++++++++++++++++ .../Services/IncidentResourcesServiceTests.cs | 14 ++++++++++++++ 8 files changed, 86 insertions(+), 6 deletions(-) diff --git a/Core/Resgrid.Services/CheckInTimerService.cs b/Core/Resgrid.Services/CheckInTimerService.cs index f81807166..b47e7eceb 100644 --- a/Core/Resgrid.Services/CheckInTimerService.cs +++ b/Core/Resgrid.Services/CheckInTimerService.cs @@ -332,7 +332,24 @@ public async Task PerformCheckInAsync(CheckInRecord record, Cance } record.Timestamp = DateTime.UtcNow; - var saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken); + + CheckInRecord saved; + try + { + saved = await _recordRepository.SaveOrUpdateAsync(record, cancellationToken); + } + catch (Exception) when (!string.IsNullOrWhiteSpace(record.IdempotencyKey)) + { + // The in-memory pre-check above is check-then-insert and races under concurrent replays; the filtered + // unique index UX_CheckInRecords_Department_IdempotencyKey is the real guard. If we lost the race, + // adopt the winner — same idempotent result as the pre-check — rather than surfacing a 500 (which would + // just trigger another retry). + var existing = await _recordRepository.GetByCallIdAsync(record.CallId); + var winner = existing?.FirstOrDefault(r => r.IdempotencyKey == record.IdempotencyKey); + if (winner != null) + return winner; + throw; + } // Real-time board refresh is best-effort: the check-in is already persisted, so a CQRS/Redis // publish failure must not fail the check-in — that would 500 the caller and a retry would diff --git a/Core/Resgrid.Services/IncidentCommandService.cs b/Core/Resgrid.Services/IncidentCommandService.cs index 57c0dc426..5261d0148 100644 --- a/Core/Resgrid.Services/IncidentCommandService.cs +++ b/Core/Resgrid.Services/IncidentCommandService.cs @@ -357,10 +357,13 @@ public async Task GetChangesSinceAsync(int departmentId, // returned again on the next sync — harmless, the client upserts idempotently). var changes = new IncidentCommandChanges { ServerTimestampMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() }; - // Method-group conversion is contravariance-aware, so this Func binds to each - // entity-typed Where(). Soft-deleted/closed/released rows are intentionally NOT filtered out here — the - // delta must surface them (with their state columns) so the client removes/updates them locally. - bool Changed(IChangeTracked e) => e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc; + // On an initial full sync (since=0 → DateTime.MinValue) return EVERY row — including any with a null + // ModifiedOn (e.g. rows created before the change-tracking column existed) — so the first pull is complete; + // incremental syncs keep the strict "changed since the cursor" filter. Method-group conversion is + // contravariance-aware, so this Func binds to each entity-typed Where(). Soft-deleted/ + // closed/released rows are intentionally surfaced (with their state columns) so the client reconciles them. + var fullSync = sinceUtc == DateTime.MinValue; + bool Changed(IChangeTracked e) => fullSync || (e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc); var commands = await _incidentCommandRepository.GetAllByDepartmentIdAsync(departmentId); if (commands != null) diff --git a/Core/Resgrid.Services/IncidentResourcesService.cs b/Core/Resgrid.Services/IncidentResourcesService.cs index aa487f744..1490c45dd 100644 --- a/Core/Resgrid.Services/IncidentResourcesService.cs +++ b/Core/Resgrid.Services/IncidentResourcesService.cs @@ -205,7 +205,9 @@ public async Task> GetAdHocPersonnelForCallAsync(in public async Task<(List Units, List Personnel)> GetAdHocChangesSinceAsync(int departmentId, DateTime sinceUtc) { - bool Changed(IChangeTracked e) => e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc; + // Full sync (since=0 → DateTime.MinValue) returns every row incl. null-ModifiedOn; incremental filters strictly. + var fullSync = sinceUtc == DateTime.MinValue; + bool Changed(IChangeTracked e) => fullSync || (e.ModifiedOn.HasValue && e.ModifiedOn.Value > sinceUtc); var units = await _adHocUnitRepository.GetAllByDepartmentIdAsync(departmentId); var personnel = await _adHocPersonnelRepository.GetAllByDepartmentIdAsync(departmentId); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs index 496b6f2cb..509a93d0d 100644 --- a/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0082_AddCheckInRecordIdempotencyKey.cs @@ -14,6 +14,11 @@ public override void Up() if (Schema.Table("CheckInRecords").Exists() && !Schema.Table("CheckInRecords").Column("IdempotencyKey").Exists()) { Alter.Table("CheckInRecords").AddColumn("IdempotencyKey").AsString(128).Nullable(); + + // At most one check-in per (department, idempotency key). Filtered so the many NULL-key (live UI) + // check-ins don't collide. Backstops the check-then-insert race in + // CheckInTimerService.PerformCheckInAsync (which adopts the winner on violation). + Execute.Sql("CREATE UNIQUE NONCLUSTERED INDEX UX_CheckInRecords_Department_IdempotencyKey ON CheckInRecords (DepartmentId, IdempotencyKey) WHERE IdempotencyKey IS NOT NULL;"); } } @@ -21,6 +26,7 @@ public override void Down() { if (Schema.Table("CheckInRecords").Exists() && Schema.Table("CheckInRecords").Column("IdempotencyKey").Exists()) { + Execute.Sql("DROP INDEX IF EXISTS UX_CheckInRecords_Department_IdempotencyKey ON CheckInRecords;"); Delete.Column("IdempotencyKey").FromTable("CheckInRecords"); } } diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs index d708a16cb..0eaaee0d2 100644 --- a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0082_AddCheckInRecordIdempotencyKeyPg.cs @@ -14,6 +14,10 @@ public override void Up() if (Schema.Table("CheckInRecords".ToLower()).Exists() && !Schema.Table("CheckInRecords".ToLower()).Column("IdempotencyKey".ToLower()).Exists()) { Alter.Table("CheckInRecords".ToLower()).AddColumn("IdempotencyKey".ToLower()).AsCustom("citext").Nullable(); + + // At most one check-in per (department, idempotency key). Partial so the many NULL-key (live UI) + // check-ins don't collide. Backstops the check-then-insert race in PerformCheckInAsync. + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_checkinrecords_department_idempotencykey ON checkinrecords (departmentid, idempotencykey) WHERE idempotencykey IS NOT NULL;"); } } @@ -21,6 +25,7 @@ public override void Down() { if (Schema.Table("CheckInRecords".ToLower()).Exists() && Schema.Table("CheckInRecords".ToLower()).Column("IdempotencyKey".ToLower()).Exists()) { + Execute.Sql("DROP INDEX IF EXISTS ux_checkinrecords_department_idempotencykey;"); Delete.Column("IdempotencyKey".ToLower()).FromTable("CheckInRecords".ToLower()); } } diff --git a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs index a7389f74e..02166025f 100644 --- a/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CheckInTimerServiceTests.cs @@ -298,6 +298,23 @@ public async Task PerformCheckInAsync_WithNewIdempotencyKey_Inserts() _recordRepo.Verify(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); } + [Test] + public async Task PerformCheckInAsync_OnConcurrentReplayUniqueViolation_AdoptsTheWinningRecord() + { + var winner = new CheckInRecord { CheckInRecordId = "winner-1", DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }; + // Race: our pre-check sees nothing, a concurrent replay commits first, so our insert hits the unique + // index; the post-violation re-query then finds the winner and we adopt it instead of 500ing. + _recordRepo.SetupSequence(x => x.GetByCallIdAsync(1)) + .ReturnsAsync(new List()) + .ReturnsAsync(new List { winner }); + _recordRepo.Setup(x => x.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("unique constraint violation")); + + var result = await _service.PerformCheckInAsync(new CheckInRecord { DepartmentId = 10, CallId = 1, UserId = "user1", IdempotencyKey = "evt-1" }); + + result.Should().BeSameAs(winner); + } + [Test] public async Task GetLastCheckInAsync_ReturnsUserCheckIn_WhenNoUnitId() { diff --git a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs index d866728b3..26ec7c489 100644 --- a/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentCommandServiceParTests.cs @@ -483,5 +483,21 @@ public async Task GetChangesSinceAsync_ReturnsOnlyRowsChangedAfterCursor_Includi changes.Commands.Should().ContainSingle().Which.IncidentCommandId.Should().Be("c-new"); changes.Nodes.Should().ContainSingle().Which.DeletedOn.Should().NotBeNull(); } + + [Test] + public async Task GetChangesSinceAsync_FullSync_ReturnsAllRowsIncludingNullModifiedOn() + { + _commandRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentCommand { IncidentCommandId = "c-tracked", DepartmentId = Dept, CallId = CallId, ModifiedOn = DateTime.UtcNow }, + new IncidentCommand { IncidentCommandId = "c-legacy", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } // never stamped + }); + + // since=0 maps to DateTime.MinValue in the controller — the full pull must include the legacy null row. + var changes = await _service.GetChangesSinceAsync(Dept, DateTime.MinValue); + + changes.Commands.Should().HaveCount(2); + changes.Commands.Should().Contain(c => c.IncidentCommandId == "c-legacy"); + } } } diff --git a/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs index 8a4e1fa82..374d17685 100644 --- a/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/IncidentResourcesServiceTests.cs @@ -176,5 +176,19 @@ public async Task GetAdHocChangesSinceAsync_ReturnsOnlyRowsChangedAfterCursor() units.Should().ContainSingle().Which.IncidentAdHocUnitId.Should().Be("u-new"); personnel.Should().ContainSingle().Which.IncidentAdHocPersonnelId.Should().Be("p-rel"); } + + [Test] + public async Task GetAdHocChangesSinceAsync_FullSync_IncludesNullModifiedOnRows() + { + _unitRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List + { + new IncidentAdHocUnit { IncidentAdHocUnitId = "u-legacy", DepartmentId = Dept, CallId = CallId, ModifiedOn = null } + }); + _personnelRepo.Setup(x => x.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(new List()); + + var (units, _) = await _service.GetAdHocChangesSinceAsync(Dept, DateTime.MinValue); + + units.Should().ContainSingle().Which.IncidentAdHocUnitId.Should().Be("u-legacy"); + } } }