From 1d9261d1a9f4992098efe9012d19ca5c6f38fad6 Mon Sep 17 00:00:00 2001 From: Shawn Jackson Date: Tue, 22 Sep 2026 21:53:50 -0700 Subject: [PATCH 1/2] RG-T55 Bug fixes from prod deploy --- .../User/Deployments/Deployments.ar.resx | 10 +- .../User/Deployments/Deployments.de.resx | 10 +- .../User/Deployments/Deployments.el.resx | 10 +- .../User/Deployments/Deployments.en.resx | 10 +- .../User/Deployments/Deployments.es.resx | 10 +- .../User/Deployments/Deployments.fr.resx | 10 +- .../User/Deployments/Deployments.it.resx | 10 +- .../User/Deployments/Deployments.pl.resx | 10 +- .../Areas/User/Deployments/Deployments.resx | 10 +- .../User/Deployments/Deployments.sv.resx | 10 +- .../User/Deployments/Deployments.uk.resx | 10 +- .../Areas/User/Inventory/Inventory.ar.resx | 2 + .../Areas/User/Inventory/Inventory.de.resx | 2 + .../Areas/User/Inventory/Inventory.el.resx | 2 + .../Areas/User/Inventory/Inventory.en.resx | 2 + .../Areas/User/Inventory/Inventory.es.resx | 2 + .../Areas/User/Inventory/Inventory.fr.resx | 2 + .../Areas/User/Inventory/Inventory.it.resx | 2 + .../Areas/User/Inventory/Inventory.pl.resx | 2 + .../Areas/User/Inventory/Inventory.sv.resx | 2 + .../Areas/User/Inventory/Inventory.uk.resx | 2 + .../Areas/User/Personnel/Person.ar.resx | 5 + .../Areas/User/Personnel/Person.de.resx | 15 + .../Areas/User/Personnel/Person.el.resx | 15 + .../Areas/User/Personnel/Person.en.resx | 15 + .../Areas/User/Personnel/Person.es.resx | 15 + .../Areas/User/Personnel/Person.fr.resx | 15 + .../Areas/User/Personnel/Person.it.resx | 15 + .../Areas/User/Personnel/Person.pl.resx | 15 + .../Areas/User/Personnel/Person.sv.resx | 15 + .../Areas/User/Personnel/Person.uk.resx | 15 + .../Areas/User/Workforce/Workforce.ar.resx | 1 + .../Areas/User/Workforce/Workforce.de.resx | 1 + .../Areas/User/Workforce/Workforce.el.resx | 1 + .../Areas/User/Workforce/Workforce.en.resx | 1 + .../Areas/User/Workforce/Workforce.es.resx | 1 + .../Areas/User/Workforce/Workforce.fr.resx | 1 + .../Areas/User/Workforce/Workforce.it.resx | 1 + .../Areas/User/Workforce/Workforce.pl.resx | 1 + .../Areas/User/Workforce/Workforce.resx | 1 + .../Areas/User/Workforce/Workforce.sv.resx | 1 + .../Areas/User/Workforce/Workforce.uk.resx | 1 + Core/Resgrid.Model/AuditLogTypes.cs | 5 +- .../Helpers/DepartmentMemberStateHelper.cs | 22 + .../Helpers/TimeConverterHelper.cs | 15 + .../Inventories/InventoryOperations.cs | 42 +- .../Inventories/InventoryWorkflowPayload.cs | 4 +- .../Invoicing/DeploymentContracts.cs | 42 + .../Invoicing/DeploymentModels.cs | 24 +- .../Repositories/IDeploymentRepositories.cs | 2 + .../IChecklistAuthorizationService.cs | 2 + .../Services/ICommunicationService.cs | 8 + .../Services/IDepartmentsService.cs | 28 +- .../Services/IDeploymentService.cs | 4 + .../IInventoryModernizationService.cs | 2 + .../Services/IInventoryOperationsService.cs | 2 + .../Services/IRecordsAuthorizationService.cs | 6 + .../Services/ITimeTrackingService.cs | 12 + .../Services/IWorkOrdersService.cs | 9 + .../Services/IWorkforceServices.cs | 6 + .../WorkflowTemplateVariableCatalog.cs | 1 + .../Resgrid.Model/WorkflowTriggerEventType.cs | 5 +- Core/Resgrid.Services/AuditService.cs | 2 + .../CertificationService.Sweep.cs | 11 +- Core/Resgrid.Services/CertificationService.cs | 13 +- .../ChecklistAssignmentService.cs | 4 +- .../ChecklistAuthorizationService.cs | 6 +- .../ChecklistReminderService.cs | 5 +- Core/Resgrid.Services/ChecklistReporting.cs | 10 +- .../ChecklistTimedReminders.cs | 9 + Core/Resgrid.Services/ChecklistsScheduling.cs | 19 +- Core/Resgrid.Services/CommunicationService.cs | 9 +- .../CommunicationTestService.cs | 6 +- .../CalOesMarsService.WorkItems.cs | 4 +- Core/Resgrid.Services/DeleteService.cs | 46 +- Core/Resgrid.Services/DepartmentsService.cs | 65 +- .../InventoryAlertNotifications.cs | 4 +- Core/Resgrid.Services/InventoryAlerts.cs | 77 +- .../InventoryAuthorizationService.cs | 3 + Core/Resgrid.Services/InventoryCounts.cs | 106 +- .../Invoicing/ContractorBillingEngine.cs | 2 +- .../Invoicing/DeploymentService.cs | 65 +- .../Invoicing/InvoicePaymentsService.cs | 2 +- .../Invoicing/ServiceContractService.cs | 2 +- .../Invoicing/TimeTrackingService.cs | 144 +- .../Records/RecordDeploymentsService.cs | 11 + .../Records/RecordEvidenceSelectionService.cs | 3 +- .../Records/RecordsAuthorizationService.cs | 8 + .../Records/RecordsDisclosureService.cs | 3 + .../Records/RecordsInspectionsService.cs | 6 +- .../Records/RecordsInvestigationsService.cs | 2 + .../Records/RecordsNotificationService.cs | 48 +- .../Records/RecordsPreventionGate.cs | 4 + .../Records/RecordsService.cs | 2 + .../WorkOrderAuthorizationService.cs | 22 +- .../WorkOrderNotificationService.cs | 15 +- .../WorkOrderRecurrenceService.cs | 10 + .../WorkflowSampleDataGenerator.cs | 8 +- .../WorkflowTemplateContextBuilder.cs | 1 + .../Workforce/CaPayDataReportingService.cs | 2 +- .../Workforce/WorkforceService.cs | 43 +- .../Migrations/M0227_AddTimeReportScopes.cs | 31 + .../Migrations/M0227_AddTimeReportScopesPg.cs | 29 + .../DeploymentRepositories.cs | 4 + .../Allocations/trigger-baseline.json | 3 +- .../Rms/RecordDeploymentsServiceTests.cs | 7 + .../RecordEvidenceSelectionServiceTests.cs | 2 +- .../Rms/RecordsDisclosureServiceTests.cs | 11 + .../Rms/RecordsInspectionsServiceTests.cs | 17 + .../Rms/RecordsInvestigationsServiceTests.cs | 3 + .../Rms/RecordsNotificationServiceTests.cs | 33 + .../Resgrid.Tests/Rms/RecordsServiceTests.cs | 11 + .../Resgrid.Tests/Rms/RmsDefinitionHarness.cs | 1 + .../Rms/RmsIdentifierPinTests.cs | 2 + Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs | 1 + .../Services/CalOesMarsServiceTests.cs | 2 + .../Services/CertificationServiceTests.cs | 38 +- .../Services/ChecklistAssignmentTests.cs | 23 + .../Services/ChecklistP1M4Tests.cs | 13 + .../Services/ChecklistPr504SecurityTests.cs | 2 +- .../Services/ChecklistReminderTests.cs | 13 + .../Services/ChecklistSchedulingTests.cs | 27 + .../Services/CommunicationServiceTests.cs | 13 + .../Services/CommunicationTestServiceTests.cs | 34 + .../Services/ContractorBillingServiceTests.cs | 1 + .../Services/DepartmentMemberStateTests.cs | 106 ++ .../Services/DeploymentLocalizationTests.cs | 2 +- .../Services/DeploymentServiceTests.cs | 43 + .../Services/InventoryDepartedHolderTests.cs | 92 + .../Services/InventoryM5Tests.cs | 33 +- .../Services/InvoicePaymentsServiceTests.cs | 1 + .../Services/MemberRemovalLifecycleTests.cs | 158 ++ .../Services/TimeReportScopeTests.cs | 182 ++ .../Services/WorkOrderAuthorizationTests.cs | 25 + .../WorkOrderMaintenanceAssignmentTests.cs | 26 + .../Services/WorkOrderNotificationTests.cs | 11 +- .../Services/WorkOrderP2M23Tests.cs | 4 + .../Services/WorkforceServicesTests.cs | 56 +- .../Web/ScriptMinificationSettingsTests.cs | 71 + .../Web/User/CertificationCreationTests.cs | 2 + .../User/LegacyCertificationsCutoverTests.cs | 44 + .../Web/User/PersonnelReactivationTests.cs | 185 ++ .../Web/User/RecordCallPickerTests.cs | 2 +- .../Workers/AuditQueueLogicTests.cs | 126 ++ .../Controllers/v4/CalOesMarsController.cs | 4 +- .../Controllers/v4/ContactsController.cs | 36 +- .../Controllers/v4/DeploymentsController.cs | 29 +- .../v4/InventoryOperationsController.cs | 3 + .../Controllers/v4/TimeReportsController.cs | 184 +- .../Models/v4/Contacts/ContactResult.cs | 35 + .../v4/Deployments/DeploymentsApiModels.cs | 41 + .../Resgrid.Web.Services.xml | 94 +- .../User/Controllers/CalOesMarsController.cs | 4 +- .../Controllers/CertificationsController.cs | 15 +- .../User/Controllers/DepartmentController.cs | 6 +- .../Controllers/DeploymentOrdersController.cs | 6 +- .../Controllers/DeploymentWizardController.cs | 6 +- .../User/Controllers/DeploymentsController.cs | 88 +- .../User/Controllers/DisclosuresController.cs | 4 +- .../Controllers/IncidentReportsController.cs | 11 +- .../User/Controllers/InventoryController.cs | 2 +- .../InventoryOperationsController.cs | 2 +- .../User/Controllers/PersonnelController.cs | 183 +- .../RecordInvestigationsController.cs | 10 +- .../User/Controllers/RecordsController.cs | 27 +- .../User/Controllers/ReportsController.cs | 9 +- .../User/Controllers/WorkforceController.cs | 8 +- .../Models/Deployments/DeploymentViews.cs | 13 +- .../User/Models/Personnel/ViewPersonView.cs | 2 + .../Records/RecordDefinitionsViewModels.cs | 3 + .../Models/Records/RecordsRms5ViewModels.cs | 2 + .../User/Models/Records/RecordsViewModels.cs | 2 + .../User/Views/Checklists/EditSchedule.cshtml | 5 + .../User/Views/Deployments/TimeReport.cshtml | 54 +- .../Areas/User/Views/Deployments/View.cshtml | 26 +- .../User/Views/IncidentReports/Edit.cshtml | 4 +- .../Views/Personnel/AddExistingUser.cshtml | 33 +- .../Views/Personnel/ReactivateUser.cshtml | 20 +- .../Views/RecordInvestigations/Custody.cshtml | 2 +- .../Areas/User/Views/Records/Details.cshtml | 2 +- .../Views/Records/_DefinitionFields.cshtml | 5 + .../Areas/User/Views/WorkOrders/Detail.cshtml | 6 +- .../Views/WorkOrders/EditRecurrence.cshtml | 5 + .../Helpers/ScriptMinificationSettings.cs | 21 + Web/Resgrid.Web/Startup.cs | 3 +- .../Logic/AuditQueueLogic.cs | 1651 +++++++++-------- .../Logic/ReportDeliveryLogic.cs | 22 +- .../Logic/SystemQueueLogic.cs | 7 +- 188 files changed, 4398 insertions(+), 1111 deletions(-) create mode 100644 Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs create mode 100644 Providers/Resgrid.Providers.Migrations/Migrations/M0227_AddTimeReportScopes.cs create mode 100644 Providers/Resgrid.Providers.MigrationsPg/Migrations/M0227_AddTimeReportScopesPg.cs create mode 100644 Tests/Resgrid.Tests/Services/DepartmentMemberStateTests.cs create mode 100644 Tests/Resgrid.Tests/Services/InventoryDepartedHolderTests.cs create mode 100644 Tests/Resgrid.Tests/Services/MemberRemovalLifecycleTests.cs create mode 100644 Tests/Resgrid.Tests/Services/TimeReportScopeTests.cs create mode 100644 Tests/Resgrid.Tests/Web/ScriptMinificationSettingsTests.cs create mode 100644 Tests/Resgrid.Tests/Web/User/PersonnelReactivationTests.cs create mode 100644 Tests/Resgrid.Tests/Workers/AuditQueueLogicTests.cs create mode 100644 Web/Resgrid.Web/Helpers/ScriptMinificationSettings.cs diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx index 054ced160..ff69c306d 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.ar.resx @@ -102,7 +102,7 @@ إضافة عضو لا يستوفي العضو متطلبات المقعد. حدد «التعيين حتى لو لم يتحقق أحد المتطلبات» للتجاوز. تقرير وقت جديد - تقرير واحد لكل يوم؛ تُعبأ الإدخالات مسبقًا من التشكيل. + تقرير واحد يوميًا لكل طاقم أو شخص؛ تُملأ القيود مسبقًا من قائمة الأفراد. لا توجد تقارير وقت بعد. تاريخ التقرير تم الإرسال @@ -275,4 +275,12 @@ يجب عودة جميع الموارد إلى القاعدة قبل إكمال الانتشار. عرض كشف الموارد تصدير الموارد بصيغة CSV + يشمل + عملية النشر بالكامل + الطاقم: {0} + فردي: {0} + قيد وقت يخص شخصًا خارج طاقم هذا التقرير أو الشخص المعني + لهذا البند وقت مسجل بالفعل في تقرير آخر لنفس اليوم + هذا الطاقم أو الشخص غير نشط في عملية النشر هذه. + لهذا الطاقم أو الشخص وقت مسجل بالفعل في تقرير آخر لهذا اليوم. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx index a4c5474b5..3f3723c52 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.de.resx @@ -102,7 +102,7 @@ Mitglied hinzufügen Das Mitglied erfüllt die Anforderungen des Platzes nicht. Aktivieren Sie „Auch bei nicht erfüllter Anforderung einteilen“, um dies zu übergehen. Neuer Zeitbericht - Ein Bericht je Tag; die Einträge werden aus der Besetzung vorbelegt. + Ein Bericht je Tag für jede Besatzung oder Person; die Einträge werden aus der Besetzung vorbelegt. Noch keine Zeitberichte. Berichtsdatum Eingereicht @@ -275,4 +275,12 @@ Alle Ressourcen müssen zurückgekehrt sein, bevor der Einsatz abgeschlossen werden kann. Manifest anzeigen Ressourcen als CSV exportieren + Gilt für + Gesamter Einsatz + Besatzung: {0} + Einzelperson: {0} + Ein Zeiteintrag betrifft jemanden außerhalb der Besatzung oder Person dieses Berichts + Für diesen Eintrag ist am selben Tag bereits Zeit in einem anderen Bericht erfasst + Diese Besatzung oder Person ist in diesem Einsatz nicht aktiv. + Für diese Besatzung oder Person ist an diesem Tag bereits Zeit in einem anderen Bericht erfasst. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx index a07823984..d155b218a 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.el.resx @@ -102,7 +102,7 @@ Προσθήκη μέλους Το μέλος δεν πληροί τις απαιτήσεις της θέσης. Επιλέξτε «Τοποθέτηση ακόμη κι αν αποτύχει μια απαίτηση» για παράκαμψη. Νέα αναφορά χρόνου - Μία αναφορά ανά ημέρα· οι καταχωρίσεις προσυμπληρώνονται από τη σύνθεση. + Μία αναφορά ανά ημέρα για κάθε πλήρωμα ή πρόσωπο· οι καταχωρίσεις συμπληρώνονται από τη σύνθεση. Δεν υπάρχουν αναφορές χρόνου ακόμη. Ημερομηνία αναφοράς Υποβλήθηκε @@ -275,4 +275,12 @@ Όλοι οι πόροι πρέπει να επιστρέψουν στη βάση πριν ολοκληρωθεί η ανάπτυξη. Προβολή καταλόγου Εξαγωγή πόρων CSV + Καλύπτει + Ολόκληρη η ανάπτυξη + Πλήρωμα: {0} + Ατομικό: {0} + Μια καταχώριση χρόνου αφορά κάποιον εκτός του πληρώματος ή του προσώπου αυτής της αναφοράς + Αυτό το αντικείμενο έχει ήδη χρόνο σε άλλη αναφορά για την ίδια ημέρα + Αυτό το πλήρωμα ή πρόσωπο δεν είναι ενεργό σε αυτή την ανάπτυξη. + Αυτό το πλήρωμα ή πρόσωπο έχει ήδη χρόνο σε άλλη αναφορά για αυτή την ημέρα. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx index 19236356b..40bb1e7e5 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.en.resx @@ -102,7 +102,7 @@ Add member The member does not meet the seat's requirements. Tick "Seat even when a requirement fails" to override. New time report - One report per day; entries are prefilled from the roster. + One report per day for each crew or person; entries are prefilled from the roster. No time reports yet. Report date Submitted @@ -275,4 +275,12 @@ Every resource must return home before the deployment can be completed. View manifest Export resource CSV + Covers + Whole deployment + Crew: {0} + Individual: {0} + A time entry is for someone outside this report's crew or person + This subject already has time on another report for the same day + That crew or person is not active on this deployment. + That crew or person already has time on another report for this day. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx index 38d460802..ee1e0c54c 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.es.resx @@ -102,7 +102,7 @@ Añadir miembro El miembro no cumple los requisitos del puesto. Marque «Asignar aunque falle un requisito» para forzarlo. Nuevo parte de horas - Un parte por día; las entradas se rellenan desde la dotación. + Un parte por día para cada tripulación o persona; las entradas se rellenan desde la dotación. Aún no hay partes de horas. Fecha del parte Enviado @@ -275,4 +275,12 @@ Todos los recursos deben regresar a su base antes de finalizar el despliegue. Ver manifiesto Exportar recursos CSV + Cubre + Todo el despliegue + Tripulación: {0} + Persona: {0} + Una entrada de tiempo corresponde a alguien ajeno a la tripulación o persona de este parte + Este sujeto ya tiene horas en otro parte del mismo día + Esa tripulación o persona no está activa en este despliegue. + Esa tripulación o persona ya tiene horas en otro parte de este día. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx index 574b3f49a..ced7563a1 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.fr.resx @@ -102,7 +102,7 @@ Ajouter un membre Le membre ne remplit pas les exigences du poste. Cochez « Affecter même si une exigence échoue » pour passer outre. Nouveau rapport de temps - Un rapport par jour ; les entrées sont pré-remplies depuis l'effectif. + Un rapport par jour pour chaque équipage ou personne ; les entrées sont pré-remplies depuis l'effectif. Aucun rapport de temps pour l'instant. Date du rapport Soumis @@ -275,4 +275,12 @@ Toutes les ressources doivent être rentrées à leur base avant de terminer le déploiement. Voir le manifeste Exporter les ressources CSV + Couvre + Tout le déploiement + Équipage : {0} + Individuel : {0} + Une entrée de temps concerne quelqu'un hors de l'équipage ou de la personne de ce rapport + Ce sujet a déjà du temps sur un autre rapport pour la même journée + Cet équipage ou cette personne n'est pas actif sur ce déploiement. + Cet équipage ou cette personne a déjà du temps sur un autre rapport pour cette journée. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx index 48936e0aa..b39ed2413 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.it.resx @@ -102,7 +102,7 @@ Aggiungi membro Il membro non soddisfa i requisiti del posto. Spunta «Assegna anche se un requisito non è soddisfatto» per forzare. Nuovo rapporto ore - Un rapporto al giorno; le voci sono precompilate dall'organico. + Un rapporto al giorno per ogni equipaggio o persona; le voci sono precompilate dall'organico. Ancora nessun rapporto ore. Data del rapporto Inviato @@ -275,4 +275,12 @@ Tutte le risorse devono rientrare alla base prima di completare il dispiegamento. Visualizza manifesto Esporta risorse CSV + Copre + Intero dispiegamento + Equipaggio: {0} + Individuale: {0} + Una voce di tempo riguarda qualcuno al di fuori dell'equipaggio o della persona di questo rapporto + Questo soggetto ha già del tempo su un altro rapporto per lo stesso giorno + Questo equipaggio o questa persona non è attivo in questo dispiegamento. + Questo equipaggio o questa persona ha già del tempo su un altro rapporto per questo giorno. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx index 4eb0d6c98..a0a8217a7 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.pl.resx @@ -102,7 +102,7 @@ Dodaj członka Członek nie spełnia wymagań stanowiska. Zaznacz „Przydziel nawet gdy wymaganie nie jest spełnione”, aby wymusić. Nowy raport czasu - Jeden raport dziennie; wpisy są wypełniane z obsady. + Jeden raport dziennie dla każdej załogi lub osoby; wpisy są wstępnie wypełniane ze składu. Brak raportów czasu. Data raportu Przesłano @@ -275,4 +275,12 @@ Wszystkie zasoby muszą wrócić do bazy przed zakończeniem działań. Wyświetl wykaz Eksportuj zasoby CSV + Obejmuje + Całe rozmieszczenie + Załoga: {0} + Indywidualny: {0} + Wpis czasu dotyczy kogoś spoza załogi lub osoby tego raportu + Ten podmiot ma już czas w innym raporcie z tego samego dnia + Ta załoga lub osoba nie jest aktywna w tym rozmieszczeniu. + Ta załoga lub osoba ma już czas w innym raporcie z tego dnia. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx index 19236356b..40bb1e7e5 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.resx @@ -102,7 +102,7 @@ Add member The member does not meet the seat's requirements. Tick "Seat even when a requirement fails" to override. New time report - One report per day; entries are prefilled from the roster. + One report per day for each crew or person; entries are prefilled from the roster. No time reports yet. Report date Submitted @@ -275,4 +275,12 @@ Every resource must return home before the deployment can be completed. View manifest Export resource CSV + Covers + Whole deployment + Crew: {0} + Individual: {0} + A time entry is for someone outside this report's crew or person + This subject already has time on another report for the same day + That crew or person is not active on this deployment. + That crew or person already has time on another report for this day. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx index 2bcd7d7ec..f26dfc8b6 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.sv.resx @@ -102,7 +102,7 @@ Lägg till medlem Medlemmen uppfyller inte platsens krav. Markera ”Bemanna även om ett krav inte uppfylls” för att åsidosätta. Ny tidrapport - En rapport per dag; posterna förifylls från bemanningen. + En rapport per dag för varje besättning eller person; posterna förifylls från bemanningen. Inga tidrapporter ännu. Rapportdatum Inskickad @@ -275,4 +275,12 @@ Alla resurser måste ha återvänt till basen innan insatsen kan avslutas. Visa manifest Exportera resurser som CSV + Omfattar + Hela insatsen + Besättning: {0} + Individuell: {0} + En tidspost gäller någon utanför rapportens besättning eller person + Detta objekt har redan tid på en annan rapport för samma dag + Den besättningen eller personen är inte aktiv i denna insats. + Den besättningen eller personen har redan tid på en annan rapport för denna dag. diff --git a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx index d3ebf3d2e..15ee780fb 100644 --- a/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Deployments/Deployments.uk.resx @@ -102,7 +102,7 @@ Додати учасника Учасник не відповідає вимогам місця. Позначте «Призначити навіть якщо вимога не виконана», щоб обійти. Новий звіт про час - Один звіт на день; записи заповнюються зі складу. + Один звіт на день для кожного екіпажу або особи; записи заповнюються зі складу. Звітів про час поки немає. Дата звіту Подано @@ -275,4 +275,12 @@ Усі ресурси мають повернутися на базу, перш ніж розгортання можна буде завершити. Переглянути маніфест Експортувати ресурси CSV + Охоплює + Усе розгортання + Екіпаж: {0} + Індивідуальний: {0} + Запис часу стосується когось поза екіпажем або особою цього звіту + Цей суб'єкт уже має час в іншому звіті за той самий день + Цей екіпаж або особа не активні в цьому розгортанні. + Цей екіпаж або особа вже мають час в іншому звіті за цей день. diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx index 1b665e0de..0f4b80c99 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.ar.resx @@ -446,6 +446,7 @@ تنتهي الصلاحية قريبًا منتهي الصلاحية إرجاع متأخر + بحوزة عضو مغادر مفتوح تمت المعالجة موعد الاستحقاق (UTC) @@ -516,6 +517,7 @@ اقتراب انتهاء صلاحية المخزون اكتمال جرد المخزون تأخر إعادة العهدة + عهدة بحوزة عضو مغادر هذا المورد مرتبط بسجل المخزون ويجب الاحتفاظ به. تم تعديل المخزون اكتمل نقل المخزون diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx index f235babd5..14a5a726c 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.de.resx @@ -570,6 +570,7 @@ Läuft bald ab Abgelaufen Überfällige Rückgabe + Bei ausgeschiedenem Mitglied Offen Erledigt Fällig am (UTC) @@ -640,6 +641,7 @@ Inventar läuft ab Inventurzählung abgeschlossen Inventarrückgabe überfällig + Inventar bei ausgeschiedenem Mitglied Diese Ressource wird im Bestandsverlauf referenziert und muss erhalten bleiben. Bestand angepasst Bestandsübertragung abgeschlossen diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx index f113f3e3d..09b2451e6 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.el.resx @@ -629,6 +629,7 @@ Λήγει σύντομα Έχει λήξει Εκπρόθεσμη επιστροφή + Σε κατοχή μέλους που αποχώρησε Ανοιχτή Επιλύθηκε Προθεσμία (UTC) @@ -699,6 +700,7 @@ Λήξη αποθέματος σύντομα Ολοκλήρωση απογραφής Εκπρόθεσμη επιστροφή εξοπλισμού + Εξοπλισμός σε κατοχή μέλους που αποχώρησε Αυτός ο πόρος αναφέρεται στο ιστορικό αποθέματος και πρέπει να διατηρηθεί. Το απόθεμα προσαρμόστηκε Η μεταφορά αποθέματος ολοκληρώθηκε diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx index bc8a0c070..532eee040 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.en.resx @@ -629,6 +629,7 @@ Expiring soon Expired Overdue return + Held by departed member Open Resolved Due at (UTC) @@ -699,6 +700,7 @@ Inventory expiring Inventory count completed Inventory return overdue + Inventory held by departed member This resource is referenced by inventory history and must be retained. Inventory adjusted Inventory transfer completed diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx index 70a16330f..9146cbbe3 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.es.resx @@ -570,6 +570,7 @@ Próxima caducidad Caducado Devolución vencida + En poder de un miembro dado de baja Abierta Resuelta Vencimiento (UTC) @@ -640,6 +641,7 @@ Inventario próximo a vencer Recuento de inventario completado Devolución de inventario atrasada + Inventario en poder de un miembro dado de baja Este recurso está vinculado al historial de inventario y debe conservarse. Inventario ajustado Transferencia de inventario completada diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx index ca5674cd9..d2639a3a0 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.fr.resx @@ -570,6 +570,7 @@ Péremption prochaine Périmé Retour en retard + Détenu par un membre parti Ouverte Résolue Échéance (UTC) @@ -640,6 +641,7 @@ Expiration prochaine du stock Inventaire terminé Retour de matériel en retard + Matériel détenu par un membre parti Cette ressource est référencée dans l’historique du stock et doit être conservée. Stock ajusté Transfert de stock terminé diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx index 3d64ef5cf..7bef66d7b 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.it.resx @@ -570,6 +570,7 @@ In scadenza Scaduto Restituzione in ritardo + In possesso di un membro uscito Aperto Risolto Scadenza (UTC) @@ -640,6 +641,7 @@ Inventario in scadenza Conteggio inventario completato Restituzione inventario scaduta + Inventario in possesso di un membro uscito Questa risorsa è citata nello storico dell’inventario e deve essere conservata. Inventario rettificato Trasferimento di inventario completato diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx index 8bd1ce156..6eb3c1d5b 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.pl.resx @@ -570,6 +570,7 @@ Wkrótce traci ważność Przeterminowane Zaległy zwrot + U byłego członka Otwarty Rozwiązany Termin (UTC) @@ -640,6 +641,7 @@ Zbliżający się termin ważności zapasów Zakończono inwentaryzację Zaległy zwrot wyposażenia + Wyposażenie u byłego członka Ten zasób jest powiązany z historią magazynu i musi zostać zachowany. Skorygowano stan magazynowy Zakończono przesunięcie magazynowe diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx index cd1b29171..ec4ba3b52 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.sv.resx @@ -570,6 +570,7 @@ Förfaller snart Utgånget Försenad återlämning + Hos avgången medlem Öppen Åtgärdad Förfaller (UTC) @@ -640,6 +641,7 @@ Lager med nära utgångsdatum Inventering slutförd Försenad återlämning + Utrustning hos avgången medlem Den här resursen refereras i lagerhistoriken och måste behållas. Lager justerat Lageröverföring slutförd diff --git a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx index 07a3d28a9..6d06b7f36 100644 --- a/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Inventory/Inventory.uk.resx @@ -570,6 +570,7 @@ Термін придатності скоро закінчиться Термін придатності закінчився Повернення прострочене + У колишнього члена Відкрите Усунено Кінцевий термін (UTC) @@ -640,6 +641,7 @@ Наближається закінчення терміну придатності запасів Інвентаризацію завершено Прострочене повернення майна + Майно у колишнього члена На цей ресурс є посилання в історії запасів, тому його потрібно зберегти. Запаси скориговано Переміщення запасів завершено diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx index b6a768ad6..e3275e74f 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.ar.resx @@ -29,6 +29,9 @@ عنوان البريد الإلكتروني (يجب أن يكون فريداً) هذا المستخدم ، بناءً على عنوان البريد الإلكتروني، كان لديه حساب Resgrid في قسم آخر. تمت إضافة حساب المستخدم <b>بإعدادات ملفه الشخصي السابقة (الاسم، أرقام الهاتف، التفضيلات، إلخ)</b> إلى القسم. نظراً لكون المستخدم في أقسام متعددة، يجب عليه تفعيل هذا القسم لرؤية معلوماته من خلال خيار "عرض أقسامك" ضمن قائمة الملف الشخصي. + إضافة مستخدم موجود + ، بناءً على عنوان البريد الإلكتروني، لديه بالفعل حساب Resgrid في قسم آخر. تؤدي الإضافة إلى ضم هذا الحساب إلى هذا القسم بإعدادات ملفه الشخصي الحالية (الاسم وأرقام الهاتف والتفضيلات). ينضم المستخدم عضوًا عاديًا، ويرى هذا القسم بعد التبديل إليه من "أقسامك" في قائمة الملف الشخصي. + إضافة إلى القسم الاسم الأول الاسم الأول المجموعة @@ -69,6 +72,8 @@ هذا المستخدم بناءً على عنوان البريد الإلكتروني، كان لديه حساب في قسم لكنه حُذف سابقاً. تمت إعادة تفعيل حساب المستخدم داخل هذا القسم وسيظهر <b>بإعدادات ملفه الشخصي السابقة (الاسم، أرقام الهاتف، التفضيلات، إلخ)</b> في القسم. + لكنه أُزيل. تؤدي إعادة التنشيط إلى استعادة الحساب داخل هذه الإدارة بإعدادات ملفه الشخصي السابقة (الاسم وأرقام الهاتف والتفضيلات). يعود الشخص عضوًا عاديًا؛ امنحه صلاحيات المسؤول مرة أخرى بشكل منفصل إذا احتاج إليها. + إعادة التنشيط تحذير: سيؤدي هذا الإجراء إلى حذف هذا الدور نهائياً. هل أنت متأكد من حذف الدور وصف الدور اسم الدور diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx index 6ca6511e8..818829afb 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.de.resx @@ -140,6 +140,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Vorhandenen Benutzer hinzufügen + + + , hat laut E-Mail-Adresse bereits ein Resgrid-Konto in einer anderen Abteilung. Beim Hinzufügen kommt dieses Konto mit seinen bestehenden Profileinstellungen (Name, Telefonnummern, Einstellungen) in diese Abteilung. Die Person tritt als normales Mitglied bei und sieht diese Abteilung, sobald sie über "Ihre Abteilungen" im Profilmenü zu ihr wechselt. + + + Zur Abteilung hinzufügen + Vorname @@ -248,6 +257,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + Abteilung, wurde aber entfernt. Durch die Reaktivierung kehrt das Konto mit seinen bisherigen Profileinstellungen (Name, Telefonnummern, Einstellungen) in diese Abteilung zurück. Die Person kehrt als normales Mitglied zurück; Administratorrechte müssen bei Bedarf separat erneut vergeben werden. + + + Reaktivieren + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx index 15ba7e8d3..83e31736e 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.el.resx @@ -189,6 +189,15 @@ , με βάση τη διεύθυνση email, είχε ήδη λογαριασμό Resgrid σε άλλο τμήμα. Ο λογαριασμός χρήστη προστέθηκε πλέον <b>με τις προηγούμενες ρυθμίσεις προφίλ του (όνομα, αριθμοί τηλεφώνου, προτιμήσεις κ.λπ.)</b> στο τμήμα. Επειδή ο χρήστης ανήκει σε πολλά τμήματα, πρέπει να ενεργοποιήσει αυτό το τμήμα για να δει τις πληροφορίες του από την επιλογή «Προβολή των Τμημάτων Σας» στο αναπτυσσόμενο μενού του προφίλ του (κάτω από τη φωτογραφία προφίλ στην επάνω αριστερή γωνία). + + Προσθήκη υπάρχοντος χρήστη + + + , με βάση τη διεύθυνση email, έχει ήδη λογαριασμό Resgrid σε άλλο τμήμα. Η προσθήκη φέρνει αυτόν τον λογαριασμό σε αυτό το τμήμα με τις υπάρχουσες ρυθμίσεις προφίλ (όνομα, τηλέφωνα, προτιμήσεις). Ο χρήστης μπαίνει ως απλό μέλος και βλέπει αυτό το τμήμα όταν μεταβεί σε αυτό από την επιλογή "Τα Τμήματά Σας" στο μενού προφίλ. + + + Προσθήκη στο τμήμα + Όνομα @@ -297,6 +306,12 @@ αλλά διαγράφηκε προηγουμένως. Ο λογαριασμός χρήστη επαναφέρθηκε και ενεργοποιήθηκε ξανά μέσα σε αυτό το τμήμα και θα εμφανίζεται <b>με τις προηγούμενες ρυθμίσεις προφίλ του (όνομα, αριθμοί τηλεφώνου, προτιμήσεις κ.λπ.)</b> στο τμήμα. + + αλλά αφαιρέθηκε. Η επανενεργοποίηση επαναφέρει τον λογαριασμό σε αυτό το τμήμα με τις προηγούμενες ρυθμίσεις προφίλ (όνομα, τηλέφωνα, προτιμήσεις). Το άτομο επιστρέφει ως απλό μέλος· δώστε ξανά δικαιώματα διαχειριστή ξεχωριστά αν τα χρειάζεται. + + + Επανενεργοποίηση + ΠΡΟΕΙΔΟΠΟΙΗΣΗ: Αυτό θα διαγράψει οριστικά αυτόν τον ρόλο. Είστε βέβαιοι ότι θέλετε να διαγράψετε τον ρόλο diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx index 01a9863c6..6ed37a2f7 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.en.resx @@ -189,6 +189,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Add Existing User + + + , based on email address, already has a Resgrid account in another department. Adding them brings that account into this department with its existing profile settings (name, phone numbers, preferences). They join as a regular member, and see this department once they switch to it from "Your Departments" in their profile menu. + + + Add to Department + First Name @@ -297,6 +306,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + department but was removed. Reactivating brings the account back inside this department with its previous profile settings (name, phone numbers, preferences). The person returns as a regular member; grant admin rights again separately if they need them. + + + Reactivate + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx index 759a70ca3..7a3fce657 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.es.resx @@ -189,6 +189,15 @@ , según la dirección de correo electrónico, ya tenía una cuenta de Resgrid en otro departamento. La cuenta de usuario ahora se ha agregado <b>con su configuración de perfil anterior (nombre, números de teléfono, preferencias, etc.)</b> al departamento. Debido a que el usuario está en varios departamentos, necesita activar este departamento para ver su información desde su opción "Ver sus departamentos" en el menú desplegable de su perfil (debajo de la imagen de perfil en la esquina superior izquierda). + + Agregar usuario existente + + + , según su dirección de correo electrónico, ya tiene una cuenta de Resgrid en otro departamento. Al agregarlo, esa cuenta se incorpora a este departamento con su configuración de perfil actual (nombre, teléfonos, preferencias). Se une como miembro normal y verá este departamento cuando cambie a él desde "Tus Departamentos" en el menú de perfil. + + + Agregar al departamento + Nombre de pila @@ -297,6 +306,12 @@ departamento pero fue borrado previamente. La cuenta de usuario ahora se ha recuperado y reactivado dentro de este departamento y aparecerá <b>con su configuración de perfil anterior (nombre, números de teléfono, preferencias, etc.)</b> en el departamento. + + departamento, pero fue eliminado. Al reactivarlo, la cuenta vuelve a este departamento con su configuración de perfil anterior (nombre, teléfonos, preferencias). La persona vuelve como miembro normal; conceda de nuevo los permisos de administrador por separado si los necesita. + + + Reactivar + ADVERTENCIA: Esto eliminará permanentemente este rol. ¿Estás seguro de que quieres eliminar el rol? diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx index f2a5143d3..af720cb0e 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.fr.resx @@ -140,6 +140,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Ajouter un utilisateur existant + + + , d'après son adresse e-mail, possède déjà un compte Resgrid dans un autre département. L'ajout intègre ce compte à ce département avec ses paramètres de profil actuels (nom, numéros de téléphone, préférences). La personne rejoint le département comme membre ordinaire et le voit une fois qu'elle y bascule depuis « Vos départements » dans le menu du profil. + + + Ajouter au département + Prénom @@ -248,6 +257,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + département, mais en a été retiré. La réactivation rétablit le compte dans ce département avec ses paramètres de profil précédents (nom, numéros de téléphone, préférences). La personne revient comme membre ordinaire ; accordez de nouveau les droits d'administrateur séparément si nécessaire. + + + Réactiver + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx index 149d0f879..63d0cdfa2 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.it.resx @@ -140,6 +140,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Aggiungi utente esistente + + + , in base all'indirizzo e-mail, ha già un account Resgrid in un altro dipartimento. L'aggiunta porta quell'account in questo dipartimento con le impostazioni del profilo esistenti (nome, numeri di telefono, preferenze). Entra come membro normale e vede questo dipartimento dopo esservi passato da "I tuoi dipartimenti" nel menu del profilo. + + + Aggiungi al dipartimento + Nome @@ -248,6 +257,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + dipartimento, ma è stato rimosso. La riattivazione riporta l'account in questo dipartimento con le precedenti impostazioni del profilo (nome, numeri di telefono, preferenze). La persona rientra come membro normale; concedere di nuovo i diritti di amministratore separatamente se necessario. + + + Riattiva + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx index 2da27d77b..ead3688f8 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.pl.resx @@ -140,6 +140,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Dodaj istniejącego użytkownika + + + , na podstawie adresu e-mail, ma już konto Resgrid w innym oddziale. Dodanie przenosi to konto do tego oddziału z istniejącymi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje). Osoba dołącza jako zwykły członek i zobaczy ten oddział po przełączeniu się na niego przez "Twoje oddziały" w menu profilu. + + + Dodaj do oddziału + Imię @@ -248,6 +257,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + oddziale, ale został usunięty. Ponowna aktywacja przywraca konto w tym oddziale z poprzednimi ustawieniami profilu (imię i nazwisko, numery telefonów, preferencje). Osoba wraca jako zwykły członek; w razie potrzeby uprawnienia administratora należy nadać ponownie osobno. + + + Aktywuj ponownie + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx index 0f10843ce..d49d213c0 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.sv.resx @@ -140,6 +140,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Lägg till befintlig användare + + + , baserat på e-postadressen, har redan ett Resgrid-konto i en annan avdelning. När du lägger till personen förs kontot in i den här avdelningen med sina befintliga profilinställningar (namn, telefonnummer, inställningar). Personen går med som vanlig medlem och ser avdelningen när hen byter till den via "Dina avdelningar" i profilmenyn. + + + Lägg till i avdelningen + Förnamn @@ -248,6 +257,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + avdelningen men har tagits bort. Återaktivering för tillbaka kontot i den här avdelningen med dess tidigare profilinställningar (namn, telefonnummer, inställningar). Personen återvänder som vanlig medlem; ge administratörsbehörighet på nytt separat om den behövs. + + + Återaktivera + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx index 88bfff373..de14c940d 100644 --- a/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Personnel/Person.uk.resx @@ -140,6 +140,15 @@ , based on email address, already had a Resgrid account in another department. The user account has now been added <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> to the department. Because the user is in multiple departments they need activate this department to see it's information from their "View Your Departments" option under their profile dropdown (under the profile picture in the upper left hand corner). + + Додати наявного користувача + + + , за адресою електронної пошти, вже має обліковий запис Resgrid в іншому підрозділі. Додавання переносить цей обліковий запис до цього підрозділу з наявними налаштуваннями профілю (ім'я, номери телефонів, уподобання). Особа приєднується як звичайний член і побачить цей підрозділ, коли перейде до нього через "Ваші підрозділи" в меню профілю. + + + Додати до підрозділу + Ім'я @@ -248,6 +257,12 @@ department but was deleted previously. The user account has now be un-deleted and reactivated inside this department and will appear <b>with their previous profile settings (name, phone numbers, preferences, etc)</b> in the department. + + підрозділі, але був видалений. Повторна активація повертає обліковий запис у цей підрозділ із попередніми налаштуваннями профілю (ім'я, номери телефонів, уподобання). Особа повертається як звичайний член; за потреби надайте права адміністратора окремо. + + + Активувати повторно + WARNING: This will permanently delete this role. Are you sure you want to delete the role diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx index 4cf5e2fb0..76172e281 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.ar.resx @@ -705,6 +705,7 @@ قراءة الاستخدام غير صالحة. نمط العمل غير صالح. هذا العضو لديه سجل عامل بالفعل. + اختر عضوًا حاليًا في هذه الإدارة. يحتاج العامل إلى عضو أو مفتاح خارجي. نوع العامل غير صالح. لم يتم العثور على العامل. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx index 1806923a5..9c6b6dd31 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.de.resx @@ -705,6 +705,7 @@ Die Nutzungsablesung ist ungültig. Der Arbeitsmodus ist ungültig. Dieses Mitglied hat bereits einen Beschäftigtendatensatz. + Wählen Sie ein aktuelles Mitglied dieser Abteilung. Eine Person benötigt ein Mitglied oder einen externen Schlüssel. Die Beschäftigungsart ist ungültig. Die Person wurde nicht gefunden. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx index fd574c337..9398c051e 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.el.resx @@ -705,6 +705,7 @@ Η ένδειξη χρήσης δεν είναι έγκυρη. Ο τρόπος εργασίας δεν είναι έγκυρος. Αυτό το μέλος έχει ήδη εγγραφή εργαζομένου. + Επιλέξτε ένα τρέχον μέλος αυτής της υπηρεσίας. Ένας εργαζόμενος χρειάζεται μέλος ή εξωτερικό κλειδί. Το είδος εργαζομένου δεν είναι έγκυρο. Ο εργαζόμενος δεν βρέθηκε. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx index 205cc76df..9bf61427a 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.en.resx @@ -705,6 +705,7 @@ The usage reading is not valid. The work mode is not valid. That member already has a worker row. + Select a current member of this department. A worker needs a member or an external key. The worker kind is not valid. The worker was not found. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx index ea1c6087b..eb12589a8 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.es.resx @@ -705,6 +705,7 @@ La lectura de uso no es válida. La modalidad de trabajo no es válida. Ese miembro ya tiene un registro de trabajador. + Seleccione un miembro actual de este departamento. Un trabajador necesita un miembro o una clave externa. El tipo de trabajador no es válido. No se encontró el trabajador. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx index 9eeda2cf0..a4901b907 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.fr.resx @@ -705,6 +705,7 @@ Le relevé d'utilisation n'est pas valide. Le mode de travail n'est pas valide. Ce membre a déjà une fiche travailleur. + Sélectionnez un membre actuel de ce service. Un travailleur a besoin d'un membre ou d'une clé externe. Le type de travailleur n'est pas valide. Le travailleur est introuvable. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx index daf3960fa..32aa86758 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.it.resx @@ -705,6 +705,7 @@ La lettura di utilizzo non è valida. La modalità di lavoro non è valida. Quel membro ha già una scheda lavoratore. + Selezionare un membro attuale di questo dipartimento. Un lavoratore richiede un membro o una chiave esterna. Il tipo di lavoratore non è valido. Il lavoratore non è stato trovato. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx index 4ff8f06be..8d433a04a 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.pl.resx @@ -705,6 +705,7 @@ Odczyt wykorzystania jest nieprawidłowy. Tryb pracy jest nieprawidłowy. Ten członek ma już rekord pracownika. + Wybierz obecnego członka tej jednostki. Pracownik wymaga członka lub klucza zewnętrznego. Rodzaj pracownika jest nieprawidłowy. Nie znaleziono pracownika. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx index 205cc76df..9bf61427a 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.resx @@ -705,6 +705,7 @@ The usage reading is not valid. The work mode is not valid. That member already has a worker row. + Select a current member of this department. A worker needs a member or an external key. The worker kind is not valid. The worker was not found. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx index 2c9612ce1..001efe140 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.sv.resx @@ -705,6 +705,7 @@ Användningsavläsningen är ogiltig. Arbetsformen är ogiltig. Den medlemmen har redan en arbetstagarpost. + Välj en nuvarande medlem i den här avdelningen. En arbetstagare behöver en medlem eller en extern nyckel. Typen av arbetstagare är ogiltig. Arbetstagaren hittades inte. diff --git a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx index ea186becd..4e04d2095 100644 --- a/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx +++ b/Core/Resgrid.Localization/Areas/User/Workforce/Workforce.uk.resx @@ -705,6 +705,7 @@ Показник використання недійсний. Режим роботи недійсний. Цей член уже має запис працівника. + Виберіть чинного члена цього підрозділу. Працівник потребує члена або зовнішнього ключа. Тип працівника недійсний. Працівника не знайдено. diff --git a/Core/Resgrid.Model/AuditLogTypes.cs b/Core/Resgrid.Model/AuditLogTypes.cs index 265f26925..d381ba4ab 100644 --- a/Core/Resgrid.Model/AuditLogTypes.cs +++ b/Core/Resgrid.Model/AuditLogTypes.cs @@ -340,6 +340,9 @@ public enum AuditLogTypes ResourceCostProfileChanged, ResourceUsageChanged, FieldCostRunCreated, - FieldCostRunFrozen + FieldCostRunFrozen, + + // Member removal lifecycle (2026-09-22): a removed membership brought back; it returns without its old admin standing. Append-only. + UserReactivated } } diff --git a/Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs b/Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs new file mode 100644 index 000000000..1f06a5e27 --- /dev/null +++ b/Core/Resgrid.Model/Helpers/DepartmentMemberStateHelper.cs @@ -0,0 +1,22 @@ +namespace Resgrid.Model.Helpers +{ + /// + /// Membership-state predicates shared by the automated paths. Two tiers, on purpose: + /// + /// — not deleted, not disabled. Who may still act in the department + /// (perform an assigned checklist, log labor on a work order). + /// — current and not hidden. Who automation addresses and who reports + /// name: reminders, escalations, digests, notifications, compliance rows, pickers. + /// + /// The nullable flags read null as false. + /// + public static class DepartmentMemberStateHelper + { + public static bool IsCurrentMember(DepartmentMember member, int departmentId) => + member != null && member.DepartmentId == departmentId && !string.IsNullOrWhiteSpace(member.UserId) && + !member.IsDeleted && !member.IsDisabled.GetValueOrDefault(); + + public static bool IsActiveMember(DepartmentMember member, int departmentId) => + IsCurrentMember(member, departmentId) && !member.IsHidden.GetValueOrDefault(); + } +} diff --git a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs index 81bb934b9..d18ad6c2f 100644 --- a/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs +++ b/Core/Resgrid.Model/Helpers/TimeConverterHelper.cs @@ -43,6 +43,21 @@ public static DateTime TimeConverter(this DateTime timestamp, Department departm } } + /// + /// The inverse of : a department-local wall-clock value to its UTC instant, resolved in the same + /// zone (Pacific when the department has none). DST gaps and overlaps resolve leniently, like the MVC DepartmentTime input + /// path, so a typed 02:30 on the spring-forward day still saves. A value already marked UTC keeps its instant. + /// + public static DateTime DepartmentLocalToUtc(this DateTime local, Department department) + { + if (local.Kind == DateTimeKind.Utc) return local; + if (local.Kind == DateTimeKind.Local) return local.ToUniversalTime(); + var timeZone = string.IsNullOrEmpty(department?.TimeZone) ? "Pacific Standard Time" : department.TimeZone; + var id = DateTimeHelpers.ConvertTimeZoneString(timeZone); + var zone = DateTimeZoneProviders.Tzdb.GetZoneOrNull(id) ?? DateTimeZoneProviders.Tzdb[TZConvert.WindowsToIana(id)]; + return LocalDateTime.FromDateTime(local).InZoneLeniently(zone).ToDateTimeUtc(); + } + public static string TimeConverterToString(this DateTime timestamp, Department department) { department ??= new Department(); diff --git a/Core/Resgrid.Model/Inventories/InventoryOperations.cs b/Core/Resgrid.Model/Inventories/InventoryOperations.cs index 9aee10124..4c5465ed1 100644 --- a/Core/Resgrid.Model/Inventories/InventoryOperations.cs +++ b/Core/Resgrid.Model/Inventories/InventoryOperations.cs @@ -4,7 +4,8 @@ namespace Resgrid.Model.Inventories { public enum InventoryCountStatus { Draft = 0, AwaitingWitness = 1, Completed = 2, Cancelled = 3 } - public enum InventoryAlertType { LowStock = 0, ExpiringSoon = 1, Expired = 2, OverdueReturn = 3 } + /// DepartedHolder: stock or assets still at a personnel location whose member was removed, disabled or hidden (a recovery task). + public enum InventoryAlertType { LowStock = 0, ExpiringSoon = 1, Expired = 2, OverdueReturn = 3, DepartedHolder = 4 } public sealed class InventoryCount : InventoryMutableRow { public string LocationId { get; set; } @@ -30,6 +31,8 @@ public sealed class InventoryCountContent public string Name { get; set; } public string Note { get; set; } public int VarianceLineCount { get; set; } + /// The locations a location-scoped count fences (root plus, when asked, its compartments and kit containers). + public List ScopeLocationIds { get; set; } public List Totals { get; set; } = new(); } public sealed class InventoryCountItemContent @@ -41,7 +44,42 @@ public sealed class InventoryCountItemContent public decimal? UnitCost { get; set; } public string CurrencyCode { get; set; } } - public sealed class InventoryCountInput { public string Id { get; set; } public string LocationId { get; set; } public string Name { get; set; } public string Note { get; set; } } + public sealed class InventoryCountInput + { + public string Id { get; set; } + public string LocationId { get; set; } + public string Name { get; set; } + public string Note { get; set; } + /// + /// Add a zero-expected line for every active bulk catalog item not already at the location, so a surplus of an unlisted + /// item can be counted. Null keeps the original behaviour (on); a field count of an apparatus turns it off because the + /// whole catalog would blow the 100-line count limit. + /// + public bool? IncludeCatalogItems { get; set; } + /// Also count the location's compartments (child locations) and the kit containers sitting in it. + public bool IncludeChildLocations { get; set; } + } + /// What a field app may do with inventory (and, for a unit, the unit's own locations to count). + public sealed class InventoryFieldAccess + { + /// Module on for the department (writes also need ). + public bool Enabled { get; set; } + public bool Migrated { get; set; } + /// AdjustInventory at the unit's holder location (or department-wide when no unit was asked for). + public bool CanCount { get; set; } + public bool CanIssue { get; set; } + public bool CanTransfer { get; set; } + public List UnitLocations { get; set; } = new(); + } + public sealed class InventoryFieldLocation + { + public string Id { get; set; } + public string Name { get; set; } + public string ParentLocationId { get; set; } + public int LocationType { get; set; } + /// The unit's holder location itself (compartments and containers hang off it). + public bool IsRoot { get; set; } + } public sealed class InventoryCountObservation { public string Id { get; set; } public decimal Quantity { get; set; } } public sealed class InventoryCountUpdate { public string CountId { get; set; } public int Revision { get; set; } public List Lines { get; set; } = new(); } public sealed class InventoryCountComplete { public string CountId { get; set; } public int Revision { get; set; } public string RequestId { get; set; } } diff --git a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs index c8cf6eb08..2d74a1a63 100644 --- a/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs +++ b/Core/Resgrid.Model/Inventories/InventoryWorkflowPayload.cs @@ -13,7 +13,7 @@ namespace Resgrid.Model.Inventories public static class InventoryWorkflowPayload { public const int CatalogVersion = 22; - public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] { 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166 }); + public static readonly IReadOnlyList Triggers = Array.AsReadOnly(new[] { 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166, 188 }); public static readonly (string Variable, string Property)[] Variables = { ("transaction_id", "TransactionId"), ("item_id", "ItemId"), ("asset_id", "AssetId"), ("lot_id", "LotId"), @@ -76,7 +76,7 @@ public static string Routing(JObject payload) foreach (var name in new[] { "TransactionType", "OldStatus", "NewStatus", "ReferenceType" }) CopyInteger(payload, safe, name); CopyInteger(payload, safe, "UsageType", 3); CopyInteger(payload, safe, "PurchaseOrderStatus", 4); CopyInteger(payload, safe, "LineCount", 100); - CopyInteger(payload, safe, "VarianceLineCount", 100); CopyInteger(payload, safe, "AlertType", 3); CopyTimestamp(payload, safe, "DueOn"); + CopyInteger(payload, safe, "VarianceLineCount", 100); CopyInteger(payload, safe, "AlertType", 4); CopyTimestamp(payload, safe, "DueOn"); if (payload["CurrencyCode"]?.Type == JTokenType.String && payload.Value("CurrencyCode") is { Length: 3 } currency && currency.All(c => c >= 'A' && c <= 'Z')) safe["CurrencyCode"] = currency; foreach (var name in new[] { "Quantity", "FromQuantityBefore", "FromQuantityAfter", "ToQuantityBefore", "ToQuantityAfter" }) { diff --git a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs index 5b92c6ffa..d892acf31 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentContracts.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; namespace Resgrid.Model.Invoicing { @@ -84,6 +85,10 @@ public sealed class TimeReportValidation public const string BreakRule = "break_rule"; public const string LongTravel = "long_travel"; public const string OutsideReportDate = "outside_report_date"; + /// A crew or individual report carries a subject outside its unit's crew/equipment or its one person (M0227). + public const string SubjectOutsideScope = "subject_outside_report_scope"; + /// The subject already has time on another live report for the same day, which would bill it twice (M0227). + public const string SubjectOnOtherReport = "subject_on_other_report"; public List Errors { get; set; } = new List(); public List Warnings { get; set; } = new List(); @@ -98,6 +103,43 @@ public sealed class TimeReportIssue public string Detail { get; set; } } + /// + /// What one person may do with a deployment's time (M0227). Managers write every subject. Anyone else writes their own + /// roster row plus, for every deployed unit they crew (seated on that unit's deployment roster, or holding an active + /// unit role on the apparatus), the unit, its crew and its equipment — the Crew Time Report a crew boss or the unit's + /// tablet files. Reads follow ; the server re-checks all of it on every write. + /// + public sealed class DeploymentTimeAccess + { + public bool CanManage { get; set; } + /// On the roster now or before (removed rows still see their history). + public bool IsRostered { get; set; } + /// The caller's active roster row, when they have one. + public string PersonnelId { get; set; } + /// Deployment unit ids the caller crews. + public List CrewUnitIds { get; set; } = new List(); + /// Subject ids (personnel, unit, equipment) the caller may write; ignored for managers. + public HashSet WritableSubjectIds { get; set; } = new HashSet(StringComparer.OrdinalIgnoreCase); + + public bool CanRead => CanManage || IsRostered || CrewUnitIds.Count > 0; + public bool CanWrite => CanManage || WritableSubjectIds.Count > 0; + public bool CanWriteSubject(string subjectId) => CanManage || (!string.IsNullOrWhiteSpace(subjectId) && WritableSubjectIds.Contains(subjectId)); + + /// Whether the caller may act on (write, sign, submit) the report as a whole: its scope must be theirs. + public bool CanActOn(DeploymentTimeReport report) + { + if (report == null) return false; + if (CanManage) return true; + return report.Scope switch + { + DeploymentTimeReportScopes.Individual => CanWriteSubject(report.DeploymentPersonnelId), + DeploymentTimeReportScopes.Crew => CrewUnitIds.Contains(report.DeploymentUnitId, StringComparer.OrdinalIgnoreCase), + // A deployment-wide report touches every crew; only a caller who may write every entry on it acts on it whole. + _ => report.Entries != null && report.Entries.Count > 0 && report.Entries.TrueForAll(e => CanWriteSubject(e.SubjectId)) + }; + } + } + /// A saved time report plus the validation that ran on it. public sealed class TimeReportSaveResult { diff --git a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs index 343ce028c..2794acc79 100644 --- a/Core/Resgrid.Model/Invoicing/DeploymentModels.cs +++ b/Core/Resgrid.Model/Invoicing/DeploymentModels.cs @@ -38,6 +38,18 @@ public enum DeploymentTimeReportStatuses Void = 4 } + /// + /// Who a daily time report covers (M0227). A deployment-wide DTR is the contractor/manager paper; a crew report is the + /// Crew Time Report (CTR) one deployed unit files for itself, its crew and its equipment; an individual report is a single + /// resource's own time. A subject may sit on only one live report per day, so the scopes never double-count. + /// + public enum DeploymentTimeReportScopes + { + Deployment = 0, + Crew = 1, + Individual = 2 + } + public enum DeploymentTimeSubjectTypes { Personnel = 0, @@ -243,6 +255,10 @@ public class DeploymentTimeReport : IEntity public int DepartmentId { get; set; } public int ReportNumber { get; set; } public DateTime ReportDate { get; set; } + /// Crew time report scope: the deployed unit whose crew and equipment this report covers (M0227). + public string DeploymentUnitId { get; set; } + /// Individual time report scope: the single roster row this report covers (M0227). + public string DeploymentPersonnelId { get; set; } /// . public int Status { get; set; } public string IncidentNumber { get; set; } @@ -274,11 +290,17 @@ public class DeploymentTimeReport : IEntity public List Entries { get; set; } = new List(); [NotMapped] public bool IsEditable => Status is (int)DeploymentTimeReportStatuses.Draft or (int)DeploymentTimeReportStatuses.Submitted; + [NotMapped] + public DeploymentTimeReportScopes Scope => !string.IsNullOrWhiteSpace(DeploymentPersonnelId) ? DeploymentTimeReportScopes.Individual + : !string.IsNullOrWhiteSpace(DeploymentUnitId) ? DeploymentTimeReportScopes.Crew : DeploymentTimeReportScopes.Deployment; + /// True while the report still counts toward billing and the one-report-per-subject-per-day rule. + [NotMapped] + public bool IsLive => !IsDeleted && Status != (int)DeploymentTimeReportStatuses.Void; [NotMapped] public string TableName => "DeploymentTimeReports"; [NotMapped] public string IdName => "DeploymentTimeReportId"; [NotMapped] public int IdType => 1; [NotMapped] [JsonIgnore] public object IdValue { get => DeploymentTimeReportId; set => DeploymentTimeReportId = (string)value; } - [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Entries", "IsEditable" }; + [NotMapped] public IEnumerable IgnoredProperties => new[] { "IdValue", "IdType", "TableName", "IdName", "Entries", "IsEditable", "Scope", "IsLive" }; } /// One time span for one subject on a DTR (multi-span per day supported). Times are UTC. diff --git a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs index 5a08ee82b..fac6ff2b2 100644 --- a/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs +++ b/Core/Resgrid.Model/Repositories/IDeploymentRepositories.cs @@ -32,6 +32,8 @@ public interface IDeploymentUnitRepository : IRepository Task> GetByDeploymentAsync(string deploymentId); /// Units seated on another open deployment overlapping the window (wizard conflict detection). Task> GetActiveAssignmentsForUnitsAsync(int departmentId, IEnumerable unitIds, DateTime windowStart, DateTime windowEnd, string excludingDeploymentId); + /// Every roster row (active or removed) for these department units; the seated crew's "my deployments" scope (M0227). + Task> GetForUnitsAsync(int departmentId, IEnumerable unitIds); } public interface IDeploymentPersonnelRepository : IRepository diff --git a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs index a797430fa..685756a50 100644 --- a/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IChecklistAuthorizationService.cs @@ -13,5 +13,7 @@ public interface IChecklistAuthorizationService Task>> ReadFilterAsync(ChecklistActor actor); Task TargetAsync(ChecklistActor actor, ChecklistTargetType type, string id); Task> TargetsAsync(ChecklistActor actor, ChecklistTargetType type); + /// The department's active members (deleted, disabled and hidden excluded): who reminders reach and whose personnel checks compliance reports count. + Task> ActiveMemberIdsAsync(int departmentId); } } diff --git a/Core/Resgrid.Model/Services/ICommunicationService.cs b/Core/Resgrid.Model/Services/ICommunicationService.cs index 078839161..dcdd05ff2 100644 --- a/Core/Resgrid.Model/Services/ICommunicationService.cs +++ b/Core/Resgrid.Model/Services/ICommunicationService.cs @@ -71,6 +71,14 @@ Task SendCancelUnitCallAsync(Call call, CallDispatchUnit dispatch, string Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false); + /// + /// Sends the notification with a push event code the app routes on when the push is tapped + /// (for example "NWO:{workOrderId}"). The code travels only in the push payload; SMS and email + /// carry the same text as the overload without it. A null or blank code keeps the default "N{id}". + /// + Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, + string title, UserProfile profile, bool sendToICApp, string eventCode); + /// /// Sends the chat. /// diff --git a/Core/Resgrid.Model/Services/IDepartmentsService.cs b/Core/Resgrid.Model/Services/IDepartmentsService.cs index b5bccb238..233b176ab 100644 --- a/Core/Resgrid.Model/Services/IDepartmentsService.cs +++ b/Core/Resgrid.Model/Services/IDepartmentsService.cs @@ -43,7 +43,11 @@ Task UpdateDepartmentAsync(Department department, Task GetUserIdForDeletedUserInDepartmentAsync(int departmentId, string email); - Task ReactivateUserAsync(int departmentId, string userId, + /// + /// Brings a removed membership back: not deleted, disabled or hidden, and never an admin (admin standing is granted + /// again deliberately, not restored from the removed row). Audited as UserReactivated; null when there is no row. + /// + Task ReactivateUserAsync(int departmentId, string userId, string reactivatingUserId, CancellationToken cancellationToken = default(CancellationToken)); Task AddExistingUserAsync(int departmentId, string userId, @@ -97,8 +101,22 @@ Task> GetAllUsersForDepartmentAsync(int departmentId, bool re Task> GetAllPersonnelNamesForDepartmentAsync(int departmentId); + /// + /// The names a person picker may offer: active members only (removed, disabled and hidden excluded), taken from the + /// unlimited roster and ordered by name. still labels historical + /// rows and the value already stored on an edit form, which may belong to someone who has since gone inactive. + /// + Task> GetSelectablePersonnelNamesAsync(int departmentId); + + /// The department's admins and managing user; removed and disabled memberships are excluded. Task> GetAllAdminsForDepartmentAsync(int departmentId); + /// + /// without hidden memberships either: the recipients of automated admin + /// digests and sweep notices (certifications, compliance documents, finance and MARS reminders, pay-data readiness). + /// + Task> GetActiveAdminsForDepartmentAsync(int departmentId); + Task> GetAllMembersForDepartmentAsync(int departmentId); Task> GetAllUsersForDepartmentUnlimitedAsync(int departmentId, bool bypassCache = false); @@ -145,6 +163,14 @@ Task SaveDepartmentCallPruningAsync(DepartmentCallPruning /// Task> GetMemberUserIdsInDepartmentAsync(int departmentId, IEnumerable userIds); + /// + /// The user ids of the department's active members: deleted, disabled and hidden memberships are excluded. + /// Unlimited (never truncated to the plan's personnel limit) and read in one query, uncached. This is the set + /// automated sweeps, digests, notifications and personnel reports address; a stored user id (an assignee, a + /// certificate holder, a report subject) outside it belongs to someone who has left or been switched off. + /// + Task> GetActiveMemberUserIdsAsync(int departmentId); + Task> GetAllDepartmentNamesAsync(); Task> GetAllDepartmentsForUserAsync(string userId); diff --git a/Core/Resgrid.Model/Services/IDeploymentService.cs b/Core/Resgrid.Model/Services/IDeploymentService.cs index 92e65427a..f561c1ebc 100644 --- a/Core/Resgrid.Model/Services/IDeploymentService.cs +++ b/Core/Resgrid.Model/Services/IDeploymentService.cs @@ -29,6 +29,10 @@ public interface IDeploymentService /// Header rows for the ids (no roster), for a batched existence / ownership check such as search authorization. Task> GetDeploymentsByIdsAsync(int departmentId, IEnumerable deploymentIds); Task IsRosteredAsync(string deploymentId, int departmentId, string userId); + /// The field member's read scope: rostered (now or before), or seated on a unit (active unit role) that is actively deployed on it. + Task CanFieldMemberSeeAsync(string deploymentId, int departmentId, string userId); + /// What the member may do with the deployment's time (M0227): own roster row, crewed units and the subjects they may write. Managers write everything. + Task GetTimeAccessAsync(Deployment deployment, string userId, bool canManage); Task SaveDeploymentAsync(Deployment deployment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); /// Planned→Standby→Active→Demobilizing→Completed, Cancelled from any open state; Completed/Cancelled are terminal. diff --git a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs index cb34fb402..5e83d7923 100644 --- a/Core/Resgrid.Model/Services/IInventoryModernizationService.cs +++ b/Core/Resgrid.Model/Services/IInventoryModernizationService.cs @@ -49,5 +49,7 @@ public interface IInventoryAuthorizationService Task CanLocationAsync(InventoryActor actor, InventoryLocation location); Task ValidateHolderAsync(InventoryActor actor, InventoryLocation location); Task IsEnabledAsync(int departmentId); + /// The department's active members (removed, disabled and hidden excluded): a personnel holder outside it has departed. + Task> ActiveMemberIdsAsync(int departmentId); } } diff --git a/Core/Resgrid.Model/Services/IInventoryOperationsService.cs b/Core/Resgrid.Model/Services/IInventoryOperationsService.cs index 6c7136626..d00535625 100644 --- a/Core/Resgrid.Model/Services/IInventoryOperationsService.cs +++ b/Core/Resgrid.Model/Services/IInventoryOperationsService.cs @@ -11,6 +11,8 @@ public interface IInventoryOperationsService Task SaveCountAsync(InventoryActor actor, InventoryCountUpdate input); Task CompleteCountAsync(InventoryActor actor, InventoryCountComplete input); Task CancelCountAsync(InventoryActor actor, string id, int revision); + /// Never throws for a member: whether inventory is usable and what the caller may do, plus the unit's countable locations. + Task GetFieldAccessAsync(InventoryActor actor, int? unitId); Task RefreshAlertsAsync(InventoryActor actor); Task BuildReportAsync(InventoryActor actor, InventoryReportInput input); } diff --git a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs index 41690f909..9941b9ac1 100644 --- a/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs +++ b/Core/Resgrid.Model/Services/IRecordsAuthorizationService.cs @@ -13,6 +13,12 @@ namespace Resgrid.Model.Services public interface IRecordsAuthorizationService { Task IsActiveMemberAsync(string userId, int departmentId); + /// + /// Whether Records work may be handed to this person (an owner, inspector, assignee or custodian): a member of the + /// department who is not removed, disabled or hidden. Stricter than , which gates who + /// may still read and act, and which a hidden member passes. + /// + Task IsAssignableMemberAsync(string userId, int departmentId); Task IsDepartmentAdminAsync(string userId, int departmentId); Task HasPermissionAsync(string userId, int departmentId, PermissionTypes permissionType); Task CanReadSourceCallAsync(string userId, int departmentId, Call call); diff --git a/Core/Resgrid.Model/Services/ITimeTrackingService.cs b/Core/Resgrid.Model/Services/ITimeTrackingService.cs index 2c8537091..f64b710ce 100644 --- a/Core/Resgrid.Model/Services/ITimeTrackingService.cs +++ b/Core/Resgrid.Model/Services/ITimeTrackingService.cs @@ -24,10 +24,22 @@ public interface ITimeTrackingService /// Allocates the next report number, copies the deployment's agency identifiers and prefills one Deployment entry per active roster subject (prior report's times when one exists). Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// + /// A scoped report (M0227): makes it that unit's Crew Time Report (the unit, its crew and its + /// equipment), one person's own report; both null is the deployment-wide DTR. One live + /// report per scope per day; subjects already on another live report that day are not prefilled (timereports_subject_covered + /// when the scope's own unit or person is). + /// + Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string deploymentUnitId, string deploymentPersonnelId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); /// Header fields only (flags, notes, identifiers); entries go through . Task UpdateTimeReportAsync(DeploymentTimeReport report, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); /// Replaces the report's entries as a batch after validation; errors leave the stored entries untouched. Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// + /// The scoped save: for a caller who does not manage deployments only the subjects in are replaced; + /// every other subject's stored entries are kept untouched (never deleted), so concurrent crews cannot erase each other. + /// + Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, DeploymentTimeAccess access, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); TimeReportValidation Validate(DeploymentTimeReport report, IReadOnlyList entries, IReadOnlyCollection rosterSubjectIds); Task SubmitTimeReportAsync(string deploymentTimeReportId, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/Services/IWorkOrdersService.cs b/Core/Resgrid.Model/Services/IWorkOrdersService.cs index b60be319c..d8b80cc1a 100644 --- a/Core/Resgrid.Model/Services/IWorkOrdersService.cs +++ b/Core/Resgrid.Model/Services/IWorkOrdersService.cs @@ -35,6 +35,8 @@ public interface IWorkOrderAuthorizationService Task ValidateAssignmentAsync(ChecklistActor actor, WorkOrder row, string userId, int? roleId); Task ChoicesAsync(ChecklistActor actor); Task> RecipientsAsync(int departmentId, WorkOrder row); + /// The department's active members (removed, disabled and hidden excluded): who automation may assign work to. + Task> ActiveMemberIdsAsync(int departmentId); } } @@ -147,7 +149,14 @@ public sealed class WorkOrderChoice { public string Id { get; set; } public stri public sealed class WorkOrderChoices { public string Currency { get; set; } = "USD"; + /// Who may be picked: active members only. public List Users { get; set; } = new List(); + /// + /// Display names for every current member the actor may view, hidden ones included (removed and disabled members are + /// not): labels history rows and keeps an existing hidden assignee on an edit form after they stop being offered in + /// . + /// + public Dictionary UserNames { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public List Roles { get; set; } = new List(); public List Units { get; set; } = new List(); public List Groups { get; set; } = new List(); diff --git a/Core/Resgrid.Model/Services/IWorkforceServices.cs b/Core/Resgrid.Model/Services/IWorkforceServices.cs index 4d401a0d7..68de260d4 100644 --- a/Core/Resgrid.Model/Services/IWorkforceServices.cs +++ b/Core/Resgrid.Model/Services/IWorkforceServices.cs @@ -35,6 +35,12 @@ public interface IWorkforceService /// Validates that a worker's periods never overlap. Task SaveEmploymentAsync(WorkforceEmployment employment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); Task DeleteEmploymentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); + /// + /// A member left the department: every open employment of their worker row is end-dated on + /// (never deleted, so past-period statutory data keeps them); one that had not started by then is withdrawn. Audited; + /// returns how many rows changed. A member with no worker row is a no-op. + /// + Task EndEmploymentsForMemberAsync(int departmentId, string userId, DateTime endOn, string actorUserId, CancellationToken cancellationToken = default); /// Validates that an employment's assignments never overlap and that the establishment is the department's. Task SaveJobAssignmentAsync(WorkforceJobAssignment assignment, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); Task DeleteJobAssignmentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default); diff --git a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs index 4674f0935..f03f1230f 100644 --- a/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs +++ b/Core/Resgrid.Model/WorkflowTemplateVariableCatalog.cs @@ -839,6 +839,7 @@ pair.Variable is "status" or "old_status" or "contract_type" or "days_until_end" case WorkflowTriggerEventType.InventoryExpiring: case WorkflowTriggerEventType.InventoryCountCompleted: case WorkflowTriggerEventType.InventoryReturnOverdue: + case WorkflowTriggerEventType.InventoryDepartedHolder: case WorkflowTriggerEventType.ControlledSubstanceRecorded: foreach (var pair in Inventories.InventoryWorkflowPayload.Variables) { diff --git a/Core/Resgrid.Model/WorkflowTriggerEventType.cs b/Core/Resgrid.Model/WorkflowTriggerEventType.cs index 92c19886a..90a41eed3 100644 --- a/Core/Resgrid.Model/WorkflowTriggerEventType.cs +++ b/Core/Resgrid.Model/WorkflowTriggerEventType.cs @@ -242,7 +242,10 @@ public enum WorkflowTriggerEventType CertificationCreditAdded = 184, TimeReportCreated = 185, TimeReportVoided = 186, - DeploymentAttachmentAdded = 187 + DeploymentAttachmentAdded = 187, + + // Inventory held by a removed, disabled or hidden member (registry 188, 2026-09-22): raised with the DepartedHolder alert. + InventoryDepartedHolder = 188 } public static class WorkflowTriggerEventTypes diff --git a/Core/Resgrid.Services/AuditService.cs b/Core/Resgrid.Services/AuditService.cs index da51f3a05..be7ab3882 100644 --- a/Core/Resgrid.Services/AuditService.cs +++ b/Core/Resgrid.Services/AuditService.cs @@ -248,6 +248,8 @@ public string GetAuditLogTypeString(AuditLogTypes logType) return "Moderation Request Completed"; case AuditLogTypes.ModerationEvidenceDownloaded: return "Moderation Evidence Downloaded"; + case AuditLogTypes.UserReactivated: + return "User Reactivated"; } return $"Unknown ({logType})"; diff --git a/Core/Resgrid.Services/CertificationService.Sweep.cs b/Core/Resgrid.Services/CertificationService.Sweep.cs index 0cb27c128..f256caeeb 100644 --- a/Core/Resgrid.Services/CertificationService.Sweep.cs +++ b/Core/Resgrid.Services/CertificationService.Sweep.cs @@ -43,6 +43,10 @@ public async Task RunExpirySweepAsync(int departmentId var leadDays = settings.GetNotifyLeadDays(); var horizon = today.AddDays(leadDays.Count > 0 ? leadDays.Max() : 60); var types = (await GetAllCertificationTypesByDepartmentAsync(departmentId)).Where(t => !t.IsDeleted).ToDictionary(t => t.DepartmentCertificationTypeId); + // Deleted, disabled and hidden members are out of every pass: their records are neither expired nor announced, + // they are not notified, enforced against or named in the digest, and no admin among them receives it. The expire + // pass catches up on its own if a member is re-enabled (any live record past its date is expired on the next run). + var activeMembers = await ActiveMemberUserIdsAsync(departmentId); var nameCache = new Dictionary(); async Task Name(string userId) { @@ -51,7 +55,8 @@ async Task Name(string userId) } // ---- Pass 1 + 2: personnel records --------------------------------------------------------------------- - var records = (await _personnelCertificationRepository.GetExpiringAsync(departmentId, horizon.AddDays(1)))?.Where(r => r.IsTyped && types.ContainsKey(r.DepartmentCertificationTypeId.Value)).ToList() ?? new List(); + var records = (await _personnelCertificationRepository.GetExpiringAsync(departmentId, horizon.AddDays(1)))? + .Where(r => r.IsTyped && types.ContainsKey(r.DepartmentCertificationTypeId.Value) && r.UserId != null && activeMembers.Contains(r.UserId)).ToList() ?? new List(); foreach (var record in records) { cancellationToken.ThrowIfCancellationRequested(); @@ -135,7 +140,7 @@ async Task Name(string userId) cancellationToken.ThrowIfCancellationRequested(); var role = await _roles.Value.GetRoleByIdAsync(roleId); if (role == null || role.DepartmentId != departmentId) continue; - var members = (await _roles.Value.GetAllMembersOfRoleAsync(roleId))?.ToList() ?? new List(); + var members = (await _roles.Value.GetAllMembersOfRoleAsync(roleId))?.Where(m => m?.UserId != null && activeMembers.Contains(m.UserId)).ToList() ?? new List(); if (members.Count == 0) continue; var roleRequirements = requirements.Where(r => r.PersonnelRoleId == roleId).ToList(); var memberIds = members.Select(m => m.UserId).Distinct().ToList(); @@ -186,7 +191,7 @@ async Task Name(string userId) { try { - var admins = await _departments.Value.GetAllAdminsForDepartmentAsync(departmentId); + var admins = await _departments.Value.GetActiveAdminsForDepartmentAsync(departmentId); var lines = new List { $"Certification summary for {today:yyyy-MM-dd}: {result.Expired + result.UnitsExpired} expired, {result.ExpiringNotified + result.UnitsExpiringNotified} expiring, {result.InGrace} in grace, {result.Removed} removed from roles." }; if (result.ExpiredNames.Count > 0) lines.Add("Expired: " + string.Join("; ", result.ExpiredNames.Take(25))); if (result.ExpiringNames.Count > 0) lines.Add("Expiring: " + string.Join("; ", result.ExpiringNames.Take(25))); diff --git a/Core/Resgrid.Services/CertificationService.cs b/Core/Resgrid.Services/CertificationService.cs index 17b11a1d1..5ac221ab7 100644 --- a/Core/Resgrid.Services/CertificationService.cs +++ b/Core/Resgrid.Services/CertificationService.cs @@ -842,7 +842,11 @@ public async Task GetExpiryDashboardAsync(int depa var leadDays = (await GetCertificationSettingsAsync(departmentId)).GetNotifyLeadDays(); var horizon = leadDays.Count > 0 ? leadDays.Max() : 60; - var people = (await GetCertificationsForDepartmentAsync(departmentId)).Where(r => r.IsTyped && byType.ContainsKey(r.DepartmentCertificationTypeId.Value)).ToList(); + // Deleted, disabled and hidden members stay out of the matrix, its totals and everything built on it (the + // dashboard, its CSV, the compliance report and its scheduled delivery); their records remain on file. + var activeMembers = await ActiveMemberUserIdsAsync(departmentId); + var people = (await GetCertificationsForDepartmentAsync(departmentId)) + .Where(r => r.IsTyped && byType.ContainsKey(r.DepartmentCertificationTypeId.Value) && r.UserId != null && activeMembers.Contains(r.UserId)).ToList(); // One cached department name list instead of a profile read per member; a member missing from it (a fresh // account, a stale list) still resolves through the profile. var departmentNames = (await _departments.Value.GetAllPersonnelNamesForDepartmentAsync(departmentId) ?? new List()) @@ -892,6 +896,13 @@ private static CertificationDashboardCell Cell(string subjectId, string subjectN #region Helpers + /// The department's active members (deleted, disabled and hidden excluded), case-insensitive; empty when none resolve. + private async Task> ActiveMemberUserIdsAsync(int departmentId) + { + var ids = await _departments.Value.GetActiveMemberUserIdsAsync(departmentId); + return ids == null ? new HashSet(StringComparer.OrdinalIgnoreCase) : new HashSet(ids, StringComparer.OrdinalIgnoreCase); + } + private async Task DisplayNameAsync(string userId) { try diff --git a/Core/Resgrid.Services/ChecklistAssignmentService.cs b/Core/Resgrid.Services/ChecklistAssignmentService.cs index 3d4d73c3d..4124ef178 100644 --- a/Core/Resgrid.Services/ChecklistAssignmentService.cs +++ b/Core/Resgrid.Services/ChecklistAssignmentService.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using Resgrid.Model.Checklists; +using Resgrid.Model.Helpers; using Resgrid.Model.Services; namespace Resgrid.Services @@ -55,7 +56,8 @@ public async Task> MembersAsync(int departmentId, int type, stri public async Task> ChoicesAsync(ChecklistActor actor) { var choices = new List(); - foreach (var member in (await _departments.GetAllMembersForDepartmentUnlimitedAsync(actor.DepartmentId, true)).Where(m => m.DepartmentId == actor.DepartmentId && !m.IsDeleted && m.IsDisabled != true)) + // Pickers offer active members only; hidden members can still perform what they are already assigned (MembersAsync). + foreach (var member in (await _departments.GetAllMembersForDepartmentUnlimitedAsync(actor.DepartmentId, true)).Where(m => DepartmentMemberStateHelper.IsActiveMember(m, actor.DepartmentId))) choices.Add(new ChecklistAssignmentChoice { Type = 1, Id = member.UserId, Name = member.User?.UserName ?? member.UserId }); foreach (var role in (await _roles.GetRolesForDepartmentUnlimitedAsync(actor.DepartmentId)).Where(r => r.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 2, Id = role.PersonnelRoleId.ToString(CultureInfo.InvariantCulture), Name = role.Name }); foreach (var group in (await _groups.GetAllGroupsForDepartmentUnlimitedThinAsync(actor.DepartmentId)).Where(g => g.DepartmentId == actor.DepartmentId)) choices.Add(new ChecklistAssignmentChoice { Type = 3, Id = group.DepartmentGroupId.ToString(CultureInfo.InvariantCulture), Name = group.Name }); diff --git a/Core/Resgrid.Services/ChecklistAuthorizationService.cs b/Core/Resgrid.Services/ChecklistAuthorizationService.cs index f2383e443..7138e4936 100644 --- a/Core/Resgrid.Services/ChecklistAuthorizationService.cs +++ b/Core/Resgrid.Services/ChecklistAuthorizationService.cs @@ -26,6 +26,8 @@ public async Task RequireMemberAsync(ChecklistActor actor) var member = await _departments.GetDepartmentMemberAsync(actor.UserId, actor.DepartmentId, true); if (member == null || member.IsDeleted || member.IsDisabled.GetValueOrDefault()) throw new ChecklistException(403, "Active department membership is required."); } + public async Task> ActiveMemberIdsAsync(int departmentId) + => await _departments.GetActiveMemberUserIdsAsync(departmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); private async Task AllowedAsync(ChecklistActor actor, PermissionTypes type, PermissionActions fallback, int? targetGroup = null) => (await PermissionFilterAsync(actor, type, fallback))(targetGroup); private async Task> PermissionFilterAsync(ChecklistActor actor, PermissionTypes type, PermissionActions fallback) @@ -103,7 +105,9 @@ public async Task> TargetsAsync(ChecklistActor actor, Chec case ChecklistTargetType.Department: ids = new[] { actor.DepartmentId.ToString() }; break; case ChecklistTargetType.Unit: ids = (await _units.GetUnitsForDepartmentAsync(actor.DepartmentId)).Select(u => u.UnitId.ToString()); break; case ChecklistTargetType.Group: ids = (await _groups.GetAllGroupsForDepartmentAsync(actor.DepartmentId)).Select(g => g.DepartmentGroupId.ToString()); break; - case ChecklistTargetType.Personnel: ids = (await _departments.GetAllMembersForDepartmentAsync(actor.DepartmentId)).Where(m => !m.IsDeleted && !m.IsDisabled.GetValueOrDefault()).Select(m => m.UserId); break; + // The picker offers active members from the unlimited roster; a schedule already aimed at someone who has since + // gone hidden still validates through TargetAsync and keeps its value on the edit form. + case ChecklistTargetType.Personnel: ids = await _departments.GetActiveMemberUserIdsAsync(actor.DepartmentId) ?? new HashSet(); break; case ChecklistTargetType.InventoryAsset: if (_assets == null || !await _assets.IsAvailableAsync(actor.DepartmentId)) return new List(); ids = (await _assets.ListAsync(actor)).Where(a => a.DepartmentId == actor.DepartmentId).Select(a => a.Id); break; diff --git a/Core/Resgrid.Services/ChecklistReminderService.cs b/Core/Resgrid.Services/ChecklistReminderService.cs index fd8371893..c5513c85e 100644 --- a/Core/Resgrid.Services/ChecklistReminderService.cs +++ b/Core/Resgrid.Services/ChecklistReminderService.cs @@ -9,6 +9,7 @@ using Resgrid.Localization; using Resgrid.Model; using Resgrid.Model.Checklists; +using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; @@ -132,8 +133,10 @@ private async Task> RecipientsAsync(ChecklistOccurrence row, Che } var department = await _departments.GetDepartmentByIdAsync(row.DepartmentId, true); if (department == null) return new HashSet(); + // Reminders and escalations reach active members only: removed, disabled and hidden members (admins included) are + // never recipients, whatever routed them here. var members = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(row.DepartmentId, true)) - .Where(m => m.DepartmentId == row.DepartmentId && !m.IsDeleted && m.IsDisabled != true && !string.IsNullOrWhiteSpace(m.UserId)).ToList(); + .Where(m => DepartmentMemberStateHelper.IsActiveMember(m, row.DepartmentId)).ToList(); var allowed = members.Select(m => m.UserId).ToHashSet(StringComparer.Ordinal); var admins = members.Where(m => m.IsAdmin == true || m.UserId == department.ManagingUserId).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal); var users = new HashSet(StringComparer.Ordinal); diff --git a/Core/Resgrid.Services/ChecklistReporting.cs b/Core/Resgrid.Services/ChecklistReporting.cs index d869e6753..d91c392a5 100644 --- a/Core/Resgrid.Services/ChecklistReporting.cs +++ b/Core/Resgrid.Services/ChecklistReporting.cs @@ -21,7 +21,10 @@ public async Task GetComplianceSummaryAsync(Checklis await RequireWriteAsync(actor); ReportRange(query); var asOf = _clock.GetUtcNow().UtcDateTime; var summary = new ChecklistComplianceSummary { FromUtc = query.FromUtc, UntilUtc = query.UntilUtc, AsOfUtc = asOf, TargetType = query.TargetType, TargetId = query.TargetId }; - summary.Entries = await ReportEntriesAsync(actor, query, asOf, summary.UnavailableSources); + // Personnel checks of deleted, disabled or hidden members are not counted or listed (the compliance report, its CSV and + // scheduled delivery, and the readiness dashboard all read this summary). The entity history keeps them on file. + var activePersonnel = await _authorization.ActiveMemberIdsAsync(actor.DepartmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + summary.Entries = await ReportEntriesAsync(actor, query, asOf, summary.UnavailableSources, activePersonnel: activePersonnel); summary.Groups = summary.Entries.GroupBy(e => (e.Target.Type, e.Target.Id)).Select(g => new ChecklistComplianceGroup { Target = g.Last().Target, Expected = g.Count(e => e.Expected), Completed = g.Count(e => e.Expected && e.Completed), @@ -40,7 +43,7 @@ public async Task> GetEntityChecklistHistoryAsync(Che return await ReportEntriesAsync(actor, query, _clock.GetUtcNow().UtcDateTime, new List()); } private async Task> ReportEntriesAsync(ChecklistActor actor, ChecklistReportQuery query, DateTime asOf, List unavailable, - HashSet<(ChecklistTargetType, string)> targets = null) + HashSet<(ChecklistTargetType, string)> targets = null, HashSet activePersonnel = null) { var result = new List(); var versions = new Dictionary(); @@ -53,7 +56,8 @@ private async Task> ReportEntriesAsync(ChecklistActor { var type = (ChecklistTargetType)occurrence.TargetType; if (occurrence.DepartmentId != actor.DepartmentId || query.TargetType.HasValue && query.TargetType != type || query.TargetId != null && query.TargetId != occurrence.TargetId - || targets != null && !targets.Contains((type, occurrence.TargetId))) continue; + || targets != null && !targets.Contains((type, occurrence.TargetId)) + || activePersonnel != null && type == ChecklistTargetType.Personnel && (occurrence.TargetId == null || !activePersonnel.Contains(occurrence.TargetId))) continue; var completion = occurrence.CompletionId == null ? null : await _store.GetAsync(actor.DepartmentId, occurrence.CompletionId); ChecklistSchedule schedule = null; if (occurrence.ScheduleId != null && !schedules.TryGetValue(occurrence.ScheduleId, out schedule)) diff --git a/Core/Resgrid.Services/ChecklistTimedReminders.cs b/Core/Resgrid.Services/ChecklistTimedReminders.cs index 148cc01f1..9521f2b03 100644 --- a/Core/Resgrid.Services/ChecklistTimedReminders.cs +++ b/Core/Resgrid.Services/ChecklistTimedReminders.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Checklists; +using Resgrid.Model.Helpers; namespace Resgrid.Services { @@ -33,6 +34,14 @@ private async Task TimedRecipientAsync(ChecklistOccurrence row, ChecklistR private async Task> ShiftCrewAsync(Unit unit) { var crew = (await _units.GetActiveRolesForUnitAsync(unit.UnitId)).Where(r => r.DepartmentId == unit.DepartmentId && r.UnitId == unit.UnitId && !string.IsNullOrWhiteSpace(r.UserId)).Select(r => r.UserId).ToHashSet(StringComparer.Ordinal); + // A unit seat still held by a removed, disabled or hidden member counts as empty, the same way RecipientsAsync treats + // it: otherwise the station fallback never runs and the shift gets no reminder at all. + if (crew.Count > 0) + { + var active = (await _departments.GetAllMembersForDepartmentUnlimitedAsync(unit.DepartmentId, true)) + .Where(m => DepartmentMemberStateHelper.IsActiveMember(m, unit.DepartmentId)).Select(m => m.UserId).ToHashSet(StringComparer.Ordinal); + crew.IntersectWith(active); + } if (crew.Count == 0 && unit.StationGroupId.HasValue && (await _groups.GetGroupByIdAsync(unit.StationGroupId.Value, true))?.DepartmentId == unit.DepartmentId) crew.UnionWith((await _groups.GetAllMembersForGroupAsync(unit.StationGroupId.Value)).Where(m => m.DepartmentId == unit.DepartmentId).Select(m => m.UserId)); return crew; diff --git a/Core/Resgrid.Services/ChecklistsScheduling.cs b/Core/Resgrid.Services/ChecklistsScheduling.cs index 39e34bfad..df6cfb4bd 100644 --- a/Core/Resgrid.Services/ChecklistsScheduling.cs +++ b/Core/Resgrid.Services/ChecklistsScheduling.cs @@ -176,6 +176,22 @@ private async Task WorkerAuditAsync(ChecklistRow row, AuditLogTypes type, DateTi if (result?.Success != true || result.IsProtected && !ProtectedDataEnvelope.HasEnvelopePrefix(audit.Data)) throw new InvalidOperationException("Checklist scheduling audit protection is unavailable."); await _audit.UpdateAsync(audit, ct); } + /// + /// A schedule addressed to one person (a Personnel target or a User assignment) is held suspended while that person is + /// removed or disabled: no occurrences, no missed marks, no ChecklistMissed events and no admin escalations for a check + /// nobody can perform. The suspended branch of the sweep resumes it without back-filled misses if the member returns. + /// + private async Task PersonSubjectsCurrentAsync(ChecklistSchedule schedule) + { + if (_assignments == null) return true; + try + { + if (schedule.TargetType == (int)ChecklistTargetType.Personnel) await _assignments.ValidateAsync(schedule.DepartmentId, (int)ChecklistAssignmentType.User, schedule.TargetId); + if (schedule.AssignmentType == (int)ChecklistAssignmentType.User) await _assignments.ValidateAsync(schedule.DepartmentId, (int)ChecklistAssignmentType.User, schedule.AssignmentId); + return true; + } + catch (ChecklistException) { return false; } + } public async Task SweepSchedulesAsync(DateTime utcNow, CancellationToken ct = default) { var result = new ChecklistScheduleSweepResult(); var now = ChecklistRecurrence.Utc(utcNow); var afterDepartment = 0; @@ -196,7 +212,8 @@ public async Task SweepSchedulesAsync(DateTime utc await _uow.CreateOrGetConnectionAsync(ct); await _store.LockDepartmentAsync(department, ct); var schedule = await _store.GetAsync(department, candidate.Id, ct); if (!schedule.IsActive) { _uow.CommitChanges(); continue; } - var enabled = await _access.CanUseChecklistsAsync(department) && (schedule.TargetType != (int)ChecklistTargetType.InventoryAsset || _assets != null && await _assets.IsAvailableAsync(department)); + var enabled = await _access.CanUseChecklistsAsync(department) && (schedule.TargetType != (int)ChecklistTargetType.InventoryAsset || _assets != null && await _assets.IsAvailableAsync(department)) + && await PersonSubjectsCurrentAsync(schedule); if (!enabled) { schedule.IsSuspended = true; schedule.UpdatedOn = now; await WorkerWriteAsync(schedule, false, ct); _uow.CommitChanges(); continue; diff --git a/Core/Resgrid.Services/CommunicationService.cs b/Core/Resgrid.Services/CommunicationService.cs index cb8434874..28b5b7f69 100644 --- a/Core/Resgrid.Services/CommunicationService.cs +++ b/Core/Resgrid.Services/CommunicationService.cs @@ -653,7 +653,12 @@ public async Task SendCancelUnitCallAsync(Call call, CallDispatchUnit disp return true; } - public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false) + public Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title = "Notification", UserProfile profile = null, bool sendToICApp = false) + { + return SendNotificationAsync(userId, departmentId, message, departmentNumber, department, title, profile, sendToICApp, null); + } + + public async Task SendNotificationAsync(string userId, int departmentId, string message, string departmentNumber, Department department, string title, UserProfile profile, bool sendToICApp, string eventCode) { if (Config.SystemBehaviorConfig.DoNotBroadcast && !Config.SystemBehaviorConfig.BypassDoNotBroadcastDepartments.Contains(departmentId)) return false; @@ -699,6 +704,8 @@ public async Task SendNotificationAsync(string userId, int departmentId, s spm.SubTitle = $"{title} {message}"; spm.DepartmentCode = department?.Code; spm.DepartmentId = departmentId; + if (!string.IsNullOrWhiteSpace(eventCode)) + spm.Id = eventCode; try { diff --git a/Core/Resgrid.Services/CommunicationTestService.cs b/Core/Resgrid.Services/CommunicationTestService.cs index 3eaf0a555..b1f43c856 100644 --- a/Core/Resgrid.Services/CommunicationTestService.cs +++ b/Core/Resgrid.Services/CommunicationTestService.cs @@ -8,6 +8,7 @@ using Resgrid.Framework; using CommunicationTestMessages = Resgrid.Localization.Areas.User.CommunicationTest.CommunicationTestMessageCatalog; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Messages; using Resgrid.Model.Providers; using Resgrid.Model.Queue; @@ -415,7 +416,10 @@ public async Task BuildRunResultsAsync(Guid communicationT var communicationTestId = run.CommunicationTestId; var departmentId = run.DepartmentId; - var members = await _departmentsService.GetAllMembersForDepartmentAsync(departmentId); + // Disabled and hidden members are not tested: nobody is paged on their account and the report does not count them + // (the member list already leaves removed members out). + var members = (await _departmentsService.GetAllMembersForDepartmentAsync(departmentId) ?? new List()) + .Where(m => DepartmentMemberStateHelper.IsActiveMember(m, departmentId)).ToList(); var profiles = await _userProfileService.GetAllProfilesForDepartmentAsync(departmentId); // A targeted test only covers the audience snapshotted when the run started, so editing diff --git a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs index 59a879b9e..9877d002d 100644 --- a/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs +++ b/Core/Resgrid.Services/CostRecovery/CalOesMarsService.WorkItems.cs @@ -79,7 +79,7 @@ public async Task IsRosteredForWorkItemAsync(string workItemId, int depart var item = await _workItems.GetByIdForDepartmentAsync(workItemId, departmentId); if (item == null || item.IsDeleted || string.IsNullOrWhiteSpace(item.DeploymentId) || string.IsNullOrWhiteSpace(userId)) return false; if (item.RecordType == (int)CalOesMarsRecordTypes.GeneratedInvoice) return false; - return await _deploymentService.IsRosteredAsync(item.DeploymentId, departmentId, userId); + return await _deploymentService.CanFieldMemberSeeAsync(item.DeploymentId, departmentId, userId); } #endregion @@ -894,7 +894,7 @@ private async Task NotifyManagersAsync(int departmentId, string message) var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); // Permission 79 defaults to department administrators; the digest goes to them (a narrower role assignment still includes admins). - foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId)) await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Cal OES MARS"); } catch (Exception ex) { Logging.LogException(ex, $"Cal OES MARS digest could not be sent for department {departmentId}."); } diff --git a/Core/Resgrid.Services/DeleteService.cs b/Core/Resgrid.Services/DeleteService.cs index 1f6032f78..1d045df00 100644 --- a/Core/Resgrid.Services/DeleteService.cs +++ b/Core/Resgrid.Services/DeleteService.cs @@ -7,6 +7,7 @@ using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; +using Resgrid.Model.Helpers; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -44,6 +45,9 @@ public class DeleteService : IDeleteService private readonly IDepartmentMemberEmergencyContactService _emergencyContactService; private readonly IInventoryStore _inventoryStore; private readonly Resgrid.Model.Repositories.Queries.IUnitOfWork _inventoryUnitOfWork; + private readonly IDeploymentService _deploymentService; + private readonly IDeploymentPersonnelRepository _deploymentPersonnel; + private readonly IWorkforceService _workforceService; public DeleteService(IAuthorizationService authorizationService, IDepartmentsService departmentsService, ICallsService callsService, IActionLogsService actionLogsService, IUsersService usersService, @@ -56,8 +60,12 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer IScheduledTasksService scheduledTasksService, IUserSessionService userSessionService, IDepartmentMemberSensitiveDataService memberSensitiveDataService, IDepartmentMemberEmergencyContactService emergencyContactService, - IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null) + IInventoryStore inventoryStore = null, Resgrid.Model.Repositories.Queries.IUnitOfWork inventoryUnitOfWork = null, + IDeploymentService deploymentService = null, IDeploymentPersonnelRepository deploymentPersonnel = null, IWorkforceService workforceService = null) { + _deploymentService = deploymentService; + _deploymentPersonnel = deploymentPersonnel; + _workforceService = workforceService; _authorizationService = authorizationService; _departmentsService = departmentsService; _callsService = callsService; @@ -106,7 +114,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer { // This is the user's only department: deactivate the whole account (same flow as the // self-service "Delete My Account") so we don't strand a login with no departments. - return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, null, null, cancellationToken); + return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, null, null, authorizingUserId, cancellationToken); } // The user belongs to other departments: revoke this department's access and @@ -126,6 +134,7 @@ public DeleteService(IAuthorizationService authorizationService, IDepartmentsSer await _departmentGroupsService.DeleteUserFromGroupsAsync(userId, departmentId, cancellationToken); await _distributionListsService.RemoveUserFromAllListsInDepartmentAsync(userId, departmentId, cancellationToken); await _scheduledTasksService.DeleteAllTasksForUserInDepartmentAsync(userId, departmentId, cancellationToken); + await ReleaseOperationalAssignmentsAsync(userId, departmentId, revokingUserId ?? userId, cancellationToken); // Department-scoped personal data goes with the membership (ADP plan 5.1). Before the // relocation these values lived on the global profile and a revoked member simply stopped @@ -155,15 +164,43 @@ await _userSessionService.RevokeDepartmentSessionsAsync(userId, departmentId, return member != null && member.IsDeleted; } + /// + /// Ends what would keep a removed member working in the department after the membership is gone: seats on open + /// deployments (a time report prefills a billable line for every active seat) and open workforce employment (the + /// current MARS salary-survey and pay-data counts). Seats are released through DeploymentService, which audits the + /// roster change and raises the roster workflow event; a closed deployment's roster is history and is left alone. + /// Employment is end-dated on the department's local removal day, never deleted, so past-period statutory data + /// (CRD pay data, MARS F-42 rosters, time reports) keeps the member. Runs before the membership is soft-deleted, so a + /// failure leaves the removal retryable. + /// + private async Task ReleaseOperationalAssignmentsAsync(string userId, int departmentId, string actingUserId, CancellationToken cancellationToken) + { + if (_deploymentPersonnel != null && _deploymentService != null) + { + foreach (var seat in (await _deploymentPersonnel.GetForUserAsync(departmentId, userId) ?? Enumerable.Empty()).Where(p => p.IsActive).ToList()) + { + try { await _deploymentService.RemovePersonnelAsync(seat.DeploymentPersonnelId, departmentId, actingUserId, null, null, cancellationToken); } + catch (InvalidOperationException ex) when (ex.Message is "deployments_closed" or "deployments_not_found") { } + } + } + + if (_workforceService != null) + { + var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); + var removalDay = department == null ? DateTime.UtcNow.Date : DateTime.UtcNow.TimeConverter(department).Date; + await _workforceService.EndEmploymentsForMemberAsync(departmentId, userId, removalDay, actingUserId, cancellationToken); + } + } + public async Task DeleteUserAccountAsync(int departmentId, string authorizingUserId, string userIdToDelete, string ipAddress, string userAgent, CancellationToken cancellationToken = default(CancellationToken)) { if (authorizingUserId != userIdToDelete) return DeleteUserResults.UnAuthroized; - return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, ipAddress, userAgent, cancellationToken); + return await DeactivateUserAccountCoreAsync(userIdToDelete, departmentId, ipAddress, userAgent, authorizingUserId, cancellationToken); } - private async Task DeactivateUserAccountCoreAsync(string userIdToDelete, int departmentId, string ipAddress, string userAgent, CancellationToken cancellationToken) + private async Task DeactivateUserAccountCoreAsync(string userIdToDelete, int departmentId, string ipAddress, string userAgent, string actingUserId, CancellationToken cancellationToken) { var departments = await _departmentsService.GetAllDepartmentsForUserAsync(userIdToDelete); @@ -194,6 +231,7 @@ private async Task DeactivateUserAccountCoreAsync(string user // legacy Addresses rows. await _memberSensitiveDataService.DeleteForMemberAsync(dm.DepartmentId, userIdToDelete, cancellationToken); await _emergencyContactService.DeleteAllForMemberAsync(dm.DepartmentId, userIdToDelete, cancellationToken); + await ReleaseOperationalAssignmentsAsync(userIdToDelete, dm.DepartmentId, actingUserId ?? userIdToDelete, cancellationToken); } } diff --git a/Core/Resgrid.Services/DepartmentsService.cs b/Core/Resgrid.Services/DepartmentsService.cs index c15835f70..3625da201 100644 --- a/Core/Resgrid.Services/DepartmentsService.cs +++ b/Core/Resgrid.Services/DepartmentsService.cs @@ -7,6 +7,7 @@ using Resgrid.Model; using Resgrid.Model.Custom; using Resgrid.Model.Events; +using Resgrid.Model.Helpers; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Search; @@ -265,14 +266,31 @@ public async Task GetUserIdForDeletedUserInDepartmentAsync(int departmen return null; } - public async Task ReactivateUserAsync(int departmentId, string userId, CancellationToken cancellationToken = default(CancellationToken)) + public async Task ReactivateUserAsync(int departmentId, string userId, string reactivatingUserId, CancellationToken cancellationToken = default(CancellationToken)) { var dm = await _departmentMembersRepository.GetDepartmentMemberByDepartmentIdAndUserIdAsync(departmentId, userId); + if (dm == null) + return null; + + var before = dm.CloneJsonToString(); dm.IsDeleted = false; dm.IsHidden = false; dm.IsDisabled = false; + // Removal clears admin standing, but a row removed before it did may still carry it: a returning member comes + // back as a regular member either way, and an admin grants admin again on purpose. + dm.IsAdmin = false; var saved = await _departmentMembersRepository.SaveOrUpdateAsync(dm, cancellationToken); + _eventAggregator.SendMessage(new AuditEvent + { + DepartmentId = departmentId, + UserId = reactivatingUserId, + Type = AuditLogTypes.UserReactivated, + Before = before, + After = saved.CloneJsonToString(), + Successful = true, + ServerName = Environment.MachineName + }); // The member row just changed its deleted/hidden/disabled flags and the department's user // list gained a name back -- both are cached reads that would otherwise serve the old answer. @@ -344,6 +362,9 @@ private void SendMembershipVisibilityRefresh(int departmentId) if (member != null) { member.IsDeleted = true; + // A removed member is not an admin of anything: clear the standing with the removal (the UserRemoved audit + // records it before and after) so a later reactivation cannot bring it back silently. + member.IsAdmin = false; await _departmentMembersRepository.SaveOrUpdateAsync(member, cancellationToken); if (_searchProjections != null) await _searchProjections.Value.RemoveAsync(departmentId, SearchEntityTypes.Personnel, userIdToDelete, cancellationToken); @@ -648,10 +669,34 @@ async Task> getDepartmentPersonnelNames() return await getDepartmentPersonnelNames(); } + public async Task> GetSelectablePersonnelNamesAsync(int departmentId) + { + var active = await GetActiveMemberUserIdsAsync(departmentId); + return (await GetAllPersonnelNamesForDepartmentAsync(departmentId) ?? new List()) + .Where(n => n != null && active.Contains(n.UserId)) + .GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).Select(g => g.First()) + .OrderBy(n => n.LastName, StringComparer.CurrentCultureIgnoreCase).ThenBy(n => n.FirstName, StringComparer.CurrentCultureIgnoreCase) + .ToList(); + } + public async Task> GetAllAdminsForDepartmentAsync(int departmentId) + { + // department.Members carries every membership row, removed ones included, and removal (DeleteUserAsync) leaves + // IsAdmin set: a removed or disabled member is not an admin of anything. + var department = await GetDepartmentByIdAsync(departmentId); + return (department?.Members ?? Enumerable.Empty()) + .Where(x => (x.IsAdmin.GetValueOrDefault() || x.UserId == department.ManagingUserId) && DepartmentMemberStateHelper.IsCurrentMember(x, departmentId)) + .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase) + .Select(g => new IdentityUser() { UserId = g.First().UserId }).ToList(); + } + + public async Task> GetActiveAdminsForDepartmentAsync(int departmentId) { var department = await GetDepartmentByIdAsync(departmentId); - return department.Members.Where(x => x.IsAdmin.GetValueOrDefault() || x.UserId == department.ManagingUserId).Select(y => new IdentityUser() { UserId = y.UserId }).ToList(); + return (department?.Members ?? Enumerable.Empty()) + .Where(x => (x.IsAdmin.GetValueOrDefault() || x.UserId == department.ManagingUserId) && DepartmentMemberStateHelper.IsActiveMember(x, departmentId)) + .GroupBy(x => x.UserId, StringComparer.OrdinalIgnoreCase) + .Select(g => new IdentityUser() { UserId = g.First().UserId }).ToList(); } public async Task> GetAllMembersForDepartmentAsync(int departmentId) @@ -875,6 +920,22 @@ public async Task> GetMemberUserIdsInDepartmentAsync(int departm return result; } + public async Task> GetActiveMemberUserIdsAsync(int departmentId) + { + var result = new HashSet(StringComparer.OrdinalIgnoreCase); + var members = await _departmentMembersRepository.GetAllDepartmentMembersUnlimitedAsync(departmentId); + if (members == null) + return result; + + foreach (var member in members) + { + if (DepartmentMemberStateHelper.IsActiveMember(member, departmentId)) + result.Add(member.UserId); + } + + return result; + } + public async Task> GetAllDepartmentNamesAsync() { return (from d in await _departmentRepository.GetAllAsync() diff --git a/Core/Resgrid.Services/InventoryAlertNotifications.cs b/Core/Resgrid.Services/InventoryAlertNotifications.cs index 8a053d6a9..9850aeece 100644 --- a/Core/Resgrid.Services/InventoryAlertNotifications.cs +++ b/Core/Resgrid.Services/InventoryAlertNotifications.cs @@ -8,6 +8,7 @@ using Resgrid.Framework; using Resgrid.Localization; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Inventories; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -36,7 +37,8 @@ public async Task ProcessDepartmentAsync(int departmentId, CancellationToke { ct.ThrowIfCancellationRequested(); var members = await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true); - var users = members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true && !string.IsNullOrWhiteSpace(m.UserId)) + // Alert pushes reach active members only; removed, disabled and hidden members are never claimed for. + var users = members.Where(m => DepartmentMemberStateHelper.IsActiveMember(m, departmentId)) .Select(m => m.UserId).Distinct(StringComparer.Ordinal).OrderBy(id => id, StringComparer.Ordinal).ToList(); var handedOff = 0; var failed = false; foreach (var user in users) diff --git a/Core/Resgrid.Services/InventoryAlerts.cs b/Core/Resgrid.Services/InventoryAlerts.cs index 84219edc9..5a5fd4a17 100644 --- a/Core/Resgrid.Services/InventoryAlerts.cs +++ b/Core/Resgrid.Services/InventoryAlerts.cs @@ -63,7 +63,13 @@ private async Task SetAlertAsync(int departmentId, InventoryAlertType type, stri row.AlertType = (int)type; row.DedupKey = key; row.ItemId = itemId; row.LocationId = locationId; row.LotId = lotId; row.AssetId = assetId; row.IssuanceId = issuanceId; row.OpenedOn = Now; row.DueOn = due; row.Quantity = quantity; await _store.InsertAsync(row); if (openAlerts != null) openAlerts[key] = row; - var trigger = type == InventoryAlertType.LowStock ? WorkflowTriggerEventType.InventoryLowStock : type == InventoryAlertType.OverdueReturn ? WorkflowTriggerEventType.InventoryReturnOverdue : WorkflowTriggerEventType.InventoryExpiring; + var trigger = type switch + { + InventoryAlertType.LowStock => WorkflowTriggerEventType.InventoryLowStock, + InventoryAlertType.OverdueReturn => WorkflowTriggerEventType.InventoryReturnOverdue, + InventoryAlertType.DepartedHolder => WorkflowTriggerEventType.InventoryDepartedHolder, + _ => WorkflowTriggerEventType.InventoryExpiring + }; var entry = await _outbox.EnqueueAsync(departmentId, "Inventory", new DomainEventEnvelope { EventName = trigger.ToString(), Trigger = trigger, SchemaVersion = 1, AggregateType = "InventoryAlert", AggregateId = row.Id, OccurredOn = Now, Payload = new { InventoryEvent = true, AlertId = row.Id, row.AlertType, row.ItemId, row.LocationId, row.LotId, row.AssetId, row.IssuanceId, row.Quantity, row.DueOn, OccurredOn = Now } }); @@ -158,7 +164,9 @@ private async Task EvaluateDatedAlertsAsync(int departmentId, List events) var desired = new HashSet(); var items = (await AllAsync(departmentId)).Where(i => !i.IsDeleted && i.IsActive).Select(i => i.Id).ToHashSet(); var lots = (await AllAsync(departmentId)).ToDictionary(l => l.Id); - async Task Add(InventoryAlertType type, string item, string location, string lot, string asset, string issuance, DateTime due, decimal quantity) + var stocks = await AllAsync(departmentId); + var assets = await AllAsync(departmentId); + async Task Add(InventoryAlertType type, string item, string location, string lot, string asset, string issuance, DateTime? due, decimal quantity) { if (!items.Contains(item)) return; if (location != null) @@ -170,10 +178,10 @@ async Task Add(InventoryAlertType type, string item, string location, string lot desired.Add(Fingerprint(new { type, itemId = item, locationId = location, lotId = lot, assetId = asset, issuanceId = issuance })); await SetAlertAsync(departmentId, type, item, location, lot, asset, issuance, true, due, quantity, events); } - foreach (var stock in await AllAsync(departmentId)) + foreach (var stock in stocks) if (!stock.IsDeleted && stock.Quantity > 0 && stock.LotId != null && lots.TryGetValue(stock.LotId, out var lot) && !lot.IsDeleted && lot.ExpiresOn <= Now.AddDays(30)) await Add(lot.ExpiresOn <= Now ? InventoryAlertType.Expired : InventoryAlertType.ExpiringSoon, stock.ItemId, stock.LocationId, stock.LotId, null, null, lot.ExpiresOn.Value, stock.Quantity); - foreach (var asset in await AllAsync(departmentId)) + foreach (var asset in assets) { var expiry = asset.ExpiresOn; if (asset.LotId != null && lots.TryGetValue(asset.LotId, out var lot) && lot.ExpiresOn.HasValue && (!expiry.HasValue || lot.ExpiresOn < expiry)) expiry = lot.ExpiresOn; @@ -183,8 +191,68 @@ async Task Add(InventoryAlertType type, string item, string location, string lot foreach (var issuance in await AllAsync(departmentId)) if (!issuance.IsDeleted && issuance.Status is 0 or 2 && issuance.Quantity > issuance.ReturnedQuantity && issuance.ExpectedReturnOn < Now) await Add(InventoryAlertType.OverdueReturn, issuance.ItemId, issuance.LocationId, issuance.LotId, issuance.AssetId, issuance.Id, issuance.ExpectedReturnOn.Value, issuance.Quantity - issuance.ReturnedQuantity); + + // Equipment still held by a member who was removed, disabled or hidden: one recovery alert per item per holding + // location (bulk stock plus serialized assets), with nothing due. It resolves once the gear is moved off that + // location or the member is active again. The expiry and overdue alerts above keep running for the same gear. + var departed = new DepartedHolders(this, departmentId); + var held = new Dictionary<(string Item, string Location), decimal>(); + foreach (var stock in stocks) + if (!stock.IsDeleted && stock.Quantity > 0 && await departed.HolderAsync(stock.LocationId) != null) + held[(stock.ItemId, stock.LocationId)] = held.GetValueOrDefault((stock.ItemId, stock.LocationId)) + stock.Quantity; + foreach (var asset in assets) + if (IsHeldAsset(asset) && await departed.HolderAsync(asset.CurrentLocationId) != null) + held[(asset.ItemId, asset.CurrentLocationId)] = held.GetValueOrDefault((asset.ItemId, asset.CurrentLocationId)) + 1; + foreach (var holding in held) + await Add(InventoryAlertType.DepartedHolder, holding.Key.Item, holding.Key.Location, null, null, null, null, holding.Value); + foreach (var alert in await AllOpenAlertsAsync(departmentId)) + { + // Membership could not be read this pass: leave departed-holder alerts as they are rather than resolve and reopen them. + if (alert.AlertType == (int)InventoryAlertType.DepartedHolder && departed.Unknown) continue; if (alert.Status == 0 && (!items.Contains(alert.ItemId) || alert.AlertType != 0 && !desired.Contains(alert.DedupKey))) { alert.Status = 1; alert.ResolvedOn = Now; await UpdateAlertMetadataAsync(alert); } + } + } + private static bool IsHeldAsset(InventoryAsset asset) => !asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3 && asset.CurrentLocationId != null; + /// + /// Resolves, per location, the member who holds it (its effective holder through containers and parents) when that + /// member is no longer active. The active-member set is read once and only when a personnel holder is met, so a + /// department without personnel locations never reads membership. A null set leaves the answer unknown: no holder + /// counts as departed and is raised. + /// + private sealed class DepartedHolders + { + private readonly InventoryModernizationService _service; private readonly int _departmentId; + private readonly Dictionary _holders = new(StringComparer.Ordinal); + private HashSet _active; private bool _loaded; + public bool Unknown { get; private set; } + public DepartedHolders(InventoryModernizationService service, int departmentId) { _service = service; _departmentId = departmentId; } + public async Task HolderAsync(string locationId) + { + if (locationId == null) return null; + if (_holders.TryGetValue(locationId, out var known)) return known; + string departed = null; + var location = await _service._store.GetAsync(_departmentId, locationId); + if (location != null && !location.IsDeleted) + { + InventoryLocation holder = null; + try { holder = await _service.EffectiveLocationAsync(_departmentId, location); } + catch (InventoryException ex) when (ex.Code is "LocationUnavailable" or "InvalidLocationHierarchy") { } + if (holder != null && !holder.IsDeleted && !string.IsNullOrWhiteSpace(holder.UserId)) + { + if (!_loaded) { _active = await _service._auth.ActiveMemberIdsAsync(_departmentId); _loaded = true; Unknown = _active == null; } + if (_active != null && !_active.Contains(holder.UserId)) departed = holder.UserId; + } + } + return _holders[locationId] = departed; + } + } + /// How much of an item a departed member still holds at one location; zero when the holder is active or unknown. + private async Task DepartedHoldingAsync(int departmentId, string itemId, string locationId) + { + if (itemId == null || locationId == null || await new DepartedHolders(this, departmentId).HolderAsync(locationId) == null) return 0; + var quantity = (await _store.RelatedAsync(departmentId, "ItemId", itemId)).Where(s => !s.IsDeleted && s.LocationId == locationId && s.Quantity > 0).Sum(s => s.Quantity); + return quantity + (await _store.RelatedAsync(departmentId, "ItemId", itemId)).Count(a => IsHeldAsset(a) && a.CurrentLocationId == locationId); } public async Task CanReceiveAlertAsync(int departmentId, string userId, string alertId) { @@ -211,6 +279,7 @@ public async Task CanReceiveAlertAsync(int departmentId, string userId, st else if (!(await _store.RelatedAsync(departmentId, "ItemId", alert.ItemId)).Any(s => !s.IsDeleted && s.LotId == alert.LotId && s.LocationId == alert.LocationId && s.Quantity > 0)) return false; if (!expiry.HasValue || (alert.AlertType == 2 ? expiry > Now : expiry <= Now || expiry > Now.AddDays(30))) return false; } + else if (alert.AlertType == (int)InventoryAlertType.DepartedHolder && await DepartedHoldingAsync(departmentId, alert.ItemId, alert.LocationId) <= 0) return false; try { await RequireAlertAccessAsync(new InventoryActor { DepartmentId = departmentId, UserId = userId }, alert); return true; } catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { return false; } } diff --git a/Core/Resgrid.Services/InventoryAuthorizationService.cs b/Core/Resgrid.Services/InventoryAuthorizationService.cs index 9985914df..269868d10 100644 --- a/Core/Resgrid.Services/InventoryAuthorizationService.cs +++ b/Core/Resgrid.Services/InventoryAuthorizationService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Inventories; @@ -19,6 +20,8 @@ public InventoryAuthorizationService(IDepartmentsService departments, IDepartmen IAuthorizationService resources, IPermissionsService permissions, IPersonnelRolesService roles, IDepartmentSettingsService settings) { _departments = departments; _groups = groups; _units = units; _resources = resources; _permissions = permissions; _roles = roles; _settings = settings; } public async Task IsEnabledAsync(int departmentId) => departmentId > 0 && (await _settings.GetDepartmentModuleSettingsAsync(departmentId, true))?.InventoryDisabled != true; + public async Task> ActiveMemberIdsAsync(int departmentId) + => await _departments.GetActiveMemberUserIdsAsync(departmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); public async Task RequireAsync(InventoryActor actor, bool write = false, PermissionTypes? permission = null, int? groupId = null) { if (actor == null || actor.DepartmentId <= 0 || string.IsNullOrWhiteSpace(actor.UserId)) throw new InventoryException(403, "MembershipRequired"); diff --git a/Core/Resgrid.Services/InventoryCounts.cs b/Core/Resgrid.Services/InventoryCounts.cs index b4928a7ca..098507e0c 100644 --- a/Core/Resgrid.Services/InventoryCounts.cs +++ b/Core/Resgrid.Services/InventoryCounts.cs @@ -18,6 +18,59 @@ private async Task RequireCountAccessAsync(InventoryActor actor, InventoryCount foreach (var line in await _store.RelatedAsync(actor.DepartmentId, "CountId", count.Id)) await LocationAsync(actor, line.LocationId, false, PermissionTypes.AdjustInventory, historical: historical); } + // A location-scoped count carries this marker: its fence covers only the positions it counts (stock rows at its locations, + // assets at or listed on it, the items, lots and locations those touch), so work elsewhere in the department does not void + // a unit's count. Fingerprints without the marker (department-wide counts, and counts started before the scoped fence) + // keep the department fence. + private const string ScopedCountFingerprint = "L1:"; + private async Task ScopedCountFingerprintAsync(int departmentId, ICollection locations, IEnumerable lines) + { + var listed = lines.ToList(); + var allLocations = (await AllAsync(departmentId)).ToDictionary(x => x.Id); + var allAssets = (await AllAsync(departmentId)).ToDictionary(x => x.Id); + // The holder chain above the counted locations (parent locations, the kit assets that are containers and where those + // sit) is fenced too: losing the bag a counted container lives in, or re-homing a compartment, voids the count. + var fencedLocations = new HashSet(locations, StringComparer.Ordinal); + var fencedAssets = listed.Where(l => l.AssetId != null).Select(l => l.AssetId).ToHashSet(); + var pending = new Queue(locations); + while (pending.Count > 0 && fencedLocations.Count < 512) + { + if (!allLocations.TryGetValue(pending.Dequeue(), out var location)) continue; + if (location.ParentLocationId != null && fencedLocations.Add(location.ParentLocationId)) pending.Enqueue(location.ParentLocationId); + if (location.ContainerAssetId != null && fencedAssets.Add(location.ContainerAssetId) && allAssets.TryGetValue(location.ContainerAssetId, out var holder) && holder.CurrentLocationId != null && fencedLocations.Add(holder.CurrentLocationId)) + pending.Enqueue(holder.CurrentLocationId); + } + var stocks = (await AllAsync(departmentId)).Where(x => x.LocationId != null && locations.Contains(x.LocationId)).OrderBy(x => x.Id).ToList(); + var assets = allAssets.Values.Where(x => x.CurrentLocationId != null && locations.Contains(x.CurrentLocationId) || fencedAssets.Contains(x.Id)).OrderBy(x => x.Id).ToList(); + var items = listed.Select(l => l.ItemId).Concat(stocks.Select(s => s.ItemId)).Concat(assets.Select(a => a.ItemId)).ToHashSet(); + var lots = listed.Select(l => l.LotId).Concat(stocks.Select(s => s.LotId)).Concat(assets.Select(a => a.LotId)).Where(x => x != null).ToHashSet(); + return ScopedCountFingerprint + Fingerprint(new { + Items = (await AllAsync(departmentId)).Where(x => items.Contains(x.Id)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }), + Stocks = stocks.Select(x => new { x.Id, x.Revision, x.Quantity }), + Assets = assets.Select(x => new { x.Id, x.Revision }), + Lots = (await AllAsync(departmentId)).Where(x => lots.Contains(x.Id)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }), + Locations = allLocations.Values.Where(x => fencedLocations.Contains(x.Id)).OrderBy(x => x.Id).Select(x => new { x.Id, x.Revision }) + }); + } + /// The root location plus, when asked, every compartment (child location) and kit container inside it, recursively. + private async Task> CountLocationsAsync(int departmentId, string rootId, bool includeChildren) + { + var set = new HashSet(StringComparer.Ordinal) { rootId }; + if (!includeChildren) return set; + var locations = (await AllAsync(departmentId)).Where(l => !l.IsDeleted).ToList(); + var assets = (await AllAsync(departmentId)).Where(a => !a.IsDeleted && a.Status is not (4 or 5 or 6)).ToDictionary(a => a.Id); + for (var grew = true; grew && set.Count < 256;) + { + grew = false; + foreach (var location in locations.Where(l => !set.Contains(l.Id))) + { + var inside = location.ParentLocationId != null && set.Contains(location.ParentLocationId) + || location.ContainerAssetId != null && assets.TryGetValue(location.ContainerAssetId, out var holder) && holder.CurrentLocationId != null && set.Contains(holder.CurrentLocationId); + if (inside) grew = set.Add(location.Id) || grew; + } + } + return set; + } // Conservative department fence also catches new positions, catalogue edits and rebuilds. // Counts and observations themselves are excluded so independent counts do not invalidate each other. private async Task CountFingerprintAsync(int departmentId) => Fingerprint(new { @@ -35,12 +88,13 @@ public Task StartCountAsync(InventoryActor actor, Inventor var count = New(actor); count.Id = input.Id; count.LocationId = input.LocationId; await RequireCountAccessAsync(actor, count, false); if (await _store.GetAsync(actor.DepartmentId, count.Id) != null) throw new InventoryException(409, "CountAlreadyExists"); - count.SnapshotOn = Now; count.SnapshotFingerprint = await CountFingerprintAsync(actor.DepartmentId); - count.Content = JsonConvert.SerializeObject(new InventoryCountContent { Name = input.Name.Trim(), Note = input.Note }); + var scope = input.LocationId == null ? null : await CountLocationsAsync(actor.DepartmentId, input.LocationId, input.IncludeChildLocations); + count.SnapshotOn = Now; + count.Content = JsonConvert.SerializeObject(new InventoryCountContent { Name = input.Name.Trim(), Note = input.Note, ScopeLocationIds = scope?.OrderBy(x => x, StringComparer.Ordinal).ToList() }); var lines = new List(); async Task Add(string itemId, string locationId, string lotId, string assetId, decimal quantity) { - if (locationId == null || input.LocationId != null && locationId != input.LocationId) return; + if (locationId == null || scope != null && !scope.Contains(locationId)) return; if (!await LiveAlertLocationAsync(actor.DepartmentId, locationId)) return; await LocationAsync(actor, locationId, false, PermissionTypes.AdjustInventory); var item = await GetAsync(actor, itemId); @@ -55,9 +109,10 @@ async Task Add(string itemId, string locationId, string lotId, string assetId, d } foreach (var stock in await AllAsync(actor.DepartmentId)) if (!stock.IsDeleted) await Add(stock.ItemId, stock.LocationId, stock.LotId, null, stock.Quantity); foreach (var asset in await AllAsync(actor.DepartmentId)) if (!asset.IsDeleted && asset.Status is 0 or 1 or 2 or 3) await Add(asset.ItemId, asset.CurrentLocationId, asset.LotId, asset.Id, 1); - if (input.LocationId != null) foreach (var item in await AllAsync(actor.DepartmentId)) + if (input.LocationId != null && (input.IncludeCatalogItems ?? true)) foreach (var item in await AllAsync(actor.DepartmentId)) if (!item.IsDeleted && item.IsActive && item.TrackingMode == 0 && !item.RequiresLotTracking && !lines.Any(l => l.ItemId == item.Id)) await Add(item.Id, input.LocationId, null, null, 0); if (lines.Count == 0) throw new InventoryException(409, "CountEmpty"); + count.SnapshotFingerprint = scope == null ? await CountFingerprintAsync(actor.DepartmentId) : await ScopedCountFingerprintAsync(actor.DepartmentId, scope, lines); await SaveAsync(actor, count); foreach (var line in lines) await SaveAsync(actor, line); await AuditAsync(actor, count, "InventoryCountStarted"); return await GetCountAsync(actor, count.Id); }); @@ -82,6 +137,43 @@ public Task SaveCountAsync(InventoryActor actor, Inventory } await SaveAsync(actor, detail.Count, false); await AuditAsync(actor, detail.Count, "InventoryCountSaved"); return await GetCountAsync(actor, detail.Count.Id); }); + public async Task GetFieldAccessAsync(InventoryActor actor, int? unitId) + { + async Task Allowed(Func check) + { + try { await check(); return true; } + catch (InventoryException) { return false; } + catch (UnauthorizedAccessException) { return false; } + } + await _auth.RequireAsync(actor); + var access = new InventoryFieldAccess { Enabled = await _auth.IsEnabledAsync(actor.DepartmentId), Migrated = await _store.HasLegacyMigrationAsync(actor.DepartmentId) }; + if (!unitId.HasValue) + { + access.CanCount = await Allowed(() => _auth.RequireAsync(actor, false, PermissionTypes.AdjustInventory)); + access.CanIssue = await Allowed(() => _auth.RequireAsync(actor, false, PermissionTypes.IssueInventory)); + access.CanTransfer = await Allowed(() => _auth.RequireAsync(actor, false, PermissionTypes.TransferInventory)); + return access; + } + var all = (await AllAsync(actor.DepartmentId)).Where(l => !l.IsDeleted).ToList(); + var root = all.Where(l => l.LocationType == (int)InventoryLocationType.Unit && l.UnitId == unitId && l.ParentLocationId == null && l.ContainerAssetId == null).OrderBy(l => l.CreatedOn).FirstOrDefault(); + if (root == null) return access; + foreach (var id in await CountLocationsAsync(actor.DepartmentId, root.Id, true)) + { + var location = all.FirstOrDefault(l => l.Id == id); + if (location == null) continue; + try + { + await LocationAsync(actor, location.Id); + var revealed = await RevealAsync(actor, location); + access.UnitLocations.Add(new InventoryFieldLocation { Id = location.Id, Name = Decode(revealed).Name, ParentLocationId = location.ParentLocationId, LocationType = location.LocationType, IsRoot = location.Id == root.Id }); + } + catch (InventoryException ex) when (ex.StatusCode is 403 or 404 || ex.Code == "LocationUnavailable") { } + } + access.CanCount = await Allowed(() => LocationAsync(actor, root.Id, false, PermissionTypes.AdjustInventory)); + access.CanIssue = await Allowed(() => LocationAsync(actor, root.Id, false, PermissionTypes.IssueInventory)); + access.CanTransfer = await Allowed(() => LocationAsync(actor, root.Id, false, PermissionTypes.TransferInventory)); + return access; + } public Task CancelCountAsync(InventoryActor actor, string id, int revision) => TransactionAsync(actor, async events => { var count = await GetAsync(actor, id); @@ -91,7 +183,11 @@ public Task CancelCountAsync(InventoryActor actor, string id, int revision) => T private async Task CountCommandAsync(InventoryActor actor, InventoryCountDetail detail, InventoryCountComplete input, bool witnessed) { if (detail.Count.Status != (witnessed ? 1 : 0) || detail.Count.Revision != input.Revision || detail.Lines.Any(l => !l.CountedQuantity.HasValue)) throw new InventoryException(409, "CountStateConflict"); - if (detail.Count.SnapshotFingerprint != await CountFingerprintAsync(actor.DepartmentId)) throw new InventoryException(409, "CountSnapshotChanged"); + var fence = detail.Count.SnapshotFingerprint?.StartsWith(ScopedCountFingerprint, StringComparison.Ordinal) == true + ? await ScopedCountFingerprintAsync(actor.DepartmentId, Decode(detail.Count).ScopeLocationIds?.ToHashSet(StringComparer.Ordinal) + ?? detail.Lines.Select(l => l.LocationId).Append(detail.Count.LocationId).Where(x => x != null).ToHashSet(StringComparer.Ordinal), detail.Lines) + : await CountFingerprintAsync(actor.DepartmentId); + if (detail.Count.SnapshotFingerprint != fence) throw new InventoryException(409, "CountSnapshotChanged"); var command = new InventoryCommand { RequestId = input.RequestId }; foreach (var row in detail.Lines.Where(l => l.CountedQuantity != l.ExpectedQuantity)) { diff --git a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs index b8120b176..2ae707c60 100644 --- a/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs +++ b/Core/Resgrid.Services/Invoicing/ContractorBillingEngine.cs @@ -296,7 +296,7 @@ private async Task NotifyAdminsAsync(int departmentId, string message) { var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); - foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId)) await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Deployment billing"); } catch (Exception ex) { Logging.LogException(ex, $"Deployment billing reminder for department {departmentId} failed."); } diff --git a/Core/Resgrid.Services/Invoicing/DeploymentService.cs b/Core/Resgrid.Services/Invoicing/DeploymentService.cs index 659e535ff..ec85c45b4 100644 --- a/Core/Resgrid.Services/Invoicing/DeploymentService.cs +++ b/Core/Resgrid.Services/Invoicing/DeploymentService.cs @@ -130,13 +130,72 @@ public async Task> GetDeploymentsForContractAsync(string servic public async Task> GetDeploymentsForUserAsync(int departmentId, string userId, bool openOnly) { var rows = (await _personnel.GetForUserAsync(departmentId, userId))?.ToList() ?? new List(); - if (rows.Count == 0) return new List(); - var ids = rows.Select(r => r.DeploymentId).Distinct().ToList(); - var deployments = (await _deployments.GetByIdsAsync(departmentId, ids))?.ToList() ?? new List(); + var ids = rows.Select(r => r.DeploymentId).ToHashSet(StringComparer.OrdinalIgnoreCase); + // The crew seated on an apparatus (active unit roles) works that unit's deployments too, whether or not the + // deployment roster names them: that is how the Unit app's tablet reaches its Crew Time Report. + var seated = await SeatedUnitIdsAsync(departmentId, userId); + if (seated.Count > 0) + foreach (var unit in (await _units.GetForUnitsAsync(departmentId, seated))?.Where(u => u.IsActive) ?? Enumerable.Empty()) + ids.Add(unit.DeploymentId); + if (ids.Count == 0) return new List(); + var deployments = (await _deployments.GetByIdsAsync(departmentId, ids.ToList()))?.ToList() ?? new List(); await ResolveDeploymentsAsync(deployments, departmentId); return openOnly ? deployments.Where(d => d.IsOpen).ToList() : deployments; } + public async Task CanFieldMemberSeeAsync(string deploymentId, int departmentId, string userId) + { + if (string.IsNullOrWhiteSpace(deploymentId) || string.IsNullOrWhiteSpace(userId)) return false; + if (await IsRosteredAsync(deploymentId, departmentId, userId)) return true; + var seated = await SeatedUnitIdsAsync(departmentId, userId); + if (seated.Count == 0) return false; + var units = await _units.GetByDeploymentAsync(deploymentId); + return units != null && units.Any(u => u.DepartmentId == departmentId && u.IsActive && seated.Contains(u.UnitId)); + } + + public async Task GetTimeAccessAsync(Deployment deployment, string userId, bool canManage) + { + var access = new DeploymentTimeAccess { CanManage = canManage }; + if (deployment == null || string.IsNullOrWhiteSpace(userId)) return access; + bool Mine(DeploymentPersonnel p) => string.Equals(p.UserId, userId, StringComparison.OrdinalIgnoreCase); + + access.IsRostered = deployment.Personnel.Any(Mine); + var own = deployment.Personnel.Where(p => p.IsActive && Mine(p)).ToList(); + access.PersonnelId = own.FirstOrDefault()?.DeploymentPersonnelId; + foreach (var row in own) access.WritableSubjectIds.Add(row.DeploymentPersonnelId); + + var activeUnits = deployment.Units.Where(u => u.IsActive).ToList(); + if (activeUnits.Count == 0) return access; + var crewed = own.Where(p => !string.IsNullOrWhiteSpace(p.DeploymentUnitId)).Select(p => p.DeploymentUnitId).ToHashSet(StringComparer.OrdinalIgnoreCase); + var seated = await SeatedUnitIdsAsync(deployment.DepartmentId, userId); + foreach (var unit in activeUnits.Where(u => crewed.Contains(u.DeploymentUnitId) || seated.Contains(u.UnitId))) + { + access.CrewUnitIds.Add(unit.DeploymentUnitId); + access.WritableSubjectIds.Add(unit.DeploymentUnitId); + // Removed crew stay writable for the crew report: a member released mid-shift still worked the first half of it. + foreach (var member in deployment.Personnel.Where(p => string.Equals(p.DeploymentUnitId, unit.DeploymentUnitId, StringComparison.OrdinalIgnoreCase))) + access.WritableSubjectIds.Add(member.DeploymentPersonnelId); + foreach (var item in deployment.Equipment.Where(e => string.Equals(e.DeploymentUnitId, unit.DeploymentUnitId, StringComparison.OrdinalIgnoreCase))) + access.WritableSubjectIds.Add(item.DeploymentEquipmentId); + } + return access; + } + + /// Units the member is seated on right now (active unit roles). A lookup failure seats them nowhere rather than failing the read. + private async Task> SeatedUnitIdsAsync(int departmentId, string userId) + { + try + { + var roles = await _unitsService.GetAllActiveRolesForUnitsByDepartmentIdAsync(departmentId); + return roles?.Where(r => r != null && string.Equals(r.UserId, userId, StringComparison.OrdinalIgnoreCase)).Select(r => r.UnitId).ToHashSet() ?? new HashSet(); + } + catch (Exception ex) + { + Logging.LogException(ex); + return new HashSet(); + } + } + public async Task> GetCostRecoveryDeploymentsReleasedBeforeAsync(int departmentId, DateTime releasedOnOrBeforeUtc) { var deployments = (await _deployments.GetCostRecoveryReleasedBeforeAsync(departmentId, releasedOnOrBeforeUtc))?.ToList() ?? new List(); diff --git a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs index 9b38b4bc6..1e7632eaa 100644 --- a/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs +++ b/Core/Resgrid.Services/Invoicing/InvoicePaymentsService.cs @@ -1130,7 +1130,7 @@ private async Task NotifyAdminsAsync(int departmentId, string message) { try { - foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId) ?? new List()) + foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId) ?? new List()) await _emailService.SendNotificationAsync(admin.Id, message, departmentId); } catch (Exception ex) diff --git a/Core/Resgrid.Services/Invoicing/ServiceContractService.cs b/Core/Resgrid.Services/Invoicing/ServiceContractService.cs index 79d010ed9..5954efcb1 100644 --- a/Core/Resgrid.Services/Invoicing/ServiceContractService.cs +++ b/Core/Resgrid.Services/Invoicing/ServiceContractService.cs @@ -413,7 +413,7 @@ private async Task NotifyAdminsAsync(int departmentId, string message) { var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); - foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId)) await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Compliance documents"); } catch (Exception ex) { Logging.LogException(ex, $"Compliance document notification for department {departmentId} failed."); } diff --git a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs index db4cacf8e..d5169d2b4 100644 --- a/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs +++ b/Core/Resgrid.Services/Invoicing/TimeTrackingService.cs @@ -129,14 +129,28 @@ public async Task> GetUnbilledApprovedReportsAsync(in #region Reports - public async Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + public Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + CreateTimeReportAsync(deploymentId, departmentId, reportDate, null, null, userId, ipAddress, userAgent, cancellationToken); + + public async Task CreateTimeReportAsync(string deploymentId, int departmentId, DateTime reportDate, string deploymentUnitId, string deploymentPersonnelId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { var deployment = await _deploymentService.GetDeploymentByIdAsync(deploymentId, departmentId); if (deployment == null) throw new InvalidOperationException("deployments_not_found"); if (!deployment.IsOpen) throw new InvalidOperationException("deployments_closed"); + deploymentUnitId = Trim(deploymentUnitId); + deploymentPersonnelId = Trim(deploymentPersonnelId); + if (deploymentUnitId != null && deploymentPersonnelId != null) throw new InvalidOperationException("timereports_scope_invalid"); + if (deploymentUnitId != null && !deployment.Units.Any(u => u.IsActive && Same(u.DeploymentUnitId, deploymentUnitId))) throw new InvalidOperationException("timereports_scope_invalid"); + if (deploymentPersonnelId != null && !deployment.Personnel.Any(p => p.IsActive && Same(p.DeploymentPersonnelId, deploymentPersonnelId))) throw new InvalidOperationException("timereports_scope_invalid"); + var day = reportDate.Date; - var existing = await _reports.GetByDeploymentAndDateAsync(deploymentId, day); - if (existing != null) throw new InvalidOperationException("timereports_date_exists"); + var sameDay = await LiveReportsOnAsync(deploymentId, departmentId, day); + if (sameDay.Any(r => Same(r.DeploymentUnitId, deploymentUnitId) && Same(r.DeploymentPersonnelId, deploymentPersonnelId))) throw new InvalidOperationException("timereports_date_exists"); + // A subject bills once a day: whoever is already on another live report for this day is not prefilled again, and a crew + // or person whose own time is already on one cannot open a second report for it. + var covered = await CoveredSubjectsAsync(deploymentId, departmentId, sameDay, null); + if ((deploymentUnitId != null && covered.Contains(deploymentUnitId)) || (deploymentPersonnelId != null && covered.Contains(deploymentPersonnelId))) + throw new InvalidOperationException("timereports_subject_covered"); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId); var timeZone = string.IsNullOrWhiteSpace(deployment.LocalTimeZoneId) ? department?.TimeZone : deployment.LocalTimeZoneId; @@ -145,24 +159,28 @@ public async Task CreateTimeReportAsync(string deploymentI var report = new DeploymentTimeReport { DeploymentId = deploymentId, DepartmentId = departmentId, ReportNumber = await _sequence.GetNextNumberAsync(departmentId, cancellationToken), ReportDate = day, + DeploymentUnitId = deploymentUnitId, DeploymentPersonnelId = deploymentPersonnelId, Status = (int)DeploymentTimeReportStatuses.Draft, IncidentNumber = deployment.IncidentNumber, ResourceOrderNumber = deployment.ResourceOrderNumber, RequestNumber = deployment.RequestNumber, CostCode = deployment.CostCode, PointOfHire = deployment.PointOfHire, AddedOn = DateTime.UtcNow, AddedByUserId = userId }; var saved = await _reports.SaveOrUpdateAsync(report, cancellationToken); - // Prefill: one Deployment entry per active roster subject, copying the previous report's span for the same subject when there is one. - var previous = (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.ReportDate < day && r.Status != (int)DeploymentTimeReportStatuses.Void).OrderByDescending(r => r.ReportDate).FirstOrDefault(); - var previousEntries = previous == null ? new List() : (await _entries.GetByReportAsync(previous.DeploymentTimeReportId))?.ToList() ?? new List(); + // Prefill: one Deployment entry per active subject in the report's scope, copying the most recent earlier span for the same subject when there is one. + var earlier = (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.ReportDate < day && r.Status != (int)DeploymentTimeReportStatuses.Void && !r.IsDeleted).ToDictionary(r => r.DeploymentTimeReportId, StringComparer.OrdinalIgnoreCase) + ?? new Dictionary(StringComparer.OrdinalIgnoreCase); + var previousEntries = earlier.Count == 0 ? new List() + : ((await _entries.GetByDeploymentAsync(deploymentId)) ?? Enumerable.Empty()).Where(e => e.DeploymentTimeReportId != null && earlier.ContainsKey(e.DeploymentTimeReportId)) + .OrderByDescending(e => earlier[e.DeploymentTimeReportId].ReportDate).ToList(); var defaultStart = ToUtc(day.AddHours(DefaultStartHour), timeZone); var defaultEnd = ToUtc(day.AddHours(DefaultEndHour), timeZone); var sort = 0; - var subjects = deployment.Personnel.Where(p => p.IsActive).Select(p => (Type: DeploymentTimeSubjectTypes.Personnel, Id: p.DeploymentPersonnelId, Cert: p.CertificationCode, Unit: p.DeploymentUnitId)) - .Concat(deployment.Units.Where(u => u.IsActive).Select(u => (Type: DeploymentTimeSubjectTypes.Unit, Id: u.DeploymentUnitId, Cert: (string)null, Unit: u.DeploymentUnitId))) - .Concat(deployment.Equipment.Where(e => e.IsActive).Select(e => (Type: DeploymentTimeSubjectTypes.Equipment, Id: e.DeploymentEquipmentId, Cert: (string)null, Unit: e.DeploymentUnitId))); + var subjects = ScopeSubjects(deployment, deploymentUnitId, deploymentPersonnelId).Where(s => !covered.Contains(s.Id)); foreach (var subject in subjects) { - var prior = previousEntries.Where(e => e.SubjectId == subject.Id && e.EntryType == (int)DeploymentTimeEntryTypes.Deployment).OrderBy(e => e.StartTime).FirstOrDefault(); + // Latest earlier day that has this subject, then its first Deployment span of that day. + var priorDay = previousEntries.FirstOrDefault(e => e.SubjectId == subject.Id && e.EntryType == (int)DeploymentTimeEntryTypes.Deployment)?.DeploymentTimeReportId; + var prior = priorDay == null ? null : previousEntries.Where(e => e.DeploymentTimeReportId == priorDay && e.SubjectId == subject.Id && e.EntryType == (int)DeploymentTimeEntryTypes.Deployment).OrderBy(e => e.StartTime).FirstOrDefault(); var start = prior == null ? defaultStart : day.Add(ToLocal(prior.StartTime, timeZone).TimeOfDay); var end = prior == null ? defaultEnd : day.Add(ToLocal(prior.EndTime, timeZone).TimeOfDay); if (prior != null) { start = ToUtc(start, timeZone); end = ToUtc(end, timeZone); if (end <= start) end = end.AddDays(1); } @@ -209,7 +227,10 @@ public async Task UpdateTimeReportAsync(DeploymentTimeRepo return await GetTimeReportByIdAsync(saved.DeploymentTimeReportId, report.DepartmentId); } - public async Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) + public Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => + SaveTimeEntriesAsync(deploymentTimeReportId, departmentId, entries, null, userId, ipAddress, userAgent, cancellationToken); + + public async Task SaveTimeEntriesAsync(string deploymentTimeReportId, int departmentId, List entries, DeploymentTimeAccess access, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { var report = await _reports.GetByIdForDepartmentAsync(deploymentTimeReportId, departmentId); if (report == null || report.IsDeleted) throw new InvalidOperationException("timereports_not_found"); @@ -231,7 +252,20 @@ public async Task SaveTimeEntriesAsync(string deploymentTi entry.Notes = Trim(entry.Notes); entry.CertificationCode = Trim(entry.CertificationCode); } - var validation = Validate(report, incoming, roster.Keys); + + // A scoped writer (a crew member, the unit tablet, one person) replaces only the subjects they may write; every other + // subject's entries stay exactly as stored, so two crews saving the same deployment-wide report never erase each other. + var current = (await _entries.GetByReportAsync(deploymentTimeReportId))?.ToList() ?? new List(); + var restricted = access != null && !access.CanManage; + var preserved = new List(); + if (restricted) + { + incoming = incoming.Where(e => access.CanWriteSubject(e.SubjectId)).ToList(); + preserved = current.Where(e => !access.CanWriteSubject(e.SubjectId)).ToList(); + } + + var validation = Validate(report, incoming.Concat(preserved).ToList(), roster.Keys); + await ValidateScopeAsync(validation, report, deployment, incoming); var result = new TimeReportSaveResult { Validation = validation }; if (!validation.IsValid) { @@ -244,14 +278,16 @@ await TransactionAsync(async () => { // Entries keep their ids across a save: the catalog-28 envelope on an entry's notes is bound to the entry's row key, // so an untouched entry (REDACTED posted back) is updated in place and a stale one deleted, never re-inserted. - var current = (await _entries.GetByReportAsync(deploymentTimeReportId))?.ToDictionary(e => e.DeploymentTimeEntryId, StringComparer.OrdinalIgnoreCase) ?? new Dictionary(StringComparer.OrdinalIgnoreCase); - var kept = new HashSet(incoming.Where(e => !string.IsNullOrWhiteSpace(e.DeploymentTimeEntryId) && current.ContainsKey(e.DeploymentTimeEntryId)).Select(e => e.DeploymentTimeEntryId), StringComparer.OrdinalIgnoreCase); - foreach (var stale in current.Values.Where(e => !kept.Contains(e.DeploymentTimeEntryId))) + var byId = current.Where(e => !string.IsNullOrWhiteSpace(e.DeploymentTimeEntryId)).ToDictionary(e => e.DeploymentTimeEntryId, StringComparer.OrdinalIgnoreCase); + var preservedIds = new HashSet(preserved.Select(e => e.DeploymentTimeEntryId), StringComparer.OrdinalIgnoreCase); + var kept = new HashSet(incoming.Where(e => !string.IsNullOrWhiteSpace(e.DeploymentTimeEntryId) && byId.ContainsKey(e.DeploymentTimeEntryId) && !preservedIds.Contains(e.DeploymentTimeEntryId)).Select(e => e.DeploymentTimeEntryId), StringComparer.OrdinalIgnoreCase); + foreach (var stale in current.Where(e => !kept.Contains(e.DeploymentTimeEntryId) && !preservedIds.Contains(e.DeploymentTimeEntryId))) await _entries.DeleteAsync(stale, cancellationToken); - var sort = 0; + // A scoped save appends after the entries it did not touch; an unrestricted save renumbers the whole report. + var sort = preserved.Count == 0 ? 0 : preserved.Max(e => e.SortOrder) + 1; foreach (var entry in incoming.OrderBy(e => e.SortOrder).ThenBy(e => e.StartTime)) { - var existingEntry = !string.IsNullOrWhiteSpace(entry.DeploymentTimeEntryId) && current.TryGetValue(entry.DeploymentTimeEntryId, out var found) ? found : null; + var existingEntry = !string.IsNullOrWhiteSpace(entry.DeploymentTimeEntryId) && byId.TryGetValue(entry.DeploymentTimeEntryId, out var found) && !preservedIds.Contains(entry.DeploymentTimeEntryId) ? found : null; if (existingEntry == null) { entry.DeploymentTimeEntryId = null; @@ -274,6 +310,70 @@ await TransactionAsync(async () => return result; } + /// + /// M0227 rules on top of : a crew or individual report carries only its own subjects, and a subject + /// already on another live report for the same day is refused (it would bill twice). + /// + private async Task ValidateScopeAsync(TimeReportValidation validation, DeploymentTimeReport report, Deployment deployment, IReadOnlyList entries) + { + var scope = ScopeSubjectIds(deployment, report); + var sameDay = await LiveReportsOnAsync(report.DeploymentId, report.DepartmentId, report.ReportDate.Date); + var covered = await CoveredSubjectsAsync(report.DeploymentId, report.DepartmentId, sameDay, report.DeploymentTimeReportId); + foreach (var entry in entries) + { + var subject = entry.SubjectId; + if (string.IsNullOrWhiteSpace(subject)) continue; + if (scope != null && !scope.Contains(subject)) + validation.Errors.Add(new TimeReportIssue { Code = TimeReportValidation.SubjectOutsideScope, SubjectId = subject, EntryId = entry.DeploymentTimeEntryId }); + else if (covered.Contains(subject)) + validation.Errors.Add(new TimeReportIssue { Code = TimeReportValidation.SubjectOnOtherReport, SubjectId = subject, EntryId = entry.DeploymentTimeEntryId }); + } + } + + /// Live (not deleted, not void) reports of the deployment on one calendar day. + private async Task> LiveReportsOnAsync(string deploymentId, int departmentId, DateTime day) => + (await _reports.GetByDeploymentAsync(deploymentId))?.Where(r => r.DepartmentId == departmentId && r.IsLive && r.ReportDate.Date == day.Date).ToList() ?? new List(); + + /// Subjects with time on any of other than . + private async Task> CoveredSubjectsAsync(string deploymentId, int departmentId, IReadOnlyCollection reports, string excludingReportId) + { + var others = new HashSet(reports.Where(r => !Same(r.DeploymentTimeReportId, excludingReportId)).Select(r => r.DeploymentTimeReportId), StringComparer.OrdinalIgnoreCase); + if (others.Count == 0) return new HashSet(StringComparer.OrdinalIgnoreCase); + var entries = (await _entries.GetByDeploymentAsync(deploymentId)) ?? Enumerable.Empty(); + return new HashSet(entries.Where(e => e.DepartmentId == departmentId && e.DeploymentTimeReportId != null && others.Contains(e.DeploymentTimeReportId) && !string.IsNullOrWhiteSpace(e.SubjectId)).Select(e => e.SubjectId), StringComparer.OrdinalIgnoreCase); + } + + /// Active subjects a new report prefills: the whole roster, one unit with its crew and equipment, or one person. + private static List<(DeploymentTimeSubjectTypes Type, string Id, string Cert)> ScopeSubjects(Deployment deployment, string deploymentUnitId, string deploymentPersonnelId) + { + if (deploymentPersonnelId != null) + return deployment.Personnel.Where(p => p.IsActive && Same(p.DeploymentPersonnelId, deploymentPersonnelId)).Select(p => (DeploymentTimeSubjectTypes.Personnel, p.DeploymentPersonnelId, p.CertificationCode)).ToList(); + bool InScope(string unitId) => deploymentUnitId == null || Same(unitId, deploymentUnitId); + return deployment.Personnel.Where(p => p.IsActive && InScope(p.DeploymentUnitId)).Select(p => (DeploymentTimeSubjectTypes.Personnel, p.DeploymentPersonnelId, p.CertificationCode)) + .Concat(deployment.Units.Where(u => u.IsActive && InScope(u.DeploymentUnitId)).Select(u => (DeploymentTimeSubjectTypes.Unit, u.DeploymentUnitId, (string)null))) + .Concat(deployment.Equipment.Where(e => e.IsActive && InScope(e.DeploymentUnitId)).Select(e => (DeploymentTimeSubjectTypes.Equipment, e.DeploymentEquipmentId, (string)null))) + .ToList(); + } + + /// Every subject (active or since removed) a scoped report may carry; null for a deployment-wide report (the roster check covers it). + private static HashSet ScopeSubjectIds(Deployment deployment, DeploymentTimeReport report) + { + switch (report.Scope) + { + case DeploymentTimeReportScopes.Individual: + return new HashSet(new[] { report.DeploymentPersonnelId }, StringComparer.OrdinalIgnoreCase); + case DeploymentTimeReportScopes.Crew: + var ids = new HashSet(StringComparer.OrdinalIgnoreCase) { report.DeploymentUnitId }; + foreach (var p in deployment.Personnel.Where(p => Same(p.DeploymentUnitId, report.DeploymentUnitId))) ids.Add(p.DeploymentPersonnelId); + foreach (var e in deployment.Equipment.Where(e => Same(e.DeploymentUnitId, report.DeploymentUnitId))) ids.Add(e.DeploymentEquipmentId); + return ids; + default: + return null; + } + } + + private static bool Same(string a, string b) => string.Equals(string.IsNullOrWhiteSpace(a) ? null : a, string.IsNullOrWhiteSpace(b) ? null : b, StringComparison.OrdinalIgnoreCase); + public TimeReportValidation Validate(DeploymentTimeReport report, IReadOnlyList entries, IReadOnlyCollection rosterSubjectIds) { var validation = new TimeReportValidation(); @@ -318,6 +418,7 @@ public async Task SubmitTimeReportAsync(string deploymentT if (deployment == null) throw new InvalidOperationException("deployments_not_found"); var entries = (await _entries.GetByReportAsync(deploymentTimeReportId))?.ToList() ?? new List(); var validation = Validate(report, entries, RosterSubjects(deployment).Keys); + await ValidateScopeAsync(validation, report, deployment, entries); var result = new TimeReportSaveResult { Validation = validation }; if (!validation.IsValid) { result.Report = await GetTimeReportByIdAsync(deploymentTimeReportId, departmentId); return result; } @@ -458,8 +559,13 @@ public static string RenderTimeReportHtml(DeploymentTimeReport report, Deploymen string D(DateTime? value) => value.HasValue ? (department == null ? value.Value.ToString("yyyy-MM-dd HH:mm") : value.Value.TimeConverter(department).ToString("yyyy-MM-dd HH:mm")) : "—"; var sb = new StringBuilder(); sb.Append("Resgrid | Daily Time Report"); - sb.Append("

").Append(E(department?.Name)).Append(" — Daily Time Report #").Append(report.ReportNumber).Append("

"); - sb.Append("
").Append(E(deployment?.Name)).Append(" · ").Append(report.ReportDate.ToString("yyyy-MM-dd")).Append(" · ").Append(E(((DeploymentTimeReportStatuses)report.Status).ToString())).Append("
"); + string Named(string id) => id != null && subjectNames != null && subjectNames.TryGetValue(id, out var n) ? n : id; + var title = report.Scope switch { DeploymentTimeReportScopes.Crew => "Crew Time Report", DeploymentTimeReportScopes.Individual => "Individual Time Report", _ => "Daily Time Report" }; + sb.Append("

").Append(E(department?.Name)).Append(" — ").Append(title).Append(" #").Append(report.ReportNumber).Append("

"); + sb.Append("
").Append(E(deployment?.Name)).Append(" · ").Append(report.ReportDate.ToString("yyyy-MM-dd")).Append(" · ").Append(E(((DeploymentTimeReportStatuses)report.Status).ToString())); + if (report.Scope == DeploymentTimeReportScopes.Crew) sb.Append(" · Crew: ").Append(E(Named(report.DeploymentUnitId))); + if (report.Scope == DeploymentTimeReportScopes.Individual) sb.Append(" · Resource: ").Append(E(Named(report.DeploymentPersonnelId))); + sb.Append("
"); sb.Append(""); sb.Append(" - - - - - - - - - + + + + + + + + + } @@ -87,7 +90,7 @@ }
- +
@@ -124,7 +127,7 @@
@localizer["ContractorSignature"]
@(r.ContractorSignedOn.HasValue ? $"{Model.ContractorSignerName} · {LocalStamp(r.ContractorSignedOn)}" : "—")
@localizer["CustomerSignature"]
@if (r.CustomerSignedOn.HasValue) { @r.CustomerSignerName · @LocalStamp(r.CustomerSignedOn) } else { — }
- @if ((Model.CanManage || Model.IsRostered) && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed) + @if (Model.CanActOnReport && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Void && status != Resgrid.Model.Invoicing.DeploymentTimeReportStatuses.Billed) { @Html.AntiForgeryToken() @@ -151,7 +154,7 @@
Incident # ").Append(E(report.IncidentNumber)).Append("Resource order # ").Append(E(report.ResourceOrderNumber)).Append("Request # ").Append(E(report.RequestNumber)).Append("
Cost code ").Append(E(report.CostCode)).Append("Point of hire ").Append(E(report.PointOfHire)).Append(""); if (report.NoClear8) sb.Append("No clear 8 "); diff --git a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs index 2bd36a8d4..b0490c702 100644 --- a/Core/Resgrid.Services/Records/RecordDeploymentsService.cs +++ b/Core/Resgrid.Services/Records/RecordDeploymentsService.cs @@ -91,6 +91,7 @@ public async Task CreateFromExternalOrderAsync(int de if (!await _authorization.HasPermissionAsync(userId, departmentId, PermissionTypes.CreateRecord)) throw new UnauthorizedAccessException("Creating a deployment is not authorized."); if (input.ArtifactData != null && input.ArtifactData.Length > 25 * 1024 * 1024) throw new ArgumentException("The order artifact exceeds 25 MB.", nameof(input)); if (!string.IsNullOrWhiteSpace(input.CurrencyCode) && !RmsCurrencies.IsSupported(input.CurrencyCode)) throw new ArgumentException($"'{input.CurrencyCode}' is not a supported currency.", nameof(input)); + foreach (var fill in input.Fills ?? new List()) await RequireAssignableFillAsync(departmentId, fill); var profileKey = input.ProfileKey.ToLowerInvariant(); var homeProfile = input.HomeProfileKey ?? (profileKey == RmsDeploymentProfiles.CrossBorder ? "us" : ProfileFor(profileKey)); @@ -213,6 +214,15 @@ private static string SafeUrl(string url) return Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps && string.IsNullOrEmpty(uri.Query) ? uri.ToString() : null; } + /// A fill is only assigned to an active member of this department: not someone removed, disabled, hidden or foreign. + private async Task RequireAssignableFillAsync(int departmentId, RecordDeploymentFillInput input) + { + if (input == null || string.IsNullOrWhiteSpace(input.AssignedUserId)) return; + input.AssignedUserId = input.AssignedUserId.Trim(); + if (!await _authorization.IsAssignableMemberAsync(input.AssignedUserId, departmentId)) + throw new ArgumentException("The assigned member must be an active member of the department.", nameof(input)); + } + private static RmsExternalOrderFill ToFill(RmsExternalOrder order, RecordDeploymentFillInput input, string userId, DateTime now) { if (string.IsNullOrWhiteSpace(input?.RequestNumber)) throw new ArgumentException("Every fill needs the external request number it answers.", nameof(input)); @@ -287,6 +297,7 @@ public async Task> ListAggregatesAsync(int depar public async Task AddFillAsync(int departmentId, string userId, string orderId, RecordDeploymentFillInput input, CancellationToken cancellationToken = default) { var order = await RequireEditableAsync(departmentId, userId, orderId); + await RequireAssignableFillAsync(departmentId, input); var fill = ToFill(order, input, userId, DateTime.UtcNow); await InTransactionAsync(async () => { diff --git a/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs b/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs index 3631aa400..37954b91c 100644 --- a/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs +++ b/Core/Resgrid.Services/Records/RecordEvidenceSelectionService.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Threading.Tasks; @@ -88,7 +89,7 @@ public async Task GetAsync(int departmentId, string use } else if (sourceKind == RmsEvidenceKind.CertificationSnapshot) { - foreach (var person in await _departments.GetAllPersonnelNamesForDepartmentAsync(departmentId)) + foreach (var person in await _departments.GetSelectablePersonnelNamesAsync(departmentId) ?? new List()) if (await _sourceAuthorization.Value.CanUserViewPersonAsync(userId, person.UserId, departmentId)) selection.Choices.Add(new RecordEvidenceChoice { Id = person.UserId, Label = person.Name }); } diff --git a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs index d2e578fed..6441fcf4f 100644 --- a/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs +++ b/Core/Resgrid.Services/Records/RecordsAuthorizationService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Resgrid.Framework; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -59,6 +60,13 @@ public async Task IsActiveMemberAsync(string userId, int departmentId) return member != null && !member.IsDeleted && !member.IsDisabled.GetValueOrDefault(); } + public async Task IsAssignableMemberAsync(string userId, int departmentId) + { + if (string.IsNullOrWhiteSpace(userId)) return false; + var member = await _departmentsService.GetDepartmentMemberAsync(userId, departmentId, true); + return DepartmentMemberStateHelper.IsActiveMember(member, departmentId); + } + public async Task IsDepartmentAdminAsync(string userId, int departmentId) { try diff --git a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs index e939aa83b..32c7b4eed 100644 --- a/Core/Resgrid.Services/Records/RecordsDisclosureService.cs +++ b/Core/Resgrid.Services/Records/RecordsDisclosureService.cs @@ -68,6 +68,9 @@ public async Task CreateRequestAsync(int departmentId, str if (request == null) throw new ArgumentNullException(nameof(request)); if (string.IsNullOrWhiteSpace(request.RequesterName)) throw new ArgumentException("A requester is required.", nameof(request)); + request.AssignedToUserId = Blank(request.AssignedToUserId); + if (request.AssignedToUserId != null && !await _authorization.IsAssignableMemberAsync(request.AssignedToUserId, departmentId)) + throw new ArgumentException("The assignee must be an active member of the department.", nameof(request)); var config = await SafeConfigAsync(departmentId); var now = DateTime.UtcNow; diff --git a/Core/Resgrid.Services/Records/RecordsInspectionsService.cs b/Core/Resgrid.Services/Records/RecordsInspectionsService.cs index 9b1649606..80e273b8c 100644 --- a/Core/Resgrid.Services/Records/RecordsInspectionsService.cs +++ b/Core/Resgrid.Services/Records/RecordsInspectionsService.cs @@ -258,6 +258,8 @@ public async Task ScheduleAsync(int departmentId, string userId, program = await _programs.GetByIdForDepartmentAsync(departmentId, programId); if (program == null || program.DeletedOn != null) throw new ArgumentException("The inspection program does not exist."); } + if (!string.IsNullOrWhiteSpace(inspectorUserId) && !await _gate.IsAssignableAsync(departmentId, inspectorUserId)) + throw new ArgumentException("The inspector must be an active member of the department."); var inspection = await CreateScheduledAsync(departmentId, userId, occupancy, program, scheduledOn, inspectorUserId, null, cancellationToken); await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Inspection scheduled", inspection.RmsInspectionId, new { inspection.InspectionNumber, occupancyId, programId, scheduledOn }, cancellationToken: cancellationToken); return inspection; @@ -363,7 +365,9 @@ public async Task ScheduleReinspectionAsync(int departmentId, str if (parent.State != (int)RmsInspectionState.ReinspectionRequired && parent.State != (int)RmsInspectionState.Completed) throw new InvalidOperationException("Only a completed inspection can be re-inspected."); var occupancy = await _occupancies.GetByIdForDepartmentAsync(departmentId, parent.RmsOccupancyId) ?? throw new InvalidOperationException("The occupancy no longer exists."); var program = string.IsNullOrWhiteSpace(parent.RmsInspectionProgramId) ? null : await _programs.GetByIdForDepartmentAsync(departmentId, parent.RmsInspectionProgramId); - var child = await CreateScheduledAsync(departmentId, userId, occupancy, program, scheduledOn, parent.InspectorUserId, parent.RmsInspectionId, cancellationToken); + // The re-inspection goes to the same inspector only while they are still an active member; otherwise it is left unassigned. + var inspector = await _gate.IsAssignableAsync(departmentId, parent.InspectorUserId) ? parent.InspectorUserId : null; + var child = await CreateScheduledAsync(departmentId, userId, occupancy, program, scheduledOn, inspector, parent.RmsInspectionId, cancellationToken); foreach (var violation in ((await _violations.GetForInspectionAsync(departmentId, inspectionId)) ?? Enumerable.Empty()).Where(v => v.IsOpen)) { violation.ReinspectionId = child.RmsInspectionId; violation.ModifiedOn = DateTime.UtcNow; violation.RowVersion++; await _violations.UpdateAsync(violation, cancellationToken, true); } await _gate.AuditAsync(departmentId, userId, RmsAccessAuditAction.Change, "Re-inspection scheduled", child.RmsInspectionId, new { parent = parent.RmsInspectionId, scheduledOn }, cancellationToken: cancellationToken); diff --git a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs index f6f540ea3..6cf337174 100644 --- a/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs +++ b/Core/Resgrid.Services/Records/RecordsInvestigationsService.cs @@ -329,6 +329,8 @@ public async Task TransferCustodyAsync(int departmentId var (investigation, _) = await RequireMemberAsync(departmentId, userId, item.RmsInvestigationCaseId, RmsInvestigationRole.Lead, RmsInvestigationRole.Investigator); RequireOpen(investigation); if (string.IsNullOrWhiteSpace(toUserId) && string.IsNullOrWhiteSpace(toExternal) && resultingState != RmsEvidenceState.Destroyed) throw new ArgumentException("Name who receives the evidence."); + if (!string.IsNullOrWhiteSpace(toUserId) && !await _gate.IsAssignableAsync(departmentId, toUserId.Trim())) + throw new ArgumentException("The receiving custodian must be an active member of the department."); if (item.State == (int)RmsEvidenceState.Destroyed) throw new InvalidOperationException("Destroyed evidence has no further custody."); reason = RecordsPreventionGate.Require(reason, 1000, "A custody transfer needs a reason."); var now = DateTime.UtcNow; diff --git a/Core/Resgrid.Services/Records/RecordsNotificationService.cs b/Core/Resgrid.Services/Records/RecordsNotificationService.cs index 1bedfe3ea..67491b49c 100644 --- a/Core/Resgrid.Services/Records/RecordsNotificationService.cs +++ b/Core/Resgrid.Services/Records/RecordsNotificationService.cs @@ -48,11 +48,16 @@ public async Task NotifyReturnedForCorrectionAsync(int departmentId, strin if (record == null || record.State != (int)RmsRecordState.Returned || string.IsNullOrWhiteSpace(record.AuthorUserId)) return false; + // The author, or the owner the record was handed to once the author left. + var targetUserId = await FirstActiveMemberAsync(departmentId, record.AuthorUserId, record.OwnerUserId); + if (targetUserId == null) + return false; + cancellationToken.ThrowIfCancellationRequested(); var department = await _departments.GetDepartmentByIdAsync(departmentId, false); var departmentNumber = await _departmentSettings.GetTextToCallNumberForDepartmentAsync(departmentId); - var author = await _profiles.GetProfileByUserIdAsync(record.AuthorUserId, false); + var author = await _profiles.GetProfileByUserIdAsync(targetUserId, false); var reviewer = string.IsNullOrWhiteSpace(record.ReviewerUserId) ? null : await _profiles.GetProfileByUserIdAsync(record.ReviewerUserId, false); var reviewerName = reviewer == null ? null : $"{reviewer.FirstName} {reviewer.LastName}".Trim(); @@ -60,7 +65,7 @@ public async Task NotifyReturnedForCorrectionAsync(int departmentId, strin try { - return await _communication.SendNotificationAsync(record.AuthorUserId, departmentId, message, departmentNumber, department, ReturnedForCorrectionTitle, author); + return await _communication.SendNotificationAsync(targetUserId, departmentId, message, departmentNumber, department, ReturnedForCorrectionTitle, author); } catch (Exception ex) { @@ -78,15 +83,19 @@ public async Task NotifySubmissionRejectedAsync(int departmentId, string r if (report == null || report.State != (int)RmsRecordState.Rejected || string.IsNullOrWhiteSpace(report.AuthorUserId)) return false; + var targetUserId = await FirstActiveMemberAsync(departmentId, report.AuthorUserId, report.OwnerUserId); + if (targetUserId == null) + return false; + cancellationToken.ThrowIfCancellationRequested(); var department = await _departments.GetDepartmentByIdAsync(departmentId, false); var departmentNumber = await _departmentSettings.GetTextToCallNumberForDepartmentAsync(departmentId); - var author = await _profiles.GetProfileByUserIdAsync(report.AuthorUserId, false); + var author = await _profiles.GetProfileByUserIdAsync(targetUserId, false); var message = BuildSubmissionRejectedMessage(report); try { - return await _communication.SendNotificationAsync(report.AuthorUserId, departmentId, message, departmentNumber, department, SubmissionRejectedTitle, author); + return await _communication.SendNotificationAsync(targetUserId, departmentId, message, departmentNumber, department, SubmissionRejectedTitle, author); } catch (Exception ex) { @@ -109,7 +118,7 @@ public async Task NotifyObligationOverdueAsync(int departmentId, string re { reference = string.IsNullOrWhiteSpace(record.RecordNumber) ? record.DraftReference : record.RecordNumber; dueOn = record.ReviewDueOn; - targetUserId = ResponsibleFor(obligation, record.ReviewerUserId, record.OwnerUserId, record.AuthorUserId); + targetUserId = await ResponsibleForAsync(departmentId, obligation, record.ReviewerUserId, record.OwnerUserId, record.AuthorUserId); detailPath = "/User/Records/Details/"; } else @@ -120,7 +129,7 @@ public async Task NotifyObligationOverdueAsync(int departmentId, string re reference = string.IsNullOrWhiteSpace(report.RecordNumber) ? report.DraftReference : report.RecordNumber; dueOn = report.ReviewDueOn; - targetUserId = ResponsibleFor(obligation, report.ReviewerUserId, report.OwnerUserId, report.AuthorUserId); + targetUserId = await ResponsibleForAsync(departmentId, obligation, report.ReviewerUserId, report.OwnerUserId, report.AuthorUserId); detailPath = "/User/IncidentReports/Details/"; } @@ -144,13 +153,30 @@ public async Task NotifyObligationOverdueAsync(int departmentId, string re } } - /// A review rests with the reviewer; a correction or a resubmission rests with the owner, else the author. - private static string ResponsibleFor(RmsRecordObligation obligation, string reviewerUserId, string ownerUserId, string authorUserId) + /// + /// A review rests with the reviewer; a correction or a resubmission rests with the owner, else the author. Whoever of + /// them has been removed, disabled or hidden is passed over for the next in line, so an obligation left with someone + /// who has gone still reaches the record's remaining owner or author rather than disappearing at delivery. + /// + private Task ResponsibleForAsync(int departmentId, RmsRecordObligation obligation, string reviewerUserId, string ownerUserId, string authorUserId) + => obligation == RmsRecordObligation.Review + ? FirstActiveMemberAsync(departmentId, reviewerUserId, ownerUserId, authorUserId) + : FirstActiveMemberAsync(departmentId, ownerUserId, authorUserId); + + /// The first candidate who is an active member of the department (removed, disabled and hidden members are never notified); null when none is. + private async Task FirstActiveMemberAsync(int departmentId, params string[] candidates) { - if (obligation == RmsRecordObligation.Review && !string.IsNullOrWhiteSpace(reviewerUserId)) - return reviewerUserId; + var active = await _departments.GetActiveMemberUserIdsAsync(departmentId); + if (active == null) + return null; + + foreach (var candidate in candidates) + { + if (!string.IsNullOrWhiteSpace(candidate) && active.Contains(candidate)) + return candidate; + } - return string.IsNullOrWhiteSpace(ownerUserId) ? authorUserId : ownerUserId; + return null; } public static string BuildObligationOverdueMessage(string reference, RmsRecordObligation obligation, DateTime? dueOn, string detailPath) diff --git a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs index 47e42b8a8..31db47995 100644 --- a/Core/Resgrid.Services/Records/RecordsPreventionGate.cs +++ b/Core/Resgrid.Services/Records/RecordsPreventionGate.cs @@ -54,6 +54,10 @@ public async Task RequireViewerAsync(int departmentId, string userId) throw new UnauthorizedAccessException("Records access is required."); } + /// Whether prevention or investigations work (an inspector, an evidence custodian) may be handed to this person: not removed, disabled, hidden or foreign. + public async Task IsAssignableAsync(int departmentId, string userId) + => !string.IsNullOrWhiteSpace(userId) && await _authorization.IsAssignableMemberAsync(userId, departmentId); + /// Changing prevention data needs the PreventionAdmin permission (registry value 69; department admins by default). public async Task RequireAdminAsync(int departmentId, string userId) { diff --git a/Core/Resgrid.Services/Records/RecordsService.cs b/Core/Resgrid.Services/Records/RecordsService.cs index 2b626ddd5..73f37f935 100644 --- a/Core/Resgrid.Services/Records/RecordsService.cs +++ b/Core/Resgrid.Services/Records/RecordsService.cs @@ -679,6 +679,8 @@ await InTransactionAsync(async () => public async Task ReassignDraftAsync(int departmentId, string userId, string recordId, string newOwnerUserId, string reason, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(newOwnerUserId)) throw new ArgumentException("A new owner is required.", nameof(newOwnerUserId)); + if (!await _authorization.IsAssignableMemberAsync(newOwnerUserId, departmentId)) + throw new ArgumentException("The new owner must be an active member of the department.", nameof(newOwnerUserId)); var record = await LoadRecordAsync(departmentId, recordId); var state = (RmsRecordState)record.State; if (RmsLifecycle.IsFinalizedFamily(state) && record.AmendsRevisionId == null || RmsLifecycle.IsTerminal(state)) diff --git a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs index b37386d67..02e53b746 100644 --- a/Core/Resgrid.Services/WorkOrderAuthorizationService.cs +++ b/Core/Resgrid.Services/WorkOrderAuthorizationService.cs @@ -4,6 +4,7 @@ using System.Threading.Tasks; using Resgrid.Model; using Resgrid.Model.Checklists; +using Resgrid.Model.Helpers; using Resgrid.Model.Services; using Resgrid.Model.WorkOrders; @@ -121,13 +122,24 @@ public async Task ChoicesAsync(ChecklistActor actor) if (c.Type == 3 && (context.Group?.DepartmentGroupId.ToString() == c.Id || await AllowedAsync(context, PermissionTypes.ManageWorkOrders, int.Parse(c.Id)))) result.Groups.Add(choice); if (c.Type == 4 && await _resources.CanUserViewUnitAsync(actor.UserId, int.Parse(c.Id))) result.Units.Add(choice); } - var profiles = await _profiles.GetSelectedUserProfilesAsync(result.Users.Select(u => u.Id).ToList()); - foreach (var user in result.Users) + // Labels also cover hidden members, who are no longer offered but can still hold and work an assignment, so their + // history and a kept assignment render by name. The same person-visibility rule applies to them. + var labelled = result.Users.Select(u => u.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + var fallbacks = result.Users.ToDictionary(u => u.Id, u => u.Name, StringComparer.OrdinalIgnoreCase); + foreach (var member in (await _departments.GetAllMembersForDepartmentUnlimitedAsync(actor.DepartmentId, true) ?? new List()) + .Where(m => DepartmentMemberStateHelper.IsCurrentMember(m, actor.DepartmentId) && !labelled.Contains(m.UserId))) { - var profile = profiles?.FirstOrDefault(p => p.UserId == user.Id); + if (!await _resources.CanUserViewPersonAsync(actor.UserId, member.UserId, actor.DepartmentId)) continue; + labelled.Add(member.UserId); fallbacks[member.UserId] = member.User?.UserName ?? member.UserId; + } + var profiles = await _profiles.GetSelectedUserProfilesAsync(labelled.ToList()); + foreach (var id in labelled) + { + var profile = profiles?.FirstOrDefault(p => string.Equals(p.UserId, id, StringComparison.OrdinalIgnoreCase)); var name = string.Join(" ", new[] { profile?.FirstName, profile?.LastName }.Where(n => !string.IsNullOrWhiteSpace(n)).Select(n => n.Trim())); - if (!string.IsNullOrWhiteSpace(name)) user.Name = name; + result.UserNames[id] = string.IsNullOrWhiteSpace(name) ? fallbacks[id] : name; } + foreach (var user in result.Users) user.Name = result.UserNames[user.Id]; result.Users = result.Users.OrderBy(u => u.Name, StringComparer.CurrentCultureIgnoreCase).ToList(); try { @@ -136,6 +148,8 @@ public async Task ChoicesAsync(ChecklistActor actor) catch (ChecklistException ex) { throw new WorkOrderException(ex.StatusCode, ex.Message); } return result; } + public async Task> ActiveMemberIdsAsync(int departmentId) + => await _departments.GetActiveMemberUserIdsAsync(departmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); public async Task> RecipientsAsync(int departmentId, WorkOrder row) { if (row?.DepartmentId != departmentId) return new List(); diff --git a/Core/Resgrid.Services/WorkOrderNotificationService.cs b/Core/Resgrid.Services/WorkOrderNotificationService.cs index 293f5bae3..e60931164 100644 --- a/Core/Resgrid.Services/WorkOrderNotificationService.cs +++ b/Core/Resgrid.Services/WorkOrderNotificationService.cs @@ -6,6 +6,7 @@ using System.Threading.Tasks; using Resgrid.Localization; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Repositories; using Resgrid.Model.Repositories.Queries; using Resgrid.Model.Services; @@ -27,6 +28,13 @@ public sealed class WorkOrderNotificationService : IWorkOrderNotificationService public WorkOrderNotificationService(IWorkOrderRepository orders, IWorkOrderAuthorizationService authorization, IReadinessAccessService access, IUnitOfWork uow, ICommunicationService communication, IDepartmentsService departments, IDepartmentSettingsService settings, IUserProfileService profiles) { _orders = orders; _authorization = authorization; _access = access; _uow = uow; _communication = communication; _departments = departments; _settings = settings; _profiles = profiles; } + /// + /// Push event code that opens the work order when the Responder app's notification is tapped. The + /// leading "N" keeps it on the notifications channel/category (not calls), and app builds that predate + /// work orders read the unmapped "n" prefix as an ordinary notification instead of misrouting it. The + /// id is routing metadata only: the app loads the order through the authorized v4 read. + /// + public static string PushEventCode(string workOrderId) => "NWO:" + workOrderId; private async Task TransactionAsync(int departmentId, Func> action) { if (_uow.Transaction != null) throw new InvalidOperationException("Work-order notification claims own their transaction."); @@ -60,7 +68,7 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) var workOrderNumber = FormattableString.Invariant($"WO-{current.NumberYear}-{current.NumberSequence:D6}"); var handedOff = await _communication.SendNotificationAsync(user, entry.DepartmentId, workOrderNumber + ": " + Strings.GetString("NotificationMessage", culture), - number, department, Strings.GetString("NotificationTitle", culture), profile); + number, department, Strings.GetString("NotificationTitle", culture), profile, false, PushEventCode(current.Id)); await FinishAsync(notice, handedOff ? 2 : 3); } catch (Exception ex) @@ -76,7 +84,8 @@ public async Task DispatchAsync(DomainEventOutboxEntry entry) private async Task IsRecipientAsync(int departmentId, WorkOrder row, string user, bool managerNotice = false) { var member = await _departments.GetDepartmentMemberAsync(user, departmentId, true); - if (member?.DepartmentId != departmentId || member.IsDeleted || member.IsDisabled == true) return false; + // Removed, disabled and hidden members are never notified, whatever their assignment or role. + if (!DepartmentMemberStateHelper.IsActiveMember(member, departmentId)) return false; if (row.CreatedBy == user) return true; var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = departmentId, UserId = user }; if ((managerNotice || row.Status is 0 or 1) && await _authorization.CanManageAsync(actor, row.TargetGroupId)) return true; @@ -91,7 +100,7 @@ private async Task IsRecipientAsync(int departmentId, WorkOrder row, strin if (row.EscalatedOn.HasValue && row.EscalationRoleId.HasValue) result.UnionWith(await _authorization.RecipientsAsync(departmentId, new WorkOrder { DepartmentId = departmentId, AssignedToRoleId = row.EscalationRoleId })); var members = await _departments.GetAllMembersForDepartmentUnlimitedAsync(departmentId, true); - foreach (var member in members.Where(m => m.DepartmentId == departmentId && !m.IsDeleted && m.IsDisabled != true)) + foreach (var member in members.Where(m => DepartmentMemberStateHelper.IsActiveMember(m, departmentId))) { var actor = new Resgrid.Model.Checklists.ChecklistActor { DepartmentId = departmentId, UserId = member.UserId }; if (member.UserId == row.CreatedBy || (managerNotice || row.Status is 0 or 1) && await _authorization.CanManageAsync(actor, row.TargetGroupId)) diff --git a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs index 1993862a0..454f167ac 100644 --- a/Core/Resgrid.Services/WorkOrderRecurrenceService.cs +++ b/Core/Resgrid.Services/WorkOrderRecurrenceService.cs @@ -332,6 +332,16 @@ await WorkerTransactionAsync(departmentId, async events => order.DueOn = due; order.OriginalDueOn = original; order.EscalateAfterMinutes = row.EscalateAfterMinutes; order.EscalationRoleId = row.EscalationRoleId; order.AssignedToUserId = row.AssignedToUserId; order.AssignedToRoleId = row.AssignedToRoleId; order.AssignedToUserIds = row.AssignedToUserIds; order.AssignedToRoleIds = row.AssignedToRoleIds; + // A preventive order is never auto-assigned to a removed, disabled or hidden member. Those assignees are + // dropped (the order keeps its roles, or opens unassigned for the managers) instead of failing the schedule + // on every sweep; the schedule itself keeps them until someone edits it. + if (order.AssignedToUserIds.Count != 0) + { + var active = await _authorization.ActiveMemberIdsAsync(departmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + var assignable = order.AssignedToUserIds.Where(active.Contains).ToList(); + if (order.AssignedToUserId != null && !active.Contains(order.AssignedToUserId)) order.AssignedToUserId = null; + order.AssignedToUserIds = assignable; + } order.Status = order.AssignedToUserIds.Count != 0 || order.AssignedToRoleIds.Count != 0 ? (int)WorkOrderStatus.Assigned : (int)WorkOrderStatus.Accepted; if (order.Status == 2) { await ValidateRecurrenceAssigneesAsync(owner, order, order.AssignedToUserIds, order.AssignedToRoleIds); order.AssignedOn = Now; } await PinSlaAsync(order); if (order.Status == (int)WorkOrderStatus.Accepted) order.ResponseOn = Now; diff --git a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs index 8ede04156..e1118a030 100644 --- a/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs +++ b/Core/Resgrid.Services/WorkflowSampleDataGenerator.cs @@ -293,6 +293,7 @@ private static void AddEventSpecificSamples(ScriptObject obj, WorkflowTriggerEve case WorkflowTriggerEventType.InventoryExpiring: case WorkflowTriggerEventType.InventoryCountCompleted: case WorkflowTriggerEventType.InventoryReturnOverdue: + case WorkflowTriggerEventType.InventoryDepartedHolder: case WorkflowTriggerEventType.ControlledSubstanceRecorded: AddInventorySamples(obj, eventType); break; @@ -748,11 +749,12 @@ private static void AddInventorySamples(ScriptObject obj, WorkflowTriggerEventTy if (eventType == WorkflowTriggerEventType.InventoryCountCompleted) payload = new Dictionary { ["CountId"] = "dddddddd-dddd-dddd-dddd-dddddddddddd", ["LocationId"] = sourceId, ["LineCount"] = 12, ["VarianceLineCount"] = 2, ["VarianceValue"] = ProtectedDataEnvelope.RedactionValue, ["OccurredOn"] = occurred }; - if (eventType is WorkflowTriggerEventType.InventoryLowStock or WorkflowTriggerEventType.InventoryExpiring or WorkflowTriggerEventType.InventoryReturnOverdue) + if (eventType is WorkflowTriggerEventType.InventoryLowStock or WorkflowTriggerEventType.InventoryExpiring or WorkflowTriggerEventType.InventoryReturnOverdue or WorkflowTriggerEventType.InventoryDepartedHolder) payload = new Dictionary { ["AlertId"] = "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee", ["ItemId"] = itemId, - ["AlertType"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? 0 : eventType == WorkflowTriggerEventType.InventoryExpiring ? 1 : 3, + ["AlertType"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? 0 : eventType == WorkflowTriggerEventType.InventoryExpiring ? 1 : eventType == WorkflowTriggerEventType.InventoryDepartedHolder ? 4 : 3, ["LocationId"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? null : sourceId, ["Quantity"] = 2m, - ["IssuanceId"] = eventType == WorkflowTriggerEventType.InventoryReturnOverdue ? issuanceId : null, ["DueOn"] = eventType == WorkflowTriggerEventType.InventoryLowStock ? null : occurred, ["OccurredOn"] = occurred }; + ["IssuanceId"] = eventType == WorkflowTriggerEventType.InventoryReturnOverdue ? issuanceId : null, + ["DueOn"] = eventType is WorkflowTriggerEventType.InventoryLowStock or WorkflowTriggerEventType.InventoryDepartedHolder ? null : occurred, ["OccurredOn"] = occurred }; var inventory = new ScriptObject(); foreach (var pair in InventoryWorkflowPayload.Variables) inventory[pair.Variable] = payload.TryGetValue(pair.Property, out var value) ? value : null; if (eventType == WorkflowTriggerEventType.InventoryAdjusted) diff --git a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs index 423b99af4..db3cb0690 100644 --- a/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs +++ b/Core/Resgrid.Services/WorkflowTemplateContextBuilder.cs @@ -260,6 +260,7 @@ public async Task BuildContextAsync( case WorkflowTriggerEventType.InventoryExpiring: case WorkflowTriggerEventType.InventoryCountCompleted: case WorkflowTriggerEventType.InventoryReturnOverdue: + case WorkflowTriggerEventType.InventoryDepartedHolder: case WorkflowTriggerEventType.ControlledSubstanceRecorded: { var modern = string.IsNullOrWhiteSpace(eventPayloadJson) ? null : JsonConvert.DeserializeObject(eventPayloadJson, new JsonSerializerSettings { FloatParseHandling = FloatParseHandling.Decimal }); diff --git a/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs b/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs index 20ceb025f..844dec5f8 100644 --- a/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs +++ b/Core/Resgrid.Services/Workforce/CaPayDataReportingService.cs @@ -622,7 +622,7 @@ private async Task NotifyAdminsAsync(int departmentId, string message) var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); var number = _departmentSettings?.Value == null ? null : await _departmentSettings.Value.GetTextToCallNumberForDepartmentAsync(departmentId); // Permission 77 defaults to department administrators; the digest goes to them (a narrower assignment still includes admins). - foreach (var admin in await _departmentsService.GetAllAdminsForDepartmentAsync(departmentId)) + foreach (var admin in await _departmentsService.GetActiveAdminsForDepartmentAsync(departmentId)) await _communication.Value.SendNotificationAsync(admin.UserId, departmentId, message, number, department, "Pay Data Reporting"); } catch (Exception ex) { Logging.LogException(ex, $"Pay data readiness digest could not be sent for department {departmentId}."); } diff --git a/Core/Resgrid.Services/Workforce/WorkforceService.cs b/Core/Resgrid.Services/Workforce/WorkforceService.cs index 2468fce29..a853762a1 100644 --- a/Core/Resgrid.Services/Workforce/WorkforceService.cs +++ b/Core/Resgrid.Services/Workforce/WorkforceService.cs @@ -8,6 +8,7 @@ using Resgrid.Framework; using Resgrid.Model; using Resgrid.Model.Events; +using Resgrid.Model.Helpers; using Resgrid.Model.Providers; using Resgrid.Model.Repositories; using Resgrid.Model.Services; @@ -36,13 +37,15 @@ public class WorkforceService : IWorkforceService private readonly IWorkforceAnnualPayFactRepository _annualFacts; private readonly IUserProfileService _userProfileService; private readonly IEventAggregator _eventAggregator; + private readonly IDepartmentsService _departments; private readonly WorkforceProtectionSeam _seam; public WorkforceService(IWorkforceEmployerProfileRepository employers, IWorkforceAffiliatedEntityRepository affiliates, IWorkforceEstablishmentRepository establishments, IWorkforceLaborContractorRepository contractors, IWorkforceWorkerRepository workers, IWorkforceEmploymentRepository employments, IWorkforceJobAssignmentRepository assignments, IWorkforceWorkEntryRepository workEntries, IWorkforceAnnualPayFactRepository annualFacts, IUserProfileService userProfileService, IEventAggregator eventAggregator, - Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) + IDepartmentsService departments, Lazy protectedWrite = null, Lazy protectedRead = null, IProtectedGrantContext grant = null) { + _departments = departments; _employers = employers; _affiliates = affiliates; _establishments = establishments; @@ -225,10 +228,22 @@ public async Task GetOrCreateWorkerForUserAsync(int departmentI if (string.IsNullOrWhiteSpace(userId)) throw new ArgumentException("A user id is required.", nameof(userId)); var existing = await _workers.GetByUserIdAsync(departmentId, userId); if (existing != null) return existing; + await RequireCurrentMemberAsync(departmentId, userId); var created = await _workers.SaveOrUpdateAsync(new WorkforceWorker { DepartmentId = departmentId, UserId = userId, AddedOn = DateTime.UtcNow, AddedByUserId = actorUserId }, cancellationToken); return created; } + /// + /// A worker row links only a current member of this department: not someone removed, disabled or from another + /// department. A hidden member may still hold (or self-report into) a worker row, so hidden is not refused. + /// + private async Task RequireCurrentMemberAsync(int departmentId, string userId) + { + var member = await _departments.GetDepartmentMemberAsync(userId, departmentId, true); + if (!DepartmentMemberStateHelper.IsCurrentMember(member, departmentId) || !string.Equals(member.UserId, userId, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("workforce_member_not_found"); + } + public async Task SaveWorkerAsync(WorkforceWorker worker, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) { if (worker == null) throw new ArgumentNullException(nameof(worker)); @@ -236,6 +251,8 @@ public async Task SaveWorkerAsync(WorkforceWorker worker, strin var existing = string.IsNullOrWhiteSpace(worker.WorkforceWorkerId) ? null : await _workers.GetByIdForDepartmentAsync(worker.WorkforceWorkerId, worker.DepartmentId); if (existing != null && existing.IsDeleted) throw new InvalidOperationException("workforce_not_found"); if (existing == null && !string.IsNullOrWhiteSpace(worker.UserId) && await _workers.GetByUserIdAsync(worker.DepartmentId, worker.UserId) != null) throw new InvalidOperationException("workforce_worker_duplicate"); + var linkedUser = Trim(worker.UserId); + if (linkedUser != null && !string.Equals(linkedUser, existing?.UserId, StringComparison.OrdinalIgnoreCase)) await RequireCurrentMemberAsync(worker.DepartmentId, linkedUser); var before = existing == null ? null : Snapshot(existing); var now = DateTime.UtcNow; var target = existing ?? new WorkforceWorker { DepartmentId = worker.DepartmentId, AddedOn = now, AddedByUserId = userId }; @@ -309,6 +326,30 @@ public async Task SaveEmploymentAsync(WorkforceEmployment e return await GetEmploymentAsync(saved.WorkforceEmploymentId, employment.DepartmentId); } + public async Task EndEmploymentsForMemberAsync(int departmentId, string userId, DateTime endOn, string actorUserId, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(userId)) return 0; + var worker = await _workers.GetByUserIdAsync(departmentId, userId); + if (worker == null || worker.IsDeleted) return 0; + var day = endOn.Date; + var now = DateTime.UtcNow; + var changed = 0; + foreach (var employment in ((await _employments.GetByWorkerAsync(worker.WorkforceWorkerId)) ?? Enumerable.Empty()) + .Where(e => e.DepartmentId == departmentId && !e.IsDeleted && (!e.EndOn.HasValue || e.EndOn.Value.Date > day)).ToList()) + { + var before = Snapshot(employment); + // An employment that had not begun by the removal day never covered a pay period: it is withdrawn rather than + // given an end before its start. Everything else keeps its history and simply ends that day. + if (employment.StartOn.Date > day) employment.IsDeleted = true; + else employment.EndOn = day; + employment.RowVersion += 1; employment.EditedOn = now; employment.EditedByUserId = actorUserId; + var saved = await _employments.SaveOrUpdateAsync(employment, cancellationToken); + Audit(departmentId, actorUserId, AuditLogTypes.WorkforceEmploymentChanged, null, null, before, saved); + changed++; + } + return changed; + } + public Task DeleteEmploymentAsync(string id, int departmentId, string userId, string ipAddress, string userAgent, CancellationToken cancellationToken = default) => SoftDeleteAsync(_employments, () => _employments.GetByIdForDepartmentAsync(id, departmentId), r => r.IsDeleted, (r, v) => r.IsDeleted = v, (r, on, by) => { r.EditedOn = on; r.EditedByUserId = by; }, departmentId, userId, ipAddress, userAgent, AuditLogTypes.WorkforceEmploymentChanged, cancellationToken); diff --git a/Providers/Resgrid.Providers.Migrations/Migrations/M0227_AddTimeReportScopes.cs b/Providers/Resgrid.Providers.Migrations/Migrations/M0227_AddTimeReportScopes.cs new file mode 100644 index 000000000..9ad0a5fab --- /dev/null +++ b/Providers/Resgrid.Providers.Migrations/Migrations/M0227_AddTimeReportScopes.cs @@ -0,0 +1,31 @@ +using FluentMigrator; + +namespace Resgrid.Providers.Migrations.Migrations +{ + /// + /// Crew and individual time reports (mobile field time accounting). A daily time report gains an optional scope: the + /// deployed unit whose Crew Time Report it is, or the single roster row it covers. The one-report-per-day rule becomes + /// one report per day per scope; SQL Server treats NULLs as equal in a unique index, so the deployment-wide report stays + /// one per day. + /// + [Migration(227)] + public class M0227_AddTimeReportScopes : Migration + { + public override void Up() + { + Execute.Sql("IF COL_LENGTH('DeploymentTimeReports', 'DeploymentUnitId') IS NULL ALTER TABLE [DeploymentTimeReports] ADD [DeploymentUnitId] nvarchar(36) NULL;"); + Execute.Sql("IF COL_LENGTH('DeploymentTimeReports', 'DeploymentPersonnelId') IS NULL ALTER TABLE [DeploymentTimeReports] ADD [DeploymentPersonnelId] nvarchar(36) NULL;"); + Execute.Sql("IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Date' AND object_id = OBJECT_ID('DeploymentTimeReports')) DROP INDEX [UX_DeploymentTimeReports_Date] ON [DeploymentTimeReports];"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Scope' AND object_id = OBJECT_ID('DeploymentTimeReports')) CREATE UNIQUE INDEX [UX_DeploymentTimeReports_Scope] ON [DeploymentTimeReports] ([DeploymentId], [ReportDate], [DeploymentUnitId], [DeploymentPersonnelId]) WHERE [IsDeleted] = 0 AND [Status] <> 4;"); + } + + public override void Down() + { + Execute.Sql("IF EXISTS (SELECT 1 FROM [DeploymentTimeReports] WHERE [DeploymentUnitId] IS NOT NULL OR [DeploymentPersonnelId] IS NOT NULL) THROW 51000, 'Crew and individual time reports exist; rolling back would merge them into one report per day.', 1;"); + Execute.Sql("IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Scope' AND object_id = OBJECT_ID('DeploymentTimeReports')) DROP INDEX [UX_DeploymentTimeReports_Scope] ON [DeploymentTimeReports];"); + Execute.Sql("IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = 'UX_DeploymentTimeReports_Date' AND object_id = OBJECT_ID('DeploymentTimeReports')) CREATE UNIQUE INDEX [UX_DeploymentTimeReports_Date] ON [DeploymentTimeReports] ([DeploymentId], [ReportDate]) WHERE [IsDeleted] = 0 AND [Status] <> 4;"); + Delete.Column("DeploymentPersonnelId").FromTable("DeploymentTimeReports"); + Delete.Column("DeploymentUnitId").FromTable("DeploymentTimeReports"); + } + } +} diff --git a/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0227_AddTimeReportScopesPg.cs b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0227_AddTimeReportScopesPg.cs new file mode 100644 index 000000000..134973696 --- /dev/null +++ b/Providers/Resgrid.Providers.MigrationsPg/Migrations/M0227_AddTimeReportScopesPg.cs @@ -0,0 +1,29 @@ +using FluentMigrator; + +namespace Resgrid.Providers.MigrationsPg.Migrations +{ + /// + /// Crew and individual time reports (see the SQL Server M0227). PostgreSQL treats NULLs as distinct in a unique index, so + /// the scope columns are coalesced to keep the deployment-wide report one per day. + /// + [Migration(227)] + public class M0227_AddTimeReportScopesPg : Migration + { + public override void Up() + { + Execute.Sql("ALTER TABLE deploymenttimereports ADD COLUMN IF NOT EXISTS deploymentunitid varchar(36) NULL;"); + Execute.Sql("ALTER TABLE deploymenttimereports ADD COLUMN IF NOT EXISTS deploymentpersonnelid varchar(36) NULL;"); + Execute.Sql("DROP INDEX IF EXISTS ux_deploymenttimereports_date;"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_deploymenttimereports_scope ON deploymenttimereports (deploymentid, reportdate, COALESCE(deploymentunitid, ''), COALESCE(deploymentpersonnelid, '')) WHERE isdeleted = FALSE AND status <> 4;"); + } + + public override void Down() + { + Execute.Sql("DO $$ BEGIN IF EXISTS (SELECT 1 FROM deploymenttimereports WHERE deploymentunitid IS NOT NULL OR deploymentpersonnelid IS NOT NULL) THEN RAISE EXCEPTION 'Crew and individual time reports exist; rolling back would merge them into one report per day.'; END IF; END $$;"); + Execute.Sql("DROP INDEX IF EXISTS ux_deploymenttimereports_scope;"); + Execute.Sql("CREATE UNIQUE INDEX IF NOT EXISTS ux_deploymenttimereports_date ON deploymenttimereports (deploymentid, reportdate) WHERE isdeleted = FALSE AND status <> 4;"); + Delete.Column("deploymentpersonnelid").FromTable("deploymenttimereports"); + Delete.Column("deploymentunitid").FromTable("deploymenttimereports"); + } + } +} diff --git a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs index 785fa9695..bdaa56823 100644 --- a/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs +++ b/Repositories/Resgrid.Repositories.DataRepository/DeploymentRepositories.cs @@ -68,6 +68,10 @@ public Task> GetActiveAssignmentsForUnitsAsync(int d $"WHERE u.{Col("DepartmentId")} = {P}DepartmentId AND {InList("UnitId", "UnitIds", "u")} AND u.{Col("RemovedOn")} IS NULL AND d.{Col("IsDeleted")} = {(IsPostgres ? "FALSE" : "0")} AND d.{Col("Status")} IN (0, 1, 2, 3) " + $"AND (d.{Col("StartOn")} IS NULL OR d.{Col("StartOn")} <= {P}WindowEnd) AND (d.{Col("EndOn")} IS NULL OR d.{Col("EndOn")} >= {P}WindowStart) AND d.{Col("DeploymentId")} <> {P}Excluding", new { DepartmentId = departmentId, UnitIds = InListValue(unitIds), WindowStart = DatabaseTimestamp(windowStart), WindowEnd = DatabaseTimestamp(windowEnd), Excluding = excludingDeploymentId ?? string.Empty }); + + public Task> GetForUnitsAsync(int departmentId, IEnumerable unitIds) => + QueryAsync($"SELECT * FROM {Tbl("DeploymentUnits")} WHERE {Col("DepartmentId")} = {P}DepartmentId AND {InList("UnitId", "UnitIds")} ORDER BY {Col("AddedOn")}", + new { DepartmentId = departmentId, UnitIds = InListValue(unitIds) }); } public class DeploymentPersonnelRepository : RmsRepositoryBase, IDeploymentPersonnelRepository diff --git a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json index e90381e79..5ef0d7364 100644 --- a/Tests/Resgrid.Tests/Allocations/trigger-baseline.json +++ b/Tests/Resgrid.Tests/Allocations/trigger-baseline.json @@ -143,5 +143,6 @@ "CertificationCreditAdded": 184, "TimeReportCreated": 185, "TimeReportVoided": 186, - "DeploymentAttachmentAdded": 187 + "DeploymentAttachmentAdded": 187, + "InventoryDepartedHolder": 188 } diff --git a/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs index e077f3514..f6f5463b0 100644 --- a/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordDeploymentsServiceTests.cs @@ -93,6 +93,13 @@ public async Task Cross_border_orders_carry_home_and_host_profiles_and_reject_ba await number.Should().ThrowAsync(); Func fill = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.Generic, OrderNumber = "L-1", IncidentName = "x", Fills = new List { new RecordDeploymentFillInput { ResourceKind = "person" } } }); await fill.Should().ThrowAsync().WithMessage("*request number*"); + // A fill is never assigned to someone who is not an active member, on the order or added later. + _h.Authorization.Setup(a => a.IsAssignableMemberAsync("departed", Dept)).ReturnsAsync(false); + Func departed = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.Generic, OrderNumber = "L-3", IncidentName = "x", Fills = new List { new RecordDeploymentFillInput { RequestNumber = "O-1", ResourceKind = "person", AssignedUserId = "departed" } } }); + await departed.Should().ThrowAsync().WithMessage("*active member*"); + var open = await _h.Deployments.CreateFromExternalOrderAsync(Dept, Admin, new RecordDeploymentCreateInput { ProfileKey = RmsDeploymentProfiles.Generic, OrderNumber = "L-4", IncidentName = "x" }); + Func addDeparted = () => _h.Deployments.AddFillAsync(Dept, Admin, open.Order.RmsExternalOrderId, new RecordDeploymentFillInput { RequestNumber = "O-2", AssignedUserId = "departed" }); + await addDeparted.Should().ThrowAsync().WithMessage("*active member*"); _h.Authorization.Setup(a => a.HasPermissionAsync("viewer", Dept, PermissionTypes.CreateRecord)).ReturnsAsync(false); Func denied = () => _h.Deployments.CreateFromExternalOrderAsync(Dept, "viewer", Iroc()); await denied.Should().ThrowAsync(); diff --git a/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs index f9314a591..d59c991e7 100644 --- a/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordEvidenceSelectionServiceTests.cs @@ -55,7 +55,7 @@ private Task Select(RmsEvidenceKind source, string chan [TestCase("incident", RmsRecordKind.IncidentReport)] public async Task Both_officer_record_kinds_offer_only_source_authorized_personnel(string id, RmsRecordKind kind) { - _departments.Setup(d => d.GetAllPersonnelNamesForDepartmentAsync(9)).ReturnsAsync(new List { + _departments.Setup(d => d.GetSelectablePersonnelNamesAsync(9)).ReturnsAsync(new List { new() { UserId = "visible", FirstName = "Visible", LastName = "Member" }, new() { UserId = "hidden", FirstName = "Sensitive", LastName = "Person" } }); _sourceAuth.Setup(a => a.CanUserViewPersonAsync("officer", "VISIBLE", 9)).ReturnsAsync(true); var selection = await _service.GetAsync(9, "officer", id, kind, RmsEvidenceKind.CertificationSnapshot); diff --git a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs index ffebe93c9..c09492591 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsDisclosureServiceTests.cs @@ -437,6 +437,17 @@ private async Task OpenRequestAsync(string profile = RmsRe new RmsRecordQuery { States = new List { (int)RmsRecordState.Finalized }, DefinitionKey = RmsDefinitionKeys.Run }, profile); } + [Test] + public async Task A_request_is_only_assigned_to_an_active_member() + { + _authorization.Setup(a => a.IsAssignableMemberAsync(It.IsAny(), Dept)).ReturnsAsync((string user, int department) => user != "departed"); + Func departed = () => _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter", JurisdictionProfile = "US-IL", ReceivedOn = DateTime.UtcNow, AssignedToUserId = "departed" }); + await departed.Should().ThrowAsync(); + _store.Outbox.Should().NotContain(o => o.EventName == "RecordDisclosureRequested"); + (await _service.CreateRequestAsync(Dept, "clerk", new RmsDisclosureRequest { RequesterName = "A. Reporter", JurisdictionProfile = "US-IL", ReceivedOn = DateTime.UtcNow, AssignedToUserId = " clerk " })) + .AssignedToUserId.Should().Be("clerk"); + } + [Test] public async Task Logging_a_request_emits_record_disclosure_requested_without_the_requester() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs index 7e5deb4f0..bbbbe84d2 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsInspectionsServiceTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading.Tasks; using FluentAssertions; +using Moq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using NUnit.Framework; @@ -91,6 +92,22 @@ public async Task Required_items_must_be_inspected_and_a_clean_inspection_passes inspection.State.Should().Be((int)RmsInspectionState.Closed); } + [Test] + public async Task Inspections_are_only_assigned_to_active_members_and_a_reinspection_drops_an_inspector_who_has_left() + { + Func outsider = () => _h.InspectionsService.ScheduleAsync(Dept, Admin, _occupancy.RmsOccupancyId, _program.RmsInspectionProgramId, DateTime.UtcNow, Outsider); + await outsider.Should().ThrowAsync(); + + var inspection = await _h.InspectionsService.ScheduleAsync(Dept, Admin, _occupancy.RmsOccupancyId, _program.RmsInspectionProgramId, DateTime.UtcNow, Member); + inspection.InspectorUserId.Should().Be(Member); + await _h.InspectionsService.CompleteAsync(Dept, Admin, inspection.RmsInspectionId, new List { new RmsInspectionItemResult { Key = "exits", Passed = false }, new RmsInspectionItemResult { Key = "ext", Passed = true } }, null, null); + (await _h.InspectionsService.ScheduleReinspectionAsync(Dept, Admin, inspection.RmsInspectionId, DateTime.UtcNow.AddDays(1))).InspectorUserId.Should().Be(Member); + + _h.Authorization.Setup(a => a.IsAssignableMemberAsync(Member, Dept)).ReturnsAsync(false); + var child = await _h.InspectionsService.ScheduleReinspectionAsync(Dept, Admin, inspection.RmsInspectionId, DateTime.UtcNow.AddDays(2)); + child.InspectorUserId.Should().BeNull("the parent's inspector is no longer an active member"); + } + [Test] public async Task Reinspection_follows_the_violations_and_closing_waits_for_verification() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs index da74400c6..078d0a6a4 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsInvestigationsServiceTests.cs @@ -84,6 +84,9 @@ public async Task Members_notes_evidence_and_custody_follow_the_case_rules() (await _h.InvestigationsService.GetCustodyChainAsync(Dept, Admin, evidence.RmsInvestigationEvidenceId)).Should().HaveCount(2); Func noReason = () => _h.InvestigationsService.TransferCustodyAsync(Dept, Investigator, evidence.RmsInvestigationEvidenceId, Admin, null, "", RmsEvidenceState.InStorage); await noReason.Should().ThrowAsync(); + Func toOutsider = () => _h.InvestigationsService.TransferCustodyAsync(Dept, Investigator, evidence.RmsInvestigationEvidenceId, Outsider, null, "Hand-off", RmsEvidenceState.InStorage); + await toOutsider.Should().ThrowAsync("evidence is only handed to an active member"); + (await _h.InvestigationsService.GetCustodyChainAsync(Dept, Admin, evidence.RmsInvestigationEvidenceId)).Should().HaveCount(2, "the refused hand-off left no link in the chain"); var referral = await _h.InvestigationsService.AddReferralAsync(Dept, Admin, caseId, "County Sheriff", "Possible incendiary", "SO-26-118"); await _h.InvestigationsService.UpdateReferralStateAsync(Dept, Investigator, referral.RmsInvestigationReferralId, RmsReferralState.Acknowledged); diff --git a/Tests/Resgrid.Tests/Rms/RecordsNotificationServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsNotificationServiceTests.cs index 2af54980e..0d2b6bf8a 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsNotificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsNotificationServiceTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -19,6 +20,7 @@ public class RecordsNotificationServiceTests private Mock _communication; private Mock _profiles; private RecordsNotificationService _service; + private HashSet _activeMembers; [SetUp] public void SetUp() @@ -33,6 +35,8 @@ public void SetUp() var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, Name = "Test FD" }); + _activeMembers = new HashSet { "author", "chief", "owner" }; + departments.Setup(d => d.GetActiveMemberUserIdsAsync(Dept)).ReturnsAsync(() => new HashSet(_activeMembers)); var settings = new Mock(); settings.Setup(s => s.GetTextToCallNumberForDepartmentAsync(Dept)).ReturnsAsync("+15555550100"); @@ -97,6 +101,35 @@ public void Message_uses_the_record_number_once_assigned_and_caps_the_reviewer_n message.Should().EndWith("/User/Records/Details/rec-1"); } + [Test] + public async Task A_returned_record_whose_author_has_left_goes_to_its_owner_and_nobody_inactive_is_ever_notified() + { + var record = Returned(); record.OwnerUserId = "owner"; + _records.Setup(r => r.GetByIdForDepartmentAsync(Dept, "rec-1")).ReturnsAsync(record); + _activeMembers.Remove("author"); + + (await _service.NotifyReturnedForCorrectionAsync(Dept, "rec-1")).Should().BeTrue(); + _communication.Verify(c => c.SendNotificationAsync("owner", Dept, It.IsAny(), It.IsAny(), It.IsAny(), RecordsNotificationService.ReturnedForCorrectionTitle, It.IsAny(), false), Times.Once); + + _activeMembers.Remove("owner"); + (await _service.NotifyReturnedForCorrectionAsync(Dept, "rec-1")).Should().BeFalse("a removed, disabled or hidden author or owner is never a recipient"); + _communication.Verify(c => c.SendNotificationAsync(It.Is(u => u == "author" || u == "owner"), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Once); + } + + [Test] + public async Task An_overdue_review_passes_over_an_inactive_reviewer_to_the_owner() + { + var record = Returned(); record.State = (int)RmsRecordState.ReadyForReview; record.OwnerUserId = "owner"; + _records.Setup(r => r.GetByIdForDepartmentAsync(Dept, "rec-1")).ReturnsAsync(record); + + (await _service.NotifyObligationOverdueAsync(Dept, "rec-1", RmsRecordObligation.Review)).Should().BeTrue(); + _communication.Verify(c => c.SendNotificationAsync("chief", Dept, It.IsAny(), It.IsAny(), It.IsAny(), RecordsNotificationService.ObligationOverdueTitle, It.IsAny(), false), Times.Once); + + _activeMembers.Remove("chief"); + (await _service.NotifyObligationOverdueAsync(Dept, "rec-1", RmsRecordObligation.Review)).Should().BeTrue(); + _communication.Verify(c => c.SendNotificationAsync("owner", Dept, It.IsAny(), It.IsAny(), It.IsAny(), RecordsNotificationService.ObligationOverdueTitle, It.IsAny(), false), Times.Once); + } + [Test] public async Task A_delivery_fault_is_logged_and_reported_as_not_sent() { diff --git a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs index 5d99753eb..8e894b172 100644 --- a/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs +++ b/Tests/Resgrid.Tests/Rms/RecordsServiceTests.cs @@ -85,6 +85,7 @@ public void SetUp() _evidence = new Mock(); _authorization = new Mock(); _authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + _authorization.Setup(a => a.IsAssignableMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); _authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); _authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); _authorization.Setup(a => a.CanReadSourceCallAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); @@ -723,6 +724,16 @@ public async Task Reassign_changes_the_owner_but_never_the_author() _store.Audits.Should().ContainSingle(a => a.Action == (int)RmsAccessAuditAction.Admin && a.Purpose == "Reassign draft"); } + [Test] + public async Task A_draft_is_never_reassigned_to_someone_who_is_not_an_active_member() + { + var created = await _service.CreateDraftAsync(Dept, "author", TrainingInput()); + _authorization.Setup(a => a.IsAssignableMemberAsync("departed", Dept)).ReturnsAsync(false); + Func reassign = () => _service.ReassignDraftAsync(Dept, "chief", created.Record.RmsOperationalRecordId, "departed", "left department"); + await reassign.Should().ThrowAsync(); + _store.Audits.Should().NotContain(a => a.Purpose == "Reassign draft"); + } + [Test] public async Task Failed_transaction_discards_and_leaves_no_outbox_row() { diff --git a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs index 1f50aa7e1..402f4e285 100644 --- a/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs +++ b/Tests/Resgrid.Tests/Rms/RmsDefinitionHarness.cs @@ -58,6 +58,7 @@ public RmsDefinitionHarness() Defs = new FakeRmsDefinitionStore(Store); Authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); + Authorization.Setup(a => a.IsAssignableMemberAsync(It.IsAny(), It.IsAny())).ReturnsAsync(true); Authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string u, int d) => u == Admin && d == Dept); Authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync(true); Authorization.Setup(a => a.CanUserViewRecordAsync(It.IsAny(), It.IsAny(), Dept)).ReturnsAsync(true); diff --git a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs index f663c4c4e..c5fbf4ac3 100644 --- a/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs +++ b/Tests/Resgrid.Tests/Rms/RmsIdentifierPinTests.cs @@ -150,6 +150,8 @@ public void Workflow_triggers_in_the_rms_1_subset_are_the_registry_values() ((int)WorkflowTriggerEventType.CertificationCreditAdded).Should().Be(184); ((int)WorkflowTriggerEventType.TimeReportCreated).Should().Be(185); ((int)WorkflowTriggerEventType.DeploymentAttachmentAdded).Should().Be(187); + // The inventory departed-holder alert took 188 on 2026-09-22. + ((int)WorkflowTriggerEventType.InventoryDepartedHolder).Should().Be(188); foreach (var value in Enumerable.Range(177, 3)) Enum.IsDefined(typeof(WorkflowTriggerEventType), value).Should().BeFalse($"WorkflowTriggerEventType {value} is reserved for the Enhanced AI add-on"); foreach (var value in Enumerable.Range(52, 48).Except(Enumerable.Range(52, 6)).Except(Enumerable.Range(58, 16)).Except(Enumerable.Range(74, 13)).Except(Enumerable.Range(87, 7)).Except(new[] { 94, 95 })) diff --git a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs index 5deb2a266..765ce085e 100644 --- a/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs +++ b/Tests/Resgrid.Tests/Rms/RmsPreventionFakes.cs @@ -385,6 +385,7 @@ public RmsPreventionHarness() Cutover.Setup(c => c.GetModuleStateAsync(It.IsAny(), It.IsAny())).ReturnsAsync((int d, bool b) => new RecordsModuleState { DepartmentId = d, FlagEnabled = RecordsUsable, Activated = RecordsUsable, ActivatedOn = DateTime.UtcNow.AddDays(-30), CutoverState = RecordsUsable ? RmsDepartmentCutoverState.Active : (RmsDepartmentCutoverState?)null }); Flags.Setup(f => f.IsEnabledAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny>())).ReturnsAsync((string key, int d, bool dv, IDictionary ctx) => !DisabledFlags.Contains(key)); Authorization.Setup(a => a.IsActiveMemberAsync(It.IsAny(), Dept)).ReturnsAsync((string u, int d) => u != Outsider); + Authorization.Setup(a => a.IsAssignableMemberAsync(It.IsAny(), Dept)).ReturnsAsync((string u, int d) => u != Outsider); Authorization.Setup(a => a.IsDepartmentAdminAsync(It.IsAny(), Dept)).ReturnsAsync((string u, int d) => DepartmentAdmins.Contains(u)); Authorization.Setup(a => a.HasPermissionAsync(It.IsAny(), Dept, It.IsAny())).ReturnsAsync((string u, int d, PermissionTypes p) => p == PermissionTypes.RecordsPreventionAdmin ? PreventionAdmins.Contains(u) : p == PermissionTypes.ViewRestrictedRecords ? RestrictedViewers.Contains(u) : p == PermissionTypes.ReviewRecords ? Reviewers.Contains(u) : DepartmentAdmins.Contains(u)); diff --git a/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs index ed6c10fd6..e11956717 100644 --- a/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CalOesMarsServiceTests.cs @@ -131,6 +131,7 @@ public void SetUp() _deployments.Setup(d => d.GetAttachmentsAsync("dep-1", DeptId)).ReturnsAsync(() => _attachments.ToList()); _deployments.Setup(d => d.GetAttachmentAsync(It.IsAny(), DeptId, It.IsAny())).ReturnsAsync((int id, int _, bool __) => _attachments.FirstOrDefault(a => a.DeploymentAttachmentId == id)); _deployments.Setup(d => d.IsRosteredAsync("dep-1", DeptId, It.IsAny())).ReturnsAsync((string _, int __, string user) => _deployment.Personnel.Any(p => p.UserId == user)); + _deployments.Setup(d => d.CanFieldMemberSeeAsync("dep-1", DeptId, It.IsAny())).ReturnsAsync((string _, int __, string user) => _deployment.Personnel.Any(p => p.UserId == user)); _deployments.Setup(d => d.GetCostRecoveryDeploymentsReleasedBeforeAsync(DeptId, It.IsAny())).ReturnsAsync(() => new List { _deployment }); var timeTracking = new Mock(); timeTracking.Setup(t => t.GetTimeReportsAsync("dep-1", DeptId)).ReturnsAsync(() => _reports.ToList()); @@ -145,6 +146,7 @@ public void SetUp() var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Test", TimeZone = "UTC" }); departments.Setup(d => d.GetAllAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); var events = new Mock(); events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); var communication = new Mock(); diff --git a/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs b/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs index 804432703..2e74b5a4e 100644 --- a/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CertificationServiceTests.cs @@ -30,6 +30,8 @@ public class CertificationServiceTests private List _credits; private DepartmentCertificationSettings _settings; private List _members; + /// The department's active members; anyone else stands for a removed, disabled or hidden member. + private HashSet _activeMembers; private readonly List _published = new List(); private readonly List<(string UserId, string Message)> _notified = new List<(string, string)>(); private Mock _roles; @@ -55,6 +57,7 @@ public void SetUp() _credits = new List(); _settings = null; _members = new List(); + _activeMembers = new HashSet { "u1", "u2", "u3", "u4", "admin-1" }; var types = new Mock(); types.Setup(r => r.GetAllByDepartmentIdAsync(Dept)).ReturnsAsync(() => _types.ToList()); @@ -150,7 +153,8 @@ public void SetUp() profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool _) => new UserProfile { UserId = id, FirstName = "Member", LastName = id }); var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, Name = "Test" }); - departments.Setup(d => d.GetAllAdminsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin-1" } }); + departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin-1" } }); + departments.Setup(d => d.GetActiveMemberUserIdsAsync(Dept)).ReturnsAsync(() => new HashSet(_activeMembers)); var departmentSettings = new Mock(); departmentSettings.Setup(d => d.GetTextToCallNumberForDepartmentAsync(Dept)).ReturnsAsync("+15555550100"); var communication = new Mock(); @@ -463,6 +467,38 @@ public async Task Enforcement_removes_only_under_enforce_and_only_after_grace_an (await _service.RunExpirySweepAsync(Dept, Today.AddDays(1))).Removed.Should().Be(1, "removal on day grace + 1"); } + [Test] + public async Task Removed_disabled_and_hidden_members_are_left_out_of_the_sweep_the_digest_and_the_dashboard() + { + // "removed-member", "disabled-member" and "hidden-member" are not in _activeMembers: the department service + // leaves them out of the active set whichever of the three flags put them there. + _settings = new DepartmentCertificationSettings { DepartmentId = Dept, NotifyLeadDaysCsv = "7", NotifyCertificationHolder = true, SendAdminDigest = true, + EnforcementMode = (int)CertificationEnforcementModes.Enforce, RoleRemovalGraceDays = 0 }; + var kept = AddRecord(1, Today.AddDays(-1), "u1"); + var removed = AddRecord(1, Today.AddDays(-1), "removed-member"); + var disabled = AddRecord(1, Today.AddDays(7), "disabled-member"); + var hidden = AddRecord(1, Today.AddDays(-40), "hidden-member", PersonnelCertificationStatuses.Expired); + _requirements.Add(new PersonnelRoleCertificationRequirement { PersonnelRoleCertificationRequirementId = 1, PersonnelRoleId = 12, DepartmentId = Dept, DepartmentCertificationTypeId = 1, IsMandatory = true, AddedOn = Today.AddYears(-1) }); + _members.Add(new PersonnelRoleUser { PersonnelRoleUserId = 1, PersonnelRoleId = 12, DepartmentId = Dept, UserId = "hidden-member" }); + + var result = await _service.RunExpirySweepAsync(Dept, Today); + + result.Expired.Should().Be(1); result.ExpiringNotified.Should().Be(0); result.Removed.Should().Be(0); result.InGrace.Should().Be(0); + kept.Status.Should().Be((int)PersonnelCertificationStatuses.Expired); + removed.Status.Should().Be((int)PersonnelCertificationStatuses.Active, "a departed member's record is not expired or announced"); + disabled.Status.Should().Be((int)PersonnelCertificationStatuses.Active); + _published.OfType().Should().ContainSingle().Which.Certification.UserId.Should().Be("u1"); + _published.OfType().Should().BeEmpty(); + _published.OfType().Should().BeEmpty("enforcement leaves inactive members alone"); + _members.Should().ContainSingle(); + _notified.Should().OnlyContain(n => n.UserId == "admin-1", "no holder notice reaches an inactive member"); + _notified.Should().ContainSingle().Which.Message.Should().Contain("1 expired, 0 expiring").And.NotContain("-member"); + + var dashboard = await _service.GetExpiryDashboardAsync(Dept, Today); + dashboard.PersonCells.Select(c => c.SubjectId).Should().Equal("u1"); + dashboard.ExpiredCount.Should().Be(1); + } + [Test] public async Task Departments_in_scope_are_the_union_of_typed_records_unit_records_and_requirements() { diff --git a/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs b/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs index 37b8f0013..ee8aef7fb 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistAssignmentTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -35,6 +36,28 @@ public async Task Assignment_membership_requires_current_same_department_members members[0].IsDisabled = true; (await service.MembersAsync(77, 4, "12")).Should().BeEmpty(); } [Test] + public async Task Pickers_offer_active_members_only_but_a_hidden_member_keeps_an_existing_assignment_and_target() + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "author" }; + var departments = new Mock(); + var members = new List { new DepartmentMember { DepartmentId = 77, UserId = "author" }, new DepartmentMember { DepartmentId = 77, UserId = "hidden", IsHidden = true }, new DepartmentMember { DepartmentId = 77, UserId = "disabled", IsDisabled = true } }; + departments.Setup(d => d.GetAllMembersForDepartmentUnlimitedAsync(77, true)).ReturnsAsync(members); + foreach (var member in members) departments.Setup(d => d.GetDepartmentMemberAsync(member.UserId, 77, true)).ReturnsAsync(member); + departments.Setup(d => d.GetActiveMemberUserIdsAsync(77)).ReturnsAsync(new HashSet { "author" }); + var roles = new Mock(); roles.Setup(r => r.GetRolesForDepartmentUnlimitedAsync(77)).ReturnsAsync(new List()); + var groups = new Mock(); groups.Setup(g => g.GetAllGroupsForDepartmentUnlimitedThinAsync(77)).ReturnsAsync(new List()); + var units = new Mock(); units.Setup(u => u.GetUnitsForDepartmentAsync(77)).ReturnsAsync(new List()); + var assignments = new ChecklistAssignmentService(departments.Object, groups.Object, units.Object, roles.Object); + (await assignments.ChoicesAsync(actor)).Where(c => c.Type == 1).Select(c => c.Id).Should().Equal("author"); + (await assignments.MembersAsync(77, 1, "hidden")).Should().Equal(new[] { "hidden" }, "a hidden member can still perform what they are already assigned"); + + var resources = new Mock(); resources.Setup(r => r.CanUserViewPersonAsync("author", It.IsAny(), 77)).ReturnsAsync(true); + var profiles = new Mock(); profiles.Setup(p => p.GetProfileByUserIdAsync(It.IsAny(), It.IsAny())).ReturnsAsync((string id, bool _) => new UserProfile { UserId = id, FirstName = "Member", LastName = id }); + var authorization = new ChecklistAuthorizationService(departments.Object, groups.Object, roles.Object, Mock.Of(), units.Object, resources.Object, profiles.Object); + (await authorization.TargetsAsync(actor, ChecklistTargetType.Personnel)).Select(t => t.Id).Should().Equal("author"); + (await authorization.TargetAsync(actor, ChecklistTargetType.Personnel, "hidden")).Id.Should().Be("hidden", "a schedule already aimed at a hidden member still validates on save"); + } + [Test] public async Task Asset_picker_revalidates_tenant_and_provider_authorization_and_hides_an_absent_module() { var actor = new ChecklistActor { DepartmentId = 77, UserId = "author" }; var departments = new Mock(); diff --git a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs index fc5e7aebb..dccb3df31 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistP1M4Tests.cs @@ -25,9 +25,12 @@ public partial class ChecklistWorkflowTests private static readonly DateTime ReportMonth = new(2026, 8, 1, 0, 0, 0, DateTimeKind.Utc); private const string ReportAsset = "81366c6a-aaad-41be-9df1-9091a42d6073"; private static ChecklistReportQuery Month() => new() { FromUtc = ReportMonth, UntilUtc = ReportMonth.AddDays(30) }; + private HashSet _reportActiveMembers; private async Task SeedReportMonth() { + _reportActiveMembers = new() { "author", "person" }; _authorization.Setup(a => a.CanReadAsync(It.IsAny(), It.IsAny())).ReturnsAsync((ChecklistActor a, ChecklistCompletion c) => a.DepartmentId == c.DepartmentId && a.UserId == "author"); + _authorization.Setup(a => a.ActiveMemberIdsAsync(77)).ReturnsAsync(() => new HashSet(_reportActiveMembers)); foreach (var target in new[] { new ChecklistTarget { Type = ChecklistTargetType.Unit, Id = "1", Name = "Engine 1" }, new ChecklistTarget { Type = ChecklistTargetType.InventoryAsset, Id = ReportAsset, Name = "=SUM(1,2)" }, new ChecklistTarget { Type = ChecklistTargetType.Personnel, Id = "person", Name = "Synthetic person" } }) { var definition = new ChecklistDefinition { DepartmentId = 77, Content = "{}" }; @@ -60,6 +63,16 @@ public async Task P1M4_seeded_month_matches_hand_calculated_unit_person_and_asse JsonConvert.SerializeObject(report).Should().NotContain("SYNTHETIC-PHI-CANARY"); } [Test] + public async Task P1M4_compliance_leaves_out_personnel_checks_of_removed_disabled_or_hidden_members_but_their_history_stays() + { + await SeedReportMonth(); _reportActiveMembers = new() { "author" }; + var report = await _service.GetComplianceSummaryAsync(_actor, Month()); + report.Groups.Select(g => g.Target.Type).Should().BeEquivalentTo(new[] { ChecklistTargetType.Unit, ChecklistTargetType.InventoryAsset }); + report.Entries.Should().NotContain(e => e.Target.Type == ChecklistTargetType.Personnel); + report.Trend.Sum(d => d.Expected).Should().Be(50, "the departed member's checks leave the denominator too"); + (await _service.GetEntityChecklistHistoryAsync(_actor, ChecklistTargetType.Personnel, "person", ReportMonth, ReportMonth.AddDays(30))).Should().HaveCount(30, "the record view keeps them on file"); + } + [Test] public async Task P1M4_reports_fail_closed_for_protected_data_disabled_flags_and_invalid_ranges_and_filter_other_members() { await SeedReportMonth(); var query = Month(); query.UntilUtc = query.FromUtc; diff --git a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs index 51eeb6172..6fdc328ab 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistPr504SecurityTests.cs @@ -83,7 +83,7 @@ public void Readiness_trigger_membership_cannot_be_replaced_through_the_public_c var collection = (IList)ChecklistWorkflowPayload.Triggers; Action change = () => collection[0] = 999; change.Should().Throw(); - ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165, 73, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166); + ChecklistWorkflowPayload.Triggers.Should().Equal(67, 68, 69, 70, 71, 72, 164, 165, 73, 167, 168, 169, 170, 171, 172, 173, 174, 175, 176, 22, 58, 59, 60, 61, 62, 63, 64, 65, 66, 166, 188); ChecklistWorkflowPayload.IsChecklist(67).Should().BeTrue(); ChecklistWorkflowPayload.IsChecklist(999).Should().BeFalse(); } diff --git a/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs b/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs index 45d074fff..94ef8a6d7 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistReminderTests.cs @@ -133,6 +133,19 @@ public async Task Reminder_pending_delivery_rechecks_flag_and_membership() harness.Communication.Invocations.Should().BeEmpty(); } [Test] + public async Task Reminders_never_reach_a_hidden_member_whether_hidden_before_or_after_the_notice_was_queued() + { + var row = await ReminderOccurrence(); var harness = Reminders(); + harness.BeforeClaim = () => harness.Members[0].IsHidden = true; + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).Suppressed.Should().Be(1); + harness.Communication.Invocations.Should().BeEmpty(); + + harness = Reminders(); harness.Members[0].IsHidden = true; + (await harness.Service.SweepAsync(row.PeriodStartUtc.Value)).HandedOff.Should().Be(0); + harness.Notices.Should().BeEmpty("a hidden admin is not a recipient of the department check"); + harness.Communication.Invocations.Should().BeEmpty(); + } + [Test] public async Task Reminder_provider_exception_retries_after_backoff_but_policy_suppression_does_not() { var row = await ReminderOccurrence(); var harness = Reminders(); var now = row.PeriodStartUtc.Value; diff --git a/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs b/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs index 8f23fd20f..f0d1d4605 100644 --- a/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs +++ b/Tests/Resgrid.Tests/Services/ChecklistSchedulingTests.cs @@ -33,6 +33,33 @@ private sealed class ScheduleClock : TimeProvider return (input, clock); } [Test] + public async Task A_schedule_assigned_to_a_member_who_has_left_is_held_suspended_and_resumes_without_back_filled_misses() + { + var clock = new ScheduleClock(); var departed = false; + var assignments = new Mock(); + assignments.Setup(a => a.ValidateAsync(77, (int)ChecklistAssignmentType.User, "assignee")) + .Returns(() => departed ? Task.FromException(new ChecklistException(400, "AssignmentUnavailable")) : Task.CompletedTask); + _service = new ChecklistsService(_store, _authorization.Object, _access.Object, _uow.Object, _audits.Object, _outbox.Object, + new Lazy(() => _read.Object), new Lazy(() => _write.Object), _scanner.Object, clock, assignments.Object); + var definition = await _service.SaveDefinitionAsync(_actor, null, 0, Form()); + await _service.PublishAsync(_actor, definition, 1); + await _service.SaveScheduleAsync(_actor, new ChecklistScheduleInput { DefinitionId = definition, Name = "Personal check", TargetId = "77", StartDate = clock.Now.Date, + AssignmentType = (int)ChecklistAssignmentType.User, AssignmentId = "assignee" }); + + departed = true; + (await _service.SweepSchedulesAsync(clock.Now.UtcDateTime)).Generated.Should().Be(0); + (await _store.ListAsync(77)).Single().IsSuspended.Should().BeTrue("nobody can perform a check assigned to someone who has left"); + clock.Now = clock.Now.AddDays(3); + var held = await _service.SweepSchedulesAsync(clock.Now.UtcDateTime); + held.Generated.Should().Be(0); held.Missed.Should().Be(0); + _events.Should().NotContain(e => e.Trigger == WorkflowTriggerEventType.ChecklistMissed); + + departed = false; + var resumed = await _service.SweepSchedulesAsync(clock.Now.UtcDateTime); + resumed.Generated.Should().BeGreaterThan(0); resumed.Missed.Should().Be(0, "the suspended days are not back-filled as missed"); + (await _store.ListAsync(77)).Single().IsSuspended.Should().BeFalse(); + } + [Test] public async Task Scheduling_pins_versions_and_generates_once_without_decrypting_or_copying_content() { var setup = await Scheduled(); diff --git a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs index d72a8465b..4ab4f5815 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationServiceTests.cs @@ -352,6 +352,19 @@ public async Task should_be_able_to_send_call() //_pushServiceMock.Verify(m => m.PushCall(It.IsAny(), Users.TestUser1Id)); } + [TestCase("NWO:00000000-0000-0000-0000-000000000019", "NWO:00000000-0000-0000-0000-000000000019")] + [TestCase(null, null)] + [TestCase(" ", null)] + public async Task notification_event_code_rides_only_on_the_push(string eventCode, string expectedId) + { + var profile = new UserProfile { UserId = TestData.Users.TestUser1Id, SendNotificationSms = true, MobileNumberVerified = true, SendNotificationPush = true }; + + await _communicationService.SendNotificationAsync(profile.UserId, 1, "WO-2026-000019: needs attention", "15555550100", new Department { Code = "ABCD" }, "Work order", profile, false, eventCode); + + _pushServiceMock.Verify(m => m.PushNotification(It.Is(s => s.Id == expectedId && s.Title == "Work order"), profile.UserId, profile), Times.Once); + _smsServiceMock.Verify(m => m.SendNotificationAsync(profile.UserId, 1, "Work order WO-2026-000019: needs attention", "15555550100", profile), Times.Once); + } + [TestCase(true, true)] [TestCase(null, true)] [TestCase(false, false)] diff --git a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs index 98122ae8f..38028dc24 100644 --- a/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/CommunicationTestServiceTests.cs @@ -244,6 +244,40 @@ public async Task should_create_results_per_user_per_channel() Times.Exactly(6)); } + [Test] + public async Task should_not_test_disabled_or_hidden_members() + { + var testId = Guid.NewGuid(); + _communicationTestRepoMock.Setup(x => x.GetByIdAsync(testId)).ReturnsAsync(new CommunicationTest + { + CommunicationTestId = testId, + DepartmentId = 1, + TestEmail = true, + ResponseWindowMinutes = 60, + Active = true + }); + + _departmentsServiceMock.Setup(x => x.GetAllMembersForDepartmentAsync(1)).ReturnsAsync(new List + { + new DepartmentMember { UserId = TestData.Users.TestUser1Id, DepartmentId = 1 }, + new DepartmentMember { UserId = TestData.Users.TestUser2Id, DepartmentId = 1, IsDisabled = true }, + new DepartmentMember { UserId = "hidden-member", DepartmentId = 1, IsHidden = true } + }); + _userProfileServiceMock.Setup(x => x.GetAllProfilesForDepartmentAsync(1, false)).ReturnsAsync(new Dictionary + { + { TestData.Users.TestUser1Id, new UserProfile { UserId = TestData.Users.TestUser1Id, MembershipEmail = "user1@test.com", EmailVerified = true } } + }); + + SetupRunAndResultPersistence(); + + var run = await StartAndBuildAsync(testId, 1, TestData.Users.TestUser1Id); + + run.TotalUsersTested.Should().Be(1); + _communicationTestResultRepoMock.Verify( + x => x.SaveOrUpdateAsync(It.Is(r => r.UserId != TestData.Users.TestUser1Id), It.IsAny(), true), + Times.Never); + } + [Test] public async Task should_hand_the_run_to_the_worker_instead_of_building_or_sending_inline() { diff --git a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs index ea6aee46e..1c9923d6f 100644 --- a/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/ContractorBillingServiceTests.cs @@ -112,6 +112,7 @@ public void SetUp() var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Test County Fire", TimeZone = "Pacific Standard Time" }); departments.Setup(d => d.GetAllAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); var outbox = new Mock(); outbox.Setup(o => o.EnqueueAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Callback((_, __, e, ___) => _published.Add(e)).ReturnsAsync(new DomainEventOutboxEntry()); diff --git a/Tests/Resgrid.Tests/Services/DepartmentMemberStateTests.cs b/Tests/Resgrid.Tests/Services/DepartmentMemberStateTests.cs new file mode 100644 index 000000000..c9cffc0ff --- /dev/null +++ b/Tests/Resgrid.Tests/Services/DepartmentMemberStateTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Helpers; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// Removed, disabled and hidden members are left out of automated reporting and notifications (the expired-certification + /// report that named departed members). These pin the shared predicates and the department-level sets built on them. + /// + [TestFixture] + public class DepartmentMemberStateTests + { + private const int Dept = 31; + + private static DepartmentMember Member(string userId, bool deleted = false, bool? disabled = null, bool? hidden = null, bool? admin = null, int departmentId = Dept) + => new DepartmentMember { DepartmentId = departmentId, UserId = userId, IsDeleted = deleted, IsDisabled = disabled, IsHidden = hidden, IsAdmin = admin }; + + private static List Roster() => new List + { + Member("active", admin: true), + Member("nulls"), + Member("removed", deleted: true, admin: true), + Member("disabled", disabled: true, admin: true), + Member("hidden", hidden: true, admin: true), + Member("owner", hidden: false, disabled: false), + Member("elsewhere", departmentId: 99) + }; + + private static DepartmentsService Service(Mock members, Mock departments, IUserProfileService profiles = null, ICacheProvider cache = null) + => new DepartmentsService(departments.Object, members.Object, Mock.Of(), Mock.Of(), + Mock.Of(), cache ?? Mock.Of(), Mock.Of(), Mock.Of(), + profiles ?? Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of()); + + [Test] + public void Current_excludes_removed_and_disabled_and_active_also_excludes_hidden() + { + var byId = Roster().ToDictionary(m => m.UserId); + DepartmentMemberStateHelper.IsCurrentMember(byId["active"], Dept).Should().BeTrue(); + DepartmentMemberStateHelper.IsCurrentMember(byId["nulls"], Dept).Should().BeTrue("null flags read as false"); + DepartmentMemberStateHelper.IsCurrentMember(byId["hidden"], Dept).Should().BeTrue("hidden members can still act"); + DepartmentMemberStateHelper.IsCurrentMember(byId["removed"], Dept).Should().BeFalse(); + DepartmentMemberStateHelper.IsCurrentMember(byId["disabled"], Dept).Should().BeFalse(); + DepartmentMemberStateHelper.IsCurrentMember(byId["elsewhere"], Dept).Should().BeFalse("another department's row"); + DepartmentMemberStateHelper.IsCurrentMember(null, Dept).Should().BeFalse(); + + DepartmentMemberStateHelper.IsActiveMember(byId["active"], Dept).Should().BeTrue(); + DepartmentMemberStateHelper.IsActiveMember(byId["nulls"], Dept).Should().BeTrue(); + DepartmentMemberStateHelper.IsActiveMember(byId["hidden"], Dept).Should().BeFalse(); + DepartmentMemberStateHelper.IsActiveMember(byId["removed"], Dept).Should().BeFalse(); + DepartmentMemberStateHelper.IsActiveMember(byId["disabled"], Dept).Should().BeFalse(); + } + + [Test] + public async Task Active_member_ids_come_from_the_unlimited_roster_and_leave_out_removed_disabled_and_hidden() + { + var members = new Mock(); + members.Setup(m => m.GetAllDepartmentMembersUnlimitedAsync(Dept)).ReturnsAsync(Roster()); + var ids = await Service(members, new Mock()).GetActiveMemberUserIdsAsync(Dept); + + ids.Should().BeEquivalentTo(new[] { "active", "nulls", "owner" }); + ids.Contains("ACTIVE").Should().BeTrue("user ids compare case-insensitively"); + } + + [Test] + public async Task Picker_names_are_active_members_only_while_the_label_list_keeps_everyone_not_removed() + { + var members = new Mock(); + members.Setup(m => m.GetAllDepartmentMembersUnlimitedAsync(Dept)).ReturnsAsync(Roster()); + var profiles = new Mock(); + // The profile list (like its query) already leaves removed members out; disabled and hidden ones are still on it. + profiles.Setup(p => p.GetAllProfilesForDepartmentAsync(Dept, It.IsAny())).ReturnsAsync(new[] { "owner", "nulls", "disabled", "hidden", "active" } + .ToDictionary(id => id, id => new UserProfile { UserId = id, FirstName = "First", LastName = id })); + var cache = new Mock(); + cache.Setup(c => c.RetrieveAsync(It.IsAny(), It.IsAny>>>(), It.IsAny())) + .Returns((string key, Func>> load, TimeSpan expiry) => load()); + var service = Service(members, new Mock(), profiles.Object, cache.Object); + + (await service.GetSelectablePersonnelNamesAsync(Dept)).Select(n => n.LastName).Should().Equal("active", "nulls", "owner"); + (await service.GetAllPersonnelNamesForDepartmentAsync(Dept)).Select(n => n.LastName).Should().BeEquivalentTo(new[] { "owner", "nulls", "disabled", "hidden", "active" }, + "history and kept values are still labelled from the full list"); + } + + [Test] + public async Task Admin_lists_drop_removed_and_disabled_admins_and_the_active_list_drops_hidden_ones_too() + { + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentWithMembersByIdAsync(Dept)).ReturnsAsync(new Department { DepartmentId = Dept, ManagingUserId = "owner", Members = Roster() }); + var service = Service(new Mock(), departments); + + (await service.GetAllAdminsForDepartmentAsync(Dept)).Select(a => a.UserId).Should().BeEquivalentTo(new[] { "active", "hidden", "owner" }, + "removal leaves IsAdmin set, so a removed or disabled admin must be filtered here"); + (await service.GetActiveAdminsForDepartmentAsync(Dept)).Select(a => a.UserId).Should().BeEquivalentTo(new[] { "active", "owner" }); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs b/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs index c9b61c046..62b80b57c 100644 --- a/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs +++ b/Tests/Resgrid.Tests/Services/DeploymentLocalizationTests.cs @@ -63,7 +63,7 @@ public void Deployment_views_controllers_and_service_errors_resolve_real_resourc foreach (var type in Enum.GetNames()) keys.Add("Attachment" + type); foreach (var type in Enum.GetNames()) keys.Add("Entry" + type); foreach (var code in new[] { Resgrid.Model.Invoicing.DeploymentRosterWarning.ScheduleConflict, Resgrid.Model.Invoicing.DeploymentRosterWarning.RoleNotHeld, Resgrid.Model.Invoicing.DeploymentRosterWarning.CertificationMissing, Resgrid.Model.Invoicing.DeploymentRosterWarning.CertificationExpiring, Resgrid.Model.Invoicing.DeploymentRosterWarning.AlreadyRostered, "inventory_issue_failed" }) keys.Add("Warning" + code); - foreach (var code in new[] { Resgrid.Model.Invoicing.TimeReportValidation.Overlap, Resgrid.Model.Invoicing.TimeReportValidation.EndBeforeStart, Resgrid.Model.Invoicing.TimeReportValidation.SubjectNotOnRoster, Resgrid.Model.Invoicing.TimeReportValidation.NoEntries, Resgrid.Model.Invoicing.TimeReportValidation.BreakRule, Resgrid.Model.Invoicing.TimeReportValidation.LongTravel, Resgrid.Model.Invoicing.TimeReportValidation.OutsideReportDate }) keys.Add("Issue" + code); + foreach (var code in new[] { Resgrid.Model.Invoicing.TimeReportValidation.Overlap, Resgrid.Model.Invoicing.TimeReportValidation.EndBeforeStart, Resgrid.Model.Invoicing.TimeReportValidation.SubjectNotOnRoster, Resgrid.Model.Invoicing.TimeReportValidation.NoEntries, Resgrid.Model.Invoicing.TimeReportValidation.BreakRule, Resgrid.Model.Invoicing.TimeReportValidation.LongTravel, Resgrid.Model.Invoicing.TimeReportValidation.OutsideReportDate, Resgrid.Model.Invoicing.TimeReportValidation.SubjectOutsideScope, Resgrid.Model.Invoicing.TimeReportValidation.SubjectOnOtherReport }) keys.Add("Issue" + code); foreach (var permission in new[] { PermissionTypes.ManageDeployments, PermissionTypes.ApproveTimeReports }) { keys.Add(permission.ToString()); diff --git a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs index 9e2717e65..56e291290 100644 --- a/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/DeploymentServiceTests.cs @@ -459,5 +459,48 @@ public async Task Header_rows_by_ids_skip_blank_ids_and_missing_rows() (await _service.GetDeploymentsByIdsAsync(DeptId, null)).Should().BeEmpty(); _deployments.Verify(r => r.GetByIdsAsync(DeptId, It.IsAny>()), Times.Once, "blank input never reaches the repository"); } + + [Test] + public async Task Time_access_covers_own_row_rostered_crew_and_the_crew_seated_on_a_deployed_unit() + { + var deployment = new Deployment + { + DeploymentId = "dep-t", DepartmentId = DeptId, + Units = { new DeploymentUnit { DeploymentUnitId = "du-1", UnitId = 1 }, new DeploymentUnit { DeploymentUnitId = "du-2", UnitId = 2 }, new DeploymentUnit { DeploymentUnitId = "du-3", UnitId = 3, RemovedOn = DateTime.UtcNow } }, + Personnel = + { + new DeploymentPersonnel { DeploymentPersonnelId = "dp-a", UserId = "alice", DeploymentUnitId = "du-1" }, + new DeploymentPersonnel { DeploymentPersonnelId = "dp-b", UserId = "bob", DeploymentUnitId = "du-1", RemovedOn = DateTime.UtcNow }, + new DeploymentPersonnel { DeploymentPersonnelId = "dp-c", UserId = "carol", DeploymentUnitId = "du-2" }, + new DeploymentPersonnel { DeploymentPersonnelId = "dp-d", UserId = "dave" } + }, + Equipment = { new DeploymentEquipment { DeploymentEquipmentId = "de-1", DeploymentUnitId = "du-2" } } + }; + // The Engine 2 tablet signs in as "tablet", seated on unit 2 (and on unit 3, whose deployment row was released). + _unitsService.Setup(u => u.GetAllActiveRolesForUnitsByDepartmentIdAsync(DeptId)).ReturnsAsync(new List + { + new UnitActiveRole { UnitId = 2, UserId = "tablet", DepartmentId = DeptId }, new UnitActiveRole { UnitId = 3, UserId = "tablet", DepartmentId = DeptId } + }); + + var alice = await _service.GetTimeAccessAsync(deployment, "alice", false); + alice.PersonnelId.Should().Be("dp-a"); + alice.CrewUnitIds.Should().Equal("du-1"); + alice.WritableSubjectIds.Should().BeEquivalentTo(new[] { "dp-a", "du-1", "dp-b" }, "a released crew member's time is still the crew's to report"); + + var dave = await _service.GetTimeAccessAsync(deployment, "dave", false); + dave.CrewUnitIds.Should().BeEmpty(); + dave.WritableSubjectIds.Should().BeEquivalentTo(new[] { "dp-d" }); + + var tablet = await _service.GetTimeAccessAsync(deployment, "tablet", false); + tablet.IsRostered.Should().BeFalse(); + tablet.CanRead.Should().BeTrue(); + tablet.CrewUnitIds.Should().Equal("du-2"); + tablet.WritableSubjectIds.Should().BeEquivalentTo(new[] { "du-2", "dp-c", "de-1" }); + + var stranger = await _service.GetTimeAccessAsync(deployment, "eve", false); + stranger.CanRead.Should().BeFalse(); + stranger.CanWrite.Should().BeFalse(); + (await _service.GetTimeAccessAsync(deployment, "eve", true)).CanWrite.Should().BeTrue("a manager writes every subject"); + } } } diff --git a/Tests/Resgrid.Tests/Services/InventoryDepartedHolderTests.cs b/Tests/Resgrid.Tests/Services/InventoryDepartedHolderTests.cs new file mode 100644 index 000000000..79120dcd7 --- /dev/null +++ b/Tests/Resgrid.Tests/Services/InventoryDepartedHolderTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Inventories; + +namespace Resgrid.Tests.Services +{ + /// + /// Equipment still held by a removed, disabled or hidden member raises a DepartedHolder recovery alert (trigger 188). + /// The existing expiry and overdue-return alerts keep running for the same gear. + /// + public sealed partial class InventoryModernizationTests + { + private InventoryLocation PersonnelLocation(string userId) => _store.Seed(new InventoryLocation { + DepartmentId = Department, LocationType = (int)InventoryLocationType.Personnel, UserId = userId, CreatedBy = _actor.UserId, CreatedOn = _clock.Utc, + Content = JsonConvert.SerializeObject(new InventoryLabel { Name = "Synthetic locker" }) }); + + private List DepartedAlerts() => _store.All().Where(a => a.AlertType == (int)InventoryAlertType.DepartedHolder).ToList(); + + [Test] + public async Task Gear_held_by_a_departed_member_raises_one_recovery_alert_per_item_that_follows_recovery_and_reactivation() + { + var active = new HashSet { "current-member" }; + _auth.Setup(x => x.ActiveMemberIdsAsync(Department)).ReturnsAsync(() => new HashSet(active)); + var bulk = Item(); var serialized = Item(InventoryTrackingMode.Serialized); + var departedLocker = PersonnelLocation("departed-member"); var currentLocker = PersonnelLocation("current-member"); var station = Location(); + SeedStock(bulk, departedLocker, 3); SeedStock(bulk, currentLocker, 5); SeedStock(bulk, station, 9); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = serialized.Id, CurrentLocationId = departedLocker.Id, Status = (int)InventoryAssetStatus.Issued }); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = serialized.Id, CurrentLocationId = departedLocker.Id, Status = (int)InventoryAssetStatus.InService }); + _store.Seed(new InventoryAsset { DepartmentId = Department, ItemId = serialized.Id, CurrentLocationId = departedLocker.Id, Status = (int)InventoryAssetStatus.Lost }); + var overdue = _store.Seed(new InventoryIssuance { DepartmentId = Department, ItemId = bulk.Id, LocationId = departedLocker.Id, IssuedToUserId = "departed-member", Quantity = 3, Status = 0, + ExpectedReturnOn = _clock.Utc.AddDays(-1) }); + + await _service.SweepAlertsAsync(Department); await _service.SweepAlertsAsync(Department); + + var alerts = DepartedAlerts(); + alerts.Should().HaveCount(2).And.OnlyContain(a => a.LocationId == departedLocker.Id && a.Status == 0 && a.DueOn == null && a.Content == null); + alerts.Single(a => a.ItemId == bulk.Id).Quantity.Should().Be(3); + alerts.Single(a => a.ItemId == serialized.Id).Quantity.Should().Be(2, "a lost asset is no longer held"); + _store.All().Should().ContainSingle(a => a.AlertType == (int)InventoryAlertType.OverdueReturn && a.IssuanceId == overdue.Id, "the overdue return keeps running for the same gear"); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryDepartedHolder).Should().Be(2, "the second sweep deduplicates"); + _events.Where(e => e.Trigger == WorkflowTriggerEventType.InventoryDepartedHolder).Should().OnlyContain(e => JObject.FromObject(e.Payload).Value("AlertType") == (int)InventoryAlertType.DepartedHolder); + _auth.Verify(x => x.ActiveMemberIdsAsync(Department), Times.Exactly(2), "membership is read once per sweep"); + + SeedStock(bulk, departedLocker, 1); await _service.SweepAlertsAsync(Department); + DepartedAlerts().Single(a => a.ItemId == bulk.Id).Quantity.Should().Be(1, "a partial recovery updates the open alert"); + SeedStock(bulk, departedLocker, 0); await _service.SweepAlertsAsync(Department); + DepartedAlerts().Single(a => a.ItemId == bulk.Id).Status.Should().Be(1, "recovering the last of it resolves the alert"); + + active.Add("departed-member"); await _service.SweepAlertsAsync(Department); + DepartedAlerts().Should().OnlyContain(a => a.Status == 1, "a member who is active again is not a departed holder"); + _events.Count(e => e.Trigger == WorkflowTriggerEventType.InventoryDepartedHolder).Should().Be(2); + } + + [Test] + public async Task Departed_holder_alerts_are_delivered_only_while_the_gear_is_still_held_and_survive_an_unreadable_roster() + { + HashSet active = new(); + _auth.Setup(x => x.ActiveMemberIdsAsync(Department)).ReturnsAsync(() => active == null ? null : new HashSet(active)); + var item = Item(); var locker = PersonnelLocation("departed-member"); SeedStock(item, locker, 2); + await _service.SweepAlertsAsync(Department); + var alert = DepartedAlerts().Single(); + + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeTrue(); + _deniedLocations.Add(locker.Id); + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse("delivery still applies the recipient's location scope"); + _deniedLocations.Remove(locker.Id); + + active = null; await _service.SweepAlertsAsync(Department); + DepartedAlerts().Single().Status.Should().Be(0, "an unreadable roster neither resolves nor reopens the alert"); + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse("delivery waits until the holder is confirmed as departed"); + + active = new HashSet { "departed-member" }; + (await _service.CanReceiveAlertAsync(Department, "recipient", alert.Id)).Should().BeFalse("the member is back"); + } + + [Test] + public void The_departed_holder_trigger_is_an_inventory_trigger_and_its_alert_type_survives_workflow_routing() + { + InventoryWorkflowPayload.IsInventory((int)WorkflowTriggerEventType.InventoryDepartedHolder).Should().BeTrue(); + var routed = JObject.Parse(InventoryWorkflowPayload.Routing(new JObject { ["InventoryEvent"] = true, ["AlertType"] = (int)InventoryAlertType.DepartedHolder, ["LocationId"] = Guid.NewGuid().ToString("D") })); + routed.Value("AlertType").Should().Be((int)InventoryAlertType.DepartedHolder); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs b/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs index e9fc41068..971b27176 100644 --- a/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs +++ b/Tests/Resgrid.Tests/Services/InventoryM5Tests.cs @@ -96,7 +96,7 @@ public async Task Counts_reject_changed_department_snapshots_without_overwriting { case "quantity": SeedStock(item, location, 6); break; case "stock-revision": var stock = _store.All().Single(); stock.Revision++; _store.Seed(stock); break; - case "new-position": SeedStock(item, Location(), 1); break; + case "new-position": SeedStock(Item(), location, 1); break; case "item-revision": item.Revision++; _store.Seed(item); break; case "location-revision": location.Revision++; _store.Seed(location); break; } @@ -107,6 +107,37 @@ public async Task Counts_reject_changed_department_snapshots_without_overwriting (await _service.GetCountAsync(_actor, detail.Count.Id)).Count.Status.Should().Be((int)InventoryCountStatus.Draft); } + [Test] + public async Task Location_counts_fence_only_their_own_positions_and_can_include_compartments_without_the_catalog() + { + // An apparatus (unit holder location) with one compartment; elsewhere in the department a station keeps its own stock. + var engine = Location(InventoryLocationType.Unit, 1); var compartment = Location(); compartment.ParentLocationId = engine.Id; _store.Seed(compartment); + var station = Location(); var hose = Item(); var gloves = Item(); var unrelated = Item(); + SeedStock(hose, engine, 4); SeedStock(gloves, compartment, 10); SeedStock(unrelated, station, 7); + + var detail = await _service.StartCountAsync(_actor, new InventoryCountInput { Id = Guid.NewGuid().ToString("D"), LocationId = engine.Id, Name = "Engine 1 check", IncludeCatalogItems = false, IncludeChildLocations = true }); + detail.Lines.Select(l => (l.ItemId, l.LocationId)).Should().BeEquivalentTo(new[] { (hose.Id, engine.Id), (gloves.Id, compartment.Id) }, "the compartment is counted and the catalog is not padded in"); + detail.Count.SnapshotFingerprint.Should().StartWith("L1:"); + + // Work at the station while the crew counts does not void the apparatus count. + SeedStock(unrelated, station, 3); unrelated.Revision++; _store.Seed(unrelated); SeedStock(Item(), Location(), 2); + var saved = await _service.SaveCountAsync(_actor, new InventoryCountUpdate { CountId = detail.Count.Id, Revision = detail.Count.Revision, + Lines = detail.Lines.Select(l => new InventoryCountObservation { Id = l.Id, Quantity = l.LocationId == compartment.Id ? 9 : 4 }).ToList() }); + await _service.CompleteCountAsync(_actor, M5Completion(saved)); + Stock(gloves, compartment).Should().Be(9); Stock(hose, engine).Should().Be(4); Stock(unrelated, station).Should().Be(3); + + // A change inside the counted scope still does. + var again = await _service.StartCountAsync(_actor, new InventoryCountInput { Id = Guid.NewGuid().ToString("D"), LocationId = engine.Id, Name = "Engine 1 recheck", IncludeCatalogItems = false, IncludeChildLocations = true }); + again = await _service.SaveCountAsync(_actor, M5Observations(again, 1)); + SeedStock(gloves, compartment, 8); + await Fails(() => _service.CompleteCountAsync(_actor, M5Completion(again)), "CountSnapshotChanged", 409); + + var access = await _service.GetFieldAccessAsync(_actor, 1); + access.UnitLocations.Select(l => (l.Id, l.IsRoot)).Should().BeEquivalentTo(new[] { (engine.Id, true), (compartment.Id, false) }); + access.CanCount.Should().BeTrue(); + (await _service.GetFieldAccessAsync(_actor, 99)).UnitLocations.Should().BeEmpty("a unit without an inventory holder location has nothing to count"); + } + [Test] public async Task Controlled_count_variance_waits_for_independent_witness_and_replays_only_the_completed_receipt() { diff --git a/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceTests.cs b/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceTests.cs index 871743dc9..e35511a1b 100644 --- a/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceTests.cs +++ b/Tests/Resgrid.Tests/Services/InvoicePaymentsServiceTests.cs @@ -94,6 +94,7 @@ public void SetUp() _access.Setup(a => a.CanUseInvoicingAsync(Dept)).ReturnsAsync(true); _departments = new Mock(); _departments.Setup(d => d.GetAllAdminsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Model.Identity.IdentityUser { Id = "admin", UserName = "chief", Email = "chief@example.test" } }); + _departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(Dept)).ReturnsAsync(new List { new Model.Identity.IdentityUser { Id = "admin", UserName = "chief", Email = "chief@example.test" } }); _departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, Name = "Test Fire" }); _email = new Mock(); _email.Setup(e => e.SendNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync(true); diff --git a/Tests/Resgrid.Tests/Services/MemberRemovalLifecycleTests.cs b/Tests/Resgrid.Tests/Services/MemberRemovalLifecycleTests.cs new file mode 100644 index 000000000..5ae2199da --- /dev/null +++ b/Tests/Resgrid.Tests/Services/MemberRemovalLifecycleTests.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json.Linq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services; + +namespace Resgrid.Tests.Services +{ + /// + /// What a member removal ends (docs/architecture/inactive-member-automation.md): admin standing, seats on open + /// deployments (billable time-report prefill) and open workforce employment (current MARS and pay-data counts). + /// + [TestFixture] + public class MemberRemovalLifecycleTests + { + private const int Dept = 41; + + #region Admin standing + + private static DepartmentsService Departments(Mock members, List audits) + { + var events = new Mock(); + events.Setup(e => e.SendMessage(It.IsAny())).Callback(audits.Add); + return new DepartmentsService(Mock.Of(), members.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), events.Object, Mock.Of(), Mock.Of()); + } + + private static Mock Members(DepartmentMember row) + { + var members = new Mock(); + members.Setup(m => m.GetDepartmentMemberByDepartmentIdAndUserIdAsync(Dept, row.UserId)).ReturnsAsync(() => row); + members.Setup(m => m.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DepartmentMember m, CancellationToken _, bool __) => m); + return members; + } + + [Test] + public async Task Removal_clears_admin_standing_and_the_removal_audit_records_it() + { + var row = new DepartmentMember { DepartmentMemberId = 7, DepartmentId = Dept, UserId = "former-admin", IsAdmin = true }; + var audits = new List(); + var removed = await Departments(Members(row), audits).DeleteUserAsync(Dept, "former-admin", "chief"); + + removed.IsDeleted.Should().BeTrue(); + removed.IsAdmin.Should().BeFalse("a removed member is not an admin of anything"); + var audit = audits.Should().ContainSingle(a => a.Type == AuditLogTypes.UserRemoved).Subject; + JObject.Parse(audit.Before).Value("IsAdmin").Should().BeTrue(); + JObject.Parse(audit.After).Value("IsAdmin").Should().BeFalse(); + } + + [Test] + public async Task Reactivation_returns_a_regular_member_even_from_a_row_removed_before_admin_was_cleared_and_is_audited() + { + // A row removed before removal cleared IsAdmin, then hidden and disabled on the way out. + var row = new DepartmentMember { DepartmentMemberId = 8, DepartmentId = Dept, UserId = "returning", IsAdmin = true, IsDeleted = true, IsDisabled = true, IsHidden = true }; + var audits = new List(); + var service = Departments(Members(row), audits); + + var member = await service.ReactivateUserAsync(Dept, "returning", "chief"); + + member.IsDeleted.Should().BeFalse(); member.IsDisabled.Should().BeFalse(); member.IsHidden.Should().BeFalse(); + member.IsAdmin.Should().BeFalse("admin standing is granted again on purpose, never restored from the removed row"); + var audit = audits.Should().ContainSingle(a => a.Type == AuditLogTypes.UserReactivated).Subject; + audit.UserId.Should().Be("chief"); audit.DepartmentId.Should().Be(Dept); + JObject.Parse(audit.Before).Value("IsAdmin").Should().BeTrue(); + JObject.Parse(audit.After).Value("IsAdmin").Should().BeFalse(); + + (await service.ReactivateUserAsync(Dept, "nobody", "chief")).Should().BeNull("there is no membership row to bring back"); + } + + #endregion + + #region Deployment seats and employment + + private sealed class Removal + { + public readonly List Calls = new List(); + public readonly List Seats = new List(); + public Mock Deployments = new Mock(); + public Mock Workforce = new Mock(); + public Mock Departments = new Mock(); + public DeleteService Service; + } + + private static Removal Build() + { + var r = new Removal(); + var seats = new Mock(); + seats.Setup(s => s.GetForUserAsync(Dept, "leaver")).ReturnsAsync(() => r.Seats.ToList()); + r.Deployments.Setup(d => d.RemovePersonnelAsync(It.IsAny(), Dept, It.IsAny(), null, null, It.IsAny())) + .ReturnsAsync((string id, int _, string actor, string __, string ___, CancellationToken ____) => + { + if (id == "seat-closed") throw new InvalidOperationException("deployments_closed"); + r.Calls.Add("seat:" + id + ":" + actor); return true; + }); + r.Workforce.Setup(w => w.EndEmploymentsForMemberAsync(Dept, "leaver", It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((int _, string __, DateTime day, string actor, CancellationToken ___) => { r.Calls.Add("employment:" + day.ToString("yyyy-MM-dd") + ":" + actor); return 1; }); + r.Departments.Setup(d => d.GetDepartmentByIdAsync(Dept, It.IsAny())).ReturnsAsync(new Department { DepartmentId = Dept, TimeZone = "UTC" }); + r.Departments.Setup(d => d.DeleteUserAsync(Dept, "leaver", "chief", It.IsAny())) + .ReturnsAsync(() => { r.Calls.Add("membership"); return null; }); + r.Departments.Setup(d => d.GetAllDepartmentsForUserAsync("leaver")).ReturnsAsync(new List + { + new DepartmentMember { DepartmentId = Dept, UserId = "leaver" }, new DepartmentMember { DepartmentId = Dept + 1, UserId = "leaver" } + }); + var authorization = new Mock(); + authorization.Setup(a => a.CanUserDeleteUserAsync(Dept, "chief", "leaver")).ReturnsAsync(true); + r.Service = new DeleteService(authorization.Object, r.Departments.Object, Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), Mock.Of(), + deploymentService: r.Deployments.Object, deploymentPersonnel: seats.Object, workforceService: r.Workforce.Object); + return r; + } + + [Test] + public async Task Removing_a_member_releases_their_open_deployment_seats_and_ends_their_employment_before_the_membership_goes() + { + var r = Build(); + r.Seats.Add(new DeploymentPersonnel { DeploymentPersonnelId = "seat-open", DeploymentId = "open", DepartmentId = Dept, UserId = "leaver" }); + r.Seats.Add(new DeploymentPersonnel { DeploymentPersonnelId = "seat-closed", DeploymentId = "closed", DepartmentId = Dept, UserId = "leaver" }); + r.Seats.Add(new DeploymentPersonnel { DeploymentPersonnelId = "seat-already-off", DeploymentId = "open", DepartmentId = Dept, UserId = "leaver", RemovedOn = DateTime.UtcNow.AddDays(-3) }); + + // The member has another department, so only this one is revoked. + (await r.Service.DeleteUserAsync(Dept, "chief", "leaver")).Should().Be(DeleteUserResults.NoFailure); + + r.Calls.Should().Equal("seat:seat-open:chief", "employment:" + DateTime.UtcNow.ToString("yyyy-MM-dd") + ":chief", "membership"); + r.Deployments.Verify(d => d.RemovePersonnelAsync("seat-already-off", It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + r.Deployments.Verify(d => d.RemovePersonnelAsync("seat-closed", Dept, "chief", null, null, It.IsAny()), Times.Once, "a closed deployment's roster is history and is skipped, not a failure"); + } + + [Test] + public async Task A_failure_releasing_a_seat_stops_the_removal_before_the_membership_is_deleted() + { + var r = Build(); + r.Seats.Add(new DeploymentPersonnel { DeploymentPersonnelId = "seat-open", DeploymentId = "open", DepartmentId = Dept, UserId = "leaver" }); + r.Deployments.Setup(d => d.RemovePersonnelAsync("seat-open", Dept, It.IsAny(), null, null, It.IsAny())).ThrowsAsync(new TimeoutException("database")); + + Func revoke = () => r.Service.RevokeDepartmentAccessAsync("leaver", Dept, "chief"); + await revoke.Should().ThrowAsync(); + r.Calls.Should().NotContain("membership", "the removal stays retryable"); + } + + #endregion + } +} diff --git a/Tests/Resgrid.Tests/Services/TimeReportScopeTests.cs b/Tests/Resgrid.Tests/Services/TimeReportScopeTests.cs new file mode 100644 index 000000000..4e1ecf0ef --- /dev/null +++ b/Tests/Resgrid.Tests/Services/TimeReportScopeTests.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Invoicing; +using Resgrid.Model.Providers; +using Resgrid.Model.Repositories; +using Resgrid.Model.Services; +using Resgrid.Services.Invoicing; + +namespace Resgrid.Tests.Services +{ + /// + /// M0227 crew and individual time reports: a unit's Crew Time Report covers only that unit, its crew and its equipment, a + /// single resource files its own report, a subject bills once a day across reports, and a scoped writer never erases the + /// entries of subjects it may not write. + /// + [TestFixture] + public class TimeReportScopeTests + { + private const int DeptId = 7; + private static readonly DateTime Day = new DateTime(2026, 9, 21); + + private List _storedReports; + private List _storedEntries; + private Deployment _deployment; + private int _nextNumber; + private TimeTrackingService _service; + + [SetUp] + public void SetUp() + { + _storedReports = new List(); + _storedEntries = new List(); + _nextNumber = 1; + // Engine 1 (alice, bob, pump), Engine 2 (carol), dave a single resource with no unit. + _deployment = new Deployment + { + DeploymentId = "dep-1", DepartmentId = DeptId, Name = "Ridge Fire", Status = (int)DeploymentStatuses.Active, Currency = "USD", LocalTimeZoneId = "UTC", + Units = + { + new DeploymentUnit { DeploymentUnitId = "unit-1", DeploymentId = "dep-1", DepartmentId = DeptId, UnitId = 1, UnitName = "Engine 1" }, + new DeploymentUnit { DeploymentUnitId = "unit-2", DeploymentId = "dep-1", DepartmentId = DeptId, UnitId = 2, UnitName = "Engine 2" } + }, + Personnel = + { + new DeploymentPersonnel { DeploymentPersonnelId = "per-a", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "alice", DeploymentUnitId = "unit-1", DisplayName = "Alice" }, + new DeploymentPersonnel { DeploymentPersonnelId = "per-b", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "bob", DeploymentUnitId = "unit-1", DisplayName = "Bob" }, + new DeploymentPersonnel { DeploymentPersonnelId = "per-c", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "carol", DeploymentUnitId = "unit-2", DisplayName = "Carol" }, + new DeploymentPersonnel { DeploymentPersonnelId = "per-d", DeploymentId = "dep-1", DepartmentId = DeptId, UserId = "dave", DisplayName = "Dave" } + }, + Equipment = { new DeploymentEquipment { DeploymentEquipmentId = "eq-1", DeploymentId = "dep-1", DepartmentId = DeptId, DeploymentUnitId = "unit-1", FreeTextName = "Pump" } } + }; + + var deploymentService = new Mock(); + deploymentService.Setup(s => s.GetDeploymentByIdAsync("dep-1", DeptId)).ReturnsAsync(() => _deployment); + var deployments = new Mock(); + deployments.Setup(r => r.GetByIdForDepartmentAsync("dep-1", DeptId)).ReturnsAsync(() => _deployment); + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, TimeZone = "UTC" }); + var sequence = new Mock(); + sequence.Setup(s => s.GetNextNumberAsync(DeptId, It.IsAny())).ReturnsAsync(() => _nextNumber++); + var reports = new Mock(); + reports.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentTimeReport t, CancellationToken _, bool __) => { t.DeploymentTimeReportId ??= Guid.NewGuid().ToString(); _storedReports.RemoveAll(x => x.DeploymentTimeReportId == t.DeploymentTimeReportId); _storedReports.Add(t); return t; }); + reports.Setup(r => r.GetByIdForDepartmentAsync(It.IsAny(), DeptId)).ReturnsAsync((string id, int _) => _storedReports.FirstOrDefault(t => t.DeploymentTimeReportId == id)); + reports.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedReports.Where(t => t.DeploymentId == id && !t.IsDeleted).OrderBy(t => t.ReportDate).ToList()); + var entries = new Mock(); + entries.Setup(r => r.SaveOrUpdateAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync((DeploymentTimeEntry e, CancellationToken _, bool __) => { e.DeploymentTimeEntryId ??= Guid.NewGuid().ToString(); _storedEntries.RemoveAll(x => x.DeploymentTimeEntryId == e.DeploymentTimeEntryId); _storedEntries.Add(e); return e; }); + entries.Setup(r => r.GetByReportAsync(It.IsAny())).ReturnsAsync((string id) => _storedEntries.Where(e => e.DeploymentTimeReportId == id).OrderBy(e => e.SortOrder).ToList()); + entries.Setup(r => r.GetByDeploymentAsync(It.IsAny())).ReturnsAsync((string id) => _storedEntries.Where(e => e.DeploymentId == id).ToList()); + entries.Setup(r => r.DeleteAsync(It.IsAny(), It.IsAny())).ReturnsAsync((DeploymentTimeEntry e, CancellationToken _) => _storedEntries.RemoveAll(x => x.DeploymentTimeEntryId == e.DeploymentTimeEntryId) > 0); + + _service = new TimeTrackingService(deployments.Object, new Mock().Object, new Mock().Object, new Mock().Object, + reports.Object, entries.Object, new Mock().Object, new Mock().Object, sequence.Object, deploymentService.Object, departments.Object, + new Mock().Object, new Mock().Object, new Mock().Object, new Mock().Object, null); + } + + private static DeploymentTimeEntry Entry(string subject, int hourStart, int hourEnd, string id = null) => new DeploymentTimeEntry + { + DeploymentTimeEntryId = id, + DeploymentPersonnelId = subject.StartsWith("per") ? subject : null, DeploymentUnitId = subject.StartsWith("unit") ? subject : null, DeploymentEquipmentId = subject.StartsWith("eq") ? subject : null, + EntryType = (int)DeploymentTimeEntryTypes.Deployment, StartTime = Day.AddHours(hourStart), EndTime = Day.AddHours(hourEnd), UnpaidBreakMinutes = 30 + }; + + private static DeploymentTimeAccess CrewOf(params string[] subjects) + { + var access = new DeploymentTimeAccess { CrewUnitIds = subjects.Where(s => s.StartsWith("unit")).ToList() }; + foreach (var subject in subjects) access.WritableSubjectIds.Add(subject); + return access; + } + + [Test] + public async Task A_crew_report_prefills_only_its_unit_crew_and_equipment() + { + var crew = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, "unit-1", null, "alice", null, null); + + crew.Scope.Should().Be(DeploymentTimeReportScopes.Crew); + crew.DeploymentUnitId.Should().Be("unit-1"); + crew.Entries.Select(e => e.SubjectId).Should().BeEquivalentTo(new[] { "per-a", "per-b", "unit-1", "eq-1" }); + crew.Entries.Single(e => e.SubjectId == "unit-1").CrewSizeSnapshot.Should().Be(2); + + var other = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, "unit-2", null, "carol", null, null); + other.Entries.Select(e => e.SubjectId).Should().BeEquivalentTo(new[] { "per-c", "unit-2" }, "each deployed unit files its own crew time report the same day"); + + (await FluentActions.Awaiting(() => _service.CreateTimeReportAsync("dep-1", DeptId, Day, "unit-1", null, "bob", null, null)).Should().ThrowAsync()) + .Which.Message.Should().Be("timereports_date_exists"); + } + + [Test] + public async Task A_single_resource_files_an_individual_report_and_a_crew_member_already_covered_cannot() + { + var dave = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, null, "per-d", "dave", null, null); + dave.Scope.Should().Be(DeploymentTimeReportScopes.Individual); + dave.Entries.Select(e => e.SubjectId).Should().Equal("per-d"); + + await _service.CreateTimeReportAsync("dep-1", DeptId, Day, "unit-1", null, "alice", null, null); + (await FluentActions.Awaiting(() => _service.CreateTimeReportAsync("dep-1", DeptId, Day, null, "per-a", "alice", null, null)).Should().ThrowAsync()) + .Which.Message.Should().Be("timereports_subject_covered", "alice's time is already on Engine 1's crew report"); + + var wide = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, null, null, "chief", null, null); + wide.Entries.Select(e => e.SubjectId).Should().BeEquivalentTo(new[] { "per-c", "unit-2" }, "the deployment-wide report skips subjects already on a crew or individual report"); + } + + [Test] + public async Task Scope_validation_refuses_foreign_subjects_and_double_billing() + { + var crew = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, "unit-1", null, "alice", null, null); + var outside = await _service.SaveTimeEntriesAsync(crew.DeploymentTimeReportId, DeptId, new List { Entry("per-a", 6, 18), Entry("per-c", 6, 18) }, null, "chief", null, null); + outside.Validation.Errors.Should().ContainSingle(e => e.Code == TimeReportValidation.SubjectOutsideScope && e.SubjectId == "per-c"); + + var dave = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, null, "per-d", "dave", null, null); + var wide = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, null, null, "chief", null, null); + var twice = await _service.SaveTimeEntriesAsync(wide.DeploymentTimeReportId, DeptId, new List { Entry("per-c", 6, 18), Entry("per-d", 6, 18) }, null, "chief", null, null); + twice.Validation.Errors.Should().ContainSingle(e => e.Code == TimeReportValidation.SubjectOnOtherReport && e.SubjectId == "per-d"); + _storedEntries.Where(e => e.DeploymentTimeReportId == dave.DeploymentTimeReportId).Should().ContainSingle(); + } + + [Test] + public async Task A_scoped_writer_replaces_only_its_own_subjects_and_keeps_everyone_else() + { + var wide = await _service.CreateTimeReportAsync("dep-1", DeptId, Day, null, null, "chief", null, null); + var stored = _storedEntries.Where(e => e.DeploymentTimeReportId == wide.DeploymentTimeReportId).ToDictionary(e => e.SubjectId, e => e.DeploymentTimeEntryId); + var carolBefore = _storedEntries.Single(e => e.SubjectId == "per-c"); + var carolStart = carolBefore.StartTime; + + // Engine 1's crew sends its own rows plus a tampered copy of Carol's; only its own land and nothing else is deleted. + var result = await _service.SaveTimeEntriesAsync(wide.DeploymentTimeReportId, DeptId, new List + { + Entry("per-a", 5, 17, stored["per-a"]), Entry("unit-1", 5, 17, stored["unit-1"]), Entry("per-c", 1, 2, stored["per-c"]) + }, CrewOf("unit-1", "per-a", "per-b", "eq-1"), "alice", null, null); + + result.Validation.IsValid.Should().BeTrue(); + var after = _storedEntries.Where(e => e.DeploymentTimeReportId == wide.DeploymentTimeReportId).ToList(); + after.Single(e => e.SubjectId == "per-a").StartTime.Should().Be(Day.AddHours(5)); + after.Single(e => e.SubjectId == "per-c").StartTime.Should().Be(carolStart, "another crew's entry is kept exactly as stored"); + after.Should().Contain(e => e.SubjectId == "unit-2" && e.DeploymentTimeEntryId == stored["unit-2"]); + after.Should().NotContain(e => e.SubjectId == "per-b" || e.SubjectId == "eq-1", "the crew removed its own rows it did not send"); + after.Should().Contain(e => e.SubjectId == "per-d", "subjects the writer may not touch are never deleted"); + } + + [Test] + public void Acting_on_a_report_needs_its_scope() + { + var crew = CrewOf("unit-1", "per-a", "per-b", "eq-1"); + crew.PersonnelId = "per-a"; + crew.CanActOn(new DeploymentTimeReport { DeploymentUnitId = "unit-1" }).Should().BeTrue(); + crew.CanActOn(new DeploymentTimeReport { DeploymentUnitId = "unit-2" }).Should().BeFalse(); + crew.CanActOn(new DeploymentTimeReport { DeploymentPersonnelId = "per-b" }).Should().BeTrue("a crew boss may file for a member of the crew"); + crew.CanActOn(new DeploymentTimeReport { DeploymentPersonnelId = "per-d" }).Should().BeFalse(); + crew.CanActOn(new DeploymentTimeReport { Entries = { Entry("per-a", 6, 8), Entry("per-c", 6, 8) } }).Should().BeFalse("a deployment-wide report with another crew's rows is not theirs to submit"); + new DeploymentTimeAccess { CanManage = true }.CanActOn(new DeploymentTimeReport { DeploymentUnitId = "unit-2" }).Should().BeTrue(); + } + } +} diff --git a/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs index a0be93279..6abd227f7 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderAuthorizationTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using FluentAssertions; using Moq; @@ -41,6 +42,30 @@ public async Task Assignment_choices_use_full_names_and_only_load_visible_profil profiles.Verify(p => p.GetSelectedUserProfilesAsync(It.Is>(ids => ids.Count == 3 && !ids.Contains("hidden"))), Times.Once); } + [Test] + public async Task Hidden_members_are_labelled_but_not_offered_and_removed_or_disabled_members_are_neither() + { + var actor = new ChecklistActor { DepartmentId = 77, UserId = "manager" }; + var departments = new Mock(); + departments.Setup(d => d.GetDepartmentMemberAsync(actor.UserId, 77, true)).ReturnsAsync(new DepartmentMember { DepartmentId = 77, UserId = actor.UserId }); + departments.Setup(d => d.GetAllMembersForDepartmentUnlimitedAsync(77, true)).ReturnsAsync(new List { + new() { DepartmentId = 77, UserId = "a" }, new() { DepartmentId = 77, UserId = "quiet", IsHidden = true }, + new() { DepartmentId = 77, UserId = "off", IsDisabled = true }, new() { DepartmentId = 77, UserId = "gone", IsDeleted = true } + }); + var assignments = new Mock(); + assignments.Setup(a => a.ChoicesAsync(actor)).ReturnsAsync(new List { new() { Type = 1, Id = "a", Name = "login-a" } }); + var resources = new Mock(); + resources.Setup(r => r.CanUserViewPersonAsync(actor.UserId, It.IsAny(), 77)).ReturnsAsync(true); + var profiles = new Mock(); + profiles.Setup(p => p.GetSelectedUserProfilesAsync(It.IsAny>())).ReturnsAsync((List ids) => ids.Select(id => new UserProfile { UserId = id, FirstName = "Member", LastName = id }).ToList()); + var service = new WorkOrderAuthorizationService(departments.Object, Mock.Of(), Mock.Of(), + Mock.Of(), Mock.Of(), resources.Object, assignments.Object, profiles.Object); + var choices = await service.ChoicesAsync(actor); + choices.Users.Should().ContainSingle().Which.Name.Should().Be("Member a"); + choices.UserNames.Keys.Should().BeEquivalentTo(new[] { "a", "quiet" }, "a hidden member keeps a label for history and kept assignments"); + choices.UserNames["quiet"].Should().Be("Member quiet"); + } + [Test] public async Task Multiple_users_and_roles_share_access_and_deduplicated_current_recipients() { diff --git a/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceAssignmentTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceAssignmentTests.cs index cf7302f8f..fc811cd64 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceAssignmentTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderMaintenanceAssignmentTests.cs @@ -55,6 +55,32 @@ public async Task Maintenance_assignments_round_trip_generate_and_allow_either_u new WorkOrderReadScope { UserId = "tech-b" }.Allows(reassigned).Should().BeFalse(); } + [Test] + public async Task Generation_drops_removed_disabled_or_hidden_assignees_instead_of_failing_the_schedule() + { + Maintenance(); + var input = Schedule(); input.AssignedToUserIds = new() { "tech-a", "departed" }; + await _service.SaveRecurrenceAsync(_actor, input); + _inactiveMaintenanceMembers.Add("departed"); + var sweep = await _service.GenerateMaintenanceAsync(77); + sweep.Generated.Should().Be(1); sweep.Errors.Should().Be(0); + var order = _store.All().Single(); + order.AssignedToUserIds.Should().Equal("tech-a"); order.Status.Should().Be((int)WorkOrderStatus.Assigned); + _auth.Verify(a => a.ValidateAssignmentAsync(It.IsAny(), It.IsAny(), "departed", null), Times.Once, "only the save validated the departed member; generation never assigns them"); + } + + [Test] + public async Task Generation_opens_an_order_unassigned_when_its_only_assignee_has_left() + { + Maintenance(); + var input = Schedule(); input.AssignedToUserIds = new() { "departed" }; + await _service.SaveRecurrenceAsync(_actor, input); + _inactiveMaintenanceMembers.Add("departed"); + (await _service.GenerateMaintenanceAsync(77)).Generated.Should().Be(1); + var order = _store.All().Single(); + order.AssignedToUserIds.Should().BeEmpty(); order.Status.Should().Be((int)WorkOrderStatus.Accepted, "an unassigned order goes to the managers"); + } + [Test] public async Task Invalid_secondary_assignee_rejects_the_entire_schedule() { diff --git a/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs b/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs index 5e74d12e0..f7d2347c3 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderNotificationTests.cs @@ -22,17 +22,18 @@ public sealed class WorkOrderNotificationTests { [TestCase(false, false),TestCase(true, false),TestCase(false, true),TestCase(true, true)] [TestCase(false, false, true), TestCase(false, true, true)] - public async Task Current_role_members_or_triage_managers_receive_localized_metadata_only_once(bool triage, bool revoked, bool multiple = false) + [TestCase(false, false, false, true)] + public async Task Current_role_members_or_triage_managers_receive_localized_metadata_only_once(bool triage, bool revoked, bool multiple = false, bool hidden = false) { var store=new Mock(); var auth=new Mock(); var access=new Mock();access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(true); var uow=new Mock();uow.Setup(u=>u.CreateOrGetConnectionAsync(It.IsAny())).ReturnsAsync((DbConnection)null); var communication=new Mock();communication.SetReturnsDefault(Task.FromResult(true)); var departments=new Mock();departments.Setup(d=>d.GetDepartmentByIdAsync(77,true)).ReturnsAsync(new Department {DepartmentId=77}); - departments.Setup(d=>d.GetDepartmentMemberAsync(It.IsAny(),77,true)).ReturnsAsync((string user,int department,bool fresh)=>new DepartmentMember {DepartmentId=department,UserId=user,IsDisabled=revoked && user!="requester"}); + departments.Setup(d=>d.GetDepartmentMemberAsync(It.IsAny(),77,true)).ReturnsAsync((string user,int department,bool fresh)=>new DepartmentMember {DepartmentId=department,UserId=user,IsDisabled=revoked && user!="requester",IsHidden=hidden && user=="tech1"}); departments.Setup(d=>d.GetAllMembersForDepartmentUnlimitedAsync(77,true)).ReturnsAsync(new List { new DepartmentMember {DepartmentId=77,UserId="requester"},new DepartmentMember {DepartmentId=77,UserId="manager"}, - new DepartmentMember {DepartmentId=77,UserId="tech1"},new DepartmentMember {DepartmentId=77,UserId="tech2"} + new DepartmentMember {DepartmentId=77,UserId="tech1",IsHidden=hidden},new DepartmentMember {DepartmentId=77,UserId="tech2"} }); auth.Setup(a=>a.CanManageAsync(It.IsAny(),It.IsAny())).ReturnsAsync((ChecklistActor a,int? g)=>a.UserId=="manager"); auth.Setup(a=>a.RecipientsAsync(77,It.IsAny())).ReturnsAsync(triage ? new List() : new List{"tech1","tech2"}); @@ -50,9 +51,11 @@ public async Task Current_role_members_or_triage_managers_receive_localized_meta departments.Verify(d=>d.GetAllMembersForDepartmentUnlimitedAsync(77,true),Times.Exactly(2)); auth.Verify(a=>a.RecipientsAsync(77,It.IsAny()),Times.Exactly(2)); var sends=communication.Invocations.Where(i=>i.Method.Name=="SendNotificationAsync").ToList(); - sends.Select(i=>(string)i.Arguments[0]).Should().BeEquivalentTo(revoked?new[]{"requester"}:triage?new[]{"requester","manager"}:new[]{"requester","tech1","tech2"}); + sends.Select(i=>(string)i.Arguments[0]).Should().BeEquivalentTo(revoked?new[]{"requester"}:triage?new[]{"requester","manager"}:hidden?new[]{"requester","tech2"}:new[]{"requester","tech1","tech2"}); foreach(var send in sends) ((string)send.Arguments[2]).Should().StartWith("WO-2026-000019: ").And.Contain("nécessite votre attention") .And.NotContain("CANARY").And.NotContain("grant").And.NotContain("http").And.NotContain("/User/").And.NotContain(row.Id); + // The order id reaches the device only as the push event code the Responder app deep-links on. + foreach(var send in sends) { send.Arguments.Should().HaveCount(9); ((string)send.Arguments[8]).Should().Be("NWO:00000000-0000-0000-0000-000000000019"); ((bool)send.Arguments[7]).Should().BeFalse(); } access.Setup(a=>a.CanUseMaintenanceAsync(77)).ReturnsAsync(false); entry.EventId=Guid.NewGuid().ToString();await service.DispatchAsync(entry); communication.Invocations.Count.Should().Be(sends.Count); diff --git a/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs b/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs index af93b01b2..982b41e0e 100644 --- a/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs +++ b/Tests/Resgrid.Tests/Services/WorkOrderP2M23Tests.cs @@ -22,6 +22,7 @@ public partial class WorkOrderP2M1Tests private sealed class MaintenanceClock : TimeProvider { public DateTime Utc = new(2026, 9, 9, 12, 0, 0, DateTimeKind.Utc); public override DateTimeOffset GetUtcNow() => new(Utc); } private MaintenanceClock _maintenanceClock; private Mock _maintenanceChecklists; + private HashSet _inactiveMaintenanceMembers; private void Maintenance() { _maintenanceClock = new(); _maintenanceChecklists = new(); @@ -33,6 +34,9 @@ private void Maintenance() var audit = new Mock(); audit.Setup(a => a.InsertAsync(It.IsAny(), It.IsAny(), It.IsAny())).ReturnsAsync((AuditLog a, CancellationToken c, bool b) => { a.AuditLogId = 1; return a; }); var units = new Mock(); units.Setup(u => u.GetUnitByIdAsync(10)).ReturnsAsync(new Unit { UnitId = 10, DepartmentId = 77 }); + // Everyone a schedule names is an active member unless a test says otherwise. + _inactiveMaintenanceMembers = new HashSet(); + _auth.Setup(a => a.ActiveMemberIdsAsync(77)).ReturnsAsync(() => new HashSet(_store.All().SelectMany(r => r.AssignedToUserIds).Where(u => !_inactiveMaintenanceMembers.Contains(u)))); _service = new WorkOrdersService(_store, _auth.Object, _access.Object, _uow.Object, audit.Object, _outbox.Object, new(() => _read.Object), new(() => _write.Object), _scanner.Object, _maintenanceClock, _store, checklists: _maintenanceChecklists.Object, maintenanceUnits: units.Object); } diff --git a/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs b/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs index 8c756508c..58cd4a8d4 100644 --- a/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs +++ b/Tests/Resgrid.Tests/Services/WorkforceServicesTests.cs @@ -40,6 +40,7 @@ public class WorkforceServicesTests private List _resourceProfiles; private List _resourceComponents; private List _usage; private List _runs; private List _lines; private List _demographics; private List _reportRuns; private List _snapshots; private List _rows; private List _artifacts; private List _audits; private List _notifications; + private Dictionary _memberStates; private List _deployments; private List _personnel; private List _units; private List _reports; private List _entries; private List _expenses; private List _marsItems; private Mock _unitOfWork; private Mock _payComponentRepository; private int _commits; private int _discards; @@ -161,13 +162,17 @@ public void SetUp() var departments = new Mock(); departments.Setup(d => d.GetDepartmentByIdAsync(DeptId, It.IsAny())).ReturnsAsync(new Department { DepartmentId = DeptId, Name = "Dept" }); departments.Setup(d => d.GetAllAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + departments.Setup(d => d.GetActiveAdminsForDepartmentAsync(DeptId)).ReturnsAsync(new List { new Resgrid.Model.Identity.IdentityUser { UserId = "admin" } }); + // Everyone is a current member of the department unless a test gives them another membership state (null = not a member). + _memberStates = new Dictionary(StringComparer.OrdinalIgnoreCase); + departments.Setup(d => d.GetDepartmentMemberAsync(It.IsAny(), DeptId, true)).ReturnsAsync((string id, int dept, bool _) => _memberStates.TryGetValue(id, out var state) ? state : new DepartmentMember { DepartmentId = dept, UserId = id }); var communication = new Mock(); communication.Setup(c => c.SendNotificationAsync(It.IsAny(), DeptId, It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((string u, int d, string m, string n, Department dep, string t, UserProfile p, bool ic) => { _notifications.Add(m); return true; }); var events = new Mock(); events.Setup(e => e.SendMessage(It.IsAny())).Callback(a => _audits.Add(a)); - _workforce = new WorkforceService(employers.Object, affiliates.Object, establishments.Object, contractors.Object, workers.Object, employments.Object, assignments.Object, workEntries.Object, facts.Object, userProfiles.Object, events.Object); + _workforce = new WorkforceService(employers.Object, affiliates.Object, establishments.Object, contractors.Object, workers.Object, employments.Object, assignments.Object, workEntries.Object, facts.Object, userProfiles.Object, events.Object, departments.Object); _payComponentRepository = payComponents; _unitOfWork = TransactionalStores(_profiles, _payComponents, _costComponents); _compensation = new CompensationCostService(profiles.Object, payComponents.Object, costComponents.Object, employments.Object, assignments.Object, events.Object, _unitOfWork.Object); @@ -217,6 +222,55 @@ private static Mock Repo(List store, Func id, Act return (worker, employment, establishment); } + [Test] + public async Task A_worker_row_links_only_a_current_member_of_the_department() + { + _memberStates["removed"] = new DepartmentMember { DepartmentId = DeptId, UserId = "removed", IsDeleted = true }; + _memberStates["disabled"] = new DepartmentMember { DepartmentId = DeptId, UserId = "disabled", IsDisabled = true }; + _memberStates["stranger"] = null; + _memberStates["hidden"] = new DepartmentMember { DepartmentId = DeptId, UserId = "hidden", IsHidden = true }; + foreach (var refused in new[] { "removed", "disabled", "stranger" }) + { + Func create = () => _workforce.GetOrCreateWorkerForUserAsync(DeptId, refused, User); + await create.Should().ThrowAsync().WithMessage("workforce_member_not_found"); + Func link = () => _workforce.SaveWorkerAsync(new WorkforceWorker { DepartmentId = DeptId, UserId = refused }, User, null, null); + await link.Should().ThrowAsync().WithMessage("workforce_member_not_found"); + } + _workers.Should().BeEmpty(); + (await _workforce.GetOrCreateWorkerForUserAsync(DeptId, "hidden", User)).UserId.Should().Be("hidden", "a hidden member may still hold a worker row"); + + // An existing row keeps working after its member leaves; only a new link is checked. + var kept = await _workforce.GetOrCreateWorkerForUserAsync(DeptId, "u1", User); + _memberStates["u1"] = new DepartmentMember { DepartmentId = DeptId, UserId = "u1", IsDisabled = true }; + (await _workforce.GetOrCreateWorkerForUserAsync(DeptId, "u1", User)).WorkforceWorkerId.Should().Be(kept.WorkforceWorkerId); + } + + [Test] + public async Task A_departed_members_open_employment_ends_on_the_removal_day_and_leaves_the_current_counts() + { + var (worker, employment, _) = await SeedWorkerAsync("leaver"); + var earlier = new WorkforceEmployment { WorkforceEmploymentId = "earlier", DepartmentId = DeptId, WorkforceWorkerId = worker.WorkforceWorkerId, WorkerKind = (int)WorkerKinds.PayrollEmployee, StartOn = new DateTime(2019, 1, 1), EndOn = new DateTime(2021, 12, 31) }; + var removalDay = DateTime.UtcNow.Date; + var notStarted = new WorkforceEmployment { WorkforceEmploymentId = "not-started", DepartmentId = DeptId, WorkforceWorkerId = worker.WorkforceWorkerId, WorkerKind = (int)WorkerKinds.PayrollEmployee, StartOn = removalDay.AddDays(30) }; + _employments.Add(earlier); _employments.Add(notStarted); + (await _demographicsService.GetCompletenessAsync(DeptId, removalDay.AddDays(1))).ActiveWorkers.Should().Be(1); + _audits.Clear(); + + (await _workforce.EndEmploymentsForMemberAsync(DeptId, "leaver", removalDay, "chief")).Should().Be(2); + + _employments.Single(e => e.WorkforceEmploymentId == employment.WorkforceEmploymentId).EndOn.Should().Be(removalDay, "end-dated, never deleted: past periods keep the member"); + _employments.Single(e => e.WorkforceEmploymentId == employment.WorkforceEmploymentId).IsDeleted.Should().BeFalse(); + earlier.EndOn.Should().Be(new DateTime(2021, 12, 31), "an employment that already ended is left alone"); + notStarted.IsDeleted.Should().BeTrue("an employment that had not begun is withdrawn rather than ended before its start"); + _audits.Should().HaveCount(2).And.OnlyContain(a => a.Type == AuditLogTypes.WorkforceEmploymentChanged && a.UserId == "chief"); + (await _demographicsService.GetCompletenessAsync(DeptId, removalDay)).ActiveWorkers.Should().Be(1, "the removal day itself is still worked"); + (await _demographicsService.GetCompletenessAsync(DeptId, removalDay.AddDays(1))).ActiveWorkers.Should().Be(0, "current-as-of counts no longer include the member"); + (await _demographicsService.GetCompletenessAsync(DeptId, new DateTime(2024, 6, 1))).ActiveWorkers.Should().Be(1, "a past period still counts them"); + + (await _workforce.EndEmploymentsForMemberAsync(DeptId, "leaver", removalDay, "chief")).Should().Be(0, "a second removal pass changes nothing"); + (await _workforce.EndEmploymentsForMemberAsync(DeptId, "no-worker-row", removalDay, "chief")).Should().Be(0); + } + [Test] public async Task Employment_periods_and_job_assignments_never_overlap() { diff --git a/Tests/Resgrid.Tests/Web/ScriptMinificationSettingsTests.cs b/Tests/Resgrid.Tests/Web/ScriptMinificationSettingsTests.cs new file mode 100644 index 000000000..5ef0783a1 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/ScriptMinificationSettingsTests.cs @@ -0,0 +1,71 @@ +using FluentAssertions; +using NUglify; +using NUglify.JavaScript; +using NUnit.Framework; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web +{ + /// + /// Guards the Release-only JS minification against NUglify's InvertIfReturn, which moved consts out of scope of + /// the functions that use them (RESGRID-WEB-1MP, RESGRID-WEB-1MK). Debug builds do not minify, so without this + /// test the regression only shows up in production. + /// + [TestFixture] + public class ScriptMinificationSettingsTests + { + // The shape of checklists.js editor(): an early-return guard, a const after it, and a sibling function that + // reads the const. InvertIfReturn wraps the const in `if (form) { ... }` and leaves render() outside it. + private const string GuardedEditor = @" +(function () { + 'use strict'; + function editor() { + const form = document.getElementById('editor'); if (!form) return; + const model = JSON.parse(form.textContent); + function render() { model.Sections.forEach(section => form.append(section.Name)); } + render(); + } + editor(); +})();"; + + private const string KeptGuard = @"if\(!(\w+)\)return;const \w+=JSON\.parse\(\1\.textContent\)"; + private const string InvertedGuard = @"if\((\w+)\)\{const \w+=JSON\.parse\(\1\.textContent\)"; + + [Test] + public void Create_DisablesInvertIfReturn() + { + // Act + var settings = ScriptMinificationSettings.Create(); + + // Assert + (settings.KillSwitch & (long)TreeModifications.InvertIfReturn).Should().NotBe(0L, + "InvertIfReturn moves consts out of scope of sibling functions in the minified /js/app scripts"); + } + + [Test] + public void Minify_WithProductionSettings_KeepsEarlyReturnGuard() + { + // Act + var result = Uglify.Js(GuardedEditor, ScriptMinificationSettings.Create()); + + // Assert + result.HasErrors.Should().BeFalse(); + result.Code.Should().MatchRegex(KeptGuard, "the guard must stay an early return so render() still sees model"); + result.Code.Should().NotMatchRegex(InvertedGuard); + } + + [Test] + public void Minify_WithDefaultSettings_InvertsGuard() + { + // Control: proves GuardedEditor still triggers InvertIfReturn, so the test above cannot pass vacuously. + // If a NUglify upgrade makes this fail, re-check the repro before trusting the production-settings test. + + // Act + var result = Uglify.Js(GuardedEditor, new CodeSettings()); + + // Assert + result.HasErrors.Should().BeFalse(); + result.Code.Should().MatchRegex(InvertedGuard); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/CertificationCreationTests.cs b/Tests/Resgrid.Tests/Web/User/CertificationCreationTests.cs index 8c877b147..6df3a99ae 100644 --- a/Tests/Resgrid.Tests/Web/User/CertificationCreationTests.cs +++ b/Tests/Resgrid.Tests/Web/User/CertificationCreationTests.cs @@ -46,6 +46,8 @@ public void SetUp() _departments = new Mock(MockBehavior.Strict); _departments.Setup(x => x.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)) .ReturnsAsync(new List { new PersonName { UserId = Subject, FirstName = "Alex", LastName = "Member" } }); + _departments.Setup(x => x.GetSelectablePersonnelNamesAsync(DepartmentId)) + .ReturnsAsync(new List { new PersonName { UserId = Subject, FirstName = "Alex", LastName = "Member" } }); _departments.Setup(x => x.GetDepartmentMemberAsync(Subject, DepartmentId, true)) .ReturnsAsync(new DepartmentMember { DepartmentId = DepartmentId, UserId = Subject }); _type = new DepartmentCertificationType { DepartmentCertificationTypeId = TypeId, DepartmentId = DepartmentId, Type = "EMT", IsActive = true }; diff --git a/Tests/Resgrid.Tests/Web/User/LegacyCertificationsCutoverTests.cs b/Tests/Resgrid.Tests/Web/User/LegacyCertificationsCutoverTests.cs index f6408dc0e..e76a15e4d 100644 --- a/Tests/Resgrid.Tests/Web/User/LegacyCertificationsCutoverTests.cs +++ b/Tests/Resgrid.Tests/Web/User/LegacyCertificationsCutoverTests.cs @@ -128,6 +128,35 @@ public async Task Legacy_member_list_and_report_stay_available_when_cutover_is_o (await Controller(departments.Object, sensitive.Object).CertificationsReport()).Should().BeOfType(); } [Test] + public async Task Legacy_report_lists_only_active_members_and_only_this_departments_records() + { + _access.Setup(x => x.IsEnabledAsync(DepartmentId)).ReturnsAsync(false); + var department = new Department { DepartmentId = DepartmentId, TimeZone = "Pacific Standard Time" }; + var departments = new Mock(); + departments.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, false)).ReturnsAsync(department); + // The helper already drops removed and disabled members; a hidden one still comes back from it. + departments.Setup(x => x.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(DepartmentId, false)).ReturnsAsync(new List { new() { Id = "active" }, new() { Id = "hidden" } }); + departments.Setup(x => x.GetActiveMemberUserIdsAsync(DepartmentId)).ReturnsAsync(new HashSet { "active" }); + // Strict: the hidden member is never even checked against the visibility matrix. + _authorization.Setup(x => x.CanUserViewPersonViaMatrixAsync("active", UserId, DepartmentId)).ReturnsAsync(true); + var certifications = new Mock(); + certifications.Setup(x => x.GetCertificationsByUserIdAsync("active")).ReturnsAsync(new List + { + new PersonnelCertification { DepartmentId = DepartmentId, UserId = "active", Name = "Here" }, + new PersonnelCertification { DepartmentId = 99, UserId = "active", Name = "Another department" } + }); + var profiles = new Mock(); + profiles.Setup(x => x.GetProfileByUserIdAsync("active", false)).ReturnsAsync(new UserProfile { UserId = "active", FirstName = "Ada", LastName = "Active" }); + var sensitive = new Mock(); + sensitive.Setup(x => x.GetResolvedForDepartmentAsync(DepartmentId, null, UserId)).ReturnsAsync(new Dictionary()); + + var view = (await Controller(departments.Object, sensitive.Object, certifications.Object, profiles.Object).CertificationsReport()).Should().BeOfType().Subject; + var model = (Resgrid.Web.Areas.User.Models.Reports.Certifications.CertificationsReportView)view.Model; + model.Rows.Should().ContainSingle(); + model.Rows[0].SubRows.Select(s => s.Name).Should().Equal("Here"); + certifications.Verify(x => x.GetCertificationsByUserIdAsync("hidden"), Times.Never); + } + [Test] public async Task Internal_report_checks_requested_department_after_authentication() { var oldToken = Resgrid.Config.SecurityConfig.InternalReportsToken; @@ -199,6 +228,21 @@ public async Task Queued_legacy_delivery_is_logged_as_skipped_without_generating result.Item1.Should().BeTrue(); tasks.Verify(x => x.CreateScheduleTaskLogAsync(task, CancellationToken.None), Times.Once); email.VerifyNoOtherCalls(); pdf.VerifyNoOtherCalls(); } + [TestCase(true, null, null)] [TestCase(false, true, null)] [TestCase(false, null, true)] + public async Task Scheduled_reports_are_not_delivered_to_a_removed_disabled_or_hidden_subscriber(bool deleted, bool? disabled, bool? hidden) + { + var task = new ScheduledTask { DepartmentId = DepartmentId, UserId = UserId, Data = ((int)ReportTypes.CertificationCompliance).ToString() }; + var tasks = new Mock(MockBehavior.Strict); + tasks.Setup(x => x.CreateScheduleTaskLogAsync(task, CancellationToken.None)).ReturnsAsync(new ScheduledTaskLog()); + var departments = new Mock(MockBehavior.Strict); + departments.Setup(x => x.GetDepartmentMemberAsync(UserId, DepartmentId, true)).ReturnsAsync(new DepartmentMember { DepartmentId = DepartmentId, UserId = UserId, IsDeleted = deleted, IsDisabled = disabled, IsHidden = hidden }); + var email = new Mock(MockBehavior.Strict); var pdf = new Mock(MockBehavior.Strict); + var worker = new ReportDeliveryLogic(tasks.Object, email.Object, pdf.Object, null, _access.Object, departments.Object); + var result = await worker.Process(new ReportDeliveryQueueItem { Department = new Department { DepartmentId = DepartmentId }, ScheduledTask = task, Email = "former@example.test" }); + result.Item1.Should().BeTrue("the skipped occurrence is logged so it is not retried"); + tasks.Verify(x => x.CreateScheduleTaskLogAsync(task, CancellationToken.None), Times.Once); + email.VerifyNoOtherCalls(); pdf.VerifyNoOtherCalls(); + } [TestCase(true)] [TestCase(false)] public async Task Shared_add_form_remains_available_in_both_modes(bool enabled) { diff --git a/Tests/Resgrid.Tests/Web/User/PersonnelReactivationTests.cs b/Tests/Resgrid.Tests/Web/User/PersonnelReactivationTests.cs new file mode 100644 index 000000000..7de9afd64 --- /dev/null +++ b/Tests/Resgrid.Tests/Web/User/PersonnelReactivationTests.cs @@ -0,0 +1,185 @@ +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Moq; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Identity; +using Resgrid.Model.Services; +using Resgrid.Providers.Claims; +using Resgrid.Web.Areas.User.Controllers; +using Resgrid.Web.Areas.User.Models.Personnel; +using Resgrid.Web.Attributes; +using Resgrid.Web.Helpers; + +namespace Resgrid.Tests.Web.User +{ + /// + /// Reactivating a removed member is a state change, so the GET only asks and the POST (antiforgery + step-up) does it. + /// + [TestFixture, NonParallelizable] + public class PersonnelReactivationTests + { + private const int DepartmentId = 77; + private const string Manager = "manager"; + private const string Returning = "returning-user"; + private IHttpContextAccessor _previousAccessor; + private DefaultHttpContext _http; + private Mock _departments; + private Mock _users; + private Mock _profiles; + private Mock _authorization; + private DepartmentMember _member; + private PersonnelController _controller; + + [SetUp] + public void SetUp() + { + _previousAccessor = ClaimsAuthorizationHelper._httpContextAccessor; + _http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { + new Claim(ClaimTypes.PrimarySid, Manager), + new Claim(ClaimTypes.PrimaryGroupSid, DepartmentId.ToString()) }, "Test")) }; + ClaimsAuthorizationHelper._httpContextAccessor = new HttpContextAccessor { HttpContext = _http }; + + _member = new DepartmentMember { DepartmentId = DepartmentId, UserId = Returning, IsDeleted = true, IsAdmin = true }; + + _departments = new Mock(); + _departments.Setup(x => x.GetDepartmentMemberAsync(Returning, DepartmentId, true)).ReturnsAsync(() => _member); + _departments.Setup(x => x.GetDepartmentByIdAsync(DepartmentId, It.IsAny())) + .ReturnsAsync(new Department { DepartmentId = DepartmentId, Name = "Station 51", ManagingUserId = Manager }); + _departments.Setup(x => x.ReactivateUserAsync(DepartmentId, Returning, Manager, It.IsAny())) + .ReturnsAsync(() => + { + _member.IsDeleted = false; + _member.IsAdmin = false; + return _member; + }); + + _users = new Mock(); + _users.Setup(x => x.GetUserById(Returning, It.IsAny())).Returns(new IdentityUser { Id = Returning, Email = "returning@example.com" }); + _profiles = new Mock(); + _profiles.Setup(x => x.GetProfileByUserIdAsync(Returning, true)).ReturnsAsync(new UserProfile { UserId = Returning, FirstName = "Alex", LastName = "Returning" }); + _authorization = new Mock(); + _authorization.Setup(x => x.CanUserAddNewUserAsync(DepartmentId, Manager)).ReturnsAsync(true); + var groups = new Mock(); + var roles = new Mock(); + roles.Setup(x => x.GetRolesForUserAsync(Returning, DepartmentId)).ReturnsAsync(new List()); + + _controller = new PersonnelController(_departments.Object, _users.Object, null, null, _profiles.Object, null, _authorization.Object, + null, roles.Object, groups.Object, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null) + { + ControllerContext = new ControllerContext { HttpContext = _http }, + TempData = new TempDataDictionary(_http, Mock.Of()) + }; + } + + [TearDown] + public void TearDown() => ClaimsAuthorizationHelper._httpContextAccessor = _previousAccessor; + + [Test] + public async Task get_asks_for_confirmation_and_changes_nothing() + { + var result = await _controller.ReactivateUser(Returning, CancellationToken.None); + + var model = result.Should().BeOfType().Subject.Model.Should().BeOfType().Subject; + model.ConfirmationPending.Should().BeTrue(); + model.User.Id.Should().Be(Returning); + _member.IsDeleted.Should().BeTrue(); + _departments.Verify(x => x.ReactivateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + _departments.Verify(x => x.InvalidateDepartmentMembers(), Times.Never); + } + + [Test] + public async Task post_reactivates_as_the_acting_user_then_redirects_to_the_result() + { + var result = await _controller.ReactivateUserPost(Returning, CancellationToken.None); + + var redirect = result.Should().BeOfType().Subject; + redirect.ActionName.Should().Be("ReactivateUser"); + redirect.RouteValues["id"].Should().Be(Returning); + _departments.Verify(x => x.ReactivateUserAsync(DepartmentId, Returning, Manager, It.IsAny()), Times.Once); + _departments.Verify(x => x.InvalidateDepartmentMembers(), Times.Once); + _departments.Verify(x => x.InvalidatePersonnelNamesInCache(DepartmentId), Times.Once); + _profiles.Verify(x => x.ClearAllUserProfilesFromCache(DepartmentId), Times.Once); + _users.Verify(x => x.ClearCacheForDepartment(DepartmentId), Times.Once); + + var shown = await _controller.ReactivateUser(Returning, CancellationToken.None); + + var model = shown.Should().BeOfType().Subject.Model.Should().BeOfType().Subject; + model.ConfirmationPending.Should().BeFalse(); + model.State.Should().StartWith("Normal", "a returning member never comes back as the admin they once were"); + } + + [Test] + public async Task post_for_a_member_already_back_changes_nothing() + { + _member.IsDeleted = false; + + var result = await _controller.ReactivateUserPost(Returning, CancellationToken.None); + + result.Should().BeOfType(); + _departments.Verify(x => x.ReactivateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task get_for_an_active_member_without_a_pending_result_goes_to_their_profile() + { + _member.IsDeleted = false; + + var result = await _controller.ReactivateUser(Returning, CancellationToken.None); + + var redirect = result.Should().BeOfType().Subject; + redirect.ActionName.Should().Be("ViewPerson"); + redirect.RouteValues["userId"].Should().Be(Returning); + } + + [TestCase("missing")] + [TestCase("other-department")] + public async Task unknown_or_foreign_members_are_not_found(string scenario) + { + _member = scenario == "missing" ? null : new DepartmentMember { DepartmentId = DepartmentId + 1, UserId = Returning, IsDeleted = true }; + + (await _controller.ReactivateUser(Returning, CancellationToken.None)).Should().BeOfType(); + (await _controller.ReactivateUserPost(Returning, CancellationToken.None)).Should().BeOfType(); + _departments.Verify(x => x.ReactivateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public async Task a_user_who_cannot_add_personnel_cannot_reactivate() + { + _authorization.Setup(x => x.CanUserAddNewUserAsync(DepartmentId, Manager)).ReturnsAsync(false); + + (await _controller.ReactivateUser(Returning, CancellationToken.None)).Should().BeOfType().Which.Url.Should().Be("/Public/Unauthorized"); + (await _controller.ReactivateUserPost(Returning, CancellationToken.None)).Should().BeOfType().Which.Url.Should().Be("/Public/Unauthorized"); + _departments.Verify(x => x.ReactivateUserAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Test] + public void only_the_antiforgery_protected_post_can_reactivate() + { + var actions = typeof(PersonnelController).GetMethods(BindingFlags.Instance | BindingFlags.Public) + .Where(m => m.Name == nameof(PersonnelController.ReactivateUser) || m.GetCustomAttribute()?.Name == nameof(PersonnelController.ReactivateUser)) + .ToArray(); + actions.Should().HaveCount(2); + + var get = actions.Single(m => m.GetCustomAttribute() != null); + get.GetCustomAttribute().Should().BeNull(); + get.GetCustomAttribute().Policy.Should().Be(ResgridResources.Personnel_Create); + + var post = actions.Single(m => m.GetCustomAttribute() != null); + post.Name.Should().Be(nameof(PersonnelController.ReactivateUserPost)); + post.GetCustomAttribute().Should().BeNull(); + post.GetCustomAttribute().Should().NotBeNull(); + post.GetCustomAttribute().Should().NotBeNull(); + post.GetCustomAttribute().Policy.Should().Be(ResgridResources.Personnel_Create); + } + } +} diff --git a/Tests/Resgrid.Tests/Web/User/RecordCallPickerTests.cs b/Tests/Resgrid.Tests/Web/User/RecordCallPickerTests.cs index 3eacf7c2a..a48d13db0 100644 --- a/Tests/Resgrid.Tests/Web/User/RecordCallPickerTests.cs +++ b/Tests/Resgrid.Tests/Web/User/RecordCallPickerTests.cs @@ -162,7 +162,7 @@ private RecordInvestigationsController InvestigationController(IRecordsInvestiga var localizer = new Mock>(); localizer.Setup(l => l[It.IsAny()]).Returns((string key) => new LocalizedString(key, key)); return new RecordInvestigationsController(investigations, occupancies.Object, Mock.Of(), _cutover.Object, - flags.Object, localizer.Object, _calls.Object, _authorization.Object) { ControllerContext = new ControllerContext { HttpContext = _http }, TempData = new TempDataDictionary(_http, Mock.Of()) }; + flags.Object, localizer.Object, _calls.Object, _authorization.Object, Mock.Of()) { ControllerContext = new ControllerContext { HttpContext = _http }, TempData = new TempDataDictionary(_http, Mock.Of()) }; } [TestCase(false)] [TestCase(true)] diff --git a/Tests/Resgrid.Tests/Workers/AuditQueueLogicTests.cs b/Tests/Resgrid.Tests/Workers/AuditQueueLogicTests.cs new file mode 100644 index 000000000..7a42a1b9d --- /dev/null +++ b/Tests/Resgrid.Tests/Workers/AuditQueueLogicTests.cs @@ -0,0 +1,126 @@ +using System.Threading.Tasks; +using FluentAssertions; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using Resgrid.Model; +using Resgrid.Model.Events; +using Resgrid.Model.Services; +using Resgrid.Workers.Framework.Logic; + +namespace Resgrid.Tests.Workers +{ + [TestFixture] + public class AuditQueueLogicTests + { + private static Task Build(AuditLogTypes type, string before = null, string after = null) + { + var userProfileService = new Mock(); + userProfileService.Setup(x => x.GetProfileByUserIdAsync("actor", It.IsAny())) + .ReturnsAsync(new UserProfile { UserId = "actor", FirstName = "Matt", LastName = "Casey" }); + + return AuditQueueLogic.BuildAuditLogAsync( + new AuditEvent { DepartmentId = 1, UserId = "actor", Type = type, Before = before, After = after }, + userProfileService.Object, Mock.Of()); + } + + private static string CallPriority(string name) => + name == null ? null : JsonConvert.SerializeObject(new DepartmentCallPriority { Name = name }); + + [TestCase(AuditLogTypes.CallPriorityAdded, "Matt Casey added a Call Priority.")] + [TestCase(AuditLogTypes.CallPriorityEdited, "Matt Casey edited a Call Priority.")] + [TestCase(AuditLogTypes.CallPriorityRemoved, "Matt Casey removed a Call Priority.")] + [TestCase(AuditLogTypes.UnitTypeAdded, "Matt Casey added a Unit Type.")] + [TestCase(AuditLogTypes.CertificationTypeRemoved, "Matt Casey removed a Certification Type.")] + [TestCase(AuditLogTypes.DocumentCategoryAdded, "Matt Casey added a Document Category.")] + [TestCase(AuditLogTypes.CustomStatusUpdated, "Matt Casey updated a Custom Status.")] + [TestCase(AuditLogTypes.CustomStatusDetailUpdated, "Matt Casey updated a Custom Status Detail.")] + public async Task Message_WithoutPayload_NamesTheActionTaken(AuditLogTypes type, string expected) + { + // These no-payload messages used to name the wrong action (an add logged as "removed", and so on). + (await Build(type)).Message.Should().Be(expected); + } + + [TestCase(AuditLogTypes.CallPriorityAdded, null, "Code 3", "Matt Casey added Call Priority Code 3")] + [TestCase(AuditLogTypes.CallPriorityEdited, "Code 3", "Emergent", "Matt Casey edited Call Priority Code 3")] + [TestCase(AuditLogTypes.CallPriorityEdited, "Code 3", null, "Matt Casey edited Call Priority Code 3")] + [TestCase(AuditLogTypes.CallPriorityRemoved, "Code 3", null, "Matt Casey removed Call Priority Code 3")] + public async Task CallPriority_Message_NamesTheActionAndPriority(AuditLogTypes type, string before, string after, string expected) + { + // Edited and Removed both logged "added"; Edited also read After while checking Before, so an + // event with only a Before threw instead of being saved. + (await Build(type, CallPriority(before), CallPriority(after))).Message.Should().Be(expected); + } + + [Test] + public async Task SettingsChanged_WithoutBefore_BuildsTheRow() + { + // The old Department deserialize threw ArgumentNullException here and the row was lost (RESGRID-WEBJOBS-88). + var auditLog = await Build(AuditLogTypes.DepartmentSettingsChanged, null, "{\"Logo\":\"removed\"}"); + + auditLog.Message.Should().Be("Matt Casey updated the department settings"); + auditLog.Data.Should().Be("Logo: removed"); + } + + [Test] + public void SettingsChanged_ListsOnlyChangedValues() + { + var data = AuditQueueLogic.GetSettingsChangedAuditData( + "{\"Name\":\"Station 1\",\"Use24HourTime\":false,\"Address\":{\"City\":\"Chicago\"}}", + "{\"Name\":\"Station 1\",\"Use24HourTime\":true,\"Address\":{\"City\":\"Evanston\"}}"); + + data.Should().Contain("Use24HourTime: false -> true"); + data.Should().Contain("Address.City: Chicago -> Evanston"); + data.Should().NotContain("Name"); + } + + [Test] + public void SettingsChanged_ReportsValuesAddedAndRemovedBetweenSnapshots() + { + var data = AuditQueueLogic.GetSettingsChangedAuditData("{\"Website\":\"a.org\"}", "{\"Phone\":\"555\"}"); + + data.Should().Contain("Website: a.org -> (none)"); + data.Should().Contain("Phone: (none) -> 555"); + } + + [Test] + public void SettingsChanged_WithoutBefore_ListsAfterValues() + { + // Records cutover activation and the Profile logo/media key actions send only an After; the + // old Department deserialize threw ArgumentNullException on the null Before (RESGRID-WEBJOBS-88). + AuditQueueLogic.GetSettingsChangedAuditData(null, "{\"Logo\":\"uploaded crest.png\"}") + .Should().Be("Logo: uploaded crest.png"); + } + + [Test] + public void SettingsChanged_KeepsDateStringsAsSent() + { + AuditQueueLogic.GetSettingsChangedAuditData(null, "{\"ActivatedOn\":\"2026-09-23T03:14:55Z\"}") + .Should().Be("ActivatedOn: 2026-09-23T03:14:55Z"); + } + + [Test] + public void SettingsChanged_NonJsonPayload_IsRecordedVerbatim() + { + // The shape the Profile page sent for logo and media key changes (RESGRID-WEBJOBS-9D). + AuditQueueLogic.GetSettingsChangedAuditData("logo", "uploaded crest.png") + .Should().Be("Before: logo; After: uploaded crest.png"); + AuditQueueLogic.GetSettingsChangedAuditData("mediaKey", "regenerated") + .Should().Be("Before: mediaKey; After: regenerated"); + } + + [Test] + public void SettingsChanged_MalformedJson_IsRecordedVerbatim() + { + AuditQueueLogic.GetSettingsChangedAuditData("{\"Name\":", null) + .Should().Be("Before: {\"Name\":; After: (none)"); + } + + [Test] + public void SettingsChanged_NoPayloadOrNoDifference_IsNoData() + { + AuditQueueLogic.GetSettingsChangedAuditData(null, " ").Should().Be("No Data"); + AuditQueueLogic.GetSettingsChangedAuditData("{\"Name\":\"A\"}", "{\"Name\":\"A\"}").Should().Be("No Data"); + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs index 8a6807fe6..18b40fb12 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/CalOesMarsController.cs @@ -133,7 +133,7 @@ public async Task> BuildF42([FromBody] Bu { if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return Failed("calmars_deployment_required"); - if (!CanManage && !await _deployments.IsRosteredAsync(input.DeploymentId, DepartmentId, UserId)) return Unauthorized(); + if (!CanManage && !await _deployments.CanFieldMemberSeeAsync(input.DeploymentId, DepartmentId, UserId)) return Unauthorized(); try { var item = await _mars.BuildF42DraftAsync(input.DeploymentId, DepartmentId, input.RmsExternalOrderFillId, UserId, Ip, Agent); @@ -150,7 +150,7 @@ public async Task> BuildExpenseClaim([Fro { if (!await EnabledAsync()) return Failed("cost_recovery_disabled", StatusCodes.Status403Forbidden); if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return Failed("calmars_deployment_required"); - if (!CanManage && !await _deployments.IsRosteredAsync(input.DeploymentId, DepartmentId, UserId)) return Unauthorized(); + if (!CanManage && !await _deployments.CanFieldMemberSeeAsync(input.DeploymentId, DepartmentId, UserId)) return Unauthorized(); try { var item = await _mars.BuildExpenseClaimDraftAsync(input.DeploymentId, DepartmentId, input.F42WorkItemId, UserId, Ip, Agent); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs index d1793d76c..0d5924255 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ContactsController.cs @@ -38,6 +38,7 @@ public class ContactsController : V4AuthenticatedApiControllerbase private readonly IUserDefinedFieldsService _userDefinedFieldsService; private readonly IProtectedReadService _protectedReadService; private readonly IProtectedWriteService _protectedWriteService; + private readonly IAddressService _addressService; public ContactsController( IContactsService contactsService, @@ -47,9 +48,11 @@ public ContactsController( IEventAggregator eventAggregator, IUserDefinedFieldsService userDefinedFieldsService, IProtectedReadService protectedReadService, - IProtectedWriteService protectedWriteService + IProtectedWriteService protectedWriteService, + IAddressService addressService ) { + _addressService = addressService; _protectedReadService = protectedReadService; _protectedWriteService = protectedWriteService; _contactsService = contactsService; @@ -122,6 +125,8 @@ public async Task> GetAllContacts() // without a valid grant, cataloged fields read as REDACTED — never envelopes. var protectedRead = await _protectedReadService.ResolveContactsForReadAsync(DepartmentId, contacts.ToList(), Request.Headers[DataProtectionController.GrantHeader].ToString(), UserId); + var categories = (await _contactsService.GetContactCategoriesForDepartmentAsync(DepartmentId) ?? new List()) + .GroupBy(c => c.ContactCategoryId).ToDictionary(g => g.Key, g => g.First()); foreach (var contact in contacts) { @@ -132,6 +137,7 @@ public async Task> GetAllContacts() editedPerson = await _userProfileService.GetProfileByUserIdAsync(contact.AddedByUserId); var contactData = ConvertContactData(contact, department, addedOnPerson, editedPerson); + if (contact.ContactCategoryId != null && categories.TryGetValue(contact.ContactCategoryId, out var listCategory)) { contactData.CategoryName = listCategory.Name; contactData.CategoryColor = listCategory.Color; } contactData.IsProtected = protectedRead.IsProtected; contactData.ProtectedReason = protectedRead.ProtectedReason; @@ -190,6 +196,17 @@ public async Task> GetContactById(string contactId) result.Data.ProtectedReason = protectedRead.ProtectedReason; result.Data.RedactedFields = protectedRead.RedactedFields; + if (!String.IsNullOrWhiteSpace(contact.ContactCategoryId)) + { + var category = await _contactsService.GetContactCategoryByIdAsync(contact.ContactCategoryId); + if (category != null && category.DepartmentId == DepartmentId) { result.Data.CategoryName = category.Name; result.Data.CategoryColor = category.Color; } + } + // Field apps show the address and hand it to a maps app; the web detail reads the same rows. + if (contact.PhysicalAddressId.HasValue) + result.Data.PhysicalAddress = ConvertAddress(await _addressService.GetAddressByIdAsync(contact.PhysicalAddressId.Value)); + if (contact.MailingAddressId.HasValue && contact.MailingAddressId != contact.PhysicalAddressId) + result.Data.MailingAddress = ConvertAddress(await _addressService.GetAddressByIdAsync(contact.MailingAddressId.Value)); + var udfValues = await _userDefinedFieldsService.GetFieldValuesForEntityAsync(DepartmentId, (int)UdfEntityType.Contact, contactId); if (udfValues != null && udfValues.Any()) { @@ -198,6 +215,16 @@ public async Task> GetContactById(string contactId) var visibleFields = await _userDefinedFieldsService.GetVisibleFieldsForActiveDefinitionAsync(DepartmentId, (int)UdfEntityType.Contact, isDeptAdmin, isGroupAdmin); var visibleFieldIds = visibleFields.Select(f => f.UdfFieldId).ToHashSet(); + var mobileFields = visibleFields.Where(f => f.IsVisibleOnMobile && f.IsEnabled).ToDictionary(f => f.UdfFieldId); + result.Data.CustomFields = udfValues + .Where(v => !String.IsNullOrWhiteSpace(v.Value) && mobileFields.ContainsKey(v.UdfFieldId)) + .Select(v => new ContactCustomFieldData + { + UdfFieldId = v.UdfFieldId, Label = mobileFields[v.UdfFieldId].Label ?? mobileFields[v.UdfFieldId].Name, Value = v.Value, + FieldDataType = mobileFields[v.UdfFieldId].FieldDataType, GroupName = mobileFields[v.UdfFieldId].GroupName, SortOrder = mobileFields[v.UdfFieldId].SortOrder + }) + .OrderBy(f => f.GroupName).ThenBy(f => f.SortOrder).ToList(); + result.Data.UdfValues = udfValues .Where(v => visibleFieldIds.Contains(v.UdfFieldId)) .Select(v => new UdfFieldValueResultData @@ -688,6 +715,13 @@ public static ContactCategoryResultData ConvertCategoryData(ContactCategory cate return cat; } + private static ContactAddressData ConvertAddress(Address address) + { + if (address == null) return null; + var parts = new[] { address.Address1, address.City, address.State, address.PostalCode, address.Country }.Where(p => !String.IsNullOrWhiteSpace(p)).Select(p => p.Trim()); + return new ContactAddressData { Address1 = address.Address1, City = address.City, State = address.State, PostalCode = address.PostalCode, Country = address.Country, Formatted = String.Join(", ", parts) }; + } + public static ContactResultData ConvertContactData(Contact contact, Department department, UserProfile addedProfile, UserProfile editedProfile) { var con = new ContactResultData(); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs index 8cd2fd47c..adddfcc14 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/DeploymentsController.cs @@ -32,12 +32,14 @@ public class DeploymentsController : V4AuthenticatedApiControllerbase private readonly IDeploymentService _deployments; private readonly IFeatureToggleService _flags; private readonly IBusinessOperationsAccessService _access; + private readonly IDepartmentsService _departments; - public DeploymentsController(IDeploymentService deployments, IFeatureToggleService flags, IBusinessOperationsAccessService access) + public DeploymentsController(IDeploymentService deployments, IFeatureToggleService flags, IBusinessOperationsAccessService access, IDepartmentsService departments) { _deployments = deployments; _flags = flags; _access = access; + _departments = departments; } internal static bool CanManage() => ClaimsAuthorizationHelper.CanManageDeployments() || ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); @@ -97,8 +99,24 @@ public async Task> GetDeployment(string id) if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); var deployment = await _deployments.GetDeploymentByIdAsync(id, DepartmentId); if (deployment == null) return NotFound(); - if (!CanView() && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); - return Ok(deployment); + return await DetailAsync(deployment); + } + + /// A field member reads a deployment they are rostered on or whose deployed unit they crew; the detail carries their time scope. + private async Task> DetailAsync(Deployment deployment) + { + var access = await _deployments.GetTimeAccessAsync(deployment, UserId, CanManage()); + if (!CanView() && !access.CanRead) return Unauthorized(); + var department = await _departments.GetDepartmentByIdAsync(DepartmentId); + var result = new DeploymentResult { Data = Map(deployment, true), PageSize = 1, Status = ResponseHelper.Success }; + result.Data.TimeAccess = new DeploymentTimeAccessData + { + CanManage = access.CanManage, CanApprove = ClaimsAuthorizationHelper.CanApproveTimeReports() || ClaimsAuthorizationHelper.IsUserDepartmentAdmin(), + PersonnelId = access.PersonnelId, CrewUnitIds = access.CrewUnitIds.ToList(), WritableSubjectIds = access.WritableSubjectIds.OrderBy(s => s, StringComparer.Ordinal).ToList(), + TimeZone = string.IsNullOrWhiteSpace(department?.TimeZone) ? "Pacific Standard Time" : department.TimeZone + }; + ResponseHelper.PopulateV4ResponseData(result); + return result; } /// The billing context behind a call (mobile: call → deployment). @@ -109,8 +127,7 @@ public async Task> GetDeploymentByCallId(int call if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); var deployment = await _deployments.GetDeploymentByCallIdAsync(callId, DepartmentId); if (deployment == null) return NotFound(); - if (!CanView() && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); - return Ok(deployment); + return await DetailAsync(deployment); } [HttpPost("SaveDeployment")] @@ -284,7 +301,7 @@ public async Task> GetRosterWarnings(string d public async Task> GetAttachments(string deploymentId) { if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); - if (!CanView() && !await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); + if (!CanView() && !await _deployments.CanFieldMemberSeeAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); var rows = await _deployments.GetAttachmentsAsync(deploymentId, DepartmentId); var result = new DeploymentAttachmentsResult { Data = rows.Select(MapAttachment).ToList(), PageSize = rows.Count, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs index cd3e06c8c..414577957 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/InventoryOperationsController.cs @@ -13,6 +13,9 @@ public sealed partial class InventoryController { private IInventoryOperationsService OperationsService => HttpContext.RequestServices.GetService() ?? throw new InventoryException(409, "OperationUnavailable"); + /// Field apps: whether inventory is usable, whether the caller may count (AdjustInventory at the unit's holder location) and the unit's countable locations. + [HttpGet("GetAccess")] + public async Task GetAccess(int? unitId = null) => Reply(await OperationsService.GetFieldAccessAsync(Actor, unitId is > 0 ? unitId : null)); [HttpGet("GetCounts")] public Task GetCounts(int page = 0, string locationId = null) => Query(new InventoryQuery { LocationId = locationId }, page); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs index 947b78e66..ad786ef4d 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/TimeReportsController.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -7,6 +8,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Resgrid.Model; +using Resgrid.Model.Helpers; using Resgrid.Model.Invoicing; using Resgrid.Model.Services; using Resgrid.Providers.Claims; @@ -18,10 +20,12 @@ namespace Resgrid.Web.Services.Controllers.v4 { /// - /// Daily time reports, entries and expenses (Workforce & Business Operations plan, Phase C5). Mobile crews file - /// DTRs from the field: a rostered member may create, edit, sign and submit reports and expenses on their own - /// deployments without any new claim; approval and void need TimeReports_Approve. Receipts upload as base64 and - /// are stored as Receipt attachments; DTOs carry UpdatedOn for delta-sync. + /// Daily time reports, entries and expenses (Workforce & Business Operations plan, Phase C5). Mobile crews file from the + /// field without any new claim: a member writes their own roster row (individual report) and, for every deployed unit they + /// crew (on the deployment roster for that unit, or seated on the apparatus through an active unit role), that unit's Crew + /// Time Report — the unit, its crew and its equipment (M0227). Entries of subjects a caller may not write are kept as stored. + /// Approval and void need TimeReports_Approve. Entry times travel as UTC instants plus department-local wall clock + /// (StartLocal/EndLocal) so field apps never do zone math. Receipts upload as base64; DTOs carry UpdatedOn for delta-sync. /// [Route("api/v{VersionId:apiVersion}/[controller]")] [ApiVersion("4.0")] @@ -29,21 +33,34 @@ namespace Resgrid.Web.Services.Controllers.v4 [Authorize] public class TimeReportsController : V4AuthenticatedApiControllerbase { + private const string LocalClockFormat = "yyyy-MM-dd'T'HH:mm"; + private static readonly string[] LocalClockFormats = { "yyyy-MM-dd'T'HH:mm", "yyyy-MM-dd'T'HH:mm:ss", "yyyy-MM-dd'T'HH:mm:ss.FFFFFFF", "yyyy-MM-dd HH:mm" }; + private readonly ITimeTrackingService _timeTracking; private readonly IDeploymentService _deployments; private readonly IFeatureToggleService _flags; + private readonly IDepartmentsService _departments; - public TimeReportsController(ITimeTrackingService timeTracking, IDeploymentService deployments, IFeatureToggleService flags) + public TimeReportsController(ITimeTrackingService timeTracking, IDeploymentService deployments, IFeatureToggleService flags, IDepartmentsService departments) { _timeTracking = timeTracking; _deployments = deployments; _flags = flags; + _departments = departments; } private Task EnabledAsync() => _flags.IsEnabledAsync(FeatureFlagKeys.Deployments, DepartmentId); private static bool CanApprove() => ClaimsAuthorizationHelper.CanApproveTimeReports() || ClaimsAuthorizationHelper.IsUserDepartmentAdmin(); - private async Task CanTouchAsync(string deploymentId) => DeploymentsController.CanManage() || await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId); - private async Task CanSeeAsync(string deploymentId) => DeploymentsController.CanView() || await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId); + + /// The deployment and the caller's time scope on it; both null when the deployment is not in this department. + private async Task<(Deployment Deployment, DeploymentTimeAccess Access)> AccessAsync(string deploymentId) + { + var deployment = await _deployments.GetDeploymentByIdAsync(deploymentId, DepartmentId); + if (deployment == null) return (null, null); + return (deployment, await _deployments.GetTimeAccessAsync(deployment, UserId, DeploymentsController.CanManage())); + } + + private static bool CanSee(DeploymentTimeAccess access) => DeploymentsController.CanView() || (access?.CanRead ?? false); private string Ip => IpAddressHelper.GetRequestIP(Request, true); private string Agent => $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; @@ -65,9 +82,13 @@ public TimeReportsController(ITimeTrackingService timeTracking, IDeploymentServi public async Task> GetTimeReports(string deploymentId) { if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); - if (!await CanSeeAsync(deploymentId)) return Unauthorized(); - var reports = await _timeTracking.GetTimeReportsAsync(deploymentId, DepartmentId); - var result = new TimeReportsResult { Data = reports.Select(r => Map(r)).ToList(), PageSize = reports.Count, Status = ResponseHelper.Success }; + var (deployment, access) = await AccessAsync(deploymentId); + if (deployment == null) return NotFound(); + if (!CanSee(access)) return Unauthorized(); + // With entries: the field apps open a day's report straight from this list (two reads for the whole deployment). + var reports = await _timeTracking.GetTimeReportsWithEntriesAsync(deploymentId, DepartmentId); + var department = await _departments.GetDepartmentByIdAsync(DepartmentId); + var result = new TimeReportsResult { Data = reports.Select(r => Map(r, department, access)).ToList(), PageSize = reports.Count, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return result; } @@ -79,8 +100,9 @@ public async Task> GetTimeReport(string id) if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); if (report == null) return NotFound(); - if (!await CanSeeAsync(report.DeploymentId)) return Unauthorized(); - return Ok(report); + var (_, access) = await AccessAsync(report.DeploymentId); + if (!CanSee(access)) return Unauthorized(); + return await OkAsync(report, access); } [HttpPost("NewTimeReport")] @@ -89,8 +111,15 @@ public async Task> NewTimeReport([FromBody] NewTi { if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return BadRequest(); - if (!await CanTouchAsync(input.DeploymentId)) return Unauthorized(); - try { return Ok(await _timeTracking.CreateTimeReportAsync(input.DeploymentId, DepartmentId, input.ReportDate, UserId, Ip, Agent, cancellationToken)); } + var (deployment, access) = await AccessAsync(input.DeploymentId); + if (deployment == null) return NotFound(); + // Managers open any scope; a crew member opens their unit's crew report; anyone opens their own individual report + // (and a crew boss one for a member of the crew). The deployment-wide DTR stays a manager's paper. + var allowed = access.CanManage + || (!string.IsNullOrWhiteSpace(input.DeploymentUnitId) && string.IsNullOrWhiteSpace(input.DeploymentPersonnelId) && access.CrewUnitIds.Contains(input.DeploymentUnitId, StringComparer.OrdinalIgnoreCase)) + || (!string.IsNullOrWhiteSpace(input.DeploymentPersonnelId) && string.IsNullOrWhiteSpace(input.DeploymentUnitId) && access.CanWriteSubject(input.DeploymentPersonnelId)); + if (!allowed) return Unauthorized(); + try { return await OkAsync(await _timeTracking.CreateTimeReportAsync(input.DeploymentId, DepartmentId, input.ReportDate, input.DeploymentUnitId, input.DeploymentPersonnelId, UserId, Ip, Agent, cancellationToken), access); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } @@ -102,14 +131,19 @@ public async Task> UpdateTimeReport([FromBody] Up if (input == null) return BadRequest(); var report = await _timeTracking.GetTimeReportByIdAsync(input.Id, DepartmentId); if (report == null) return NotFound(); - if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); + var (_, access) = await AccessAsync(report.DeploymentId); + if (access == null || !access.CanActOn(report)) return Unauthorized(); report.IncidentNumber = input.IncidentNumber; report.ResourceOrderNumber = input.ResourceOrderNumber; report.RequestNumber = input.RequestNumber; report.CostCode = input.CostCode; report.PointOfHire = input.PointOfHire; report.NoClear8 = input.NoClear8; report.UnsafeConditionsStandDown = input.UnsafeConditionsStandDown; report.Notes = input.Notes; report.RmsExternalOrderFillId = input.RmsExternalOrderFillId; - try { return Ok(await _timeTracking.UpdateTimeReportAsync(report, UserId, Ip, Agent, cancellationToken)); } + try { return await OkAsync(await _timeTracking.UpdateTimeReportAsync(report, UserId, Ip, Agent, cancellationToken), access); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } - /// Replaces the report's entries as a batch. Validation errors come back with the unchanged report and status Failure. + /// + /// Replaces the entries of the subjects the caller may write (every subject for a manager); other subjects' entries stay as + /// stored. StartLocal/EndLocal, when sent, are department-local wall clock and win over StartTime/EndTime. Validation + /// errors come back with the unchanged report and status Failure. + /// [HttpPost("SaveTimeEntries")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SaveTimeEntries([FromBody] SaveTimeEntriesInput input, CancellationToken cancellationToken) @@ -118,17 +152,27 @@ public async Task> SaveTimeEntries([FromBody] Sav if (input == null) return BadRequest(); var report = await _timeTracking.GetTimeReportByIdAsync(input.TimeReportId, DepartmentId); if (report == null) return NotFound(); - if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); + var (_, access) = await AccessAsync(report.DeploymentId); + if (access == null || !access.CanWrite) return Unauthorized(); + // A scoped report belongs to its crew or person; nobody else writes into it even for their own subjects. + if (report.Scope != DeploymentTimeReportScopes.Deployment && !access.CanActOn(report)) return Unauthorized(); + var department = await _departments.GetDepartmentByIdAsync(DepartmentId); try { - var entries = (input.Entries ?? new List()).Select(e => new DeploymentTimeEntry + var entries = new List(); + foreach (var e in input.Entries ?? new List()) { - DeploymentTimeEntryId = string.IsNullOrWhiteSpace(e.Id) ? null : e.Id, - DeploymentPersonnelId = e.DeploymentPersonnelId, DeploymentUnitId = e.DeploymentUnitId, DeploymentEquipmentId = e.DeploymentEquipmentId, EntryType = e.EntryType, StartTime = e.StartTime, EndTime = e.EndTime, - PaidBreakMinutes = e.PaidBreakMinutes, UnpaidBreakMinutes = e.UnpaidBreakMinutes, CrewSizeSnapshot = e.CrewSizeSnapshot, CertificationCode = e.CertificationCode, MileageKm = e.MileageKm, FuelDeductionLitres = e.FuelDeductionLitres, - AgencySuppliedMeals = e.AgencySuppliedMeals, AgencySuppliedAccommodation = e.AgencySuppliedAccommodation, Notes = e.Notes, SortOrder = e.SortOrder - }).ToList(); - return Ok(await _timeTracking.SaveTimeEntriesAsync(input.TimeReportId, DepartmentId, entries, UserId, Ip, Agent, cancellationToken)); + if (e == null) continue; + if (!TryResolve(e.StartLocal, e.StartTime, department, out var start) || !TryResolve(e.EndLocal, e.EndTime, department, out var end)) return Failed("timereports_time_invalid"); + entries.Add(new DeploymentTimeEntry + { + DeploymentTimeEntryId = string.IsNullOrWhiteSpace(e.Id) ? null : e.Id, + DeploymentPersonnelId = e.DeploymentPersonnelId, DeploymentUnitId = e.DeploymentUnitId, DeploymentEquipmentId = e.DeploymentEquipmentId, EntryType = e.EntryType, StartTime = start, EndTime = end, + PaidBreakMinutes = e.PaidBreakMinutes, UnpaidBreakMinutes = e.UnpaidBreakMinutes, CrewSizeSnapshot = e.CrewSizeSnapshot, CertificationCode = e.CertificationCode, MileageKm = e.MileageKm, FuelDeductionLitres = e.FuelDeductionLitres, + AgencySuppliedMeals = e.AgencySuppliedMeals, AgencySuppliedAccommodation = e.AgencySuppliedAccommodation, Notes = e.Notes, SortOrder = e.SortOrder + }); + } + return await OkAsync(await _timeTracking.SaveTimeEntriesAsync(input.TimeReportId, DepartmentId, entries, access, UserId, Ip, Agent, cancellationToken), access, department); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } @@ -141,8 +185,9 @@ public async Task> SubmitTimeReport([FromBody] Ti if (input == null) return BadRequest(); var report = await _timeTracking.GetTimeReportByIdAsync(input.Id, DepartmentId); if (report == null) return NotFound(); - if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); - try { return Ok(await _timeTracking.SubmitTimeReportAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken)); } + var (_, access) = await AccessAsync(report.DeploymentId); + if (access == null || !access.CanActOn(report)) return Unauthorized(); + try { return await OkAsync(await _timeTracking.SubmitTimeReportAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken), access); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } @@ -153,7 +198,12 @@ public async Task> ApproveTimeReport([FromBody] T { if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); if (input == null) return BadRequest(); - try { return Ok(await _timeTracking.ApproveTimeReportAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken)); } + try + { + var approved = await _timeTracking.ApproveTimeReportAsync(input.Id, DepartmentId, UserId, Ip, Agent, cancellationToken); + var (_, access) = await AccessAsync(approved.DeploymentId); + return await OkAsync(approved, access); + } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } @@ -164,10 +214,16 @@ public async Task> VoidTimeReport([FromBody] Time if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); if (input == null) return BadRequest(); if (!CanApprove()) return Unauthorized(); - try { return Ok(await _timeTracking.VoidTimeReportAsync(input.Id, DepartmentId, input.Reason, UserId, Ip, Agent, cancellationToken)); } + try + { + var voided = await _timeTracking.VoidTimeReportAsync(input.Id, DepartmentId, input.Reason, UserId, Ip, Agent, cancellationToken); + var (_, access) = await AccessAsync(voided.DeploymentId); + return await OkAsync(voided, access); + } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } + /// Crew boss / contractor signature (the acting user) and/or the customer signer's typed name. [HttpPost("SignTimeReport")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SignTimeReport([FromBody] SignTimeReportInput input, CancellationToken cancellationToken) @@ -176,8 +232,9 @@ public async Task> SignTimeReport([FromBody] Sign if (input == null) return BadRequest(); var report = await _timeTracking.GetTimeReportByIdAsync(input.Id, DepartmentId); if (report == null) return NotFound(); - if (!await CanTouchAsync(report.DeploymentId)) return Unauthorized(); - try { return Ok(await _timeTracking.SignTimeReportAsync(input.Id, DepartmentId, input.ContractorSigned, input.CustomerSignerName, UserId, Ip, Agent, cancellationToken)); } + var (_, access) = await AccessAsync(report.DeploymentId); + if (access == null || !access.CanActOn(report)) return Unauthorized(); + try { return await OkAsync(await _timeTracking.SignTimeReportAsync(input.Id, DepartmentId, input.ContractorSigned, input.CustomerSignerName, UserId, Ip, Agent, cancellationToken), access); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Failed(ex.Message); } } @@ -188,7 +245,8 @@ public async Task GetTimeReportPdf(string id) if (!await EnabledAsync()) return StatusCode(StatusCodes.Status403Forbidden); var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); if (report == null) return NotFound(); - if (!await CanSeeAsync(report.DeploymentId)) return Unauthorized(); + var (_, access) = await AccessAsync(report.DeploymentId); + if (!CanSee(access)) return Unauthorized(); try { var pdf = await _timeTracking.GetTimeReportPdfAsync(id, DepartmentId); @@ -206,20 +264,40 @@ public async Task GetTimeReportPdf(string id) public async Task> GetExpenses(string deploymentId) { if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); - if (!await CanSeeAsync(deploymentId)) return Unauthorized(); + var (deployment, access) = await AccessAsync(deploymentId); + if (deployment == null) return NotFound(); + if (!CanSee(access)) return Unauthorized(); var rows = await _timeTracking.GetExpensesAsync(deploymentId, DepartmentId); var result = new ExpensesResult { Data = rows.Select(MapExpense).ToList(), PageSize = rows.Count, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return result; } + /// A field member adds expenses on a deployment they may write time for and edits only the ones they added; managers edit any. [HttpPost("SaveExpense")] [ProducesResponseType(StatusCodes.Status200OK)] public async Task> SaveExpense([FromBody] SaveExpenseInput input, CancellationToken cancellationToken) { if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return BadRequest(); - if (!await CanTouchAsync(input.DeploymentId)) return Unauthorized(); + var (deployment, access) = await AccessAsync(input.DeploymentId); + if (deployment == null) return NotFound(); + if (!access.CanWrite) return Unauthorized(); + if (!access.CanManage) + { + if (!string.IsNullOrWhiteSpace(input.Id)) + { + var existing = await _timeTracking.GetExpenseByIdAsync(input.Id, DepartmentId); + if (existing == null) return NotFound(); + if (!string.Equals(existing.AddedByUserId, UserId, StringComparison.OrdinalIgnoreCase)) return Unauthorized(); + } + if (!string.IsNullOrWhiteSpace(input.TimeReportId)) + { + var linked = await _timeTracking.GetTimeReportByIdAsync(input.TimeReportId, DepartmentId); + if (linked == null) return NotFound(); + if (!access.CanActOn(linked)) return Unauthorized(); + } + } byte[] receipt = null; if (!string.IsNullOrWhiteSpace(input.ReceiptData)) { @@ -251,7 +329,9 @@ public async Task> DeleteExpense(string if (!await EnabledAsync()) return Failed("deployments_disabled", StatusCodes.Status403Forbidden); var expense = await _timeTracking.GetExpenseByIdAsync(id, DepartmentId); if (expense == null) return NotFound(); - if (!await CanTouchAsync(expense.DeploymentId)) return Unauthorized(); + var (_, access) = await AccessAsync(expense.DeploymentId); + if (access == null || !access.CanWrite) return Unauthorized(); + if (!access.CanManage && !string.Equals(expense.AddedByUserId, UserId, StringComparison.OrdinalIgnoreCase)) return Unauthorized(); try { await _timeTracking.DeleteExpenseAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); @@ -266,18 +346,32 @@ public async Task> DeleteExpense(string #region Mapping - private ActionResult Ok(DeploymentTimeReport report) + /// A department-local wall clock wins when sent; otherwise the UTC instant (the converter already normalised it). + private static bool TryResolve(string local, DateTime utc, Department department, out DateTime value) + { + value = utc; + if (string.IsNullOrWhiteSpace(local)) return utc != default; + if (!DateTime.TryParseExact(local.Trim(), LocalClockFormats, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) return false; + value = DateTime.SpecifyKind(parsed, DateTimeKind.Unspecified).DepartmentLocalToUtc(department); + return true; + } + + private static string LocalClock(DateTime utc, Department department) => utc.TimeConverter(department ?? new Department()).ToString(LocalClockFormat, CultureInfo.InvariantCulture); + + private async Task> OkAsync(DeploymentTimeReport report, DeploymentTimeAccess access) { - var result = new TimeReportResult { Data = Map(report), PageSize = 1, Status = ResponseHelper.Success }; + var department = await _departments.GetDepartmentByIdAsync(DepartmentId); + var result = new TimeReportResult { Data = Map(report, department, access), PageSize = 1, Status = ResponseHelper.Success }; ResponseHelper.PopulateV4ResponseData(result); return result; } - private ActionResult Ok(TimeReportSaveResult save) + private async Task> OkAsync(TimeReportSaveResult save, DeploymentTimeAccess access, Department department = null) { + department ??= await _departments.GetDepartmentByIdAsync(DepartmentId); var result = new TimeReportResult { - Data = save.Report == null ? null : Map(save.Report), + Data = save.Report == null ? null : Map(save.Report, department, access), Errors = save.Validation.Errors.Select(MapIssue).ToList(), Warnings = save.Validation.Warnings.Select(MapIssue).ToList(), PageSize = 1, @@ -288,16 +382,18 @@ private ActionResult Ok(TimeReportSaveResult save) return result; } - internal static TimeReportData Map(DeploymentTimeReport r) => new TimeReportData + internal static TimeReportData Map(DeploymentTimeReport r, Department department = null, DeploymentTimeAccess access = null) => new TimeReportData { - Id = r.DeploymentTimeReportId, DeploymentId = r.DeploymentId, ReportNumber = r.ReportNumber, ReportDate = r.ReportDate, Status = r.Status, IncidentNumber = r.IncidentNumber, ResourceOrderNumber = r.ResourceOrderNumber, + Id = r.DeploymentTimeReportId, DeploymentId = r.DeploymentId, ReportNumber = r.ReportNumber, ReportDate = r.ReportDate, Scope = (int)r.Scope, DeploymentUnitId = r.DeploymentUnitId, DeploymentPersonnelId = r.DeploymentPersonnelId, + CanAct = access?.CanActOn(r) ?? false, Status = r.Status, IncidentNumber = r.IncidentNumber, ResourceOrderNumber = r.ResourceOrderNumber, RequestNumber = r.RequestNumber, CostCode = r.CostCode, PointOfHire = r.PointOfHire, NoClear8 = r.NoClear8, UnsafeConditionsStandDown = r.UnsafeConditionsStandDown, ContractorSignedByUserId = r.ContractorSignedByUserId, ContractorSignedOn = r.ContractorSignedOn, CustomerSignerName = r.CustomerSignerName, CustomerSignedOn = r.CustomerSignedOn, SubmittedByUserId = r.SubmittedByUserId, SubmittedOn = r.SubmittedOn, ApprovedByUserId = r.ApprovedByUserId, ApprovedOn = r.ApprovedOn, InvoiceId = r.InvoiceId, RmsExternalOrderFillId = r.RmsExternalOrderFillId, Notes = r.Notes, AddedOn = r.AddedOn, UpdatedOn = r.EditedOn ?? r.AddedOn, - Entries = r.Entries.Select(e => new TimeEntryData + Entries = (r.Entries ?? new List()).Select(e => new TimeEntryData { Id = e.DeploymentTimeEntryId, SubjectType = e.SubjectType, DeploymentPersonnelId = e.DeploymentPersonnelId, DeploymentUnitId = e.DeploymentUnitId, DeploymentEquipmentId = e.DeploymentEquipmentId, EntryType = e.EntryType, - StartTime = e.StartTime, EndTime = e.EndTime, PaidBreakMinutes = e.PaidBreakMinutes, UnpaidBreakMinutes = e.UnpaidBreakMinutes, CrewSizeSnapshot = e.CrewSizeSnapshot, CertificationCode = e.CertificationCode, MileageKm = e.MileageKm, + StartTime = e.StartTime, EndTime = e.EndTime, StartLocal = LocalClock(e.StartTime, department), EndLocal = LocalClock(e.EndTime, department), + PaidBreakMinutes = e.PaidBreakMinutes, UnpaidBreakMinutes = e.UnpaidBreakMinutes, CrewSizeSnapshot = e.CrewSizeSnapshot, CertificationCode = e.CertificationCode, MileageKm = e.MileageKm, FuelDeductionLitres = e.FuelDeductionLitres, AgencySuppliedMeals = e.AgencySuppliedMeals, AgencySuppliedAccommodation = e.AgencySuppliedAccommodation, Notes = e.Notes, SortOrder = e.SortOrder, Hours = e.Hours }).ToList() }; @@ -308,7 +404,7 @@ private ActionResult Ok(TimeReportSaveResult save) { Id = e.DeploymentExpenseId, DeploymentId = e.DeploymentId, TimeReportId = e.DeploymentTimeReportId, ExpenseDate = e.ExpenseDate, ExpenseType = e.ExpenseType, MealCode = e.MealCode, City = e.City, Description = e.Description, Amount = e.Amount, Currency = e.Currency, PreApproved = e.PreApproved, Billable = e.Billable, ReceiptAttachmentId = e.ReceiptAttachmentId, - AddedOn = e.AddedOn, UpdatedOn = e.EditedOn ?? e.AddedOn + AddedByUserId = e.AddedByUserId, AddedOn = e.AddedOn, UpdatedOn = e.EditedOn ?? e.AddedOn }; #endregion diff --git a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs index 02c147d79..5576a5a34 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Contacts/ContactResult.cs @@ -49,6 +49,20 @@ public class ContactResultData public virtual ContactCategory Category { get; set; } + /// The contact category's display name and color (the Category entity itself is never sent). + public string CategoryName { get; set; } + + public string CategoryColor { get; set; } + + /// Resolved physical address (contact detail only; null on list rows or when none is set). + public ContactAddressData PhysicalAddress { get; set; } + + /// Resolved mailing address when it differs from the physical one (contact detail only). + public ContactAddressData MailingAddress { get; set; } + + /// Mobile-visible custom field values with their labels, in form order (contact detail only). + public List CustomFields { get; set; } = new List(); + public string FirstName { get; set; } public string MiddleName { get; set; } @@ -136,4 +150,25 @@ public class ContactResultData /// public List UdfValues { get; set; } } + + public class ContactAddressData + { + public string Address1 { get; set; } + public string City { get; set; } + public string State { get; set; } + public string PostalCode { get; set; } + public string Country { get; set; } + /// One-line form for display and for handing to a maps app. + public string Formatted { get; set; } + } + + public class ContactCustomFieldData + { + public string UdfFieldId { get; set; } + public string Label { get; set; } + public string Value { get; set; } + public int FieldDataType { get; set; } + public string GroupName { get; set; } + public int SortOrder { get; set; } + } } diff --git a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs index eea74ee85..4d4ea003d 100644 --- a/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs +++ b/Web/Resgrid.Web.Services/Models/v4/Deployments/DeploymentsApiModels.cs @@ -68,6 +68,23 @@ public class DeploymentData public List Units { get; set; } = new List(); public List Personnel { get; set; } = new List(); public List Equipment { get; set; } = new List(); + /// What the caller may do with this deployment's time (detail reads only; null on list rows). + public DeploymentTimeAccessData TimeAccess { get; set; } + } + + /// + /// The caller's time scope on one deployment (M0227), computed by the server so a field app never infers it: their own + /// roster row (individual report), the deployed units they crew (crew time report) and every subject id they may write. + /// + public class DeploymentTimeAccessData + { + public bool CanManage { get; set; } + public bool CanApprove { get; set; } + public string PersonnelId { get; set; } + public List CrewUnitIds { get; set; } = new List(); + public List WritableSubjectIds { get; set; } = new List(); + /// The department zone time entries are written and shown in (StartLocal/EndLocal). + public string TimeZone { get; set; } } public class DeploymentUnitData @@ -252,6 +269,14 @@ public class TimeReportData public string DeploymentId { get; set; } public int ReportNumber { get; set; } public DateTime ReportDate { get; set; } + /// DeploymentTimeReportScopes value: 0 deployment-wide DTR, 1 crew time report, 2 individual. + public int Scope { get; set; } + /// Crew time report: the deployment unit whose crew it covers. + public string DeploymentUnitId { get; set; } + /// Individual report: the roster row it covers. + public string DeploymentPersonnelId { get; set; } + /// Whether the caller may write, sign and submit this report (its scope is theirs); the server re-checks. + public bool CanAct { get; set; } /// DeploymentTimeReportStatuses value. public int Status { get; set; } public string IncidentNumber { get; set; } @@ -287,8 +312,19 @@ public class TimeEntryData public string DeploymentEquipmentId { get; set; } /// DeploymentTimeEntryTypes value. public int EntryType { get; set; } + /// UTC instant ("Z"). + [Newtonsoft.Json.JsonConverter(typeof(Resgrid.Web.Services.Helpers.UtcDateTimeConverter))] public DateTime StartTime { get; set; } + /// UTC instant ("Z"). + [Newtonsoft.Json.JsonConverter(typeof(Resgrid.Web.Services.Helpers.UtcDateTimeConverter))] public DateTime EndTime { get; set; } + /// + /// Department-local wall clock "yyyy-MM-ddTHH:mm" — what the crew writes on a paper time report. On save a non-empty + /// value wins over and is converted in the department's zone, so a field app never does zone math. + /// + public string StartLocal { get; set; } + /// Department-local wall clock end; see . + public string EndLocal { get; set; } public int PaidBreakMinutes { get; set; } public int UnpaidBreakMinutes { get; set; } public int? CrewSizeSnapshot { get; set; } @@ -314,6 +350,10 @@ public class NewTimeReportInput { public string DeploymentId { get; set; } public DateTime ReportDate { get; set; } + /// Crew time report for this deployment unit (the unit, its crew and equipment). + public string DeploymentUnitId { get; set; } + /// Individual time report for this roster row. + public string DeploymentPersonnelId { get; set; } } public class UpdateTimeReportInput @@ -375,6 +415,7 @@ public class ExpenseData public bool PreApproved { get; set; } public bool Billable { get; set; } public int? ReceiptAttachmentId { get; set; } + public string AddedByUserId { get; set; } public DateTime AddedOn { get; set; } public DateTime? UpdatedOn { get; set; } } diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 728caf345..4c6b1a571 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1698,6 +1698,9 @@ Whether the deployment core is available to this department and what the caller may do. Always answers. + + A field member reads a deployment they are rostered on or whose deployed unit they crew; the detail carries their time scope. + The billing context behind a call (mobile: call → deployment). @@ -2424,6 +2427,9 @@ Bulk items at/below their reorder point, using only stock locations this caller can view. Page is the catalog page. + + Field apps: whether inventory is usable, whether the caller may count (AdjustInventory at the unit's holder location) and the unit's countable locations. + Customer invoices (Workforce & Business Operations plan, Phase B, B7). Read-mostly: list, detail, PDF, and @@ -3838,14 +3844,32 @@ - Daily time reports, entries and expenses (Workforce & Business Operations plan, Phase C5). Mobile crews file - DTRs from the field: a rostered member may create, edit, sign and submit reports and expenses on their own - deployments without any new claim; approval and void need TimeReports_Approve. Receipts upload as base64 and - are stored as Receipt attachments; DTOs carry UpdatedOn for delta-sync. + Daily time reports, entries and expenses (Workforce & Business Operations plan, Phase C5). Mobile crews file from the + field without any new claim: a member writes their own roster row (individual report) and, for every deployed unit they + crew (on the deployment roster for that unit, or seated on the apparatus through an active unit role), that unit's Crew + Time Report — the unit, its crew and its equipment (M0227). Entries of subjects a caller may not write are kept as stored. + Approval and void need TimeReports_Approve. Entry times travel as UTC instants plus department-local wall clock + (StartLocal/EndLocal) so field apps never do zone math. Receipts upload as base64; DTOs carry UpdatedOn for delta-sync. + + The deployment and the caller's time scope on it; both null when the deployment is not in this department. + - Replaces the report's entries as a batch. Validation errors come back with the unchanged report and status Failure. + + Replaces the entries of the subjects the caller may write (every subject for a manager); other subjects' entries stay as + stored. StartLocal/EndLocal, when sent, are department-local wall clock and win over StartTime/EndTime. Validation + errors come back with the unchanged report and status Failure. + + + + Crew boss / contractor signature (the acting user) and/or the customer signer's typed name. + + + A field member adds expenses on a deployment they may write time for and edits only the ones they added; managers edit any. + + + A department-local wall clock wins when sent; otherwise the UTC instant (the converter already normalised it). @@ -8728,11 +8752,26 @@ ADP: stable catalog field ids ("contacts.email") whose values are REDACTED. + + The contact category's display name and color (the Category entity itself is never sent). + + + Resolved physical address (contact detail only; null on list rows or when none is set). + + + Resolved mailing address when it differs from the physical one (contact detail only). + + + Mobile-visible custom field values with their labels, in form order (contact detail only). + User Defined Field values for this contact + + One-line form for display and for handing to a maps app. + CertificationCategories value. @@ -11328,9 +11367,33 @@ DeploymentFinanceModes value. + + What the caller may do with this deployment's time (detail reads only; null on list rows). + + + + The caller's time scope on one deployment (M0227), computed by the server so a field app never infers it: their own + roster row (individual report), the deployed units they crew (crew time report) and every subject id they may write. + + + + The department zone time entries are written and shown in (StartLocal/EndLocal). + DeploymentAttachmentTypes value. + + DeploymentTimeReportScopes value: 0 deployment-wide DTR, 1 crew time report, 2 individual. + + + Crew time report: the deployment unit whose crew it covers. + + + Individual report: the roster row it covers. + + + Whether the caller may write, sign and submit this report (its scope is theirs); the server re-checks. + DeploymentTimeReportStatuses value. @@ -11340,6 +11403,27 @@ DeploymentTimeEntryTypes value. + + UTC instant ("Z"). + + + UTC instant ("Z"). + + + + Department-local wall clock "yyyy-MM-ddTHH:mm" — what the crew writes on a paper time report. On save a non-empty + value wins over and is converted in the department's zone, so a field app never does zone math. + + + + Department-local wall clock end; see . + + + Crew time report for this deployment unit (the unit, its crew and equipment). + + + Individual time report for this roster row. + DeploymentExpenseTypes value. diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs index 6f3998e03..5e2cd9e97 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CalOesMarsController.cs @@ -387,7 +387,7 @@ public async Task Queue(int? type = null) [HttpPost, ValidateAntiForgeryToken] public Task BuildF42(string deploymentId, string fillId) => GuardedAsync(async () => { - if (!IsManager && !await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); + if (!IsManager && !await _deployments.CanFieldMemberSeeAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); var item = await _mars.BuildF42DraftAsync(deploymentId, DepartmentId, fillId, UserId, Ip, Agent); return Saved("WorkItem", new { id = item.CalOesMarsWorkItemId }); }, "Queue"); @@ -395,7 +395,7 @@ public Task BuildF42(string deploymentId, string fillId) => Guard [HttpPost, ValidateAntiForgeryToken] public Task BuildExpense(string deploymentId, string f42Id) => GuardedAsync(async () => { - if (!IsManager && !await _deployments.IsRosteredAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); + if (!IsManager && !await _deployments.CanFieldMemberSeeAsync(deploymentId, DepartmentId, UserId)) return Unauthorized(); var item = await _mars.BuildExpenseClaimDraftAsync(deploymentId, DepartmentId, f42Id, UserId, Ip, Agent); return Saved("WorkItem", new { id = item.CalOesMarsWorkItemId }); }, "Queue"); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs index 2697e3bb5..b3d581716 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/CertificationsController.cs @@ -110,11 +110,16 @@ private IActionResult Saved(string redirectAction, object routeValues = null) return RedirectToAction(redirectAction, routeValues); } + /// Every member's name, inactive ones included: labels records and history. private async Task> PersonnelNamesAsync() - { - var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); - return (names ?? new List()).GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First().Name, StringComparer.OrdinalIgnoreCase); - } + => ToNameMap(await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)); + + /// The members the Add form may pick: removed, disabled and hidden members are not offered. + private async Task> SelectablePersonnelNamesAsync() + => ToNameMap(await _departments.GetSelectablePersonnelNamesAsync(DepartmentId)); + + private static Dictionary ToNameMap(List names) + => (names ?? new List()).GroupBy(n => n.UserId, StringComparer.OrdinalIgnoreCase).ToDictionary(g => g.Key, g => g.First().Name, StringComparer.OrdinalIgnoreCase); /// /// ADP reveal endpoint (plan 7.2) for the record and unit pages. The grant rides the X-Resgrid-Protected-Grant @@ -180,7 +185,7 @@ private async Task AddViewAsync(CertificationRecordInput i return Page(new CertificationAddView { Input = input, - Personnel = await PersonnelNamesAsync(), + Personnel = await SelectablePersonnelNamesAsync(), Types = (await _certifications.GetAllCertificationTypesByDepartmentAsync(DepartmentId) ?? new List()) .Where(t => t.DepartmentId == DepartmentId && !t.IsDeleted && t.IsActive && !t.IsUnitScoped) .OrderBy(t => t.Type).ToList() diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs index 4d3a96561..626945c79 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DepartmentController.cs @@ -967,7 +967,7 @@ public async Task ProfileLogo(IFormFile logo, CancellationToken c using var stream = new MemoryStream(); await logo.CopyToAsync(stream, cancellationToken); await _departmentProfileMediaService.UploadLogoAsync(DepartmentId, UserId, Path.GetFileName(logo.FileName), logo.ContentType, stream.ToArray(), cancellationToken); - SendProfileAudit("logo", "uploaded " + Path.GetFileName(logo.FileName)); + SendProfileAudit(null, JsonConvert.SerializeObject(new { Logo = "uploaded " + Path.GetFileName(logo.FileName) })); result = await BuildProfileModelAsync(); result.Message = _departmentLocalizer["ProfileLogoSaved"]; @@ -990,7 +990,7 @@ public async Task RemoveProfileLogo(CancellationToken cancellatio return Unauthorized(); await _departmentProfileMediaService.RemoveLogoAsync(DepartmentId, UserId, cancellationToken); - SendProfileAudit("logo", "removed"); + SendProfileAudit(null, JsonConvert.SerializeObject(new { Logo = "removed" })); var result = await BuildProfileModelAsync(); result.Message = _departmentLocalizer["ProfileLogoRemoved"]; @@ -1006,7 +1006,7 @@ public async Task RegenerateProfileMediaKey(CancellationToken can return Unauthorized(); await _departmentProfileMediaService.RegenerateMediaKeyAsync(DepartmentId, UserId, cancellationToken); - SendProfileAudit("mediaKey", "regenerated"); + SendProfileAudit(null, JsonConvert.SerializeObject(new { MediaKey = "regenerated" })); var result = await BuildProfileModelAsync(); result.Message = _departmentLocalizer["ProfileKeyRegenerated"]; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentOrdersController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentOrdersController.cs index 5192b60e3..3405630b8 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentOrdersController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentOrdersController.cs @@ -255,7 +255,9 @@ private async Task BuildDetailsAsync(string id) try { aggregate = await _deployments.GetAsync(DepartmentId, UserId, id); } catch (UnauthorizedAccessException) { return null; } if (aggregate == null) return null; + // Existing fills are labelled from every member's name; the new-fill picker offers active members only. var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + var selectable = await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List(); var units = await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); return new RecordDeploymentDetailsView { @@ -263,7 +265,7 @@ private async Task BuildDetailsAsync(string id) PersonnelNames = names.GroupBy(n => n.UserId).ToDictionary(g => g.Key, g => g.First().Name), CanEdit = ClaimsAuthorizationHelper.CanCreateRecord() && aggregate.Order.Status != (int)RmsExternalOrderStatus.ClosedOut, ProvenanceStatement = _localizer["DeploymentsIntro"].Value, OperationalDeploymentId = (await _operations.GetDeploymentByExternalOrderIdAsync(id, DepartmentId))?.DeploymentId, - Personnel = names.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(), + Personnel = selectable.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(), AvailableUnits = units.OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList() }; } @@ -274,7 +276,7 @@ private async Task PopulateAsync(RecordDeploymentNewView model) model.Profiles = RmsDeploymentProfiles.All.Select(p => new SelectListItem { Value = p, Text = p }).ToList(); var groups = await _groups.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); model.Stations = groups.OrderBy(g => g.Name).Select(g => new SelectListItem { Value = g.DepartmentGroupId.ToString(), Text = g.Name }).ToList(); - var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + var names = await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List(); model.Personnel = names.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(); var units = await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); model.AvailableUnits = units.OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs index 782cfe19d..f6240db20 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentWizardController.cs @@ -126,7 +126,7 @@ private async Task LoadRosterAsync(WizardView view, BidConversionContext context var personConflicts = new HashSet(StringComparer.OrdinalIgnoreCase); try { - var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + var names = await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List(); foreach (var warning in await _deployments.GetWindowConflictsAsync(DepartmentId, windowStart, windowEnd, names.Select(n => n.UserId), units.Select(u => u.UnitId))) { if (warning.Code != DeploymentRosterWarning.ScheduleConflict) continue; @@ -142,8 +142,8 @@ private async Task LoadRosterAsync(WizardView view, BidConversionContext context Seats = (seats.TryGetValue(u.UnitId, out var roleList) ? roleList : new List()).Select(r => new WizardSeat { UnitRoleId = r.UnitRoleId, Name = r.Name, PersonnelRoleRequired = r.PersonnelRoleRequired, PersonnelRoleId = r.PersonnelRoleId }).ToList() }).ToList(); - // Personnel roster with status, staffing, roles and typed certifications (Phase D) for step 3. - var people = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); + // Personnel roster with status, staffing, roles and typed certifications (Phase D) for step 3: active members only. + var people = await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List(); var roleMap = new Dictionary>(StringComparer.OrdinalIgnoreCase); try { roleMap = await _roles.GetAllRolesForUsersInDepartmentAsync(DepartmentId) ?? roleMap; } catch (Exception ex) { Logging.LogException(ex, "Deployment wizard: roles unavailable."); } var statusMap = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs index 86723c5d0..b27a6a93e 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DeploymentsController.cs @@ -133,10 +133,13 @@ private async Task AccessibleAsync(string deploymentId) { var deployment = await _deployments.GetDeploymentByIdAsync(deploymentId, DepartmentId); if (deployment == null) return null; - if (CanView || deployment.Personnel.Any(p => p.UserId == UserId)) return deployment; + if (CanView || (await TimeAccessAsync(deployment)).CanRead) return deployment; return null; } + /// The caller's time scope on the deployment (M0227): own row, crewed units, writable subjects. Managers write everything. + private Task TimeAccessAsync(Deployment deployment) => _deployments.GetTimeAccessAsync(deployment, UserId, CanManage); + private async Task> PersonnelNamesAsync() { var names = await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId); @@ -351,7 +354,8 @@ public async Task View(string id, string tab = "roster") { var deployment = await AccessibleAsync(id); if (deployment == null) return NotFound(); - var view = Page(new DeploymentDetailView { Deployment = deployment, Tab = tab ?? "roster", IsRostered = deployment.Personnel.Any(p => p.UserId == UserId) }); + var access = await TimeAccessAsync(deployment); + var view = Page(new DeploymentDetailView { Deployment = deployment, Tab = tab ?? "roster", IsRostered = access.IsRostered, TimeAccess = access }); view.Department = await _departments.GetDepartmentByIdAsync(DepartmentId); view.TimeReports = await _timeTracking.GetTimeReportsAsync(id, DepartmentId); view.Expenses = await _timeTracking.GetExpensesAsync(id, DepartmentId); @@ -370,7 +374,8 @@ public async Task View(string id, string tab = "roster") if (CanManage) { view.Units = (await _units.GetUnitsForDepartmentUnlimitedAsync(DepartmentId) ?? new List()).OrderBy(u => u.Name).ToList(); - view.Personnel = (await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(p => p.LastName).ThenBy(p => p.FirstName).ToList(); + // The add-to-roster picker offers active members only; the roster itself is labelled from UserNames. + view.Personnel = (await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List()).OrderBy(p => p.LastName).ThenBy(p => p.FirstName).ToList(); foreach (var unit in deployment.Units.Where(u => u.IsActive)) { try { view.UnitRoles[unit.UnitId] = await _units.GetRolesForUnitAsync(unit.UnitId) ?? new List(); } @@ -507,7 +512,7 @@ public async Task UploadAttachment(string id, int attachmentType, { var deployment = await AccessibleAsync(id); if (deployment == null) return NotFound(); - if (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); + if (!(await TimeAccessAsync(deployment)).CanWrite) return Unauthorized(); var upload = await ReadUploadAsync(file, cancellationToken); if (upload.Error != null) return Refused(400, upload.Error, "View", new { id, tab = "files" }); if (upload.Data == null) return Refused(400, "deployments_attachment_empty", "View", new { id, tab = "files" }); @@ -567,14 +572,22 @@ public async Task ExportTimeEntries(string id) #region Time reports [HttpPost, ValidateAntiForgeryToken] - public async Task NewTimeReport(string id, DateTime reportDate, CancellationToken cancellationToken) + public async Task NewTimeReport(string id, DateTime reportDate, string scope, CancellationToken cancellationToken) { var deployment = await AccessibleAsync(id); if (deployment == null) return NotFound(); - if (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId)) return Unauthorized(); + var access = await TimeAccessAsync(deployment); + // "crew:{deploymentUnitId}" is that unit's Crew Time Report, "person:{deploymentPersonnelId}" one person's report and an + // empty scope the deployment-wide DTR, which only a manager opens. A member without a choice files their own time. + string unitId = null, personnelId = null; + if (!string.IsNullOrWhiteSpace(scope) && scope.StartsWith("crew:", StringComparison.Ordinal)) unitId = scope.Substring(5); + else if (!string.IsNullOrWhiteSpace(scope) && scope.StartsWith("person:", StringComparison.Ordinal)) personnelId = scope.Substring(7); + else if (!access.CanManage) { personnelId = access.PersonnelId; if (personnelId == null) unitId = access.CrewUnitIds.FirstOrDefault(); } + var allowed = access.CanManage || (unitId != null && access.CrewUnitIds.Contains(unitId, StringComparer.OrdinalIgnoreCase)) || (personnelId != null && access.CanWriteSubject(personnelId)); + if (!allowed) return Unauthorized(); try { - var report = await _timeTracking.CreateTimeReportAsync(id, DepartmentId, reportDate, UserId, Ip, Agent, cancellationToken); + var report = await _timeTracking.CreateTimeReportAsync(id, DepartmentId, reportDate, unitId, personnelId, UserId, Ip, Agent, cancellationToken); return RedirectToAction("TimeReport", new { id = report.DeploymentTimeReportId }); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", new { id, tab = "time" }); } @@ -596,12 +609,28 @@ public async Task TimeReport(string id) private async Task BuildTimeReportViewAsync(DeploymentTimeReport report, Deployment deployment) { - var view = Page(new TimeReportEditView { Report = report, Deployment = deployment, IsRostered = deployment.Personnel.Any(p => p.UserId == UserId) }); + var view = Page(new TimeReportEditView { Report = report, Deployment = deployment, IsRostered = deployment.Personnel.Any(p => p.UserId == UserId), Access = await TimeAccessAsync(deployment) }); view.Department = await _departments.GetDepartmentByIdAsync(DepartmentId); view.TimeZone = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ZoneId; foreach (var p in deployment.Personnel) { view.SubjectNames[p.DeploymentPersonnelId] = p.DisplayName ?? p.UserId; if (p.IsActive) view.Subjects.Add((p.DeploymentPersonnelId, (int)DeploymentTimeSubjectTypes.Personnel, p.DisplayName ?? p.UserId)); } foreach (var u in deployment.Units) { view.SubjectNames[u.DeploymentUnitId] = u.UnitName ?? u.UnitId.ToString(); if (u.IsActive) view.Subjects.Add((u.DeploymentUnitId, (int)DeploymentTimeSubjectTypes.Unit, u.UnitName ?? u.UnitId.ToString())); } foreach (var e in deployment.Equipment) { var n = e.FreeTextName ?? e.InventoryAssetId ?? e.InventoryItemId; view.SubjectNames[e.DeploymentEquipmentId] = n; if (e.IsActive) view.Subjects.Add((e.DeploymentEquipmentId, (int)DeploymentTimeSubjectTypes.Equipment, n)); } + // A crew report offers its unit, crew and equipment; an individual report its one person; and a member only the subjects they may write. + bool InScope(string subjectId) => report.Scope switch + { + DeploymentTimeReportScopes.Individual => string.Equals(subjectId, report.DeploymentPersonnelId, StringComparison.OrdinalIgnoreCase), + DeploymentTimeReportScopes.Crew => string.Equals(subjectId, report.DeploymentUnitId, StringComparison.OrdinalIgnoreCase) + || deployment.Personnel.Any(p => p.DeploymentPersonnelId == subjectId && string.Equals(p.DeploymentUnitId, report.DeploymentUnitId, StringComparison.OrdinalIgnoreCase)) + || deployment.Equipment.Any(e => e.DeploymentEquipmentId == subjectId && string.Equals(e.DeploymentUnitId, report.DeploymentUnitId, StringComparison.OrdinalIgnoreCase)), + _ => true + }; + view.Subjects = view.Subjects.Where(s => InScope(s.Id) && view.CanWriteSubject(s.Id)).ToList(); + view.ScopeName = report.Scope switch + { + DeploymentTimeReportScopes.Crew => string.Format(_strings["ScopeCrew"].Value, view.SubjectNames.TryGetValue(report.DeploymentUnitId, out var unitName) ? unitName : report.DeploymentUnitId), + DeploymentTimeReportScopes.Individual => string.Format(_strings["ScopeIndividual"].Value, view.SubjectNames.TryGetValue(report.DeploymentPersonnelId, out var personName) ? personName : report.DeploymentPersonnelId), + _ => _strings["ScopeDeployment"].Value + }; view.Expenses = (await _timeTracking.GetExpensesAsync(deployment.DeploymentId, DepartmentId)).Where(e => e.DeploymentTimeReportId == report.DeploymentTimeReportId).ToList(); if (!string.IsNullOrWhiteSpace(report.ContractorSignedByUserId)) view.ContractorSignerName = (await _profiles.GetProfileByUserIdAsync(report.ContractorSignedByUserId))?.FullName.AsFirstNameLastName; return view; @@ -613,13 +642,20 @@ public async Task SaveTimeReport(string id, string incidentNumber var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); if (report == null) return NotFound(); var deployment = await AccessibleAsync(report.DeploymentId); - if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + if (deployment == null) return Unauthorized(); + var access = await TimeAccessAsync(deployment); + var actsOnReport = access.CanActOn(report); + // A scoped report is its crew's or person's alone; on a deployment-wide report a member writes only their own subjects. + if (!access.CanWrite || (report.Scope != DeploymentTimeReportScopes.Deployment && !actsOnReport)) return Unauthorized(); var timeZone = Resgrid.Web.Helpers.DepartmentTime.From(ViewData).ZoneId; try { - report.IncidentNumber = incidentNumber; report.ResourceOrderNumber = resourceOrderNumber; report.RequestNumber = requestNumber; report.CostCode = costCode; report.PointOfHire = pointOfHire; - report.NoClear8 = noClear8; report.UnsafeConditionsStandDown = unsafeConditionsStandDown; report.Notes = notes; - await _timeTracking.UpdateTimeReportAsync(report, UserId, Ip, Agent, cancellationToken); + if (actsOnReport) + { + report.IncidentNumber = incidentNumber; report.ResourceOrderNumber = resourceOrderNumber; report.RequestNumber = requestNumber; report.CostCode = costCode; report.PointOfHire = pointOfHire; + report.NoClear8 = noClear8; report.UnsafeConditionsStandDown = unsafeConditionsStandDown; report.Notes = notes; + await _timeTracking.UpdateTimeReportAsync(report, UserId, Ip, Agent, cancellationToken); + } var rows = (entries ?? new List()).Where(e => e != null && !string.IsNullOrWhiteSpace(e.SubjectId) && !string.IsNullOrWhiteSpace(e.Start) && !string.IsNullOrWhiteSpace(e.End)).ToList(); var mapped = new List(); @@ -639,11 +675,12 @@ public async Task SaveTimeReport(string id, string incidentNumber else entry.DeploymentEquipmentId = row.SubjectId; mapped.Add(entry); } - var result = await _timeTracking.SaveTimeEntriesAsync(id, DepartmentId, mapped, UserId, Ip, Agent, cancellationToken); + var result = await _timeTracking.SaveTimeEntriesAsync(id, DepartmentId, mapped, access, UserId, Ip, Agent, cancellationToken); if (!result.Validation.IsValid) { RememberIssues(result.Validation); return RedirectToAction("TimeReport", new { id }); } if (string.Equals(action, "submit", StringComparison.OrdinalIgnoreCase)) { + if (!access.CanActOn(result.Report)) return Unauthorized(); var submit = await _timeTracking.SubmitTimeReportAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); RememberIssues(submit.Validation); if (!submit.Validation.IsValid) return RedirectToAction("TimeReport", new { id }); @@ -696,7 +733,7 @@ public async Task SignTimeReport(string id, bool contractorSigned var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); if (report == null) return NotFound(); var deployment = await AccessibleAsync(report.DeploymentId); - if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + if (deployment == null || !(await TimeAccessAsync(deployment)).CanActOn(report)) return Unauthorized(); try { await _timeTracking.SignTimeReportAsync(id, DepartmentId, contractorSigned, customerSignerName, UserId, Ip, Agent, cancellationToken); return Saved("TimeReport", new { id }); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } } @@ -717,7 +754,7 @@ public async Task FileTimeReportPdf(string id, CancellationToken var report = await _timeTracking.GetTimeReportByIdAsync(id, DepartmentId); if (report == null) return NotFound(); var deployment = await AccessibleAsync(report.DeploymentId); - if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + if (deployment == null || !(await TimeAccessAsync(deployment)).CanActOn(report)) return Unauthorized(); try { await _timeTracking.GenerateTimeReportPdfAsync(id, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("TimeReport", new { id }); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "TimeReport", new { id }); } } @@ -731,7 +768,22 @@ public async Task SaveExpense(ExpenseInput input, IFormFile recei { if (input == null || string.IsNullOrWhiteSpace(input.DeploymentId)) return BadRequest(); var deployment = await AccessibleAsync(input.DeploymentId); - if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + if (deployment == null) return Unauthorized(); + var access = await TimeAccessAsync(deployment); + if (!access.CanWrite) return Unauthorized(); + // A member edits only the expenses they added and links them only to a report that is theirs; managers edit any. + if (!access.CanManage && !string.IsNullOrWhiteSpace(input.DeploymentExpenseId)) + { + var existing = await _timeTracking.GetExpenseByIdAsync(input.DeploymentExpenseId, DepartmentId); + if (existing == null) return NotFound(); + if (!string.Equals(existing.AddedByUserId, UserId, StringComparison.OrdinalIgnoreCase)) return Unauthorized(); + } + if (!access.CanManage && !string.IsNullOrWhiteSpace(input.DeploymentTimeReportId)) + { + var linked = await _timeTracking.GetTimeReportByIdAsync(input.DeploymentTimeReportId, DepartmentId); + if (linked == null) return NotFound(); + if (!access.CanActOn(linked)) return Unauthorized(); + } var back = string.IsNullOrWhiteSpace(input.DeploymentTimeReportId) ? ("View", (object)new { id = input.DeploymentId, tab = "expenses" }) : ("TimeReport", new { id = input.DeploymentTimeReportId }); var upload = await ReadUploadAsync(receipt, cancellationToken); if (upload.Error != null) return Refused(400, upload.Error, back.Item1, back.Item2); @@ -756,7 +808,9 @@ public async Task DeleteExpense(string id, string deploymentExpen var expense = await _timeTracking.GetExpenseByIdAsync(deploymentExpenseId, DepartmentId); if (expense == null) return NotFound(); var deployment = await AccessibleAsync(expense.DeploymentId); - if (deployment == null || (!CanManage && !deployment.Personnel.Any(p => p.UserId == UserId))) return Unauthorized(); + if (deployment == null) return Unauthorized(); + var access = await TimeAccessAsync(deployment); + if (!access.CanWrite || (!access.CanManage && !string.Equals(expense.AddedByUserId, UserId, StringComparison.OrdinalIgnoreCase))) return Unauthorized(); var back = new { id = deployment.DeploymentId, tab = "expenses" }; try { await _timeTracking.DeleteExpenseAsync(deploymentExpenseId, DepartmentId, UserId, Ip, Agent, cancellationToken); return Saved("View", back); } catch (InvalidOperationException ex) when (IsDomainError(ex)) { return Refused(400, ex.Message, "View", back); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs index 6c1f2b427..638c6d4bf 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/DisclosuresController.cs @@ -69,7 +69,9 @@ public async Task Index(int? state) CanViewRestricted = await _authorization.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ViewRestrictedRecords), PersonnelNames = await PersonnelNamesAsync() }; - model.Personnel = model.PersonnelNames.OrderBy(kvp => kvp.Value).Select(kvp => new SelectListItem { Value = kvp.Key, Text = kvp.Value }).ToList(); + // The new-request assignee picker offers active members only; PersonnelNames still labels existing requests. + model.Personnel = (await _departmentsService.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List()).Where(n => !string.IsNullOrWhiteSpace(n.UserId)) + .OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(); if (moduleState.RecordsUsable) model.Requests = await _disclosures.QueryAsync(DepartmentId, UserId, states, 0, 200); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs index d3ddc45eb..9be6da4c7 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/IncidentReportsController.cs @@ -933,7 +933,16 @@ private async Task BuildEditAsync(IncidentReportAggregat model.ExposureItemTypes = Codes("exposure_item"); model.ExposureDamageTypes = Codes("exposure_damage"); model.DisplacementCauseCodes = Codes("displace_cause"); - model.Personnel = (await PersonnelNamesAsync()).OrderBy(kvp => kvp.Value).Select(kvp => new SelectListItem { Value = kvp.Key, Text = kvp.Value }).ToList(); + // The member pickers offer active members only. A member already named on this report who has since gone inactive + // stays in the list (under their stored id, so the row keeps its selection) rather than being cleared on save. + var pickable = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var person in await _departmentsService.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new List()) + if (!string.IsNullOrWhiteSpace(person.UserId)) pickable[person.UserId] = person.Name; + var labels = await PersonnelNamesAsync(); + var options = pickable.Select(kvp => new SelectListItem { Value = kvp.Key, Text = kvp.Value }).ToList(); + foreach (var kept in (model.Casualties ?? new List()).Select(c => c.PersonnelUserId).Where(id => !string.IsNullOrWhiteSpace(id) && !pickable.ContainsKey(id)).Distinct(StringComparer.OrdinalIgnoreCase)) + options.Add(new SelectListItem { Value = kept, Text = labels.TryGetValue(kept, out var keptName) ? keptName : kept }); + model.Personnel = options.OrderBy(o => o.Text).ToList(); model.ApplyProtection(aggregate.Protection); return model; } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs index cbd9fd8d8..49afdf232 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryController.cs @@ -127,7 +127,7 @@ public async Task Index(string tab = "OnHand", int page = 0, stri if (view.CanWrite && tab == "Locations") foreach (var group in await _groups.GetAllGroupsForDepartmentAsync(DepartmentId)) if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = 1, GroupId = group.DepartmentGroupId })) view.Groups.Add(new() { Id = group.DepartmentGroupId.ToString(), Name = group.Name }); - foreach (var person in await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)) + foreach (var person in await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new()) if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = 3, UserId = person.UserId })) view.People.Add(new() { Id = person.UserId, Name = person.Name }); return View("Workspace", view); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs index de01a1699..5e08ecf82 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/InventoryOperationsController.cs @@ -62,7 +62,7 @@ public async Task Operations(string tab = "Counts", int page = 0, foreach (var unit in await _units.GetUnitsForDepartmentAsync(DepartmentId) ?? new()) if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = (int)InventoryLocationType.Unit, UnitId = unit.UnitId })) view.Units.Add(new InventoryChoice { Id = unit.UnitId.ToString(CultureInfo.InvariantCulture), Name = unit.Name }); - foreach (var person in await _departments.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new()) + foreach (var person in await _departments.GetSelectablePersonnelNamesAsync(DepartmentId) ?? new()) if (await _auth.CanLocationAsync(Actor, new InventoryLocation { DepartmentId = DepartmentId, LocationType = (int)InventoryLocationType.Personnel, UserId = person.UserId })) view.People.Add(new InventoryChoice { Id = person.UserId, Name = person.Name }); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index beaeb1394..84bfd1fc6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -1839,81 +1839,152 @@ public async Task GetMembersForRole(int id) return Json(role.Users?.Select(x => x.UserId).ToList() ?? new List()); } + /// + /// Confirmation page for bringing a removed member back (AddPerson lands here when the e-mail matches one). + /// It changes nothing: the reactivation is the POST below. After that POST it shows the result once. + /// [HttpGet] - [Authorize(Policy = ResgridResources.Personnel_View)] + [Authorize(Policy = ResgridResources.Personnel_Create)] [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] public async Task ReactivateUser(string id, CancellationToken cancellationToken) { - ViewPersonView model = new ViewPersonView(); - model.Profile = await _userProfileService.GetProfileByUserIdAsync(id, true); - model.User = _usersService.GetUserById(id); + if (!await _authorizationService.CanUserAddNewUserAsync(DepartmentId, UserId)) + return Unauthorized(); var member = await _departmentsService.GetDepartmentMemberAsync(id, DepartmentId); - if (member != null) - model.Department = await _departmentsService.GetDepartmentByIdAsync(member.DepartmentId); - else - model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + if (member == null || member.DepartmentId != DepartmentId) + return NotFound(); - model.Group = await _departmentGroupsService.GetGroupForUserAsync(id, DepartmentId); - var roles = await _personnelRolesService.GetRolesForUserAsync(id, DepartmentId); + var justReactivated = string.Equals(TempData[ReactivatedUserTempDataKey] as string, id, StringComparison.OrdinalIgnoreCase); + if (!member.IsDeleted && !justReactivated) + return RedirectToAction("ViewPerson", "Personnel", new { area = "User", userId = id }); - if (roles != null && roles.Count > 0) - { - foreach (var role in roles) - { - if (string.IsNullOrWhiteSpace(model.Roles)) - model.Roles = role.Name; - else - model.Roles += string.Format(", {0}", role.Name); - } - } - else + var model = await BuildMemberConfirmationViewAsync(id, member); + model.ConfirmationPending = member.IsDeleted; + + return View(model); + } + + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Personnel_Create)] + [RequiresRecentTwoFactor] + [ActionName("ReactivateUser")] + public async Task ReactivateUserPost(string id, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserAddNewUserAsync(DepartmentId, UserId)) + return Unauthorized(); + + var member = await _departmentsService.GetDepartmentMemberAsync(id, DepartmentId); + if (member == null || member.DepartmentId != DepartmentId) + return NotFound(); + + // A second submit (double click, back button) finds the member already back and changes nothing. + if (member.IsDeleted) { - model.Roles = "None"; + await _departmentsService.ReactivateUserAsync(DepartmentId, id, UserId, cancellationToken); + + _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); + _departmentsService.InvalidateDepartmentUsersInCache(DepartmentId); + _departmentsService.InvalidatePersonnelNamesInCache(DepartmentId); + _departmentsService.InvalidateDepartmentMembers(); + _usersService.ClearCacheForDepartment(DepartmentId); } - StringBuilder sb = new StringBuilder(); + TempData[ReactivatedUserTempDataKey] = id; + return RedirectToAction("ReactivateUser", "Personnel", new { area = "User", id }); + } + + private const string ReactivatedUserTempDataKey = "ReactivatedUserId"; + private const string AddedExistingUserTempDataKey = "AddedExistingUserId"; + + /// + /// Confirmation page for adding an account that already exists in another department (AddPerson lands here when + /// the e-mail matches one). It changes nothing: the add is the POST below. After that POST it shows the result once. + /// + [HttpGet] + [Authorize(Policy = ResgridResources.Personnel_Create)] + [ResponseCache(NoStore = true, Location = ResponseCacheLocation.None)] + public async Task AddExistingUser(string id, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserAddNewUserAsync(DepartmentId, UserId)) + return Unauthorized(); + + if (string.IsNullOrWhiteSpace(id) || _usersService.GetUserById(id) == null) + return NotFound(); + + var member = await _departmentsService.GetDepartmentMemberAsync(id, DepartmentId); if (member != null) { - if (member.IsAdmin.HasValue && member.IsAdmin.Value || - model.Department.ManagingUserId == id) - sb.Append("Admin"); - else - sb.Append("Normal"); + // A removed member comes back through reactivation, never as a second membership row. + if (member.IsDeleted) + return RedirectToAction("ReactivateUser", "Personnel", new { area = "User", id }); - if (member.IsDisabled.HasValue && member.IsDisabled.Value) - sb.Append(sb.Length > 0 ? ", Disabled" : "Disabled"); + var justAdded = string.Equals(TempData[AddedExistingUserTempDataKey] as string, id, StringComparison.OrdinalIgnoreCase); + if (!justAdded) + return RedirectToAction("ViewPerson", "Personnel", new { area = "User", userId = id }); + } - if (member.IsHidden.HasValue && member.IsHidden.Value) - sb.Append(sb.Length > 0 ? ", Hidden" : "Hidden"); + var model = await BuildMemberConfirmationViewAsync(id, member); + model.ConfirmationPending = member == null; - model.State = sb.ToString(); - } + return View(model); + } - await _departmentsService.ReactivateUserAsync(DepartmentId, id, cancellationToken); + [HttpPost] + [ValidateAntiForgeryToken] + [Authorize(Policy = ResgridResources.Personnel_Create)] + [RequiresRecentTwoFactor] + [ActionName("AddExistingUser")] + public async Task AddExistingUserPost(string id, CancellationToken cancellationToken) + { + if (!await _authorizationService.CanUserAddNewUserAsync(DepartmentId, UserId)) + return Unauthorized(); - _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); - _departmentsService.InvalidateDepartmentUsersInCache(DepartmentId); - _departmentsService.InvalidatePersonnelNamesInCache(DepartmentId); - _departmentsService.InvalidateDepartmentMembers(); - _usersService.ClearCacheForDepartment(DepartmentId); + if (string.IsNullOrWhiteSpace(id) || _usersService.GetUserById(id) == null) + return NotFound(); - return View(model); + var member = await _departmentsService.GetDepartmentMemberAsync(id, DepartmentId); + if (member != null && member.IsDeleted) + return RedirectToAction("ReactivateUser", "Personnel", new { area = "User", id }); + + // A second submit (double click, back button) finds the member already in and changes nothing. + if (member == null) + { + var added = await _departmentsService.AddExistingUserAsync(DepartmentId, id, cancellationToken); + + if (added != null) + { + var auditEvent = new AuditEvent(); + auditEvent.DepartmentId = DepartmentId; + auditEvent.UserId = UserId; + auditEvent.Type = AuditLogTypes.UserAdded; + auditEvent.After = added.CloneJsonToString(); + auditEvent.Successful = true; + auditEvent.IpAddress = IpAddressHelper.GetRequestIP(Request, true); + auditEvent.ServerName = Environment.MachineName; + auditEvent.UserAgent = $"{Request.Headers["User-Agent"]} {Request.Headers["Accept-Language"]}"; + _eventAggregator.SendMessage(auditEvent); + } + + _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); + _departmentsService.InvalidateDepartmentUsersInCache(DepartmentId); + _departmentsService.InvalidatePersonnelNamesInCache(DepartmentId); + _departmentsService.InvalidateDepartmentMembers(); + _usersService.ClearCacheForDepartment(DepartmentId); + } + + TempData[AddedExistingUserTempDataKey] = id; + return RedirectToAction("AddExistingUser", "Personnel", new { area = "User", id }); } - [HttpGet] - [Authorize(Policy = ResgridResources.Personnel_View)] - public async Task AddExistingUser(string id, CancellationToken cancellationToken) + /// The ReactivateUser / AddExistingUser page model. is null for an account not yet in the department. + private async Task BuildMemberConfirmationViewAsync(string id, DepartmentMember member) { ViewPersonView model = new ViewPersonView(); model.Profile = await _userProfileService.GetProfileByUserIdAsync(id, true); - model.User = _usersService.GetUserById(id, true); - - var member = await _departmentsService.GetDepartmentMemberAsync(id, DepartmentId); - if (member != null) - model.Department = await _departmentsService.GetDepartmentByIdAsync(member.DepartmentId); - else - model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); + model.User = _usersService.GetUserById(id); + model.Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId); model.Group = await _departmentGroupsService.GetGroupForUserAsync(id, DepartmentId); var roles = await _personnelRolesService.GetRolesForUserAsync(id, DepartmentId); @@ -1951,15 +2022,7 @@ public async Task AddExistingUser(string id, CancellationToken ca model.State = sb.ToString(); } - await _departmentsService.AddExistingUserAsync(DepartmentId, id, cancellationToken); - - _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); - _departmentsService.InvalidateDepartmentUsersInCache(DepartmentId); - _departmentsService.InvalidatePersonnelNamesInCache(DepartmentId); - _departmentsService.InvalidateDepartmentMembers(); - _usersService.ClearCacheForDepartment(DepartmentId); - - return View(model); + return model; } [HttpGet] diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs index 055b7f657..0523ac7ff 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordInvestigationsController.cs @@ -29,11 +29,13 @@ public class RecordInvestigationsController : RecordsPreventionMvcControllerBase private readonly IUserProfileService _profiles; private readonly ICallsService _calls; private readonly IRecordsAuthorizationService _authorization; + private readonly IDepartmentsService _departments; public RecordInvestigationsController(IRecordsInvestigationsService investigations, IRecordsOccupancyService occupancies, IUserProfileService profiles, IRecordsCutoverService cutover, IFeatureToggleService featureToggles, IStringLocalizer localizer, - ICallsService calls, IRecordsAuthorizationService authorization) : base(cutover, featureToggles, localizer) + ICallsService calls, IRecordsAuthorizationService authorization, IDepartmentsService departments) : base(cutover, featureToggles, localizer) { + _departments = departments; _investigations = investigations; _occupancies = occupancies; _profiles = profiles; @@ -63,7 +65,9 @@ public async Task Details(string id) var model = Prepare(new RecordInvestigationDetailsView { Aggregate = aggregate }); var profiles = await _profiles.GetAllProfilesForDepartmentAsync(DepartmentId) ?? new Dictionary(); model.UserNames = profiles.ToDictionary(p => p.Key, p => (p.Value.FirstName + " " + p.Value.LastName).Trim(), StringComparer.Ordinal); - model.DepartmentUsers = profiles.OrderBy(p => p.Value.LastName).ThenBy(p => p.Value.FirstName).Select(p => new SelectListItem { Value = p.Key, Text = (p.Value.FirstName + " " + p.Value.LastName).Trim() }).ToList(); + // Adding a case member offers active members only; UserNames still labels everyone already on the case. + var active = await _departments.GetActiveMemberUserIdsAsync(DepartmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + model.DepartmentUsers = profiles.Where(p => active.Contains(p.Key)).OrderBy(p => p.Value.LastName).ThenBy(p => p.Value.FirstName).Select(p => new SelectListItem { Value = p.Key, Text = (p.Value.FirstName + " " + p.Value.LastName).Trim() }).ToList(); model.Members = aggregate.Members.Where(m => m.IsActive).Select(m => new SelectListItem { Value = m.UserId, Text = model.UserName(m.UserId) }).ToList(); if (model.IsLead) model.Audit = await _investigations.GetAccessAuditAsync(DepartmentId, UserId, id, 50); return View(model); @@ -202,6 +206,8 @@ public async Task Custody(string id, string evidenceId) var model = Prepare(new RecordInvestigationCustodyView { CaseId = id, Evidence = evidence, Chain = await _investigations.GetCustodyChainAsync(DepartmentId, UserId, evidenceId) }); var profiles = await _profiles.GetAllProfilesForDepartmentAsync(DepartmentId) ?? new Dictionary(); model.UserNames = profiles.ToDictionary(p => p.Key, p => (p.Value.FirstName + " " + p.Value.LastName).Trim(), StringComparer.Ordinal); + var active = await _departments.GetActiveMemberUserIdsAsync(DepartmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + model.Custodians = model.UserNames.Where(u => active.Contains(u.Key)).ToDictionary(u => u.Key, u => u.Value, StringComparer.Ordinal); return View(model); } catch (UnauthorizedAccessException) { return Forbid(); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index d9a05395a..66778a5e9 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -1239,8 +1239,12 @@ private async Task BuildSettingsAsync(RecordsModuleState mo model.DisclosureReleaseApproverUserId = disclosure.ReleaseApproverUserId; model.RedactionProfiles = RmsRedactionProfiles.All.Select(p => new SelectListItem { Value = p, Text = _localizer["RedactionProfile" + p] }).ToList(); model.ReleaseApprovers.Add(new SelectListItem { Value = string.Empty, Text = _localizer["DisclosureApproverAnyAdmin"] }); - foreach (var person in (await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List()).OrderBy(p => p.Name)) - model.ReleaseApprovers.Add(new SelectListItem { Value = person.UserId, Text = person.Name }); + var approvers = await SelectablePersonnelNamesAsync(); + // A saved approver who has since gone inactive stays listed (and selected) rather than silently reverting to "any admin" on save. + if (!string.IsNullOrWhiteSpace(disclosure.ReleaseApproverUserId) && !approvers.ContainsKey(disclosure.ReleaseApproverUserId)) + model.ReleaseApprovers.Add(new SelectListItem { Value = disclosure.ReleaseApproverUserId, Text = (await PersonnelNamesAsync()).TryGetValue(disclosure.ReleaseApproverUserId, out var approverName) ? approverName : disclosure.ReleaseApproverUserId }); + foreach (var person in approvers.OrderBy(p => p.Value, StringComparer.CurrentCultureIgnoreCase)) + model.ReleaseApprovers.Add(new SelectListItem { Value = person.Key, Text = person.Value }); var layout = await _printLayouts.GetDepartmentDefaultAsync(DepartmentId); model.PrintLayout = layout.Config ?? RecordsPrintLayoutConfig.Default(); @@ -1356,6 +1360,7 @@ private async Task BuildDetailAsync(string id) Aggregate = aggregate, Department = await _departmentsService.GetDepartmentByIdAsync(DepartmentId, false), PersonnelNames = await PersonnelNamesAsync(), + ReassignCandidates = await SelectablePersonnelNamesAsync(), GroupNames = groups.ToDictionary(g => g.DepartmentGroupId, g => g.Name), CanEdit = CanEditRecord(aggregate.Record), CanFinalize = ClaimsAuthorizationHelper.CanFinalizeRecords(), @@ -1587,8 +1592,9 @@ private async Task BuildDefinitionFormAsync(RecordAggr } var groups = await _departmentGroupsService.GetAllGroupsForDepartmentAsync(DepartmentId) ?? new List(); form.Stations = groups.OrderBy(g => g.Name).Select(g => new SelectListItem { Value = g.DepartmentGroupId.ToString(), Text = g.Name }).ToList(); - var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); - form.Personnel = names.OrderBy(n => n.Name).Select(n => new SelectListItem { Value = n.UserId, Text = n.Name }).ToList(); + // Person fields pick active members; a value already on the record keeps its label through PersonnelLabels. + form.Personnel = (await SelectablePersonnelNamesAsync()).OrderBy(n => n.Value, StringComparer.CurrentCultureIgnoreCase).Select(n => new SelectListItem { Value = n.Key, Text = n.Value }).ToList(); + form.PersonnelLabels = await PersonnelNamesAsync(); var units = await _unitsService.GetUnitsForDepartmentAsync(DepartmentId) ?? new List(); form.AvailableUnits = units.OrderBy(u => u.Name).Select(u => new SelectListItem { Value = u.UnitId.ToString(), Text = u.Name }).ToList(); // Additional typed call-reference fields retain their own lists; the report's primary CallId uses the paged picker. @@ -1666,7 +1672,7 @@ private async Task PopulateBulkAsync(RecordsIndexView model) model.CanBulkPacket = await _recordsAuthorizationService.HasPermissionAsync(UserId, DepartmentId, PermissionTypes.ExportRecords); if (model.CanBulkAssign) { - var names = await PersonnelNamesAsync(); + var names = await SelectablePersonnelNamesAsync(); model.Reviewers = names.OrderBy(n => n.Value, StringComparer.CurrentCultureIgnoreCase).Select(n => new SelectListItem { Value = n.Key, Text = n.Value }).ToList(); } } @@ -1720,11 +1726,18 @@ public async Task BulkDownload(string id) return File(run.Data, run.ContentType ?? "application/octet-stream", run.FileName ?? "packet"); } + /// Every member's name, inactive ones included: labels record content and history. private async Task> PersonnelNamesAsync() + => NameMap(await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId)); + + /// The members a picker may offer: removed, disabled and hidden members are left out. + private async Task> SelectablePersonnelNamesAsync() + => NameMap(await _departmentsService.GetSelectablePersonnelNamesAsync(DepartmentId)); + + private static Dictionary NameMap(List names) { - var names = await _departmentsService.GetAllPersonnelNamesForDepartmentAsync(DepartmentId) ?? new List(); var map = new Dictionary(StringComparer.OrdinalIgnoreCase); - foreach (var name in names) + foreach (var name in names ?? new List()) { if (!string.IsNullOrWhiteSpace(name.UserId)) map[name.UserId] = name.Name; diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs index 8b4f40f74..dd0a4e3f6 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ReportsController.cs @@ -870,7 +870,10 @@ private async Task CreateCertificationsReportModel(int var model = new CertificationsReportView(); model.Rows = new List(); - var users = await _departmentsService.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(departmentId); + // Active members only: deleted, disabled and hidden members are not reported. + var activeMembers = await _departmentsService.GetActiveMemberUserIdsAsync(departmentId) ?? new HashSet(); + var users = (await _departmentsService.GetAllUsersForDepartmentUnlimitedMinusDisabledAsync(departmentId) ?? new List()) + .Where(u => u?.UserId != null && activeMembers.Contains(u.UserId)).ToList(); var department = await _departmentsService.GetDepartmentByIdAsync(departmentId, false); // Department-scoped, protected identification number (plan 5.1) — never the global column. @@ -886,7 +889,9 @@ private async Task CreateCertificationsReportModel(int var person = new CertificationsReportRow(); person.SubRows = new List(); - var certifications = await _certificationService.GetCertificationsByUserIdAsync(user.UserId); + // The holder's records across every department they belong to; this report is this department's only. + var certifications = (await _certificationService.GetCertificationsByUserIdAsync(user.UserId))? + .Where(c => c != null && c.DepartmentId == departmentId).ToList(); if (certifications != null && certifications.Count > 0) { diff --git a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs index cdbee8adc..c5a94d384 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/WorkforceController.cs @@ -38,6 +38,7 @@ public sealed class WorkforceController : SecureBaseController private readonly IBusinessOperationsAccessService _access; private readonly IPersonnelRolesService _roles; private readonly IUserProfileService _profiles; + private readonly IDepartmentsService _departments; private readonly IUnitsService _units; private readonly IDeploymentService _deployments; private readonly IBidsService _bids; @@ -46,8 +47,9 @@ public sealed class WorkforceController : SecureBaseController public WorkforceController(IWorkforceService workforce, ICompensationCostService compensation, IFieldCostingService costing, IPayDataDemographicsService demographics, ICaPayDataReportingService reporting, IBusinessOperationsAccessService access, IPersonnelRolesService roles, IUserProfileService profiles, IUnitsService units, IDeploymentService deployments, IBidsService bids, ICallsService calls, - IStringLocalizer strings) + IStringLocalizer strings, IDepartmentsService departments) { + _departments = departments; _workforce = workforce; _compensation = compensation; _costing = costing; @@ -292,7 +294,9 @@ public async Task Workers() view.EmploymentCounts = employments.GroupBy(e => e.WorkforceWorkerId).ToDictionary(g => g.Key, g => g.Count()); var names = await MemberNamesAsync(); var linked = new HashSet(view.Workers.Where(w => w.UserId != null).Select(w => w.UserId), StringComparer.OrdinalIgnoreCase); - view.Members = names.Where(n => !linked.Contains(n.Key)).OrderBy(n => n.Value).Select(n => new SelectListItem(n.Value, n.Key)).ToList(); + // The add-worker picker offers active members only (the service still refuses removed, disabled or foreign ids). + var active = await _departments.GetActiveMemberUserIdsAsync(DepartmentId) ?? new HashSet(StringComparer.OrdinalIgnoreCase); + view.Members = names.Where(n => active.Contains(n.Key) && !linked.Contains(n.Key)).OrderBy(n => n.Value).Select(n => new SelectListItem(n.Value, n.Key)).ToList(); return View(view); } diff --git a/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs b/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs index 2b152c43d..5e127e9bb 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Deployments/DeploymentViews.cs @@ -135,7 +135,9 @@ public class DeploymentDetailView : DeploymentPageView public ContractorChargeSet Charges { get; set; } public ContractComplianceResult Compliance { get; set; } public List Invoices { get; set; } = new List(); - public bool CanEditTime => CanManage || IsRostered; + /// The viewer's time scope (M0227); drives which reports they may open and which crews/people the new-report form offers. + public DeploymentTimeAccess TimeAccess { get; set; } + public bool CanEditTime => CanManage || (TimeAccess?.CanWrite ?? IsRostered); public string Tab { get; set; } = "roster"; public decimal TotalHours { get; set; } public decimal TotalExpenses { get; set; } @@ -152,7 +154,14 @@ public class TimeReportEditView : DeploymentPageView public List Expenses { get; set; } = new List(); public TimeReportValidation Validation { get; set; } = new TimeReportValidation(); public bool IsRostered { get; set; } - public bool CanEdit => (CanManage || IsRostered) && Report != null && Report.IsEditable; + /// The viewer's time scope (M0227). A scoped report is editable only by its crew/person; on a deployment-wide report a member edits their own subjects' rows. + public DeploymentTimeAccess Access { get; set; } + public bool CanEdit => Report != null && Report.IsEditable && (CanManage || (Access != null && (Access.CanActOn(Report) || (Report.Scope == DeploymentTimeReportScopes.Deployment && Access.CanWrite)))); + /// Header fields, signatures and submit belong to whoever may act on the whole report. + public bool CanActOnReport => Report != null && (CanManage || (Access?.CanActOn(Report) ?? false)); + public bool CanWriteSubject(string subjectId) => CanManage || (Access?.CanWriteSubject(subjectId) ?? false); + /// "Whole deployment", "Crew: Engine 1" or "Individual: A. Smith". + public string ScopeName { get; set; } public string ContractorSignerName { get; set; } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs index d9bf990ce..427d6f76e 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs @@ -17,5 +17,7 @@ public class ViewPersonView public ActionLog ActionLog { get; set; } public Department Department { get; set; } public string State { get; set; } + /// ReactivateUser / AddExistingUser: nothing has changed yet and the page asks for confirmation (false once done). + public bool ConfirmationPending { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs index 6f4a4ad21..62c0ce9ed 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordDefinitionsViewModels.cs @@ -412,7 +412,10 @@ public class RecordDefinitionFormView : RecordsBaseView public int AttachmentClassification { get; set; } = 1; public Department Department { get; set; } public List Stations { get; set; } = new List(); + /// Who a Person field may pick: active members only. public List Personnel { get; set; } = new List(); + /// Every member's name, inactive ones included: keeps a Person value already on the record selected and labelled. + public Dictionary PersonnelLabels { get; set; } = new Dictionary(System.StringComparer.OrdinalIgnoreCase); public List AvailableUnits { get; set; } = new List(); public List Calls { get; set; } = new List(); public List Contacts { get; set; } = new List(); diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs index 3fef47bea..e55f45d87 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsRms5ViewModels.cs @@ -352,6 +352,8 @@ public class RecordInvestigationCustodyView : RecordsPreventionBaseView public string CaseId { get; set; } public List Chain { get; set; } = new List(); public Dictionary UserNames { get; set; } = new Dictionary(); + /// Who evidence may be handed to: active members only (UserNames still labels the chain). + public Dictionary Custodians { get; set; } = new Dictionary(); public string UserName(string id) => string.IsNullOrWhiteSpace(id) ? "" : UserNames.TryGetValue(id, out var n) ? n : id; } diff --git a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs index 348c21777..9dbb86240 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Records/RecordsViewModels.cs @@ -247,6 +247,8 @@ public class RecordDetailView : RecordsBaseView public bool CanExport { get; set; } public bool CanViewRestricted { get; set; } public bool CanReassign { get; set; } + /// Who a draft may be reassigned to: active members only (PersonnelNames still labels everyone on the record). + public Dictionary ReassignCandidates { get; set; } = new Dictionary(); public RecordPrintProvenance Provenance { get; set; } public RmsOperationalRecordType RecordType => (RmsOperationalRecordType)Aggregate.Record.RecordType.GetValueOrDefault(); public RmsRecordState State => (RmsRecordState)Aggregate.Record.State; diff --git a/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml b/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml index f80b69a67..f4e80ac45 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Checklists/EditSchedule.cshtml @@ -44,6 +44,11 @@ -
-
+
+
+
-
-
+
+
-
-
+
+
@@ -67,16 +68,18 @@ @for (var i = 0; i < entries.Count; i++) { var e = entries[i]; + // Another crew's row on a deployment-wide report is shown but never posted, so the server keeps it as stored. + var rowEditable = Model.CanEdit && Model.CanWriteSubject(e.SubjectId);
@if (Model.CanEdit) { } @e.Hours.ToString("0.00")h@if (rowEditable) { } @e.Hours.ToString("0.00")h
} - @if ((Model.CanManage || Model.IsRostered) && r.IsEditable) + @if (Model.CanActOnReport && r.IsEditable) { @await Html.PartialAsync("_ExpenseForm", (d.DeploymentId, r.DeploymentTimeReportId, d.Currency)) } @@ -159,7 +162,7 @@
- @if (canApprove || canVoid || Model.CanManage || Model.IsRostered) + @if (canApprove || canVoid || Model.CanActOnReport) {
@localizer["Review"]
@@ -172,7 +175,10 @@ { @Html.AntiForgeryToken() } -
@Html.AntiForgeryToken()
+ @if (Model.CanActOnReport) + { +
@Html.AntiForgeryToken()
+ }
} diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml index e154fdf9d..f6ad4374b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/View.cshtml @@ -260,6 +260,21 @@
@Html.AntiForgeryToken() + @{ + // Who the new report covers: managers pick any scope, a member their own crews and themselves (M0227). + var scopeOptions = new List<(string Value, string Label)>(); + if (Model.CanManage) scopeOptions.Add(("", localizer["ScopeDeployment"].Value)); + foreach (var u in d.Units.Where(u => u.IsActive && (Model.CanManage || (Model.TimeAccess?.CrewUnitIds.Contains(u.DeploymentUnitId) ?? false)))) + scopeOptions.Add(("crew:" + u.DeploymentUnitId, string.Format(localizer["ScopeCrew"].Value, u.UnitName ?? u.UnitId.ToString()))); + foreach (var p in d.Personnel.Where(p => p.IsActive && (Model.CanManage || (Model.TimeAccess?.CanWriteSubject(p.DeploymentPersonnelId) ?? false)))) + scopeOptions.Add(("person:" + p.DeploymentPersonnelId, string.Format(localizer["ScopeIndividual"].Value, p.DisplayName ?? p.UserId))); + } + @if (scopeOptions.Count > 0) + { + + } @localizer["NewTimeReportHelp"]
@@ -268,12 +283,19 @@ else { - + @foreach (var r in Model.TimeReports.OrderByDescending(r => r.ReportDate)) { - + + + diff --git a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml index b9d5ac529..41e362837 100644 --- a/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/IncidentReports/Edit.cshtml @@ -402,7 +402,7 @@ @@ -518,7 +518,7 @@ @foreach (var person in Model.Personnel) { - + } diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml index c0db1dff6..ad8d4511b 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml @@ -3,12 +3,13 @@ @model Resgrid.Web.Areas.User.Models.Personnel.ViewPersonView @inject IStringLocalizer localizer @{ - ViewBag.Title = "Resgrid | " + @localizer["AddExistingUserHeader"]; + var header = Model.ConfirmationPending ? localizer["AddExistingUserConfirmHeader"] : localizer["AddExistingUserHeader"]; + ViewBag.Title = "Resgrid | " + header; }
-

@localizer["AddExistingUserHeader"]

+

@header

@@ -33,14 +34,28 @@
-
@localizer["AddExistingUserHeader"]
+
@header
-

- @localizer["ExistingUserHelp1"] (@Model.Profile.FullName.AsFirstNameLastName)@localizer["ExistingUserHelp2"] -

+ @if (Model.ConfirmationPending) + { +

+ @localizer["ExistingUserHelp1"] (@Model.Profile.FullName.AsFirstNameLastName)@localizer["ExistingUserConfirmText"] +

+
+ @Html.AntiForgeryToken() + @commonLocalizer["Cancel"] + + + } + else + { +

+ @localizer["ExistingUserHelp1"] (@Model.Profile.FullName.AsFirstNameLastName)@localizer["ExistingUserHelp2"] +

+ }
#@localizer["ReportDate"]@localizer["Status"]@localizer["Submitted"]@localizer["Approved"]@localizer["Invoice"]
#@localizer["ReportDate"]@localizer["TimeReportScope"]@localizer["Status"]@localizer["Submitted"]@localizer["Approved"]@localizer["Invoice"]
@r.ReportNumber@r.ReportDate.ToString("yyyy-MM-dd")@await Html.PartialAsync("_ReportStatusBadge", r.Status)@r.ReportNumber@r.ReportDate.ToString("yyyy-MM-dd")@(r.Scope switch + { + Resgrid.Model.Invoicing.DeploymentTimeReportScopes.Crew => string.Format(localizer["ScopeCrew"].Value, d.Units.FirstOrDefault(u => u.DeploymentUnitId == r.DeploymentUnitId)?.UnitName ?? r.DeploymentUnitId), + Resgrid.Model.Invoicing.DeploymentTimeReportScopes.Individual => string.Format(localizer["ScopeIndividual"].Value, d.Personnel.FirstOrDefault(p => p.DeploymentPersonnelId == r.DeploymentPersonnelId)?.DisplayName ?? r.DeploymentPersonnelId), + _ => localizer["ScopeDeployment"].Value + })@await Html.PartialAsync("_ReportStatusBadge", r.Status) @(r.SubmittedOn.HasValue ? $"{UserName(r.SubmittedByUserId)} · {Local(r.SubmittedOn)}" : "—")@(r.ApprovedOn.HasValue ? $"{UserName(r.ApprovedByUserId)} · {Local(r.ApprovedOn)}" : "—") @if (!string.IsNullOrWhiteSpace(r.InvoiceId)) { } @localizer["Open"]
@@ -55,6 +70,9 @@ + @* The phone numbers of someone not yet in the department are not shown before they join. *@ + @if (!Model.ConfirmationPending) + { + } }
@localizer["Email"]: @Model.User.Email
@localizer["PhoneNumbers"]: @@ -69,6 +87,7 @@ }
diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml index d013a1801..8dec55872 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml @@ -38,9 +38,23 @@
-

- @localizer["ReactivatePersonText1"] (@Model.Profile.FullName.AsFirstNameLastName), @localizer["ReactivatePersonText2"] @Model.Department.Name @localizer["ReactivatePersonText3"] -

+ @if (Model.ConfirmationPending) + { +

+ @localizer["ReactivatePersonText1"] (@Model.Profile.FullName.AsFirstNameLastName), @localizer["ReactivatePersonText2"] @Model.Department.Name @localizer["ReactivatePersonConfirmText"] +

+
+ @Html.AntiForgeryToken() + @commonLocalizer["Cancel"] + +
+ } + else + { +

+ @localizer["ReactivatePersonText1"] (@Model.Profile.FullName.AsFirstNameLastName), @localizer["ReactivatePersonText2"] @Model.Department.Name @localizer["ReactivatePersonText3"] +

+ }
diff --git a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml index 1fb6aff7a..8312b3e9d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/RecordInvestigations/Custody.cshtml @@ -38,7 +38,7 @@

@L["TransferCustody"]

@Html.AntiForgeryToken() -
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml index 8ab637fb8..fbbe1b7e4 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Records/Details.cshtml @@ -303,7 +303,7 @@ "); if (!withheld) + { + // A person already on the record who is no longer offered (disabled or hidden since) stays selected instead of being cleared on save. + if (field.Type == RmsFieldType.Person && !string.IsNullOrWhiteSpace(Reference) && !list.Any(i => string.Equals(i.Value, Reference, StringComparison.OrdinalIgnoreCase))) + sb.Append(""); foreach (var item in list) sb.Append(""); + } sb.Append(""); break; } diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml index d66979fd6..116ab5e34 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/Detail.cshtml @@ -28,9 +28,9 @@ ViewData["Title"] = o.Number + " — " + o.Title; ViewData["CanWrite"] = d.CanWrite; - // Labor, activity and reporter rows carry user ids; show the member's name when the - // choice list still knows them, and fall back to the id for someone who has since left. - string UserName(string userId) => Model.Choices.Users.FirstOrDefault(x => x.Id == userId)?.Name ?? userId; + // Labor, activity and reporter rows carry user ids; show the member's name (hidden members included, from + // the label map), and fall back to the id for someone who has since left or been disabled. + string UserName(string userId) => userId != null && Model.Choices.UserNames.TryGetValue(userId, out var label) ? label : Model.Choices.Users.FirstOrDefault(x => x.Id == userId)?.Name ?? userId; string RoleName(int? roleId) => Model.Choices.Roles.FirstOrDefault(x => x.Id == roleId?.ToString())?.Name ?? roleId?.ToString(); var assignees = o.AssignedToUserIds.Select(UserName).Concat(o.AssignedToRoleIds.Select(id => RoleName(id))).ToList(); diff --git a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml index 26c8258ce..4715b3f02 100644 --- a/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/WorkOrders/EditRecurrence.cshtml @@ -164,6 +164,11 @@
", RegexOptions.Singleline)) + { + var tag = ""; + var name = Attribute(tag, "name"); + if (name == null || Regex.IsMatch(tag, "\\sdisabled\\b")) continue; + var options = Regex.Matches(select.Groups[2].Value, "]*>").Select(o => o.Value).ToList(); + var chosen = options.FirstOrDefault(o => Regex.IsMatch(o, "\\sselected\\b")) ?? options.FirstOrDefault(); + fields[name] = WebUtility.HtmlDecode(Attribute(chosen ?? string.Empty, "value") ?? string.Empty); + } + return fields; + } + + private static string Attribute(string tag, string name) + { + var match = Regex.Match(tag, "\\s" + name + "=\"([^\"]*)\""); + return match.Success ? match.Groups[1].Value : null; + } + + private static async Task WithServer(Mock operations, Mock time, Func test) + { + var root = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (root != null && !File.Exists(Path.Combine(root.FullName, "Resgrid.sln"))) root = root.Parent; + var builder = WebApplication.CreateBuilder(new WebApplicationOptions { ContentRootPath = Path.Combine(root!.FullName, "Web", "Resgrid.Web"), EnvironmentName = "Testing" }); + builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddHttpContextAccessor(); builder.Services.AddLocalization(); builder.Services.AddDataProtection().UseEphemeralDataProtectionProvider(); + builder.Services.AddAuthentication("test").AddScheme("test", _ => { }); + builder.Services.AddAuthorization(); + builder.Services.AddControllersWithViews(o => o.Filters.Add(new BodyOnly())).AddApplicationPart(typeof(DeploymentsController).Assembly); + builder.Services.AddSingleton(operations.Object); builder.Services.AddSingleton(time.Object); + var departments = new Mock(); + departments.Setup(x => x.GetDepartmentByIdAsync(77, It.IsAny())).ReturnsAsync(new Department { DepartmentId = 77, Name = "Synthetic department", TimeZone = "UTC" }); + var flags = new Mock(); + flags.Setup(x => x.IsEnabledAsync(FeatureFlagKeys.Deployments, 77, It.IsAny(), It.IsAny>())).ReturnsAsync(true); + builder.Services.AddSingleton(departments.Object); builder.Services.AddSingleton(flags.Object); + foreach (var parameter in typeof(DeploymentsController).GetConstructors().Single().GetParameters()) + { + var type = parameter.ParameterType; + if (parameter.HasDefaultValue || builder.Services.Any(s => s.ServiceType == type) || (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(Microsoft.Extensions.Localization.IStringLocalizer<>))) continue; + builder.Services.AddSingleton(type, ((Mock)Activator.CreateInstance(typeof(Mock<>).MakeGenericType(type))).Object); + } + await using var app = builder.Build(); + var previous = ClaimsAuthorizationHelper._httpContextAccessor; + ClaimsAuthorizationHelper._httpContextAccessor = app.Services.GetRequiredService(); + app.Use(async (context, next) => { try { await next(); } catch (Exception ex) { context.Response.StatusCode = 500; await context.Response.WriteAsync(ex.ToString()); } }); + app.UseRequestLocalization(new RequestLocalizationOptions().SetDefaultCulture("en").AddSupportedCultures("en").AddSupportedUICultures("en")); + app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); + app.MapControllerRoute("areas", "{area:exists}/{controller}/{action=Index}/{id?}"); + try + { + await app.StartAsync(); + using var handler = new HttpClientHandler { AllowAutoRedirect = false, CookieContainer = new CookieContainer() }; + using var client = new HttpClient(handler) { BaseAddress = new Uri(app.Urls.Single()) }; + await test(client); + } + finally { await app.StopAsync(); ClaimsAuthorizationHelper._httpContextAccessor = previous; } + } + + private sealed class BodyOnly : IResultFilter + { + public void OnResultExecuting(ResultExecutingContext context) + { + if (context.Result is not ViewResult view) return; + var action = (ControllerActionDescriptor)context.ActionDescriptor; + context.Result = new PartialViewResult { ViewName = $"/Areas/User/Views/{action.ControllerName}/{view.ViewName ?? action.ActionName}.cshtml", ViewData = view.ViewData, TempData = view.TempData }; + } + public void OnResultExecuted(ResultExecutedContext context) { } + } + + /// A rostered member with no deployment claims: not a manager, so the time access decides every row. + private sealed class MemberAuthentication : AuthenticationHandler + { + public MemberAuthentication(IOptionsMonitor options, ILoggerFactory logger, UrlEncoder encoder) : base(options, logger, encoder) { } + protected override Task HandleAuthenticateAsync() + { + var claims = new[] { new Claim(ClaimTypes.PrimarySid, "member"), new Claim(ClaimTypes.NameIdentifier, "member"), new Claim(ClaimTypes.PrimaryGroupSid, "77") }; + return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, Scheme.Name)), Scheme.Name))); + } + } + } +} diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs index 7f8a09cd0..24846134b 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ChecklistRunsController.cs @@ -76,7 +76,9 @@ public async Task UploadChecklistRunFile([FromForm] string id, [F return Reply(ChecklistRunData.From(await Checklists.GetRunAsync(Actor, id), UserId)); } /// In-memory image transport for native clients, with the same size, scan, revision and ADP rules as multipart uploads. - [HttpPost("UploadChecklistRunFile"), Consumes("application/json"), RequestSizeLimit(15 * 1024 * 1024)] + /// Shares the multipart action's path (the Responder and Unit apps post JSON to it), so it is hidden from the OpenAPI + /// document: Swashbuckle rejects the whole document when two visible actions share a method and path (RESGRID-API-9F). + [HttpPost("UploadChecklistRunFile"), Consumes("application/json"), RequestSizeLimit(15 * 1024 * 1024), ApiExplorerSettings(IgnoreApi = true)] public async Task UploadChecklistRunEvidence([FromBody] ChecklistEvidenceInput input) { Required(input); await Checklists.AddFileAtRevisionAsync(Actor, input.Id, input.ItemId, input.Revision, input.Name, input.ContentType, input.Data); diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs index a262323fd..5c904ea29 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentAnalysisController.cs @@ -55,6 +55,7 @@ public IncidentAnalysisController(IIncidentAnalysisService analysis, IIncidentRe } /// Same system-principal gate as the other Records controllers (registry section 4.4). + [NonAction] public void OnActionExecuting(ActionExecutingContext context) { if (!RecordsSystemPrincipal.IsSystemPrincipal(User)) @@ -65,6 +66,7 @@ public void OnActionExecuting(ActionExecutingContext context) title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); } + [NonAction] public void OnActionExecuted(ActionExecutedContext context) { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs index 1bc3973a0..468dac430 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/IncidentReportsController.cs @@ -100,6 +100,7 @@ public async Task ExchangeHistory(string submissionId, Cancellati /// Record grant for this department is refused before the action runs, and a granted one is confined to /// reads because no mutating Record policy is ever issued to it (registry section 4.4). /// + [NonAction] public void OnActionExecuting(ActionExecutingContext context) { if (!RecordsSystemPrincipal.IsSystemPrincipal(User)) @@ -110,6 +111,7 @@ public void OnActionExecuting(ActionExecutingContext context) title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); } + [NonAction] public void OnActionExecuted(ActionExecutedContext context) { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs index 7a67c07bc..a10c29d20 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordEvidenceController.cs @@ -52,6 +52,7 @@ public RecordEvidenceController(IRecordsEvidenceService evidence, IRecordsCutove } /// Same system-principal gate as the other Records controllers (registry section 4.4). + [NonAction] public void OnActionExecuting(ActionExecutingContext context) { if (!RecordsSystemPrincipal.IsSystemPrincipal(User)) @@ -62,6 +63,7 @@ public void OnActionExecuting(ActionExecutingContext context) title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); } + [NonAction] public void OnActionExecuted(ActionExecutedContext context) { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs index de7e81812..ab8f0289a 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordSummariesController.cs @@ -40,6 +40,7 @@ public RecordSummariesController(IRecordOperationalSummaryService summaries, IRe } /// A system principal with no configured Record grant for the resolved department is refused before any action runs. + [NonAction] public void OnActionExecuting(ActionExecutingContext context) { if (IsSystemPrincipal && SystemGrant == null) @@ -47,6 +48,7 @@ public void OnActionExecuting(ActionExecutingContext context) title: "This system principal has no configured Record grant for this department.", type: "record_grant_missing"); } + [NonAction] public void OnActionExecuted(ActionExecutedContext context) { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs index 1479f5bfa..ba81efa97 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordsController.cs @@ -79,6 +79,7 @@ public RecordsController(IRecordsService recordsService, IRecordsCutoverService /// A user principal is untouched. Mutating actions need no further guard: their policies are never /// issued to a system principal, so the claim check has already refused them. /// + [NonAction] public void OnActionExecuting(ActionExecutingContext context) { var gate = SystemPrincipalGate(); @@ -86,6 +87,7 @@ public void OnActionExecuting(ActionExecutingContext context) context.Result = gate; } + [NonAction] public void OnActionExecuted(ActionExecutedContext context) { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs b/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs index 8b7656d5a..dfa737266 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/RecordsPreventionApiControllerBase.cs @@ -25,12 +25,14 @@ protected RecordsPreventionApiControllerBase(IRecordsCutoverService cutover) Cutover = cutover; } + [NonAction] public void OnActionExecuting(ActionExecutingContext context) { if (RecordsSystemPrincipal.IsSystemPrincipal(User)) context.Result = Problem(statusCode: StatusCodes.Status403Forbidden, title: "Prevention and investigation endpoints accept member principals only.", type: "record_prevention_member_only"); } + [NonAction] public void OnActionExecuted(ActionExecutedContext context) { } diff --git a/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs b/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs index 6a5ba0281..2069fbad7 100644 --- a/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs +++ b/Web/Resgrid.Web.Services/Controllers/v4/ScimController.cs @@ -35,6 +35,7 @@ public class ScimController : ControllerBase private readonly UserManager _userManager; private readonly IExternalIdentityLinkService _externalIdentityLinkService; private readonly IUserSessionService _userSessionService; + private readonly ILimitsService _limitsService; public ScimController( IDepartmentSsoService ssoService, @@ -43,7 +44,8 @@ public ScimController( ISystemAuditsService systemAuditsService, UserManager userManager, IExternalIdentityLinkService externalIdentityLinkService, - IUserSessionService userSessionService) + IUserSessionService userSessionService, + ILimitsService limitsService) { _ssoService = ssoService; _departmentsService = departmentsService; @@ -52,6 +54,7 @@ public ScimController( _userManager = userManager; _externalIdentityLinkService = externalIdentityLinkService; _userSessionService = userSessionService; + _limitsService = limitsService; } // -- GET /scim/v2/Users ------------------------------------------------ @@ -132,6 +135,7 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserRetrieved, [ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status409Conflict)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task CreateUser( [FromHeader(Name = SsoConfig.ScimDepartmentIdHeader)] int departmentId, [FromBody] ScimUserResource resource, @@ -148,6 +152,14 @@ await SaveScimAuditAsync(departmentId, null, AuditLogTypes.ScimUserCreated, return ScimBadRequest("userName is required."); } + // A new member takes a personnel seat: refuse before any account is created. + if (!await _limitsService.CanDepartmentAddNewUserAsync(departmentId, true)) + { + await SaveScimAuditAsync(departmentId, null, AuditLogTypes.ScimUserCreated, + successful: false, data: $"Rejected: personnel limit reached userName={resource.UserName}"); + return ScimPersonnelLimitReached(); + } + var email = resource.Emails?.FirstOrDefault()?.Value ?? resource.UserName; var existing = await _userManager.FindByEmailAsync(email); if (existing != null) @@ -223,6 +235,7 @@ await SaveScimAuditAsync(departmentId, newUser.Id, AuditLogTypes.ScimUserCreated [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task ReplaceUser( string id, [FromHeader(Name = SsoConfig.ScimDepartmentIdHeader)] int departmentId, @@ -259,6 +272,14 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserDeactivated, var member = await _departmentsService.GetDepartmentMemberAsync(id, departmentId); if (member is { IsDisabled: true }) { + // Disabled members do not count against the plan; enabling one takes a seat. Refuse the whole PUT. + if (!member.IsDeleted && !await _limitsService.CanDepartmentAddNewUserAsync(departmentId, true)) + { + await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserReactivated, + successful: false, data: "Rejected: personnel limit reached (active=true via PUT)"); + return ScimPersonnelLimitReached(); + } + member.IsDisabled = false; await _departmentsService.SaveDepartmentMemberAsync(member, cancellationToken); await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserReactivated, @@ -286,6 +307,7 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserUpdated, [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status404NotFound)] [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status403Forbidden)] public async Task PatchUser( string id, [FromHeader(Name = SsoConfig.ScimDepartmentIdHeader)] int departmentId, @@ -313,6 +335,15 @@ await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserUpdated, var member = await _departmentsService.GetDepartmentMemberAsync(id, departmentId); if (member != null) { + // Disabled members do not count against the plan; enabling one takes a seat. + if (active && member.IsDisabled == true && !member.IsDeleted && + !await _limitsService.CanDepartmentAddNewUserAsync(departmentId, true)) + { + await SaveScimAuditAsync(departmentId, id, AuditLogTypes.ScimUserReactivated, + successful: false, data: $"Rejected: personnel limit reached (active=true op={op.Op})"); + return ScimPersonnelLimitReached(); + } + member.IsDisabled = !active; if (!active) member.IsDeleted = false; // deactivate without hard-delete await _departmentsService.SaveDepartmentMemberAsync(member, cancellationToken); @@ -540,6 +571,15 @@ private IActionResult ScimNotFound(string id) => detail = $"User {id} not found." }); + // 403: the department's plan has no personnel seat left (or the plan could not be checked). + private IActionResult ScimPersonnelLimitReached() => + StatusCode(StatusCodes.Status403Forbidden, new + { + schemas = new[] { "urn:ietf:params:scim:api:messages:2.0:Error" }, + status = "403", + detail = "The department has reached the personnel limit of its plan, or the plan could not be checked. Remove or disable a member, or upgrade the plan, then retry." + }); + private IActionResult ScimBadRequest(string detail) => BadRequest(new { diff --git a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml index 4c6b1a571..a9da096a7 100644 --- a/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml +++ b/Web/Resgrid.Web.Services/Resgrid.Web.Services.xml @@ -1197,6 +1197,8 @@ In-memory image transport for native clients, with the same size, scan, revision and ADP rules as multipart uploads. + Shares the multipart action's path (the Responder and Unit apps post JSON to it), so it is hidden from the OpenAPI + document: Swashbuckle rejects the whole document when two visible actions share a method and path (RESGRID-API-9F). @@ -16067,6 +16069,12 @@ Respects the provided cancellation token for timeout control. + + + The v4 OpenAPI document settings. Shared with SwaggerDocumentTests, which generates the whole document, + so a conflicting method/path pair fails the test suite instead of /swagger/v4/swagger.json in production. + + Parsing for the pipe delimited DispatchList string clients send when creating or editing a call. diff --git a/Web/Resgrid.Web.Services/Startup.cs b/Web/Resgrid.Web.Services/Startup.cs index 90076b550..2504f7121 100644 --- a/Web/Resgrid.Web.Services/Startup.cs +++ b/Web/Resgrid.Web.Services/Startup.cs @@ -25,6 +25,7 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Mvc; using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; using Newtonsoft.Json.Serialization; using Resgrid.Model.Providers; using Resgrid.Model.Services; @@ -216,48 +217,7 @@ public void ConfigureServices(IServiceCollection services) services.AddSwaggerGen(); services.AddSwaggerGenNewtonsoftSupport(); - services.ConfigureSwaggerGen(options => - { - options.CustomSchemaIds(type => type.ToString()); - - // add JWT Authentication - var securityScheme = new OpenApiSecurityScheme - { - Name = "JWT Authentication", - Description = "Enter JWT Bearer token **_only_**", - In = ParameterLocation.Header, - Type = SecuritySchemeType.Http, - Scheme = "bearer", // must be lower case - BearerFormat = "JWT", - Reference = new OpenApiReference - { - Id = JwtBearerDefaults.AuthenticationScheme, - Type = ReferenceType.SecurityScheme - } - }; - - options.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme); - options.AddSecurityRequirement(new OpenApiSecurityRequirement - { - {securityScheme, new string[] { }} - }); - - options.SwaggerDoc("v4", - - new OpenApiInfo - { - Title = "Resgrid API", - Version = "v4", - Description = "The Resgrid Computer Aided Dispatch (CAD) API reference. Documentation: https://resgrid-core.readthedocs.io/en/latest/api/index.html", - Contact = new OpenApiContact() { Email = "team@resgrid.com", Name = "Resgrid Team", Url = new Uri("https://resgrid.com") }, - TermsOfService = new Uri("https://resgrid.com/Public/Terms") - } - ); - - var filePath = Path.Combine(AppContext.BaseDirectory, "Resgrid.Web.Services.xml"); - options.IncludeXmlComments(filePath); - //options.DescribeAllEnumsAsStrings(); - }); + services.ConfigureSwaggerGen(ConfigureSwagger); services.AddSignalR(hubOptions => { @@ -708,6 +668,53 @@ public void ConfigureServices(IServiceCollection services) //} } + /// + /// The v4 OpenAPI document settings. Shared with SwaggerDocumentTests, which generates the whole document, + /// so a conflicting method/path pair fails the test suite instead of /swagger/v4/swagger.json in production. + /// + public static void ConfigureSwagger(SwaggerGenOptions options) + { + options.CustomSchemaIds(type => type.ToString()); + + // add JWT Authentication + var securityScheme = new OpenApiSecurityScheme + { + Name = "JWT Authentication", + Description = "Enter JWT Bearer token **_only_**", + In = ParameterLocation.Header, + Type = SecuritySchemeType.Http, + Scheme = "bearer", // must be lower case + BearerFormat = "JWT", + Reference = new OpenApiReference + { + Id = JwtBearerDefaults.AuthenticationScheme, + Type = ReferenceType.SecurityScheme + } + }; + + options.AddSecurityDefinition(securityScheme.Reference.Id, securityScheme); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + {securityScheme, new string[] { }} + }); + + options.SwaggerDoc("v4", + + new OpenApiInfo + { + Title = "Resgrid API", + Version = "v4", + Description = "The Resgrid Computer Aided Dispatch (CAD) API reference. Documentation: https://resgrid-core.readthedocs.io/en/latest/api/index.html", + Contact = new OpenApiContact() { Email = "team@resgrid.com", Name = "Resgrid Team", Url = new Uri("https://resgrid.com") }, + TermsOfService = new Uri("https://resgrid.com/Public/Terms") + } + ); + + var filePath = Path.Combine(AppContext.BaseDirectory, "Resgrid.Web.Services.xml"); + options.IncludeXmlComments(filePath); + //options.DescribeAllEnumsAsStrings(); + } + public void ConfigureContainer(ContainerBuilder builder) { builder.RegisterType().As().SingleInstance(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs index 2d49c1632..3917b17ed 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ContactsController.cs @@ -337,6 +337,13 @@ public async Task Add(AddContactView model, CancellationToken can Address physicalAddress = new Address(); Address mailingAddress = new Address(); + // A new contact carries no server-assigned ids. The form posts none, but the binder would accept them: a posted + // ContactId would overwrite (and move) another department's contact, and address ids are global integers, so a + // posted PhysicalAddressId/MailingAddressId would let the detail pages read, and Edit rewrite, any address row. + model.Contact.ContactId = null; + model.Contact.PhysicalAddressId = null; + model.Contact.MailingAddressId = null; + if (ModelState.IsValid) { var auditEvent = new AuditEvent(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs index 0080e7593..650c7b641 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/HomeController.cs @@ -82,6 +82,7 @@ public class HomeController : SecureBaseController private readonly IProtectedReadService _protectedReadService; private readonly IDepartmentMemberSensitiveDataService _memberSensitiveDataService; private readonly IDepartmentDataProtectionService _dataProtectionService; + private readonly IStringLocalizer _editProfileLocalizer; public HomeController(IDepartmentsService departmentsService, IUsersService usersService, IActionLogsService actionLogsService, IUserStateService userStateService, IDepartmentGroupsService departmentGroupsService, Resgrid.Model.Services.IAuthorizationService authorizationService, @@ -96,7 +97,8 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService, IDepartmentMemberEmergencyContactService emergencyContactService, IProtectedReadService protectedReadService, IDepartmentMemberSensitiveDataService memberSensitiveDataService, - IDepartmentDataProtectionService dataProtectionService) + IDepartmentDataProtectionService dataProtectionService, + IStringLocalizer editProfileLocalizer) { _departmentsService = departmentsService; _usersService = usersService; @@ -134,6 +136,7 @@ public HomeController(IDepartmentsService departmentsService, IUsersService user _protectedReadService = protectedReadService; _memberSensitiveDataService = memberSensitiveDataService; _dataProtectionService = dataProtectionService; + _editProfileLocalizer = editProfileLocalizer; _localizer = factory.Create("Home.Dashboard", new AssemblyName(typeof(SupportedLocales).GetTypeInfo().Assembly.FullName).Name); } @@ -798,6 +801,15 @@ public async Task EditUserProfile(EditProfileModel model, IFormCo } } + // Disabled members do not count against the plan's personnel limit, so enabling one takes a seat. Checked + // before anything is saved, with fresh counts (the cached limits live 14 days). Only a department admin's + // change to the flag is applied (see the member save below), so only that is checked. + if (callerIsDepartmentAdmin && !model.IsDisabled && targetDepartmentMember is { IsDisabled: true, IsDeleted: false } && + !await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true)) + { + ModelState.AddModelError(nameof(model.IsDisabled), _editProfileLocalizer["EnableUserPersonnelLimitReached"]); + } + if (ModelState.IsValid) { var auditEvent = new AuditEvent(); diff --git a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs index 84bfd1fc6..33a555b4c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/PersonnelController.cs @@ -362,6 +362,7 @@ public async Task AddPerson() model.User = _usersService.GetUserById(UserId); model.Profile = new UserProfile(); model.SendAccountCreationNotification = true; + model.PersonnelLimitReached = !await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true); ViewBag.Carriers = model.Carrier.ToSelectList(); ViewBag.Countries = new SelectList(Countries.CountryNames); @@ -609,7 +610,10 @@ public async Task AddPerson(AddPersonModel model, IFormCollection ModelState.AddModelError("Username", $"The username {model.Username} has already been taken, please try another."); } - if (ModelState.IsValid) + // The plan's personnel limit is enforced here, not only by hiding the Add button (fresh counts: the cached ones live 14 days). + model.PersonnelLimitReached = !await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true); + + if (ModelState.IsValid && !model.PersonnelLimitReached) { var user = new IdentityUser { UserName = model.Username, Email = model.Email, SecurityStamp = Guid.NewGuid().ToString().ToUpper() }; var result = await _userManager.CreateAsync(user, model.NewPassword); @@ -1861,6 +1865,8 @@ public async Task ReactivateUser(string id, CancellationToken can var model = await BuildMemberConfirmationViewAsync(id, member); model.ConfirmationPending = member.IsDeleted; + if (model.ConfirmationPending) + model.PersonnelLimitReached = !await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true); return View(model); } @@ -1882,6 +1888,10 @@ public async Task ReactivateUserPost(string id, CancellationToken // A second submit (double click, back button) finds the member already back and changes nothing. if (member.IsDeleted) { + // A returning member takes a personnel seat; at the plan's limit the confirmation page says so instead. + if (!await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true)) + return RedirectToAction("ReactivateUser", "Personnel", new { area = "User", id }); + await _departmentsService.ReactivateUserAsync(DepartmentId, id, UserId, cancellationToken); _userProfileService.ClearAllUserProfilesFromCache(DepartmentId); @@ -1927,6 +1937,8 @@ public async Task AddExistingUser(string id, CancellationToken ca var model = await BuildMemberConfirmationViewAsync(id, member); model.ConfirmationPending = member == null; + if (model.ConfirmationPending) + model.PersonnelLimitReached = !await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true); return View(model); } @@ -1951,6 +1963,10 @@ public async Task AddExistingUserPost(string id, CancellationToke // A second submit (double click, back button) finds the member already in and changes nothing. if (member == null) { + // At the plan's personnel limit the confirmation page says so instead. + if (!await _limitsService.CanDepartmentAddNewUserAsync(DepartmentId, true)) + return RedirectToAction("AddExistingUser", "Personnel", new { area = "User", id }); + var added = await _departmentsService.AddExistingUserAsync(DepartmentId, id, cancellationToken); if (added != null) diff --git a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs index b000a2927..8968b9f9c 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/ProfileController.cs @@ -66,6 +66,8 @@ public class ProfileController : SecureBaseController private readonly IEventAggregator _eventAggregator; private readonly IProtectedReadService _protectedReadService; private readonly IBusinessOperationsAccessService _businessOperationsAccess; + private readonly ILimitsService _limitsService; + private readonly IStringLocalizer _profileLocalizer; public ProfileController(IDepartmentsService departmentsService, IUsersService usersService, Model.Services.IAuthorizationService authorizationService, IUserProfileService userProfileService, IScheduledTasksService scheduledTasksService, ICertificationService certificationService, @@ -76,7 +78,8 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u IExternalIdentityLinkService externalIdentityLinkService, IUserSessionService userSessionService, ISystemAuditsService systemAuditsService, IDepartmentGroupsService departmentGroupsService, IDepartmentSettingsService departmentSettingsService, IPasswordRecoveryService passwordRecoveryService, - IEventAggregator eventAggregator, IProtectedReadService protectedReadService, IBusinessOperationsAccessService businessOperationsAccess) + IEventAggregator eventAggregator, IProtectedReadService protectedReadService, IBusinessOperationsAccessService businessOperationsAccess, + ILimitsService limitsService, IStringLocalizer profileLocalizer) { _departmentsService = departmentsService; _usersService = usersService; @@ -102,6 +105,8 @@ public ProfileController(IDepartmentsService departmentsService, IUsersService u _eventAggregator = eventAggregator; _protectedReadService = protectedReadService; _businessOperationsAccess = businessOperationsAccess; + _limitsService = limitsService; + _profileLocalizer = profileLocalizer; } #endregion Private Members and Constructors @@ -1538,9 +1543,14 @@ public async Task JoinDepartment(int id, string code) if (await _departmentsService.IsMemberOfDepartmentAsync(id, UserId)) return "You are already a member of this department and cannot join it again."; + if (!await _limitsService.CanDepartmentAddNewUserAsync(id, true)) + return _profileLocalizer["JoinDepartmentFull"]; + return null; } + private const string JoinDepartmentErrorTempDataKey = "JoinDepartmentError"; // read by YourDepartments.cshtml + [HttpPost] [Authorize(Policy = ResgridResources.Personnel_View)] [ValidateAntiForgeryToken] @@ -1558,7 +1568,16 @@ public async Task JoinDepartment(IFormCollection form) return RedirectToAction("YourDepartments"); if (!await _departmentsService.IsMemberOfDepartmentAsync(int.Parse(departmentId), UserId)) + { + // Joining takes a personnel seat; the pre-check normally says so first, this covers a race or a direct post. + if (!await _limitsService.CanDepartmentAddNewUserAsync(int.Parse(departmentId), true)) + { + TempData[JoinDepartmentErrorTempDataKey] = _profileLocalizer["JoinDepartmentFull"].Value; + return RedirectToAction("YourDepartments"); + } + await _departmentsService.JoinDepartmentAsync(int.Parse(departmentId), UserId); + } return RedirectToAction("YourDepartments"); } diff --git a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs index 66778a5e9..77ee59531 100644 --- a/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs +++ b/Web/Resgrid.Web/Areas/User/Controllers/RecordsController.cs @@ -1165,12 +1165,15 @@ public async Task Settings(RecordsSettingsView model, Cancellatio await _departmentSettingsService.SetRecordsSearchConfigAsync(DepartmentId, searchConfig, cancellationToken); // Setting 77 (plan section 4.9): the statutory clock is bounded, the profile must be one the disclosure - // workflow knows, and the release approver must be a current member so a departed user is never the gate. + // workflow knows, and a newly chosen release approver must be a current member so a departed user is never made the gate. var disclosure = await _departmentSettingsService.GetRecordsDisclosureConfigAsync(DepartmentId, true) ?? new RecordsDisclosureConfig(); disclosure.StatutoryClockDays = Math.Max(1, Math.Min(365, model.DisclosureStatutoryClockDays)); disclosure.DefaultRedactionProfile = RmsRedactionProfiles.IsKnown(model.DisclosureDefaultRedactionProfile) ? model.DisclosureDefaultRedactionProfile : RmsRedactionProfiles.Standard; var approver = string.IsNullOrWhiteSpace(model.DisclosureReleaseApproverUserId) ? null : model.DisclosureReleaseApproverUserId.Trim(); - if (approver != null && !await _recordsAuthorizationService.IsActiveMemberAsync(approver, DepartmentId)) + // Only a newly chosen approver is checked. The saved one comes back selected even after going inactive (see + // BuildSettingsAsync), and saving an unrelated setting must not quietly turn the gate into "any admin". + var unchanged = approver != null && string.Equals(approver, disclosure.ReleaseApproverUserId, StringComparison.OrdinalIgnoreCase); + if (approver != null && !unchanged && !await _recordsAuthorizationService.IsActiveMemberAsync(approver, DepartmentId)) approver = null; disclosure.ReleaseApproverUserId = approver; await _departmentSettingsService.SetRecordsDisclosureConfigAsync(DepartmentId, disclosure, cancellationToken); diff --git a/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs b/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs index 77940d959..371060218 100644 --- a/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs +++ b/Web/Resgrid.Web/Areas/User/Models/AddPersonModel.cs @@ -23,6 +23,8 @@ public class AddPersonModel: BaseUserModel public string GroupName { get; set; } public bool GroupAdmin { get; set; } public bool IsGroupAdminAdding { get; set; } + /// The department is at its plan's personnel limit (or the plan could not be checked), so nobody can be added. + public bool PersonnelLimitReached { get; set; } [Required] [MaxLength(50)] diff --git a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs index 427d6f76e..8f9799a31 100644 --- a/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs +++ b/Web/Resgrid.Web/Areas/User/Models/Personnel/ViewPersonView.cs @@ -19,5 +19,7 @@ public class ViewPersonView public string State { get; set; } /// ReactivateUser / AddExistingUser: nothing has changed yet and the page asks for confirmation (false once done). public bool ConfirmationPending { get; set; } + /// The department is at its plan's personnel limit (or the plan could not be checked), so the confirmation cannot proceed. + public bool PersonnelLimitReached { get; set; } } } diff --git a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml index 996861f55..3f49d713e 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Deployments/TimeReport.cshtml @@ -65,20 +65,23 @@
+ @{ var postIndex = 0; } @for (var i = 0; i < entries.Count; i++) { var e = entries[i]; // Another crew's row on a deployment-wide report is shown but never posted, so the server keeps it as stored. var rowEditable = Model.CanEdit && Model.CanWriteSubject(e.SubjectId); - - - - - - - - - + // Posted indices must be gapless: the binder stops at the first missing entries[n], so a read-only row takes none. + var n = rowEditable ? postIndex++ : -1; + + + + + + + + + } @@ -191,14 +194,16 @@ (function () { var table = document.getElementById('entries'); if (!table) return; var body = table.querySelector('tbody'); + // Read-only rows (another crew's, posted by nobody) take no index; the writable ones stay numbered 0..n-1 with no gap. function reindex() { - var rows = body.querySelectorAll('tr'); - for (var i = 0; i < rows.length; i++) rows[i].querySelectorAll('[name]').forEach(function (el) { el.name = el.name.replace(/entries\[\d+\]/, 'entries[' + i + ']'); }); + var rows = body.querySelectorAll('tr:not(.entry-readonly)'); + for (var i = 0; i < rows.length; i++) rows[i].querySelectorAll('[name]').forEach(function (el) { el.name = el.name.replace(/entries\[-?\d+\]/, 'entries[' + i + ']'); }); } body.addEventListener('click', function (ev) { var b = ev.target.closest('.remove-row'); if (!b) return; b.closest('tr').remove(); reindex(); }); var add = document.getElementById('addRow'); if (add) add.addEventListener('click', function () { - var rows = body.querySelectorAll('tr'); var clone; + // A new row starts from the caller's own last row, never a disabled one naming another crew's subject. + var rows = body.querySelectorAll('tr:not(.entry-readonly)'); var clone; if (rows.length) { clone = rows[rows.length - 1].cloneNode(true); clone.querySelectorAll('input[type=text],input[type=number],input.entry-id,input[name$=".CertificationCode"]').forEach(function (i) { i.value = ''; i.removeAttribute('data-adp-field'); }); } else { clone = document.createElement('tr'); diff --git a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml index 8ace0f433..248924726 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Home/EditUserProfile.cshtml @@ -484,6 +484,7 @@
+
diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml index ad8d4511b..d315a94d6 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddExistingUser.cshtml @@ -44,11 +44,19 @@

@localizer["ExistingUserHelp1"] (@Model.Profile.FullName.AsFirstNameLastName)@localizer["ExistingUserConfirmText"]

- - @Html.AntiForgeryToken() + @if (Model.PersonnelLimitReached) + { +
@localizer["PersonnelLimitReached"]
@commonLocalizer["Cancel"] - - + } + else + { +
+ @Html.AntiForgeryToken() + @commonLocalizer["Cancel"] + + + } } else { diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml index e8cf80fd9..6d1a6baf7 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/AddPerson.cshtml @@ -36,6 +36,10 @@ @Html.AntiForgeryToken()
+ @if (Model.PersonnelLimitReached) + { +
@localizer["PersonnelLimitReached"]
+ }

@localizer["AccountInformationHeader"] @@ -206,7 +210,7 @@
@commonLocalizer["Cancel"] - +
diff --git a/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml b/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml index 8dec55872..80621123d 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Personnel/ReactivateUser.cshtml @@ -43,11 +43,19 @@

@localizer["ReactivatePersonText1"] (@Model.Profile.FullName.AsFirstNameLastName), @localizer["ReactivatePersonText2"] @Model.Department.Name @localizer["ReactivatePersonConfirmText"]

-
- @Html.AntiForgeryToken() + @if (Model.PersonnelLimitReached) + { +
@localizer["PersonnelLimitReached"]
@commonLocalizer["Cancel"] - - + } + else + { +
+ @Html.AntiForgeryToken() + @commonLocalizer["Cancel"] + + + } } else { diff --git a/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml b/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml index 53d5333d4..7e9098993 100644 --- a/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml +++ b/Web/Resgrid.Web/Areas/User/Views/Profile/YourDepartments.cshtml @@ -29,6 +29,15 @@

+@if (TempData["JoinDepartmentError"] is string joinDepartmentError) +{ +
+
+
@joinDepartmentError
+
+
+} +
diff --git a/Web/Resgrid.Web/Controllers/AccountController.cs b/Web/Resgrid.Web/Controllers/AccountController.cs index e54540ef8..2a95d5bb1 100644 --- a/Web/Resgrid.Web/Controllers/AccountController.cs +++ b/Web/Resgrid.Web/Controllers/AccountController.cs @@ -58,6 +58,7 @@ public class AccountController : Controller private readonly IUserSessionService _userSessionService; private readonly IExternalIdentityLinkService _externalIdentityLinkService; private readonly IPasswordRecoveryService _passwordRecoveryService; + private readonly ILimitsService _limitsService; public AccountController( UserManager userManager, SignInManager signInManager, @@ -67,7 +68,7 @@ public AccountController( IDepartmentSsoService departmentSsoService, IStringLocalizer secLocalizer, IUserSessionService userSessionService, IExternalIdentityLinkService externalIdentityLinkService, - IPasswordRecoveryService passwordRecoveryService) + IPasswordRecoveryService passwordRecoveryService, ILimitsService limitsService) { _userManager = userManager; _signInManager = signInManager; @@ -87,6 +88,7 @@ public AccountController( _userSessionService = userSessionService; _externalIdentityLinkService = externalIdentityLinkService; _passwordRecoveryService = passwordRecoveryService; + _limitsService = limitsService; } #endregion Private Members and Constructors @@ -997,6 +999,7 @@ public async Task CompleteInvite(string inviteCode) model.DepartmentName = department.Name; model.Email = model.Invite.EmailAddress; model.Code = inviteCode.ToString(); + model.DepartmentFull = !await _limitsService.CanDepartmentAddNewUserAsync(department.DepartmentId, true); return View(model); } @@ -1006,7 +1009,18 @@ public async Task CompleteInvite(string inviteCode) [ValidateAntiForgeryToken] public async Task CompleteInvite(CompleteInviteModel model, CancellationToken cancellationToken) { - model.Invite = await _invitesService.GetInviteByCodeAsync(Guid.Parse(model.Code)); + if (!Guid.TryParse(model.Code, out var code)) + return RedirectToAction("MissingInvite"); + + model.Invite = await _invitesService.GetInviteByCodeAsync(code); + + if (model.Invite == null) + return RedirectToAction("MissingInvite"); + + if (model.Invite.CompletedOn.HasValue) + return RedirectToAction("CompletedInvite"); + + model.DepartmentName = (await _departmentsService.GetDepartmentByIdAsync(model.Invite.DepartmentId, true))?.Name; model.Email = model.Invite.EmailAddress; if (!StringHelpers.ValidateEmail(model.Email)) @@ -1020,7 +1034,10 @@ public async Task CompleteInvite(CompleteInviteModel model, Cance ModelState.AddModelError("EmailAddresses", string.Format("The email address {0} is already in use in this department on another. Email address can only be used once per account in the system. Use the account recovery form to recover your username and password.", model.Email)); } - if (ModelState.IsValid) + // The new member would take a personnel seat: at the plan's limit no account is created (fresh counts, not the 14-day cache). + model.DepartmentFull = !await _limitsService.CanDepartmentAddNewUserAsync(model.Invite.DepartmentId, true); + + if (ModelState.IsValid && !model.DepartmentFull) { var user = new IdentityUser { UserName = model.UserName, Email = model.Email, SecurityStamp = Guid.NewGuid().ToString() }; var result = await _userManager.CreateAsync(user, model.Password); diff --git a/Web/Resgrid.Web/Models/AccountModels.cs b/Web/Resgrid.Web/Models/AccountModels.cs index eded4884d..c55ce1a8c 100644 --- a/Web/Resgrid.Web/Models/AccountModels.cs +++ b/Web/Resgrid.Web/Models/AccountModels.cs @@ -89,6 +89,8 @@ public class RegisterModel public class CompleteInviteModel { public Invite Invite { get; set; } + /// The inviting department is at its plan's personnel limit (or the plan could not be checked), so no account is created. + public bool DepartmentFull { get; set; } public string DepartmentName { get; set; } diff --git a/Web/Resgrid.Web/Views/Account/CompleteInvite.cshtml b/Web/Resgrid.Web/Views/Account/CompleteInvite.cshtml index 49e5a292b..8742a4aa5 100644 --- a/Web/Resgrid.Web/Views/Account/CompleteInvite.cshtml +++ b/Web/Resgrid.Web/Views/Account/CompleteInvite.cshtml @@ -37,6 +37,11 @@ @localizer["CompleteInviteText3"]

+ @if (Model.DepartmentFull) + { +
@localizer["CompleteInviteDepartmentFull"]
+ } +
@localizer["SignUpTerms1"] Resgrid's @localizer["TermsOfUse"] & @localizer["PrivacyPolicy"] @@ -77,7 +82,7 @@
- + @Html.HiddenFor(x => x.Email) @Html.HiddenFor(x => x.Code) diff --git a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs index 51cd44832..f1376995c 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/ReportDeliveryLogic.cs @@ -49,7 +49,7 @@ public async Task> Process(ReportDeliveryQueueItem item) return Tuple.Create(true, "Report subscriber is not an active department member."); } } - catch (Exception ex) { Logging.LogException(ex); return Tuple.Create(false, "Report subscriber membership could not be verified."); } + catch (Exception ex) { Logging.LogException(ex, $"Report subscriber membership could not be verified for scheduled task {item.ScheduledTask.ScheduledTaskId} in department {item.ScheduledTask.DepartmentId}."); return Tuple.Create(false, "Report subscriber membership could not be verified."); } } if (item?.ScheduledTask?.Data is "6" or "7" or "8" or "9" or "10" or "11" or "12" or "13") diff --git a/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs b/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs index 35747fb52..5d394ec2a 100644 --- a/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs +++ b/Workers/Resgrid.Workers.Framework/Logic/SystemQueueLogic.cs @@ -256,157 +256,14 @@ public class SystemQueueLogic if (auditEvent != null) { + // Same row as the audit queue writes (actor, IP, user agent, subject, fallback message); the copy of its switch + // that used to live here had drifted from it. var auditLogsRepository = Bootstrapper.GetKernel().Resolve(); var userProfileService = Bootstrapper.GetKernel().Resolve(); + var auditService = Bootstrapper.GetKernel().Resolve(); - var profile = await userProfileService.GetProfileByUserIdAsync(auditEvent.UserId); - - var auditLog = new AuditLog(); - auditLog.DepartmentId = auditEvent.DepartmentId; - auditLog.UserId = auditEvent.UserId; - auditLog.LogType = (int)auditEvent.Type; - - switch (auditEvent.Type) - { - case AuditLogTypes.DepartmentSettingsChanged: - auditLog.Message = string.Format("{0} updated the department settings", profile.FullName.AsFirstNameLastName); - // Not every producer sends a Department (or a Before); see AuditQueueLogic. - auditLog.Data = AuditQueueLogic.GetSettingsChangedAuditData(auditEvent.Before, auditEvent.After); - break; - case AuditLogTypes.UserAdded: - if (!String.IsNullOrWhiteSpace(auditEvent.After)) - { - var userAddedIdentityUser = JsonConvert.DeserializeObject(auditEvent.After); - var newProfile = await userProfileService.GetProfileByUserIdAsync(userAddedIdentityUser.UserId); - auditLog.Message = string.Format("{0} added new user {1}", profile.FullName.AsFirstNameLastName, newProfile.FullName.AsFirstNameLastName); - - auditLog.Data = $"New UserId: {newProfile.UserId}"; - } - break; - case AuditLogTypes.UserRemoved: - - if (!String.IsNullOrWhiteSpace(auditEvent.Before)) - { - var userRemovedIdentityUser = JsonConvert.DeserializeObject(auditEvent.Before); - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} removed user {userRemovedIdentityUser.FullName.AsFirstNameLastName}"; - auditLog.Data = "No Data"; - } - - break; - case AuditLogTypes.GroupAdded: - if (!String.IsNullOrWhiteSpace(auditEvent.After)) - { - var groupAddedGroup = JsonConvert.DeserializeObject(auditEvent.After); - if (groupAddedGroup.Type.HasValue && groupAddedGroup.Type.Value == (int)DepartmentGroupTypes.Station) - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} added station group {groupAddedGroup.Name}"; - else - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} added organizational group {groupAddedGroup.Name}"; - - auditLog.Data = $"GroupId: {groupAddedGroup.DepartmentGroupId}"; - } - break; - case AuditLogTypes.GroupRemoved: - if (!String.IsNullOrWhiteSpace(auditEvent.Before)) - { - var groupRemovedGroup = JsonConvert.DeserializeObject(auditEvent.Before); - auditLog.Message = string.Format("{0} removed group {1}", profile.FullName.AsFirstNameLastName, groupRemovedGroup.Name); - auditLog.Data = "No Data"; - } - break; - case AuditLogTypes.GroupChanged: - if (!String.IsNullOrWhiteSpace(auditEvent.Before) && !String.IsNullOrWhiteSpace(auditEvent.After)) - { - var groupUpdatedBeforeGroup = JsonConvert.DeserializeObject(auditEvent.Before); - var groupUpdatedAfterGroup = JsonConvert.DeserializeObject(auditEvent.After); - - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} updated group {groupUpdatedAfterGroup.Name}"; - var compareLogicGroup = new CompareLogic(); - - ComparisonResult resultGroup = compareLogicGroup.Compare(groupUpdatedBeforeGroup, groupUpdatedAfterGroup); - auditLog.Data = resultGroup.DifferencesString; - } - break; - case AuditLogTypes.UnitAdded: - if (!String.IsNullOrWhiteSpace(auditEvent.After)) - { - var unitedAddedUnit = JsonConvert.DeserializeObject(auditEvent.After); - auditLog.Message = string.Format("{0} added unit {1}", profile.FullName.AsFirstNameLastName, unitedAddedUnit.Name); - auditLog.Data = $"UnitId: {unitedAddedUnit.UnitId}"; - } - break; - case AuditLogTypes.UnitRemoved: - if (!String.IsNullOrWhiteSpace(auditEvent.Before)) - { - var unitedRemovedUnit = JsonConvert.DeserializeObject(auditEvent.Before); - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} removed unit {unitedRemovedUnit.Name}"; - auditLog.Data = "No Data"; - } - break; - case AuditLogTypes.UnitChanged: - if (!String.IsNullOrWhiteSpace(auditEvent.Before) && !String.IsNullOrWhiteSpace(auditEvent.After)) - { - var unitUpdatedBeforeUnit = JsonConvert.DeserializeObject(auditEvent.Before); - var unitUpdatedAfterUnit = JsonConvert.DeserializeObject(auditEvent.After); - - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} updated unit {unitUpdatedAfterUnit.Name}"; - - var compareLogicUnit = new CompareLogic(); - ComparisonResult resultUnit = compareLogicUnit.Compare(unitUpdatedBeforeUnit, unitUpdatedAfterUnit); - auditLog.Data = resultUnit.DifferencesString; - } - break; - case AuditLogTypes.ProfileUpdated: - if (!String.IsNullOrWhiteSpace(auditEvent.Before) && !String.IsNullOrWhiteSpace(auditEvent.After)) - { - var profileUpdatedBeforeProfile = JsonConvert.DeserializeObject(auditEvent.Before); - var profileUpdatedAfterProfile = JsonConvert.DeserializeObject(auditEvent.After); - - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} updated the profile for {profileUpdatedBeforeProfile.FullName.AsFirstNameLastName}"; - - var compareLogicProfile = new CompareLogic(); - ComparisonResult resultProfile = compareLogicProfile.Compare(profileUpdatedBeforeProfile, profileUpdatedAfterProfile); - auditLog.Data = resultProfile.DifferencesString; - } - break; - case AuditLogTypes.PermissionsChanged: - if (!String.IsNullOrWhiteSpace(auditEvent.Before) && !String.IsNullOrWhiteSpace(auditEvent.After)) - { - var updatePermissionBefore = JsonConvert.DeserializeObject(auditEvent.Before); - var updatePermissionAfter = JsonConvert.DeserializeObject(auditEvent.After); - - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} updated the department permissions"; - - var compareLogicProfile = new CompareLogic(); - ComparisonResult resultProfile = compareLogicProfile.Compare(updatePermissionBefore, updatePermissionAfter); - auditLog.Data = resultProfile.DifferencesString; - } - break; - case AuditLogTypes.SubscriptionUpdated: - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} changed (upgrade or downgrade) the active subscription of department id {auditEvent.DepartmentId}"; - auditLog.Data = "No Data"; - break; - case AuditLogTypes.SubscriptionBillingInfoUpdated: - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} updated the subscription billing information for department id {auditEvent.DepartmentId}"; - auditLog.Data = "No Data"; - break; - case AuditLogTypes.SubscriptionCancelled: - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} canceled the active subscription of department id {auditEvent.DepartmentId}"; - auditLog.Data = "No Data"; - break; - case AuditLogTypes.SubscriptionCreated: - auditLog.Message = $"{profile.FullName.AsFirstNameLastName} created a new active subscription for department id {auditEvent.DepartmentId}"; - auditLog.Data = "No Data"; - break; - } - - if (String.IsNullOrWhiteSpace(auditLog.Data)) - auditLog.Data = "No Data"; - - if (!String.IsNullOrWhiteSpace(auditLog.Message)) - { - auditLog.LoggedOn = DateTime.UtcNow; - await auditLogsRepository.SaveOrUpdateAsync(auditLog, cancellationToken); - } + var auditLog = await AuditQueueLogic.BuildAuditLogAsync(auditEvent, userProfileService, auditService); + await auditLogsRepository.SaveOrUpdateAsync(auditLog, cancellationToken); } break; default:
@localizer["Subject"]@localizer["EntryType"]@localizer["Start"]@localizer["End"]@localizer["PaidBreak"]@localizer["UnpaidBreak"]@localizer["Km"]@localizer["Notes"]
@if (rowEditable) { } @e.Hours.ToString("0.00")h