From 12baad2410c782c5fbe4746b844e05a8048474ed Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Tue, 25 Aug 2026 11:02:42 -0700 Subject: [PATCH 1/3] VPR-64 feat(phone): schoolwide and unit phone lists --- .gitignore | 3 + VueApp/.fallowrc.json | 1 + VueApp/src/CMS/components/FileFormDialog.vue | 366 ++--- VueApp/src/CMS/components/PersonSelector.vue | 28 +- VueApp/src/Personnel/App.vue | 14 + .../__tests__/person-selector.test.ts | 99 ++ .../phone-list-add-record-dialog.test.ts | 158 ++ .../__tests__/phone-list-data-fetch.test.ts | 128 ++ .../__tests__/phone-list-maintain.test.ts | 226 +++ .../phone-list-modified-date-service.test.ts | 29 + .../phone-list-route-changes.test.ts | 119 ++ .../__tests__/phone-list-service.test.ts | 66 + .../__tests__/phone-list-unit-service.test.ts | 92 ++ .../__tests__/phone-list-unit-table.test.ts | 115 ++ .../Personnel/__tests__/phone-list.test.ts | 126 ++ .../phone-person-options-service.test.ts | 69 + .../__tests__/router-permissions.test.ts | 99 ++ .../svm-add-frequent-number-dialog.test.ts | 104 ++ .../__tests__/svm-add-record-dialog.test.ts | 349 +++++ .../__tests__/svm-data-fetch.test.ts | 372 +++++ .../svm-frequent-number-service.test.ts | 46 + .../svm-frequent-number-table.test.ts | 35 + .../svm-modified-date-service.test.ts | 29 + .../__tests__/svm-phone-section-table.test.ts | 37 + .../__tests__/svm-phones-maintain.test.ts | 384 +++++ .../Personnel/__tests__/svm-phones.test.ts | 66 + .../__tests__/svm-section-service.test.ts | 31 + .../__tests__/svm-unit-service.test.ts | 81 ++ VueApp/src/Personnel/__tests__/test-utils.ts | 26 + .../__tests__/use-add-record-dialog.test.ts | 111 ++ .../Personnel/components/PersonSelector.vue | 63 + .../components/PhoneListAddRecordDialog.vue | 173 +++ .../components/PhoneListUnitTable.vue | 86 ++ .../components/RecordActionButton.vue | 28 + .../components/SVMAddFrequentNumberDialog.vue | 99 ++ .../components/SVMAddRecordDialog.vue | 274 ++++ .../components/SVMFrequentNumberTable.vue | 73 + .../components/SVMPhoneSectionTable.vue | 60 + .../composables/phone-list-data-fetch.ts | 89 ++ .../Personnel/composables/svm-data-fetch.ts | 276 ++++ .../composables/use-add-record-dialog.ts | 110 ++ .../composables/use-person-helper.ts | 50 + VueApp/src/Personnel/index.html | 12 + VueApp/src/Personnel/pages/Home.vue | 6 + VueApp/src/Personnel/pages/PhoneList.vue | 114 ++ .../src/Personnel/pages/PhoneListMaintain.vue | 158 ++ VueApp/src/Personnel/pages/SVMPhones.vue | 67 + .../src/Personnel/pages/SVMPhonesMaintain.vue | 188 +++ VueApp/src/Personnel/personnel.ts | 10 + VueApp/src/Personnel/router/index.ts | 58 + VueApp/src/Personnel/router/routes.ts | 57 + .../phone-list-modified-date-service.ts | 19 + .../Personnel/services/phone-list-service.ts | 27 + .../services/phone-list-unit-service.ts | 40 + .../services/phone-person-options-service.ts | 16 + .../services/svm-frequent-number-service.ts | 36 + .../services/svm-modified-date-service.ts | 19 + .../Personnel/services/svm-section-service.ts | 24 + .../Personnel/services/svm-unit-service.ts | 43 + .../Personnel/types/phone-list-phone-types.ts | 77 + VueApp/src/Personnel/types/phone-types.ts | 40 + VueApp/src/Personnel/types/svm-phone-types.ts | 137 ++ VueApp/src/components/RecordFormDialog.vue | 113 ++ .../__tests__/record-form-dialog.test.ts | 130 ++ VueApp/src/composables/ViperFetch.ts | 9 +- .../__tests__/use-person-search.test.ts | 82 ++ VueApp/src/composables/use-person-search.ts | 41 + VueApp/vueapp.esproj | 4 +- .../Utilities/PersonSearchHelperTests.cs | 137 ++ test/Personnel/PhoneListServiceTests.cs | 78 + .../Personnel/PhoneListUnitControllerTests.cs | 234 +++ test/Personnel/PhoneListUnitServiceTests.cs | 306 ++++ .../PhonePersonLookupServiceTests.cs | 157 ++ .../PhoneSVMFrequentNumberControllerTests.cs | 101 ++ .../PhoneSVMFrequentNumberServiceTests.cs | 116 ++ test/Personnel/PhoneSVMSectionServiceTests.cs | 51 + test/Personnel/PhoneSVMUnitServiceTests.cs | 516 +++++++ .../CMS/Controllers/CMSOptionsController.cs | 23 +- .../Controllers/PhoneListController.cs | 48 + .../PhoneListModifiedDateController.cs | 36 + .../Controllers/PhoneListUnitController.cs | 132 ++ .../Controllers/PhonePersonController.cs | 59 + .../PhoneSVMFrequentNumberController.cs | 79 + .../PhoneSVMModifiedDateController.cs | 32 + .../Controllers/PhoneSVMSectionController.cs | 29 + .../Controllers/PhoneSVMUnitController.cs | 84 ++ .../Personnel/Models/AugmentedViperPerson.cs | 22 + web/Areas/Personnel/Models/PersonnelMapper.cs | 13 + web/Areas/Personnel/Models/PhoneList.cs | 24 + web/Areas/Personnel/Models/PhoneListInfo.cs | 16 + web/Areas/Personnel/Models/PhoneListUnit.cs | 20 + .../Models/PhoneListUnitDataRequests.cs | 15 + .../Personnel/Models/PhoneListUnitPerson.cs | 24 + web/Areas/Personnel/Models/PhonePerson.cs | 21 + .../Personnel/Models/SVMFrequentNumber.cs | 22 + .../Models/SVMFrequentNumberRequests.cs | 11 + web/Areas/Personnel/Models/SVMSection.cs | 18 + web/Areas/Personnel/Models/SVMUnit.cs | 22 + .../Personnel/Models/SVMUnitDataRequests.cs | 19 + web/Areas/Personnel/Models/SVMUnitPerson.cs | 27 + web/Areas/Personnel/Models/ViperPerson.cs | 17 + web/Areas/Personnel/PhonesDbContext.cs | 216 +++ .../Scripts/MigratePhoneListsData.cs | 1294 +++++++++++++++++ .../Scripts/PhoneListsDataAnalysis.cs | 509 +++++++ .../Scripts/PhoneListsMigration.csproj | 27 + .../Scripts/PhoneListsScriptHelper.cs | 293 ++++ web/Areas/Personnel/Scripts/Program.cs | 58 + web/Areas/Personnel/Scripts/RunAnalysis.bat | 88 ++ .../Personnel/Scripts/RunMigrateData.bat | 101 ++ .../Personnel/Services/PhoneListService.cs | 31 + .../Services/PhoneListUnitService.cs | 280 ++++ .../Services/PhonePermissionsService.cs | 32 + .../Services/PhonePersonLookupService.cs | 71 + .../Services/PhoneSVMFrequentNumberService.cs | 119 ++ .../Services/PhoneSVMSectionService.cs | 29 + .../Personnel/Services/PhoneSVMUnitService.cs | 332 +++++ web/Classes/Utilities/PersonSearchHelper.cs | 102 ++ web/Program.cs | 6 +- web/Viper.csproj | 4 + web/appsettings.Development.json | 1 + web/appsettings.Production.json | 1 + web/appsettings.Test.json | 1 + 122 files changed, 12163 insertions(+), 258 deletions(-) create mode 100644 VueApp/src/Personnel/App.vue create mode 100644 VueApp/src/Personnel/__tests__/person-selector.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-list.test.ts create mode 100644 VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/router-permissions.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-phones.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-section-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/svm-unit-service.test.ts create mode 100644 VueApp/src/Personnel/__tests__/test-utils.ts create mode 100644 VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts create mode 100644 VueApp/src/Personnel/components/PersonSelector.vue create mode 100644 VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue create mode 100644 VueApp/src/Personnel/components/PhoneListUnitTable.vue create mode 100644 VueApp/src/Personnel/components/RecordActionButton.vue create mode 100644 VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue create mode 100644 VueApp/src/Personnel/components/SVMAddRecordDialog.vue create mode 100644 VueApp/src/Personnel/components/SVMFrequentNumberTable.vue create mode 100644 VueApp/src/Personnel/components/SVMPhoneSectionTable.vue create mode 100644 VueApp/src/Personnel/composables/phone-list-data-fetch.ts create mode 100644 VueApp/src/Personnel/composables/svm-data-fetch.ts create mode 100644 VueApp/src/Personnel/composables/use-add-record-dialog.ts create mode 100644 VueApp/src/Personnel/composables/use-person-helper.ts create mode 100644 VueApp/src/Personnel/index.html create mode 100644 VueApp/src/Personnel/pages/Home.vue create mode 100644 VueApp/src/Personnel/pages/PhoneList.vue create mode 100644 VueApp/src/Personnel/pages/PhoneListMaintain.vue create mode 100644 VueApp/src/Personnel/pages/SVMPhones.vue create mode 100644 VueApp/src/Personnel/pages/SVMPhonesMaintain.vue create mode 100644 VueApp/src/Personnel/personnel.ts create mode 100644 VueApp/src/Personnel/router/index.ts create mode 100644 VueApp/src/Personnel/router/routes.ts create mode 100644 VueApp/src/Personnel/services/phone-list-modified-date-service.ts create mode 100644 VueApp/src/Personnel/services/phone-list-service.ts create mode 100644 VueApp/src/Personnel/services/phone-list-unit-service.ts create mode 100644 VueApp/src/Personnel/services/phone-person-options-service.ts create mode 100644 VueApp/src/Personnel/services/svm-frequent-number-service.ts create mode 100644 VueApp/src/Personnel/services/svm-modified-date-service.ts create mode 100644 VueApp/src/Personnel/services/svm-section-service.ts create mode 100644 VueApp/src/Personnel/services/svm-unit-service.ts create mode 100644 VueApp/src/Personnel/types/phone-list-phone-types.ts create mode 100644 VueApp/src/Personnel/types/phone-types.ts create mode 100644 VueApp/src/Personnel/types/svm-phone-types.ts create mode 100644 VueApp/src/components/RecordFormDialog.vue create mode 100644 VueApp/src/components/__tests__/record-form-dialog.test.ts create mode 100644 VueApp/src/composables/__tests__/use-person-search.test.ts create mode 100644 VueApp/src/composables/use-person-search.ts create mode 100644 test/Classes/Utilities/PersonSearchHelperTests.cs create mode 100644 test/Personnel/PhoneListServiceTests.cs create mode 100644 test/Personnel/PhoneListUnitControllerTests.cs create mode 100644 test/Personnel/PhoneListUnitServiceTests.cs create mode 100644 test/Personnel/PhonePersonLookupServiceTests.cs create mode 100644 test/Personnel/PhoneSVMFrequentNumberControllerTests.cs create mode 100644 test/Personnel/PhoneSVMFrequentNumberServiceTests.cs create mode 100644 test/Personnel/PhoneSVMSectionServiceTests.cs create mode 100644 test/Personnel/PhoneSVMUnitServiceTests.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneListController.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneListUnitController.cs create mode 100644 web/Areas/Personnel/Controllers/PhonePersonController.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs create mode 100644 web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs create mode 100644 web/Areas/Personnel/Models/AugmentedViperPerson.cs create mode 100644 web/Areas/Personnel/Models/PersonnelMapper.cs create mode 100644 web/Areas/Personnel/Models/PhoneList.cs create mode 100644 web/Areas/Personnel/Models/PhoneListInfo.cs create mode 100644 web/Areas/Personnel/Models/PhoneListUnit.cs create mode 100644 web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs create mode 100644 web/Areas/Personnel/Models/PhoneListUnitPerson.cs create mode 100644 web/Areas/Personnel/Models/PhonePerson.cs create mode 100644 web/Areas/Personnel/Models/SVMFrequentNumber.cs create mode 100644 web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs create mode 100644 web/Areas/Personnel/Models/SVMSection.cs create mode 100644 web/Areas/Personnel/Models/SVMUnit.cs create mode 100644 web/Areas/Personnel/Models/SVMUnitDataRequests.cs create mode 100644 web/Areas/Personnel/Models/SVMUnitPerson.cs create mode 100644 web/Areas/Personnel/Models/ViperPerson.cs create mode 100644 web/Areas/Personnel/PhonesDbContext.cs create mode 100644 web/Areas/Personnel/Scripts/MigratePhoneListsData.cs create mode 100644 web/Areas/Personnel/Scripts/PhoneListsDataAnalysis.cs create mode 100644 web/Areas/Personnel/Scripts/PhoneListsMigration.csproj create mode 100644 web/Areas/Personnel/Scripts/PhoneListsScriptHelper.cs create mode 100644 web/Areas/Personnel/Scripts/Program.cs create mode 100644 web/Areas/Personnel/Scripts/RunAnalysis.bat create mode 100644 web/Areas/Personnel/Scripts/RunMigrateData.bat create mode 100644 web/Areas/Personnel/Services/PhoneListService.cs create mode 100644 web/Areas/Personnel/Services/PhoneListUnitService.cs create mode 100644 web/Areas/Personnel/Services/PhonePermissionsService.cs create mode 100644 web/Areas/Personnel/Services/PhonePersonLookupService.cs create mode 100644 web/Areas/Personnel/Services/PhoneSVMFrequentNumberService.cs create mode 100644 web/Areas/Personnel/Services/PhoneSVMSectionService.cs create mode 100644 web/Areas/Personnel/Services/PhoneSVMUnitService.cs create mode 100644 web/Classes/Utilities/PersonSearchHelper.cs diff --git a/.gitignore b/.gitignore index b6b7ed2e4..aabccddee 100644 --- a/.gitignore +++ b/.gitignore @@ -507,6 +507,9 @@ web/Areas/Effort/Scripts/AnalysisOutput/ web/Areas/Effort/Scripts/RemediationOutput/ web/Areas/Effort/Scripts/Effort_Database_Schema_And_Data_LEGACY.txt +# PhoneLists migration script outputs +web/Areas/Personnel/Scripts/AnalysisOutput/ + # Code-quality tool outputs (fallow cache + jscpd + ReSharper reports) .fallow/ VueApp/.fallow/ diff --git a/VueApp/.fallowrc.json b/VueApp/.fallowrc.json index f3d919fd4..3d0ab74c5 100644 --- a/VueApp/.fallowrc.json +++ b/VueApp/.fallowrc.json @@ -16,6 +16,7 @@ "src/ClinicalScheduler/index.html", "src/Computing/index.html", "src/Effort/index.html", + "src/Personnel/index.html", "src/Students/index.html" ], "ignorePatterns": [ diff --git a/VueApp/src/CMS/components/FileFormDialog.vue b/VueApp/src/CMS/components/FileFormDialog.vue index 6b09d8d30..d4f5426fb 100644 --- a/VueApp/src/CMS/components/FileFormDialog.vue +++ b/VueApp/src/CMS/components/FileFormDialog.vue @@ -1,19 +1,121 @@ @@ -238,7 +178,7 @@ import { useFetch } from "@/composables/ViperFetch" import { useUnsavedChanges } from "@/composables/use-unsaved-changes" import PermissionSelector from "@/CMS/components/PermissionSelector.vue" import PersonSelector from "@/CMS/components/PersonSelector.vue" -import StatusBanner from "@/components/StatusBanner.vue" +import RecordFormDialog from "@/components/RecordFormDialog.vue" import { CMS_ACCEPTED_EXTENSIONS } from "@/CMS/file-types" import type { CmsFile, CmsFileNameCheck, CmsFilePerson } from "@/CMS/types/" @@ -260,7 +200,6 @@ const { get, postForm, putForm, createUrlSearchParams } = useFetch() const acceptedExtensions = CMS_ACCEPTED_EXTENSIONS const isEdit = computed(() => props.file !== null) -const formRef = ref() const saving = ref(false) const formError = ref("") @@ -361,13 +300,6 @@ function resetForm() { conflict.value = null showConflict.value = false formError.value = "" - formRef.value?.resetValidation() -} - -async function handleClose() { - if (await confirmClose()) { - emit("update:modelValue", false) - } } // The q-form focuses the first invalid field on a failed submit; this surfaces a matching diff --git a/VueApp/src/CMS/components/PersonSelector.vue b/VueApp/src/CMS/components/PersonSelector.vue index cdc25a60e..08db7b2d6 100644 --- a/VueApp/src/CMS/components/PersonSelector.vue +++ b/VueApp/src/CMS/components/PersonSelector.vue @@ -43,8 +43,8 @@ diff --git a/VueApp/src/Personnel/App.vue b/VueApp/src/Personnel/App.vue new file mode 100644 index 000000000..9ef43f3b5 --- /dev/null +++ b/VueApp/src/Personnel/App.vue @@ -0,0 +1,14 @@ + + + diff --git a/VueApp/src/Personnel/__tests__/person-selector.test.ts b/VueApp/src/Personnel/__tests__/person-selector.test.ts new file mode 100644 index 000000000..8b61fd117 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/person-selector.test.ts @@ -0,0 +1,99 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import PersonSelector from "../components/PersonSelector.vue" +import { searchPeopleOptions } from "../services/phone-person-options-service" +import type { AugmentedViperPerson } from "../types/phone-types" + +/** + * Personnel's PersonSelector is never mounted for real by the dialog tests (they stub it away + * to isolate what those tests are about), so nothing else exercises its actual wiring: passing + * listCode through to searchPeopleOptions, reflecting results into QSelect's options, and the + * clear-to-sparse-fallback behavior on selection. + */ + +vi.mock("../services/phone-person-options-service", () => ({ + searchPeopleOptions: vi.fn<(...args: unknown[]) => unknown>(), +})) + +function mountSelector(listCode = "") { + return mount(PersonSelector, { + props: { modelValue: { iamId: "", fullName: "" }, label: "Employee", listCode }, + global: { plugins: [Quasar] }, + }) +} + +function applyUpdate(fn: () => void): void { + fn() +} + +async function triggerFilter(wrapper: ReturnType, value: string): Promise { + await wrapper.findComponent({ name: "QSelect" }).vm.$emit("filter", value, applyUpdate) +} + +describe("personSelector.vue", () => { + it("scopes the search to the given listCode", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(searchPeopleOptions).mockResolvedValue([]) + const wrapper = mountSelector("VMDO") + + await triggerFilter(wrapper, "smith") + + expect(searchPeopleOptions).toHaveBeenCalledWith("smith", "VMDO") + }) + + it("reflects the search results into the QSelect options", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const people: AugmentedViperPerson[] = [ + { + personId: 1, + firstName: "Amy", + lastName: "Smith", + fullName: "Amy Smith", + iamId: "asmith", + currentEmployee: true, + mailId: "asmith", + phoneData: null, + }, + ] + vi.mocked(searchPeopleOptions).mockResolvedValue(people) + const wrapper = mountSelector() + + await triggerFilter(wrapper, "smith") + + expect(wrapper.findComponent({ name: "QSelect" }).props("options")).toStrictEqual(people) + }) + + it("emits the selected person when one is chosen", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const person: AugmentedViperPerson = { + personId: 1, + firstName: "Amy", + lastName: "Smith", + fullName: "Amy Smith", + iamId: "asmith", + currentEmployee: true, + mailId: "asmith", + phoneData: null, + } + const wrapper = mountSelector() + + await wrapper.findComponent({ name: "QSelect" }).vm.$emit("update:model-value", person) + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[person]]) + }) + + it("emits a sparse fallback person when the selection is cleared", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const wrapper = mountSelector() + + await wrapper.findComponent({ name: "QSelect" }).vm.$emit("update:model-value", null) + + const emitted = wrapper.emitted("update:modelValue") + expect(emitted).toHaveLength(1) + expect((emitted![0]![0] as AugmentedViperPerson).iamId).toBe("") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts b/VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts new file mode 100644 index 000000000..0810449b7 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-add-record-dialog.test.ts @@ -0,0 +1,158 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { createRouter, createWebHistory } from "vue-router" +import { Quasar, Notify } from "quasar" +import { createPinia, setActivePinia } from "pinia" +import PhoneListAddRecordDialog from "../components/PhoneListAddRecordDialog.vue" +import { phoneListUnitService } from "../services/phone-list-unit-service" +import type { PhoneListDisplayRecord } from "../types/phone-list-phone-types" +import { apiResult } from "./test-utils" + +/** + * PhoneListAddRecordDialog covers add vs edit flows for a unit-scoped phone record. Add mode + * requires an employee to be picked via PersonSelector (stubbed here, mirroring CMS's + * FileFormDialog tests) before it will submit; edit mode has the employee pre-filled from + * editData and no PersonSelector at all. These tests mount the real dialog + RecordFormDialog + + * QForm so submission goes through the real validation gate, and mock the unit service so no + * network call happens. + */ + +vi.mock("../services/phone-list-unit-service", () => ({ + phoneListUnitService: { + addUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +const selectorStub = { + props: ["modelValue", "label", "listCode"], + emits: ["update:modelValue"], + template: "
", +} + +function mountDialog(props: { + modelValue: boolean + unit: { name: string; id: number } + listCode: string + editData?: PhoneListDisplayRecord | null +}) { + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createWebHistory(), + routes: [{ path: "/", component: { template: "
" } }], + }) + return mount(PhoneListAddRecordDialog, { + props, + global: { + plugins: [[Quasar, { plugins: { Notify } }], router, pinia], + stubs: { PersonSelector: selectorStub }, + }, + attachTo: document.body, + }) +} + +function editRecord(overrides: Partial = {}): PhoneListDisplayRecord { + return { + fullName: "Amy Smith", + name: "Smith, Amy", + employeeIam: "asmith", + employeeMailId: "asmith", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + listFirst: false, + unitPersonId: 7, + unitId: 10, + unitName: "Dean's Office", + modifiedBy: "jdoe", + modifiedDate: null, + ...overrides, + } +} + +async function submitDialogForm(wrapper: ReturnType): Promise { + await flushPromises() + await wrapper.findComponent({ name: "QForm" }).find("form").trigger("submit") + await flushPromises() +} + +function bodyText(): string { + return document.body.textContent ?? "" +} + +// QDialog teleports its content to document.body, so a wrapper-scoped find() misses it - +// query the document directly, mirroring the bodyText() helper above. +function personSelectorExists(): boolean { + return document.querySelector(".selector-stub") !== null +} + +function resetTestState(): void { + vi.clearAllMocks() + document.body.innerHTML = "" +} + +describe("phoneListAddRecordDialog.vue - add vs edit mode", () => { + it("shows the PersonSelector picker in add mode, and hides the employee/modified-by summary", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ modelValue: true, unit: { name: "Dean's Office", id: 10 }, listCode: "VMDO" }) + await flushPromises() + + expect(bodyText()).toContain("Add Phone Record") + expect(bodyText()).toContain("Upload") + expect(personSelectorExists()).toBeTruthy() + expect(bodyText()).not.toContain("Employee:") + expect(bodyText()).not.toContain("Modified By:") + }) + + it("shows the employee/modified-by summary in edit mode, and hides the PersonSelector picker", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ + modelValue: true, + unit: { name: "Dean's Office", id: 10 }, + listCode: "VMDO", + editData: editRecord(), + }) + await flushPromises() + + expect(bodyText()).toContain("Edit Phone Record") + expect(bodyText()).toContain("Employee: Smith, Amy") + expect(bodyText()).toContain("Modified By: jdoe") + expect(bodyText()).toContain("Save Changes") + expect(personSelectorExists()).toBeFalsy() + }) + + it("blocks submit with a validation message when no employee is selected in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ modelValue: true, unit: { name: "Dean's Office", id: 10 }, listCode: "VMDO" }) + + await submitDialogForm(wrapper) + + expect(phoneListUnitService.addUnitPersonData).not.toHaveBeenCalled() + expect(bodyText()).toContain("Please select an employee.") + }) + + it("submits the update payload and emits saved + close in edit mode", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(phoneListUnitService.updateUnitPersonData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + unit: { name: "Dean's Office", id: 10 }, + listCode: "VMDO", + editData: editRecord(), + }) + + await submitDialogForm(wrapper) + + expect(phoneListUnitService.updateUnitPersonData).toHaveBeenCalledWith( + "VMDO", + 7, + expect.objectContaining({ unitId: 10, employeeIam: "asmith", phone: "530-555-1000" }), + ) + expect(wrapper.emitted("saved")).toBeTruthy() + expect(wrapper.emitted("update:modelValue")).toContainEqual([false]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts b/VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts new file mode 100644 index 000000000..d93457396 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-data-fetch.test.ts @@ -0,0 +1,128 @@ +import { getPhoneListData } from "../composables/phone-list-data-fetch" +import { phoneListUnitService } from "../services/phone-list-unit-service" +import type { PhoneListUnitAPIResponse, PhoneListUnitPerson } from "../types/phone-list-phone-types" + +vi.mock("../services/phone-list-unit-service", () => ({ + phoneListUnitService: { + getUnitsByList: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +function makeUnitPerson(overrides: Partial = {}): PhoneListUnitPerson { + return { + phoneListUnitPersonId: 1, + phoneListUnitId: 10, + personIam: "person01", + listFirst: false, + phoneListUnit: null, + person: { + personIam: "person01", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: 1, + firstName: "Ada", + lastName: "Lovelace", + fullName: "Ada Lovelace", + iamId: "person01", + currentEmployee: true, + mailId: "alovelace", + }, + viperModPerson: null, + }, + modifiedBy: null, + modifiedDate: null, + viperModPerson: null, + ...overrides, + } +} + +function makeUnit(persons: PhoneListUnitPerson[]): PhoneListUnitAPIResponse { + return { + phoneListUnitId: 10, + phoneListId: 1, + name: "Dean's Office", + sortOrder: null, + phoneList: null, + phoneListUnitPersons: persons, + } +} + +describe("getPhoneListData()", () => { + it("omits the direct phone column for non-internal, view-only callers", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([makeUnit([makeUnitPerson()])]) + + const units = await getPhoneListData("VMDO", false, false) + + const columnNames = units[0]!.cols!.map((c) => c.name) + expect(columnNames).not.toContain("directPhone") + expect(columnNames).not.toContain("edit") + expect(columnNames).not.toContain("delete") + expect(columnNames).not.toContain("listFirst") + }) + + it("shows the direct phone column for internal viewers even outside edit mode", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([makeUnit([makeUnitPerson()])]) + + const units = await getPhoneListData("VMDO", false, true) + + const columnNames = units[0]!.cols!.map((c) => c.name) + expect(columnNames).toContain("directPhone") + expect(columnNames).not.toContain("edit") + }) + + it("shows maintain-only columns in edit mode regardless of internal status", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([makeUnit([makeUnitPerson()])]) + + const units = await getPhoneListData("VMDO", true, false) + + const columnNames = units[0]!.cols!.map((c) => c.name) + expect(columnNames).toContain("directPhone") + expect(columnNames).toContain("listFirst") + expect(columnNames).toContain("edit") + expect(columnNames).toContain("delete") + }) + + it("drops rows for former employees whose person record is gone, and falls back to a sparse name otherwise", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListUnitService.getUnitsByList).mockResolvedValue([ + makeUnit([ + makeUnitPerson({ person: null }), + makeUnitPerson({ + phoneListUnitPersonId: 2, + personIam: "person02", + person: { + personIam: "person02", + phone: "530-555-3000", + directPhone: "530-555-4000", + office: "Room 200", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: null, + viperModPerson: null, + }, + }), + ]), + ]) + + const units = await getPhoneListData("VMDO", false, false) + + expect(units[0]!.rows).toHaveLength(1) + expect(units[0]!.rows[0]!.name).toBe(", ") + expect(units[0]!.rows[0]!.fullName).toBe("") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts b/VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts new file mode 100644 index 000000000..afbff49ad --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-maintain.test.ts @@ -0,0 +1,226 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import PhoneListMaintain from "../pages/PhoneListMaintain.vue" +import PhoneListAddRecordDialog from "../components/PhoneListAddRecordDialog.vue" +import { getPhoneListData } from "../composables/phone-list-data-fetch.ts" +import { phoneListService } from "../services/phone-list-service.ts" +import { phoneListUnitService } from "../services/phone-list-unit-service.ts" +import type { PhoneListUnit } from "../types/phone-list-phone-types" +import { apiError } from "./test-utils" + +/** + * PhoneListMaintain shows a StatusBanner only when a delete/save action reports an error + * (v-if="errorMessage"). Reaching that state means driving the real child chain (PhoneListUnitTable + * -> RecordActionButton "delete" -> the confirm dialog -> phoneListUnitService), since errorMessage + * is page-local state with no prop/route to set it directly. + */ + +vi.mock("../composables/phone-list-data-fetch.ts", () => ({ + getPhoneListData: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/phone-list-service.ts", () => ({ + phoneListService: { getPhoneListInfo: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-unit-service.ts", () => ({ + phoneListUnitService: { + addUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + deleteUnitPersonData: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) +const mockReplace = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { code: "VMDO" } }), + useRouter: () => ({ replace: (...args: unknown[]) => mockReplace(...args) }), +})) +// Stub only the public useQuasar export, so the toast the page raises can be asserted directly. +// Quasar components resolve $q through their own internals, so QTable and friends still render. +const { mockNotify } = vi.hoisted(() => ({ mockNotify: vi.fn<(...args: unknown[]) => unknown>() })) +vi.mock("quasar", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useQuasar: () => ({ notify: mockNotify }) } +}) +vi.mock("@/composables/use-confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirmAction: vi.fn<(...args: unknown[]) => unknown>().mockResolvedValue(true) }), +})) + +/** Stubs a list the caller may maintain, which is the precondition for the editor to render. */ +function stubListInfo(canMaintain = true): void { + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue({ + phoneListId: 1, + code: "VMDO", + name: "Dean's Office Phone List", + canMaintain, + canViewDirectPhone: true, + }) +} + +function unitWithDeletableRow(): PhoneListUnit { + return { + name: "Dean's Office", + id: 10, + cols: [ + { name: "name", label: "Name", field: "name", align: "left" }, + { name: "edit", label: "Edit", field: "edit", align: "left" }, + { name: "delete", label: "Delete", field: "delete", align: "left" }, + ], + rows: [ + { + fullName: "Amy Smith", + name: "Smith, Amy", + employeeIam: "asmith", + employeeMailId: "asmith", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + listFirst: false, + unitPersonId: 7, + unitId: 10, + unitName: "Dean's Office", + modifiedBy: null, + modifiedDate: null, + }, + ], + } +} + +const personSelectorStub = { + props: ["modelValue", "label", "listCode"], + emits: ["update:modelValue"], + template: "
", +} + +function mountPage() { + return mount(PhoneListMaintain, { + global: { + plugins: [[Quasar, { plugins: { Notify } }]], + stubs: { PersonSelector: personSelectorStub }, + }, + }) +} + +function findAddButton(wrapper: ReturnType) { + return wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "add") +} + +describe("phoneListMaintain.vue - error banner", () => { + it("hides the error banner on a normal load", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.findComponent({ name: "StatusBanner" }).exists()).toBeFalsy() + }) + + it("raises a toast carrying the server message when a delete fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([unitWithDeletableRow()]) + vi.mocked(phoneListUnitService.deleteUnitPersonData).mockResolvedValue(apiError(["Failed to delete record"])) + const wrapper = mountPage() + await flushPromises() + + const deleteButton = wrapper + .findAllComponents({ name: "RecordActionButton" }) + .find((btn) => btn.props("action") === "delete") + expect(deleteButton).toBeTruthy() + await deleteButton!.vm.$emit("action") + await flushPromises() + + // A failed delete is transient, so it is reported as a toast rather than the banner, + // which would otherwise persist past the reload that follows. + expect(wrapper.findComponent({ name: "StatusBanner" }).exists()).toBeFalsy() + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "negative", message: "Failed to delete record" }), + ) + }) + + it("opens the add dialog scoped to the clicked unit", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([unitWithDeletableRow()]) + const wrapper = mountPage() + await flushPromises() + + await findAddButton(wrapper)!.trigger("click") + await flushPromises() + + const dialog = wrapper.findComponent(PhoneListAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + expect(dialog.props("unit")).toMatchObject({ name: "Dean's Office", id: 10 }) + }) + + it("opens the edit dialog pre-filled when edit is clicked on a row", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([unitWithDeletableRow()]) + const wrapper = mountPage() + await flushPromises() + + const editButton = wrapper + .findAllComponents({ name: "RecordActionButton" }) + .find((btn) => btn.props("action") === "edit") + expect(editButton).toBeTruthy() + await editButton!.vm.$emit("action") + await flushPromises() + + const dialog = wrapper.findComponent(PhoneListAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + // RecordFormDialog's QDialog teleports its content to document.body. + expect(document.body.textContent).toContain("Edit Phone Record") + expect(document.body.textContent).toContain("Employee: Smith, Amy") + }) + + it("redirects away, without fetching rows, when the caller cannot maintain the list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + document.body.innerHTML = "" + // The maintain role is the list's own, so no static route guard can gate this page. + // The API rejects the writes regardless; this keeps a non-maintainer out of an editor + // whose every save would fail. + stubListInfo(false) + + mountPage() + await flushPromises() + + expect(mockReplace).toHaveBeenCalledWith({ name: "PersonnelHome" }) + expect(getPhoneListData).not.toHaveBeenCalled() + }) + + it("reloads the phone data when the dialog reports a save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubListInfo() + // RecordFormDialog's QDialog teleports to document.body, so clear any leftover content + // from a previous test's dialog before asserting on document.body.textContent. + document.body.innerHTML = "" + vi.mocked(getPhoneListData).mockResolvedValue([]) + const wrapper = mountPage() + await flushPromises() + const callsBeforeSave = vi.mocked(getPhoneListData).mock.calls.length + + await wrapper.findComponent(PhoneListAddRecordDialog).vm.$emit("saved", true) + await flushPromises() + + expect(vi.mocked(getPhoneListData).mock.calls.length).toBeGreaterThan(callsBeforeSave) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts b/VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts new file mode 100644 index 000000000..19ef8ea80 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-modified-date-service.test.ts @@ -0,0 +1,29 @@ +import { phoneListModifiedDateService } from "../services/phone-list-modified-date-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +describe("phoneListModifiedDateService()", () => { + it("returns the modified date scoped to the given list code", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: "2026-01-01T00:00:00" }) + + const result = await phoneListModifiedDateService.getModifiedDate("VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/modifiedDate")) + expect(result).toBe("2026-01-01T00:00:00") + }) + + it("returns null when there is no modified date on record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: null }) + + const result = await phoneListModifiedDateService.getModifiedDate("VMDO") + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts b/VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts new file mode 100644 index 000000000..7050e9922 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-route-changes.test.ts @@ -0,0 +1,119 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import { createRouter, createMemoryHistory } from "vue-router" +import PhoneList from "../pages/PhoneList.vue" +import PhoneListMaintain from "../pages/PhoneListMaintain.vue" +import { getPhoneListData } from "../composables/phone-list-data-fetch" +import { phoneListModifiedDateService } from "../services/phone-list-modified-date-service.ts" +import { phoneListService } from "../services/phone-list-service.ts" + +/** + * Every unit list renders through one :code route, so Vue Router reuses the page component when + * the code changes instead of remounting it - a mounted hook fires only for the first list. These + * tests drive a real router rather than a stubbed useRoute, because that reuse is exactly the + * behaviour at issue: a stubbed route cannot reproduce it. + * + * On the maintain page the stale-code case is worse than stale display, since listCode is what + * scopes every write the page sends. + */ + +vi.mock("../composables/phone-list-data-fetch", () => ({ + getPhoneListData: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/phone-list-modified-date-service.ts", () => ({ + phoneListModifiedDateService: { getModifiedDate: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-service.ts", () => ({ + phoneListService: { getPhoneListInfo: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-unit-service.ts", () => ({ + phoneListUnitService: { deleteUnitPersonData: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("@/composables/use-confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirmAction: vi.fn<(...args: unknown[]) => unknown>() }), +})) + +function stubList(code: string) { + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue({ + phoneListId: 1, + code, + name: `${code} Phone List`, + canMaintain: true, + canViewDirectPhone: true, + }) +} + +/** Mounts the page behind a real router so param-only navigation reuses the component. */ +async function mountUnderRouter(component: unknown) { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: "/Personnel/PhoneList/:code", component: component as never, name: "PhoneList" }, + { path: "/Personnel/", component: { template: "
" }, name: "PersonnelHome" }, + ], + }) + const wrapper = mount( + { template: "" }, + { global: { plugins: [[Quasar, { plugins: { Notify } }], router] } }, + ) + await router.push("/Personnel/PhoneList/VMDO") + await router.isReady() + await flushPromises() + return { router, wrapper } +} + +describe("phone list pages - changing the route code", () => { + it("refetches the newly named list when the code changes", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubList("VMDO") + vi.mocked(phoneListModifiedDateService.getModifiedDate).mockResolvedValue(null) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const { router } = await mountUnderRouter(PhoneList) + expect(phoneListService.getPhoneListInfo).toHaveBeenCalledWith("VMDO") + + stubList("OTHER") + await router.push("/Personnel/PhoneList/OTHER") + await flushPromises() + + expect(phoneListService.getPhoneListInfo).toHaveBeenLastCalledWith("OTHER") + expect(getPhoneListData).toHaveBeenLastCalledWith("OTHER", false, true) + }) + + it("shows the new list's name rather than the previous one", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubList("VMDO") + vi.mocked(phoneListModifiedDateService.getModifiedDate).mockResolvedValue(null) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const { router, wrapper } = await mountUnderRouter(PhoneList) + expect(wrapper.find("h1").text()).toBe("VMDO Phone List") + + stubList("OTHER") + await router.push("/Personnel/PhoneList/OTHER") + await flushPromises() + + expect(wrapper.find("h1").text()).toBe("OTHER Phone List") + }) + + it("rescopes the maintain page to the new list, so edits go to the right one", async () => { + expect.hasAssertions() + vi.clearAllMocks() + stubList("VMDO") + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const { router, wrapper } = await mountUnderRouter(PhoneListMaintain) + + stubList("OTHER") + await router.push("/Personnel/PhoneList/OTHER") + await flushPromises() + + // The dialog receives listCode and passes it to every save. A stale value here would + // write the edits for the new list into the previous one. + const dialog = wrapper.findComponent({ name: "PhoneListAddRecordDialog" }) + expect(dialog.props("listCode")).toBe("OTHER") + expect(getPhoneListData).toHaveBeenLastCalledWith("OTHER", true, true) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-service.test.ts b/VueApp/src/Personnel/__tests__/phone-list-service.test.ts new file mode 100644 index 000000000..31d54f57f --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-service.test.ts @@ -0,0 +1,66 @@ +import { phoneListService } from "../services/phone-list-service" + +/** + * The "null on failure" convention is what PhoneList.vue and PhoneListMaintain.vue branch on to + * decide whether to show a not-found banner, so a regression here would surface as a silently + * blank page rather than a caught error. Lookup is by the list's stable code, so a list renamed + * for display keeps resolving. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + }), +})) + +const listInfo = { + phoneListId: 5, + code: "VMDO", + name: "Dean's Office", + canMaintain: true, + canViewDirectPhone: true, +} + +describe("phoneListService()", () => { + it("returns the list info on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: listInfo }) + + const result = await phoneListService.getPhoneListInfo("VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO")) + expect(result).toStrictEqual(listInfo) + }) + + it("percent-encodes a code so it cannot break out of the path segment", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: listInfo }) + + await phoneListService.getPhoneListInfo("a/b c") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/a%2Fb%20c")) + }) + + it("returns null when the request fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await phoneListService.getPhoneListInfo("VMDO") + + expect(result).toBeNull() + }) + + it("returns null when the request succeeds but finds no matching list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: null }) + + const result = await phoneListService.getPhoneListInfo("Nonexistent") + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts b/VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts new file mode 100644 index 000000000..8dec9daed --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-unit-service.test.ts @@ -0,0 +1,92 @@ +import { phoneListUnitService } from "../services/phone-list-unit-service" + +/** + * Every call is addressed by the list's code. The API resolves that code to a list and runs its + * own permission check against that list, so the code in the path is what scopes the request - + * these tests pin that the code actually reaches the URL. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +const mockDel = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + del: (...args: unknown[]) => mockDel(...args), + }), +})) + +const formData = { + unitId: 1, + office: "", + employeeIam: "person01", + phone: "", + directPhone: "", + listFirst: false, +} + +describe("phoneListUnitService()", () => { + it("returns the API results for getUnitsByList", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const units = [ + { + phoneListUnitId: 1, + phoneListId: 1, + name: "Dean's Office", + sortOrder: null, + phoneList: null, + phoneListUnitPersons: [], + }, + ] + mockGet.mockResolvedValue({ success: true, result: units }) + + const result = await phoneListUnitService.getUnitsByList("VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/units")) + expect(result).toStrictEqual(units) + }) + + it("normalizes a null or empty getUnitsByList result to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await phoneListUnitService.getUnitsByList("VMDO") + + expect(result).toStrictEqual([]) + }) + + it("posts new unit-person data to the unitPerson endpoint for the given list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: true }) + + await phoneListUnitService.addUnitPersonData("VMDO", formData) + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/unitPerson"), formData) + }) + + it("puts updated unit-person data under the list code and record id", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPut.mockResolvedValue({ success: true, result: true }) + + await phoneListUnitService.updateUnitPersonData("VMDO", 7, formData) + + expect(mockPut).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/unitPerson/7"), formData) + }) + + it("deletes a unit-person record under the list code", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockDel.mockResolvedValue({ success: true, result: true }) + + await phoneListUnitService.deleteUnitPersonData("VMDO", 7) + + expect(mockDel).toHaveBeenCalledWith(expect.stringContaining("phonelist/VMDO/unitPerson/7")) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts b/VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts new file mode 100644 index 000000000..507875f2c --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list-unit-table.test.ts @@ -0,0 +1,115 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import PhoneListUnitTable from "../components/PhoneListUnitTable.vue" +import type { PhoneListDisplayRecord, PhoneListUnit } from "../types/phone-list-phone-types" + +/** + * PhoneListUnitTable is the UI enforcement point for the same maintain/internal-access + * permission model already covered at the service layer (PhoneListUnitService, + * PhonePersonLookupService): isMaintain gates the "add" button and swaps the name cell between + * a maintainer-facing default render and a viewer-facing mailto link (or plain text when the + * person has no mail id). + */ + +const cols = [ + { name: "name", label: "Name", field: "name", align: "left" as const }, + { name: "listFirst", label: "List First", field: "listFirst", align: "center" as const }, + { name: "edit", label: "Edit", field: "edit", align: "left" as const }, + { name: "delete", label: "Delete", field: "delete", align: "left" as const }, +] + +function makeRow(overrides: Partial = {}): PhoneListDisplayRecord { + return { + fullName: "Amy Smith", + name: "Smith, Amy", + employeeIam: "asmith", + employeeMailId: "asmith", + phone: "530-555-1000", + directPhone: "530-555-2000", + office: "Room 100", + listFirst: false, + unitPersonId: 1, + unitId: 10, + unitName: "Dean's Office", + modifiedBy: null, + modifiedDate: null, + ...overrides, + } +} + +function makeUnit(rows: PhoneListDisplayRecord[]): PhoneListUnit { + return { name: "Dean's Office", id: 10, cols, rows } +} + +function mountTable(props: { unit: PhoneListUnit; loading: boolean; isMaintain: boolean; search: string }) { + return mount(PhoneListUnitTable, { + props, + global: { plugins: [Quasar] }, + }) +} + +// QTable always renders a .q-table__title element from its own :title prop, so it can't +// distinguish the custom top-left slot (the "add" button) from the base title - check for the +// button itself instead. +function hasAddButton(wrapper: ReturnType): boolean { + return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add") +} + +describe("phoneListUnitTable.vue - isMaintain gating", () => { + it("shows the add button and skips the mailto-link cell when isMaintain is true", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([makeRow({ employeeMailId: "asmith" })]), + loading: false, + isMaintain: true, + search: "", + }) + + expect(hasAddButton(wrapper)).toBeTruthy() + expect(wrapper.find("a[href^='mailto:']").exists()).toBeFalsy() + }) + + it("hides the add button and links the name as a mailto anchor when the row has a mail id and isMaintain is false", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([makeRow({ employeeMailId: "asmith", name: "Smith, Amy" })]), + loading: false, + isMaintain: false, + search: "", + }) + + expect(hasAddButton(wrapper)).toBeFalsy() + const link = wrapper.find("a[href='mailto:asmith@ucdavis.edu']") + expect(link.exists()).toBeTruthy() + expect(link.text()).toBe("Smith, Amy") + }) + + it("shows plain text instead of a mailto link when the row has no mail id and isMaintain is false", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([makeRow({ employeeMailId: "", name: "Smith, Amy" })]), + loading: false, + isMaintain: false, + search: "", + }) + + expect(wrapper.find("a[href^='mailto:']").exists()).toBeFalsy() + expect(wrapper.text()).toContain("Smith, Amy") + }) + + it("shows a check icon only on the row where listFirst is true", () => { + expect.hasAssertions() + const wrapper = mountTable({ + unit: makeUnit([ + makeRow({ unitPersonId: 1, name: "First, Person", listFirst: true }), + makeRow({ unitPersonId: 2, name: "Second, Person", listFirst: false }), + ]), + loading: false, + isMaintain: true, + search: "", + }) + + const checkIcons = wrapper.findAllComponents({ name: "QIcon" }).filter((icon) => icon.props("name") === "check") + expect(checkIcons).toHaveLength(1) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-list.test.ts b/VueApp/src/Personnel/__tests__/phone-list.test.ts new file mode 100644 index 000000000..ba11ff7db --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-list.test.ts @@ -0,0 +1,126 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import PhoneList from "../pages/PhoneList.vue" +import { getPhoneListData } from "../composables/phone-list-data-fetch" +import { phoneListModifiedDateService } from "../services/phone-list-modified-date-service.ts" +import { phoneListService } from "../services/phone-list-service.ts" + +/** + * PhoneList is the read-only view of any unit list, resolved from the :code route param. It + * hides its "Updated"/internal banner while the initial fetch is in flight (v-if="!loading"), + * and within that block only shows the "FOR INTERNAL USE ONLY" notice for callers with + * direct-phone access - the UI-level counterpart of the canViewDirectPhone flag already covered + * at the composable/service layer. + */ + +vi.mock("../composables/phone-list-data-fetch", () => ({ + getPhoneListData: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/phone-list-modified-date-service.ts", () => ({ + phoneListModifiedDateService: { getModifiedDate: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/phone-list-service.ts", () => ({ + phoneListService: { getPhoneListInfo: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("vue-router", () => ({ + useRoute: () => ({ params: { code: "VMDO" } }), +})) + +// Never actually resolves, to simulate a fetch that's still in flight. +function neverResolves(): Promise { + // eslint-disable-next-line avoid-new, no-empty-function -- deliberately pending forever, to simulate an in-flight fetch + return new Promise(() => {}) +} + +function mountPage() { + return mount(PhoneList, { + global: { plugins: [[Quasar, { plugins: { Notify } }]] }, + }) +} + +function stubListInfo(canViewDirectPhone: boolean): void { + vi.clearAllMocks() + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue({ + phoneListId: 1, + code: "VMDO", + name: "Dean's Office Phone List", + canMaintain: false, + canViewDirectPhone, + }) + vi.mocked(phoneListModifiedDateService.getModifiedDate).mockResolvedValue(null) +} + +describe("phoneList.vue - loading, naming, and internal-use banner", () => { + it("hides the Updated/internal-use block while the initial fetch is in flight", async () => { + expect.hasAssertions() + stubListInfo(true) + // Never resolves, so the fetch stays in flight: loading flips true (in onMounted) and + // never flips back. + vi.mocked(getPhoneListData).mockReturnValue(neverResolves()) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).not.toContain("Click on a name to send an email") + expect(wrapper.text()).not.toContain("FOR INTERNAL USE ONLY") + }) + + it("shows the internal-use notice once loaded, for a caller with internal access", async () => { + expect.hasAssertions() + stubListInfo(true) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("Click on a name to send an email") + expect(wrapper.text()).toContain("FOR INTERNAL USE ONLY") + }) + + it("hides the internal-use notice once loaded, for a caller without internal access", async () => { + expect.hasAssertions() + stubListInfo(false) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("Click on a name to send an email") + expect(wrapper.text()).not.toContain("FOR INTERNAL USE ONLY") + }) + + it("takes its heading from the list rather than hard-coded page copy", async () => { + expect.hasAssertions() + stubListInfo(false) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.find("h1").text()).toBe("Dean's Office Phone List") + }) + + it("fetches the list named by the route param", async () => { + expect.hasAssertions() + stubListInfo(false) + vi.mocked(getPhoneListData).mockResolvedValue([]) + + mountPage() + await flushPromises() + + expect(phoneListService.getPhoneListInfo).toHaveBeenCalledWith("VMDO") + expect(getPhoneListData).toHaveBeenCalledWith("VMDO", false, false) + }) + + it("reports an unknown list code instead of rendering an empty list", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(phoneListService.getPhoneListInfo).mockResolvedValue(null) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("could not be found") + expect(getPhoneListData).not.toHaveBeenCalled() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts b/VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts new file mode 100644 index 000000000..0a248a120 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/phone-person-options-service.test.ts @@ -0,0 +1,69 @@ +import { searchPeopleOptions } from "../services/phone-person-options-service" + +/** + * SearchPeopleOptions deliberately returns null (not []) on a failed request, so + * usePersonSearch's callers can fall back safely via `result ?? []` while still distinguishing + * "no matches" from "the fetch failed" for anyone who cares to. + */ + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + createUrlSearchParams: (obj: Record) => { + const params = new URLSearchParams() + for (const [k, v] of Object.entries(obj)) { + if (v !== null && v !== undefined) { + params.append(k, v.toString()) + } + } + return params + }, + }), +})) + +describe("searchPeopleOptions()", () => { + it("returns the matching people, scoped to the given list code", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const people = [ + { + personId: 1, + firstName: "Amy", + lastName: "Smith", + fullName: "Amy Smith", + iamId: "asmith", + currentEmployee: true, + mailId: "asmith", + phoneData: null, + }, + ] + mockGet.mockResolvedValue({ success: true, result: people }) + + const result = await searchPeopleOptions("Smith", "VMDO") + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("search=Smith")) + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("listCode=VMDO")) + expect(result).toStrictEqual(people) + }) + + it("returns an empty array (not null) when the search matches nobody", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [] }) + + const result = await searchPeopleOptions("Nonexistent") + + expect(result).toStrictEqual([]) + }) + + it("returns null (not an empty array) when the request fails", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await searchPeopleOptions("Smith") + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/router-permissions.test.ts b/VueApp/src/Personnel/__tests__/router-permissions.test.ts new file mode 100644 index 000000000..e1278f456 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/router-permissions.test.ts @@ -0,0 +1,99 @@ +import { createPinia, setActivePinia } from "pinia" +import { useUserStore } from "@/store/UserStore" +import { router } from "../router" + +// The real beforeEach guard calls requireLogin, which hits the network and needs a Quasar/inject +// context. Stub it (Vitest hoists this above the imports) so the test exercises only the +// permission-driven redirect, not the auth plumbing. +vi.mock("@/composables/RequireLogin", () => ({ + useRequireLogin: () => ({ requireLogin: () => Promise.resolve(true) }), + getLoginUrl: () => ({ value: "" }), +})) + +// The guard also fetches SVMSecure.PhoneLists.* permissions on every non-internal navigation; +// stub it to resolve with no extra permissions so the test controls the permission set directly +// via the user store. +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +// Park on a neutral route first so the push to the target route is never a redundant +// navigation (which would resolve to a NavigationFailure and leave currentRoute unchanged). +async function goTo(path: string): Promise { + await router.push("/__reset__") + await router.push(path) +} + +function withPermissions(permissions: string[]) { + setActivePinia(createPinia()) + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: [] }) + useUserStore().setPermissions(permissions) +} + +describe("personnel router - permission gating", () => { + it("redirects a caller without SVMMaintain away from SVMPhonesMaintain", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/SVMPhonesMaintain") + + expect(router.currentRoute.value.name).toBe("PersonnelHome") + }) + + it("allows a caller with SVMMaintain onto SVMPhonesMaintain", async () => { + expect.hasAssertions() + withPermissions(["SVMSecure.PhoneLists.SVMMaintain"]) + + await goTo("/Personnel/SVMPhonesMaintain") + + expect(router.currentRoute.value.name).toBe("MaintainSchoolwidePhones") + }) + + it("allows navigation to unrestricted routes regardless of phone-list permissions", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/PhoneList/VMDO") + + expect(router.currentRoute.value.name).toBe("PhoneList") + }) + + it("routes any list code through the one generic phone-list page", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/PhoneList/SOMEOTHER") + + expect(router.currentRoute.value.name).toBe("PhoneList") + expect(router.currentRoute.value.params.code).toBe("SOMEOTHER") + }) + + it("lets the maintain route resolve without a static permission", async () => { + expect.hasAssertions() + // The required role is the list's own MaintainRole, so it cannot be known before the + // list is fetched. The page redirects on canMaintain=false and the API rejects writes; + // the router deliberately does not gate here. + withPermissions([]) + + await goTo("/Personnel/PhoneList/VMDO/Maintain") + + expect(router.currentRoute.value.name).toBe("MaintainPhoneList") + }) + + it("redirects the pre-Code VMDO paths so published links keep working", async () => { + expect.hasAssertions() + withPermissions([]) + + await goTo("/Personnel/VMDOPhones") + + expect(router.currentRoute.value.name).toBe("PhoneList") + expect(router.currentRoute.value.params.code).toBe("VMDO") + + await goTo("/Personnel/VMDOPhonesMaintain") + + expect(router.currentRoute.value.name).toBe("MaintainPhoneList") + expect(router.currentRoute.value.params.code).toBe("VMDO") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts b/VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts new file mode 100644 index 000000000..2a7956111 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-add-frequent-number-dialog.test.ts @@ -0,0 +1,104 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import SVMAddFrequentNumberDialog from "../components/SVMAddFrequentNumberDialog.vue" +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" +import type { SVMFrequentNumberRecord } from "../types/svm-phone-types" +import { apiResult } from "./test-utils" + +/** + * SVMAddFrequentNumberDialog is the one add/edit dialog with no custom validate() - + * useAddRecordDialog's validate always returns null, so both required fields (label, phone) are + * enforced entirely by QForm's own :rules. That's a different validation path than + * PhoneListAddRecordDialog/SVMAddRecordDialog exercise, where a custom validate() blocks submit. + */ + +vi.mock("../services/svm-frequent-number-service", () => ({ + svmFrequentNumberService: { + addFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + updateFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +function mountDialog(props: { modelValue: boolean; editFrequentData?: SVMFrequentNumberRecord | null }) { + return mount(SVMAddFrequentNumberDialog, { + props, + global: { plugins: [[Quasar, { plugins: { Notify } }]] }, + attachTo: document.body, + }) +} + +async function submitDialogForm(wrapper: ReturnType): Promise { + await flushPromises() + await wrapper.findComponent({ name: "QForm" }).find("form").trigger("submit") + await flushPromises() +} + +function bodyText(): string { + return document.body.textContent ?? "" +} + +function resetTestState(): void { + vi.clearAllMocks() + document.body.innerHTML = "" +} + +describe("sVMAddFrequentNumberDialog.vue - add vs edit mode", () => { + it("shows the Add Frequent Number title and an Upload button in add mode", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ modelValue: true }) + await flushPromises() + + expect(bodyText()).toContain("Add Frequent Number") + expect(bodyText()).toContain("Upload") + }) + + it("shows the Edit Frequent Number title, pre-filled fields, and a Save Changes button in edit mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + editFrequentData: { label: "Front Desk", phone: "530-555-1000", entryId: 5 }, + }) + await flushPromises() + + expect(bodyText()).toContain("Edit Frequent Number") + expect(bodyText()).toContain("Save Changes") + const inputs = wrapper.findAllComponents({ name: "QInput" }) + expect(inputs[0]!.props("modelValue")).toBe("Front Desk") + expect(inputs[1]!.props("modelValue")).toBe("530-555-1000") + }) + + it("blocks submit via QForm's own field rules when required fields are empty", async () => { + expect.hasAssertions() + resetTestState() + // No custom validate() exists on this dialog (unlike the other two), so this exercises + // QForm's native :rules gate instead of useAddRecordDialog's validate-then-block path. + const wrapper = mountDialog({ modelValue: true }) + + await submitDialogForm(wrapper) + + expect(svmFrequentNumberService.addFrequentNumber).not.toHaveBeenCalled() + expect(bodyText()).toContain("Please complete the required fields before saving.") + }) + + it("submits the update payload and emits saved + close in edit mode", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(svmFrequentNumberService.updateFrequentNumber).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + editFrequentData: { label: "Front Desk", phone: "530-555-1000", entryId: 5 }, + }) + + await submitDialogForm(wrapper) + + expect(svmFrequentNumberService.updateFrequentNumber).toHaveBeenCalledWith(5, { + label: "Front Desk", + phone: "530-555-1000", + entryId: 5, + }) + expect(wrapper.emitted("saved")).toBeTruthy() + expect(wrapper.emitted("update:modelValue")).toContainEqual([false]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts b/VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts new file mode 100644 index 000000000..bfa8c9ed9 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-add-record-dialog.test.ts @@ -0,0 +1,349 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { createRouter, createWebHistory } from "vue-router" +import { Quasar, Notify } from "quasar" +import { createPinia, setActivePinia } from "pinia" +import SVMAddRecordDialog from "../components/SVMAddRecordDialog.vue" +import { svmUnitService } from "../services/svm-unit-service" +import type { QSelectOption } from "quasar" +import type { SVMPhoneDisplayRecord, UnitAdminStaff, UnitFaxNumber, UnitOptions } from "../types/svm-phone-types" +import { apiResult } from "./test-utils" + +/** + * SVMAddRecordDialog covers add vs edit flows for a section-scoped SVM record. Unlike + * PhoneListAddRecordDialog, the Unit field is a real Quasar q-select gated by its own :rules + * (add mode only - edit mode swaps it for plain text), while the dean/director requirement is + * enforced by useAddRecordDialog's custom validate(), same as the other dialogs. Editing lets + * these tests reach that custom validation gate directly via editData, without needing to + * simulate picking an option in the real q-select. These tests mount the real dialog + + * RecordFormDialog + QForm and mock svmUnitService so no network call happens. + */ + +vi.mock("../services/svm-unit-service", () => ({ + svmUnitService: { + addUnitData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitData: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) + +const selectorStub = { + props: ["modelValue", "label", "listCode"], + emits: ["update:modelValue"], + template: "
", +} + +// Mirror the props the component actually declares, so a prop-shape change is a compile error +// here rather than a structural near-miss the mount silently accepts. +function mountDialog(props: { + modelValue: boolean + section: QSelectOption + units: UnitOptions[] + unitFaxNumbers: UnitFaxNumber[] + unitAdminStaff?: UnitAdminStaff[] + editData?: SVMPhoneDisplayRecord | null +}) { + const { unitAdminStaff = [], ...rest } = props + const pinia = createPinia() + setActivePinia(pinia) + const router = createRouter({ + history: createWebHistory(), + routes: [{ path: "/", component: { template: "
" } }], + }) + return mount(SVMAddRecordDialog, { + props: { ...rest, unitAdminStaff }, + global: { + plugins: [[Quasar, { plugins: { Notify } }], router, pinia], + stubs: { PersonSelector: selectorStub }, + }, + attachTo: document.body, + }) +} + +function editRecord(overrides: Partial = {}): SVMPhoneDisplayRecord { + return { + sectionName: "VMDO", + unitName: "Dean's Office", + unitId: 10, + unitAbbrv: "DO", + officeLocation: "Room 100", + officeFax: "530-555-9999", + deanDirectorFullName: "Dean Person", + deanDirectorDisplayName: "Dean Person", + deanDirectorInterim: null, + deanDirectorIam: "dean01", + deanDirectorUnitPersonId: 1, + deanDirectorPhone: "530-555-1000", + deanDirectorModifiedDate: null, + deanDirectorModifiedBy: "jdoe", + adminStaffFullName: "Staff Person", + adminStaffDisplayName: "Staff Person", + adminStaffInterim: null, + adminStaffIam: "staff01", + adminStaffUnitPersonId: 2, + adminStaffPhone: "530-555-2000", + adminStaffModifiedDate: null, + adminStaffModifiedBy: "jdoe", + entryId: 1, + isOnlyRowForUnit: true, + ...overrides, + } +} + +async function submitDialogForm(wrapper: ReturnType): Promise { + await flushPromises() + await wrapper.findComponent({ name: "QForm" }).find("form").trigger("submit") + await flushPromises() +} + +function bodyText(): string { + return document.body.textContent ?? "" +} + +function resetTestState(): void { + vi.clearAllMocks() + document.body.innerHTML = "" +} + +describe("sVMAddRecordDialog.vue - add vs edit mode", () => { + it("shows the Add Phone Record title, Upload button, and the Unit q-select in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + }) + await flushPromises() + + expect(bodyText()).toContain("Add Phone Record") + expect(bodyText()).toContain("Upload") + // The Unit q-select is add-mode-only; deanDirectorInterim/staffInterim selects are always + // present, so 3 selects (vs. 2 in edit mode) confirms it rendered. + expect(wrapper.findAllComponents({ name: "QSelect" })).toHaveLength(3) + }) + + it("hides the unit/modified-by summary text in add mode", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + }) + await flushPromises() + + expect(bodyText()).not.toContain("Unit: ") + expect(bodyText()).not.toContain("Dean/Director Modified") + expect(bodyText()).not.toContain("Admin Staff Modified") + }) + + it("autofills the fax from the picked unit and submits it under the selected unit in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [{ section: 1, units: [{ label: "Dean's Office", value: "10" }] }], + unitFaxNumbers: [{ unitId: 10, fax: "530-555-9999" }], + }) + await flushPromises() + + // UnitId (number) vs. the q-select option's value (string "10"): a prior regression + // compared these directly and always missed, silently skipping the fax autofill. + await wrapper + .findAllComponents({ name: "QSelect" })[0]! + .vm.$emit("update:model-value", { label: "Dean's Office", value: "10" }) + await flushPromises() + const [deanSelector] = wrapper.findAllComponents(selectorStub) + await deanSelector!.vm.$emit("update:model-value", { iamId: "dean01", fullName: "Dean Person" }) + vi.mocked(svmUnitService.addUnitData).mockResolvedValue(apiResult({ result: true })) + + await submitDialogForm(wrapper) + + expect(svmUnitService.addUnitData).toHaveBeenCalledWith("10", expect.objectContaining({ fax: "530-555-9999" })) + }) +}) + +// Split from the describe above so neither function grows past the linter's line-count budget. +describe("sVMAddRecordDialog.vue - admin staff autofill in add mode", () => { + it("autofills the admin staff from the picked unit's existing staff record in add mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [{ section: 1, units: [{ label: "Dean's Office", value: "10" }] }], + unitFaxNumbers: [], + unitAdminStaff: [ + { + unitId: 10, + staffIam: "staff01", + staffFullName: "Staff Person", + staffPhone: "530-555-2000", + staffInterim: "Interim", + staffUnitPersonId: 2, + }, + ], + }) + await flushPromises() + + await wrapper + .findAllComponents({ name: "QSelect" })[0]! + .vm.$emit("update:model-value", { label: "Dean's Office", value: "10" }) + await flushPromises() + + // Adding another leader to a unit that already has staff should start the form with that + // staff record - same as edit mode - rather than leaving it for the user to look up again. + const [deanSelector, staffSelector] = wrapper.findAllComponents(selectorStub) + expect(staffSelector!.props("modelValue")).toMatchObject({ iamId: "staff01", fullName: "Staff Person" }) + + await deanSelector!.vm.$emit("update:model-value", { iamId: "dean01", fullName: "Dean Person" }) + vi.mocked(svmUnitService.addUnitData).mockResolvedValue(apiResult({ result: true })) + await submitDialogForm(wrapper) + + expect(svmUnitService.addUnitData).toHaveBeenCalledWith( + "10", + expect.objectContaining({ + staffIam: "staff01", + staffPhone: "530-555-2000", + staffInterim: "Interim", + staffUnitPerson: 2, + }), + ) + }) + + it("clears the admin staff fields when switching to a unit with no existing staff record", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [ + { + section: 1, + units: [ + { label: "Dean's Office", value: "10" }, + { label: "Anatomy", value: "20" }, + ], + }, + ], + unitFaxNumbers: [], + unitAdminStaff: [ + { + unitId: 10, + staffIam: "staff01", + staffFullName: "Staff Person", + staffPhone: "530-555-2000", + staffInterim: "", + staffUnitPersonId: 2, + }, + ], + }) + await flushPromises() + const [unitSelect] = wrapper.findAllComponents({ name: "QSelect" }) + + await unitSelect!.vm.$emit("update:model-value", { label: "Dean's Office", value: "10" }) + await flushPromises() + await unitSelect!.vm.$emit("update:model-value", { label: "Anatomy", value: "20" }) + await flushPromises() + + // Otherwise unit 10's staff would silently be carried over and saved under unit 20. + const [deanSelector, staffSelector] = wrapper.findAllComponents(selectorStub) + expect(staffSelector!.props("modelValue")).toMatchObject({ iamId: "", fullName: "" }) + + await deanSelector!.vm.$emit("update:model-value", { iamId: "dean02", fullName: "Other Dean" }) + vi.mocked(svmUnitService.addUnitData).mockResolvedValue(apiResult({ result: true })) + await submitDialogForm(wrapper) + + expect(svmUnitService.addUnitData).toHaveBeenCalledWith( + "20", + expect.objectContaining({ staffIam: "", staffPhone: "", staffUnitPerson: -1 }), + ) + }) +}) + +describe("sVMAddRecordDialog.vue - edit mode", () => { + it("shows the Edit Phone Record title, unit summary, and Save Changes button in edit mode", async () => { + expect.hasAssertions() + resetTestState() + mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + await flushPromises() + + expect(bodyText()).toContain("Edit Phone Record") + expect(bodyText()).toContain("Unit: Dean's Office") + expect(bodyText()).toContain("Save Changes") + }) + + it("shows the modified-by summary and hides the Unit q-select in edit mode", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + await flushPromises() + + expect(bodyText()).toContain("Dean/Director Modified") + expect(bodyText()).toContain("Admin Staff Modified") + expect(bodyText()).toContain("by jdoe") + expect(wrapper.findAllComponents({ name: "QSelect" })).toHaveLength(2) + }) + + it("blocks submit with a validation message when the record has no leadership", async () => { + expect.hasAssertions() + resetTestState() + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord({ deanDirectorIam: "", deanDirectorFullName: "" }), + }) + + await submitDialogForm(wrapper) + + expect(svmUnitService.updateUnitData).not.toHaveBeenCalled() + expect(bodyText()).toContain("Must specify leadership.") + }) + + it("submits the update payload and emits saved + close in edit mode", async () => { + expect.hasAssertions() + resetTestState() + vi.mocked(svmUnitService.updateUnitData).mockResolvedValue(apiResult({ result: true })) + const wrapper = mountDialog({ + modelValue: true, + section: { label: "VMDO", value: "1" }, + units: [], + unitFaxNumbers: [], + editData: editRecord(), + }) + + await submitDialogForm(wrapper) + + expect(svmUnitService.updateUnitData).toHaveBeenCalledWith( + 10, + expect.objectContaining({ + fax: "530-555-9999", + location: "Room 100", + deanIam: "dean01", + deanPhone: "530-555-1000", + deanUnitPerson: 1, + staffIam: "staff01", + staffPhone: "530-555-2000", + staffUnitPerson: 2, + }), + ) + expect(wrapper.emitted("saved")).toBeTruthy() + expect(wrapper.emitted("update:modelValue")).toContainEqual([false]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts b/VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts new file mode 100644 index 000000000..4257ee22c --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-data-fetch.test.ts @@ -0,0 +1,372 @@ +import { getFrequentlyCalledNumbers, getSVMData } from "../composables/svm-data-fetch" +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" +import { svmSectionService } from "../services/svm-section-service" +import { svmUnitService } from "../services/svm-unit-service" +import type { + SVMFrequentNumberAPIResponse, + SVMSectionAPIResponse, + SVMUnitAPIResponse, + SVMUnitPerson, +} from "../types/svm-phone-types" + +vi.mock("../services/svm-section-service", () => ({ + svmSectionService: { getSections: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/svm-unit-service", () => ({ + svmUnitService: { getAllUnits: vi.fn<(...args: unknown[]) => unknown>() }, +})) +vi.mock("../services/svm-frequent-number-service", () => ({ + svmFrequentNumberService: { getFrequentNumbers: vi.fn<(...args: unknown[]) => unknown>() }, +})) + +function makeSection(overrides: Partial = {}): SVMSectionAPIResponse { + return { + sectionId: 1, + name: "VMDO", + includeAbbrv: false, + unitName: "Unit", + directorTitle: "Director", + sortOrder: 1, + ...overrides, + } +} + +function makeUnitPerson(overrides: Partial = {}): SVMUnitPerson { + return { + unitPersonId: 1, + unitId: 10, + personIam: "dean01", + office: "Room 100", + posType: "Dean", + interim: null, + modifiedDate: null, + modifiedBy: null, + unit: null, + person: { + personIam: "dean01", + phone: "530-555-1000", + directPhone: "", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: 1, + firstName: "Dean", + lastName: "Person", + fullName: "Dean Person", + iamId: "dean01", + currentEmployee: true, + mailId: "", + }, + viperModPerson: null, + }, + viperModPerson: null, + ...overrides, + } +} + +function makeUnit(overrides: Partial = {}): SVMUnitAPIResponse { + return { + unitId: 10, + sectionId: 1, + name: "Dean's Office", + abbrv: "DO", + sortOrder: 1, + fax: "530-555-9999", + section: null, + unitPersons: [], + ...overrides, + } +} + +/** A unit whose only active person is admin staff - no dean/director row. */ +function staffOnlyUnit(unitId: number, unitPersonId: number, iamId: string): SVMUnitAPIResponse { + return makeUnit({ + unitId, + name: `Unit ${unitId}`, + unitPersons: [ + makeUnitPerson({ + unitPersonId, + unitId, + personIam: iamId, + posType: "Staff", + person: { + personIam: iamId, + phone: "530-555-2000", + directPhone: "", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: unitPersonId, + firstName: "Staff", + lastName: iamId, + fullName: `Staff ${iamId}`, + iamId, + currentEmployee: true, + mailId: "", + }, + viperModPerson: null, + }, + }), + ], + }) +} + +describe("getSVMData()", () => { + it("builds view-mode columns with location/phone/fax fields, and edit-mode columns with edit/delete", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([]) + + const view = await getSVMData(false) + const edit = await getSVMData(true) + + const viewCols = view.newSections[0]!.cols!.map((c) => c.name) + const editCols = edit.newSections[0]!.cols!.map((c) => c.name) + expect(viewCols).toStrictEqual([ + "unitName", + "location", + "deanDirector", + "dirPhone", + "fax", + "adminStaff", + "adminPhone", + ]) + expect(editCols).toStrictEqual(["unitName", "deanDirector", "adminStaff", "edit", "delete"]) + }) + + it("includes the abbreviation column only in view mode when the section flags includeAbbrv", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection({ includeAbbrv: true })]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([]) + + const view = await getSVMData(false) + const edit = await getSVMData(true) + + expect(view.newSections[0]!.cols!.map((c) => c.name)).toContain("abbreviation") + expect(edit.newSections[0]!.cols!.map((c) => c.name)).not.toContain("abbreviation") + }) + + it("splits leaders from the shared admin staff record and appends the interim suffix", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ + unitPersons: [ + makeUnitPerson({ interim: "Interim" }), + makeUnitPerson({ + unitPersonId: 2, + personIam: "staff01", + posType: "Staff", + person: { + personIam: "staff01", + phone: "530-555-2000", + directPhone: "", + office: "Room 100", + modifiedDate: null, + modifiedBy: null, + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: { + personId: 2, + firstName: "Staff", + lastName: "Person", + fullName: "Staff Person", + iamId: "staff01", + currentEmployee: true, + mailId: "", + }, + viperModPerson: null, + }, + }), + ], + }), + ]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.deanDirectorDisplayName).toBe("Dean Person (Interim)") + expect(row.adminStaffDisplayName).toBe("Staff Person") + expect(row.adminStaffIam).toBe("staff01") + }) + + it("produces no rows, without throwing, when a unit has no unitPersons", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: null })]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toStrictEqual([]) + }) + + it("blanks the admin staff fields when a unit has a leader but no assigned staff", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: [makeUnitPerson()] })]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.adminStaffDisplayName).toBe("") + expect(row.adminStaffUnitPersonId).toBe(-1) + }) + + it("falls back to an empty person, without throwing, when a leader's person record is null", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitPersons: [makeUnitPerson({ person: null })] }), + ]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.deanDirectorFullName).toBe("") + expect(row.deanDirectorIam).toBe("") + expect(row.deanDirectorPhone).toBe("") + }) + + it("keeps a staff-only unit visible by standing in a blank dean/director", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([staffOnlyUnit(10, 7, "staff01")]) + + const { newSections } = await getSVMData(false) + + // Rows are emitted per leader, so without the stand-in this unit would produce no row at + // all and the staff member would silently vanish from the list. + expect(newSections[0]!.rows).toHaveLength(1) + const row = newSections[0]!.rows[0]! + expect(row.adminStaffIam).toBe("staff01") + expect(row.adminStaffUnitPersonId).toBe(7) + expect(row.deanDirectorDisplayName).toBe("") + // Must stay -1 so SVMPhonesMaintain's delete guard skips the nonexistent dean row. + expect(row.deanDirectorUnitPersonId).toBe(-1) + }) + + it("gives staff-only rows distinct row keys", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + staffOnlyUnit(10, 7, "staff01"), + staffOnlyUnit(11, 8, "staff02"), + ]) + + const { newSections } = await getSVMData(false) + + // The q-table row-key comes from entryId. The stand-in leader carries -1, so falling back + // to the staff's id is what keeps two staff-only units in one section from colliding and + // letting Vue reuse one row's DOM for the other. + const keys = newSections[0]!.rows.map((r) => r.entryId) + expect(keys).toStrictEqual([7, 8]) + }) + + it("flags a row as the last for its unit only when the unit yields one row", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ + unitPersons: [ + makeUnitPerson({ unitPersonId: 1, personIam: "dean01" }), + makeUnitPerson({ unitPersonId: 2, personIam: "dean02" }), + ], + }), + ]) + + const { newSections: twoLeaders } = await getSVMData(false) + + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([makeSection()]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([makeUnit({ unitPersons: [makeUnitPerson()] })]) + + const { newSections: oneLeader } = await getSVMData(false) + + // The delete confirmation reads this to decide whether to name the admin staff as being + // removed, since the API keeps them while any leader row still lists them. + expect(twoLeaders[0]!.rows.map((r) => r.isOnlyRowForUnit)).toStrictEqual([false, false]) + expect(oneLeader[0]!.rows[0]!.isOnlyRowForUnit).toBeTruthy() + }) +}) + +describe("getSVMData() - section grouping", () => { + it("fetches units once and groups them onto their own sections", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([ + makeSection({ sectionId: 1, name: "VMDO" }), + makeSection({ sectionId: 2, name: "Departments" }), + ]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitId: 10, sectionId: 1, name: "Dean's Office", unitPersons: [makeUnitPerson()] }), + makeUnit({ + unitId: 20, + sectionId: 2, + name: "Anatomy", + unitPersons: [makeUnitPerson({ unitPersonId: 5, unitId: 20 })], + }), + ]) + + const { newSections, newUnitOptions } = await getSVMData(false) + + // One request for the whole list rather than one per section, so page load no longer + // costs a round trip per section. + expect(svmUnitService.getAllUnits).toHaveBeenCalledOnce() + expect(newSections[0]!.rows.map((r) => r.unitName)).toStrictEqual(["Dean's Office"]) + expect(newSections[1]!.rows.map((r) => r.unitName)).toStrictEqual(["Anatomy"]) + expect(newUnitOptions).toStrictEqual([ + { section: 1, units: [{ label: "Dean's Office", value: "10" }] }, + { section: 2, units: [{ label: "Anatomy", value: "20" }] }, + ]) + }) + + it("leaves a section with no units empty rather than borrowing another section's", async () => { + expect.hasAssertions() + vi.clearAllMocks() + vi.mocked(svmSectionService.getSections).mockResolvedValue([ + makeSection({ sectionId: 1, name: "VMDO" }), + makeSection({ sectionId: 2, name: "Departments" }), + ]) + vi.mocked(svmUnitService.getAllUnits).mockResolvedValue([ + makeUnit({ unitId: 20, sectionId: 2, unitPersons: [makeUnitPerson({ unitId: 20 })] }), + ]) + + const { newSections } = await getSVMData(false) + + expect(newSections[0]!.rows).toStrictEqual([]) + expect(newSections[1]!.rows).toHaveLength(1) + }) +}) + +describe("getFrequentlyCalledNumbers()", () => { + it("maps numberId to entryId", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const apiResponse: SVMFrequentNumberAPIResponse[] = [ + { numberId: 5, label: "Front Desk", phone: "530-555-1000", sortOrder: null }, + ] + vi.mocked(svmFrequentNumberService.getFrequentNumbers).mockResolvedValue(apiResponse) + + const results = await getFrequentlyCalledNumbers() + + expect(results).toStrictEqual([{ label: "Front Desk", phone: "530-555-1000", entryId: 5 }]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts b/VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts new file mode 100644 index 000000000..4fa5760af --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-frequent-number-service.test.ts @@ -0,0 +1,46 @@ +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: vi.fn<(...args: unknown[]) => unknown>(), + del: vi.fn<(...args: unknown[]) => unknown>(), + }), +})) + +describe("svmFrequentNumberService()", () => { + it("returns the frequent numbers on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const numbers = [{ numberId: 1, label: "Front Desk", phone: "530-555-1000", sortOrder: null }] + mockGet.mockResolvedValue({ success: true, result: numbers }) + + const result = await svmFrequentNumberService.getFrequentNumbers() + + expect(result).toStrictEqual(numbers) + }) + + it("normalizes a failed request to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await svmFrequentNumberService.getFrequentNumbers() + + expect(result).toStrictEqual([]) + }) + + it("posts new frequent number data to the base endpoint", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: true }) + const formData = { label: "Front Desk", phone: "530-555-1000", entryId: -1 } + + await svmFrequentNumberService.addFrequentNumber(formData) + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("frequentnumbers"), formData) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts b/VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts new file mode 100644 index 000000000..b7e27a8f3 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-frequent-number-table.test.ts @@ -0,0 +1,35 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import SVMFrequentNumberTable from "../components/SVMFrequentNumberTable.vue" +import type { SVMFrequentNumberRecord } from "../types/svm-phone-types" + +const numbers: SVMFrequentNumberRecord[] = [{ label: "Front Desk", phone: "530-555-1000", entryId: 1 }] + +function mountTable(editRecords: boolean) { + return mount(SVMFrequentNumberTable, { + props: { frequentNumbers: numbers, loading: false, editRecords, search: "" }, + global: { plugins: [Quasar] }, + }) +} + +function hasAddButton(wrapper: ReturnType): boolean { + return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add") +} + +describe("sVMFrequentNumberTable.vue - editRecords gating", () => { + it("shows the add button and edit/delete action buttons when editRecords is true", () => { + expect.hasAssertions() + const wrapper = mountTable(true) + + expect(hasAddButton(wrapper)).toBeTruthy() + expect(wrapper.findAllComponents({ name: "RecordActionButton" })).toHaveLength(2) + }) + + it("hides the add button and edit/delete action buttons when editRecords is false", () => { + expect.hasAssertions() + const wrapper = mountTable(false) + + expect(hasAddButton(wrapper)).toBeFalsy() + expect(wrapper.findAllComponents({ name: "RecordActionButton" })).toHaveLength(0) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts b/VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts new file mode 100644 index 000000000..5f76d3bf0 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-modified-date-service.test.ts @@ -0,0 +1,29 @@ +import { svmModifiedDateService } from "../services/svm-modified-date-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +describe("svmModifiedDateService()", () => { + it("returns the modified date", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: "2026-01-01T00:00:00" }) + + const result = await svmModifiedDateService.getModifiedDate() + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("svm/modifiedDate")) + expect(result).toBe("2026-01-01T00:00:00") + }) + + it("returns null when there is no modified date on record", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: true, result: null }) + + const result = await svmModifiedDateService.getModifiedDate() + + expect(result).toBeNull() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts b/VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts new file mode 100644 index 000000000..0c04f56ac --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-phone-section-table.test.ts @@ -0,0 +1,37 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import SVMPhoneSectionTable from "../components/SVMPhoneSectionTable.vue" +import type { SVMPhoneSection } from "../types/svm-phone-types" + +const cols = [{ name: "unitName", label: "Unit", field: "unitName", align: "left" as const }] + +function makeSection(): SVMPhoneSection { + return { title: "VMDO", id: 1, cols, rows: [] } +} + +function mountTable(isModify: boolean) { + return mount(SVMPhoneSectionTable, { + props: { section: makeSection(), loading: false, isModify, search: "" }, + global: { plugins: [Quasar] }, + }) +} + +function hasAddButton(wrapper: ReturnType): boolean { + return wrapper.findAllComponents({ name: "QBtn" }).some((btn) => btn.props("icon") === "add") +} + +describe("sVMPhoneSectionTable.vue - isModify gating", () => { + it("shows the add button when isModify is true", () => { + expect.hasAssertions() + const wrapper = mountTable(true) + + expect(hasAddButton(wrapper)).toBeTruthy() + }) + + it("hides the add button when isModify is false", () => { + expect.hasAssertions() + const wrapper = mountTable(false) + + expect(hasAddButton(wrapper)).toBeFalsy() + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts b/VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts new file mode 100644 index 000000000..53ecf36f6 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-phones-maintain.test.ts @@ -0,0 +1,384 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar, Notify } from "quasar" +import SVMPhonesMaintain from "../pages/SVMPhonesMaintain.vue" +import SVMAddRecordDialog from "../components/SVMAddRecordDialog.vue" +import SVMAddFrequentNumberDialog from "../components/SVMAddFrequentNumberDialog.vue" +import SVMFrequentNumberTable from "../components/SVMFrequentNumberTable.vue" +import { getFrequentlyCalledNumbers, getSVMData } from "../composables/svm-data-fetch.ts" +import { svmUnitService } from "../services/svm-unit-service" +import { svmFrequentNumberService } from "../services/svm-frequent-number-service.ts" +import type { SVMFrequentNumberRecord, SVMPhoneDisplayRecord, SVMPhoneSection } from "../types/svm-phone-types" +import { apiError, apiResult } from "./test-utils" + +/** + * SVMPhonesMaintain reports delete outcomes as toasts rather than as a page banner, since the + * reload that follows a delete would wipe page-local state before the user read it. Reaching one + * means driving the real child chain (SVMPhoneSectionTable / SVMFrequentNumberTable -> + * RecordActionButton -> the confirm dialog -> the service), because the page exposes no state a + * test could set directly. + * + * The page maintains two independent lists - unit rows and frequently called numbers - through + * near-identical add/edit/delete paths, so both are exercised here. + */ + +vi.mock("../composables/svm-data-fetch.ts", () => ({ + getSVMData: vi.fn<(...args: unknown[]) => unknown>(), + getFrequentlyCalledNumbers: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/svm-unit-service", () => ({ + svmUnitService: { + addUnitData: vi.fn<(...args: unknown[]) => unknown>(), + updateUnitData: vi.fn<(...args: unknown[]) => unknown>(), + deleteRow: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) +vi.mock("../services/svm-frequent-number-service.ts", () => ({ + svmFrequentNumberService: { + addFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + updateFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + deleteFrequentNumber: vi.fn<(...args: unknown[]) => unknown>(), + }, +})) +// Stub only the public useQuasar export, so the toasts the page raises can be asserted directly. +// Quasar components resolve $q through their own internals, so QTable and friends still render. +const { mockNotify } = vi.hoisted(() => ({ mockNotify: vi.fn<(...args: unknown[]) => unknown>() })) +vi.mock("quasar", async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, useQuasar: () => ({ notify: mockNotify }) } +}) + +const { mockConfirmAction } = vi.hoisted(() => ({ + mockConfirmAction: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("@/composables/use-confirm-dialog", () => ({ + useConfirmDialog: () => ({ confirmAction: mockConfirmAction }), +})) + +function sectionWithDeletableRow(rowOverrides: Partial = {}): SVMPhoneSection { + return { + title: "VMDO", + id: 1, + cols: [ + { name: "unitName", label: "Unit", field: "unitName", align: "left" }, + { name: "edit", label: "Edit", field: "edit", align: "left" }, + { name: "delete", label: "Delete", field: "delete", align: "left" }, + ], + rows: [ + { + sectionName: "VMDO", + unitName: "Dean's Office", + unitId: 10, + unitAbbrv: "DO", + officeLocation: "Room 100", + officeFax: "530-555-9999", + deanDirectorFullName: "Dean Person", + deanDirectorDisplayName: "Dean Person", + deanDirectorInterim: null, + deanDirectorIam: "dean01", + deanDirectorUnitPersonId: 1, + deanDirectorPhone: "530-555-1000", + deanDirectorModifiedDate: null, + deanDirectorModifiedBy: null, + adminStaffFullName: null, + adminStaffDisplayName: "", + adminStaffInterim: null, + adminStaffIam: null, + adminStaffUnitPersonId: null, + adminStaffPhone: null, + adminStaffModifiedDate: null, + adminStaffModifiedBy: null, + entryId: 1, + isOnlyRowForUnit: true, + ...rowOverrides, + }, + ], + } +} + +const frequentNumber: SVMFrequentNumberRecord = { label: "Front Desk", phone: "530-555-1000", entryId: 7 } + +const personSelectorStub = { + props: ["modelValue", "label", "listId"], + emits: ["update:modelValue"], + template: "
", +} + +/** + * Mounts the page with the data the test cares about, waiting out the onMounted load. + * Anything not named here loads empty. + */ +async function mountPage(data: { sections?: SVMPhoneSection[]; frequentNumbers?: SVMFrequentNumberRecord[] } = {}) { + vi.mocked(getSVMData).mockResolvedValue({ + newSections: data.sections ?? [], + newUnitOptions: [], + newUnitFaxNumbers: [], + newUnitAdminStaff: [], + }) + vi.mocked(getFrequentlyCalledNumbers).mockResolvedValue(data.frequentNumbers ?? []) + + const wrapper = mount(SVMPhonesMaintain, { + global: { + plugins: [[Quasar, { plugins: { Notify } }]], + stubs: { PersonSelector: personSelectorStub }, + }, + }) + await flushPromises() + return wrapper +} + +type Page = Awaited> + +function findAddButton(root: Pick) { + return root.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "add") +} + +/** Clicks an edit/delete button within one table, so the two tables' buttons cannot be confused. */ +async function clickAction(root: Pick, action: "edit" | "delete"): Promise { + const button = root.findAllComponents({ name: "RecordActionButton" }).find((btn) => btn.props("action") === action) + expect(button).toBeTruthy() + await button!.vm.$emit("action") + await flushPromises() +} + +/** + * Per-test reset, called as the first line of each test rather than from a beforeEach, since + * vitest/no-hooks is on and the rest of the suite keeps its setup inside the test body. + * Pass false to decline the confirmation dialog. + */ +function resetMocks(confirmed = true) { + vi.clearAllMocks() + mockConfirmAction.mockResolvedValue(confirmed) + // RecordFormDialog's QDialog teleports to document.body, which outlives the wrapper, so a + // previous test's dialog would still be there when asserting on document.body.textContent. + document.body.innerHTML = "" +} + +describe("sVMPhonesMaintain.vue - delete outcomes", () => { + it("raises no toast on a normal load", async () => { + expect.hasAssertions() + resetMocks() + + await mountPage() + + expect(mockNotify).not.toHaveBeenCalled() + }) + + it("raises a toast carrying the server message when a delete fails", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmUnitService.deleteRow).mockResolvedValue(apiError(["Failed to delete record"])) + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await clickAction(wrapper, "delete") + + // A failed delete is transient, so it is reported as a toast rather than a banner, + // which would otherwise persist past the reload that follows. + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "negative", message: "Failed to delete record" }), + ) + }) + + it("opens the add dialog scoped to the clicked section", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await findAddButton(wrapper.findComponent({ name: "SVMPhoneSectionTable" }))!.trigger("click") + await flushPromises() + + const dialog = wrapper.findComponent(SVMAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + expect(dialog.props("section")).toStrictEqual({ label: "VMDO", value: "1" }) + }) + + it("opens the edit dialog pre-filled when edit is clicked on a row", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await clickAction(wrapper.findComponent({ name: "SVMPhoneSectionTable" }), "edit") + + const dialog = wrapper.findComponent(SVMAddRecordDialog) + expect(dialog.props("modelValue")).toBeTruthy() + // RecordFormDialog's QDialog teleports its content to document.body. + expect(document.body.textContent).toContain("Edit Phone Record") + expect(document.body.textContent).toContain("Unit: Dean's Office") + }) + + it("reloads the phone data when the dialog reports a save", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage() + const callsBeforeSave = vi.mocked(getSVMData).mock.calls.length + + await wrapper.findComponent(SVMAddRecordDialog).vm.$emit("saved", true) + await flushPromises() + + expect(vi.mocked(getSVMData).mock.calls.length).toBeGreaterThan(callsBeforeSave) + }) +}) + +describe("sVMPhonesMaintain.vue - delete confirmation", () => { + it("names both people in the delete confirmation when the row is the unit's last", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ + sections: [sectionWithDeletableRow({ adminStaffFullName: "Staff Person" })], + }) + + await clickAction(wrapper, "delete") + + // IsOnlyRowForUnit means no other row is left to list the admin staff, so this delete + // really does remove both. + expect(mockConfirmAction).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Dean Person and Staff Person") }), + ) + }) + + it("names only the dean/director when another row still lists the admin staff", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ + sections: [sectionWithDeletableRow({ adminStaffFullName: "Staff Person", isOnlyRowForUnit: false })], + }) + + await clickAction(wrapper, "delete") + + // The admin staff survives this delete, so promising their removal would be wrong. + const [confirmArgs] = vi.mocked(mockConfirmAction).mock.calls[0] as [{ message: string }] + expect(confirmArgs.message).toContain("Dean Person") + expect(confirmArgs.message).not.toContain("Staff Person") + }) + + it("does not delete anything when the confirmation is declined", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ sections: [sectionWithDeletableRow()] }) + + await clickAction(wrapper, "delete") + + expect(svmUnitService.deleteRow).not.toHaveBeenCalled() + }) + + it("deletes the row by its row key, in a single call", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmUnitService.deleteRow).mockResolvedValue(apiResult({ result: true })) + const wrapper = await mountPage({ + sections: [sectionWithDeletableRow({ adminStaffFullName: "Staff Person" })], + }) + + await clickAction(wrapper, "delete") + + // One call, not one per underlying record: the leader and the admin staff are removed + // together server-side, so the pair cannot half-apply. + expect(svmUnitService.deleteRow).toHaveBeenCalledExactlyOnceWith(1) + }) +}) + +describe("sVMPhonesMaintain.vue - frequently called numbers", () => { + it("opens the frequent number dialog in add mode", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await findAddButton(wrapper.findComponent(SVMFrequentNumberTable))!.trigger("click") + await flushPromises() + + const dialog = wrapper.findComponent(SVMAddFrequentNumberDialog) + expect(dialog.props("modelValue")).toBeTruthy() + // Null rather than a leftover row: the dialog reads this prop to decide add vs edit. + expect(dialog.props("editFrequentData")).toBeNull() + }) + + it("opens the frequent number dialog pre-filled when edit is clicked on a row", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "edit") + + const dialog = wrapper.findComponent(SVMAddFrequentNumberDialog) + expect(dialog.props("modelValue")).toBeTruthy() + expect(dialog.props("editFrequentData")).toStrictEqual(frequentNumber) + }) + + it("clears the edited row when the dialog closes, so the next add starts blank", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "edit") + + // Closing the dialog is what resets the edit target. Without it, the next add would open + // pre-filled with this row and overwrite it on save. + await wrapper.findComponent(SVMAddFrequentNumberDialog).vm.$emit("update:modelValue", false) + await flushPromises() + + expect(wrapper.findComponent(SVMAddFrequentNumberDialog).props("editFrequentData")).toBeNull() + }) + + it("names the number's location in the delete confirmation", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(mockConfirmAction).toHaveBeenCalledWith( + expect.objectContaining({ message: expect.stringContaining("Front Desk") }), + ) + }) + + it("does not delete a frequent number when the confirmation is declined", async () => { + expect.hasAssertions() + resetMocks(false) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(svmFrequentNumberService.deleteFrequentNumber).not.toHaveBeenCalled() + }) + + it("deletes the frequent number by its entry id and reloads", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmFrequentNumberService.deleteFrequentNumber).mockResolvedValue(apiResult({ result: true })) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + const callsBeforeDelete = vi.mocked(getFrequentlyCalledNumbers).mock.calls.length + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(svmFrequentNumberService.deleteFrequentNumber).toHaveBeenCalledExactlyOnceWith(7) + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "positive", message: "Record deleted" }), + ) + expect(vi.mocked(getFrequentlyCalledNumbers).mock.calls.length).toBeGreaterThan(callsBeforeDelete) + }) + + it("raises a toast carrying the server message when a frequent number delete fails", async () => { + expect.hasAssertions() + resetMocks() + vi.mocked(svmFrequentNumberService.deleteFrequentNumber).mockResolvedValue( + apiError(["Failed to delete record"]), + ) + const wrapper = await mountPage({ frequentNumbers: [frequentNumber] }) + + await clickAction(wrapper.findComponent(SVMFrequentNumberTable), "delete") + + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ type: "negative", message: "Failed to delete record" }), + ) + }) + + it("reloads the phone data when the frequent number dialog reports a save", async () => { + expect.hasAssertions() + resetMocks() + const wrapper = await mountPage() + const callsBeforeSave = vi.mocked(getFrequentlyCalledNumbers).mock.calls.length + + await wrapper.findComponent(SVMAddFrequentNumberDialog).vm.$emit("saved", true) + await flushPromises() + + expect(vi.mocked(getFrequentlyCalledNumbers).mock.calls.length).toBeGreaterThan(callsBeforeSave) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-phones.test.ts b/VueApp/src/Personnel/__tests__/svm-phones.test.ts new file mode 100644 index 000000000..5b66c13fb --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-phones.test.ts @@ -0,0 +1,66 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import SVMPhones from "../pages/SVMPhones.vue" +import { getFrequentlyCalledNumbers, getSVMData } from "../composables/svm-data-fetch" +import { svmModifiedDateService } from "../services/svm-modified-date-service.ts" + +/** + * SVMPhones hides its "Updated" line while the initial fetch is in flight (v-if="!loading"). + * Unlike PhoneList, the SVM list has no isInternal-gated content: DirectPhone is always + * blanked for SVM data (see PhoneSVMUnitService), so there's no internal-use banner here. + */ + +vi.mock("../composables/svm-data-fetch", () => ({ + getSVMData: vi.fn<(...args: unknown[]) => unknown>(), + getFrequentlyCalledNumbers: vi.fn<(...args: unknown[]) => unknown>(), +})) +vi.mock("../services/svm-modified-date-service.ts", () => ({ + svmModifiedDateService: { getModifiedDate: vi.fn<(...args: unknown[]) => unknown>() }, +})) + +// Never actually resolves, to simulate a fetch that's still in flight. +function neverResolves(): Promise { + // eslint-disable-next-line avoid-new, no-empty-function -- deliberately pending forever, to simulate an in-flight fetch + return new Promise(() => {}) +} + +function mountPage() { + return mount(SVMPhones, { + global: { plugins: [Quasar] }, + }) +} + +function stubDataServices(): void { + vi.clearAllMocks() + vi.mocked(getFrequentlyCalledNumbers).mockResolvedValue([]) + vi.mocked(svmModifiedDateService.getModifiedDate).mockResolvedValue(null) +} + +describe("sVMPhones.vue - loading", () => { + it("hides the Updated line while the initial fetch is in flight", async () => { + expect.hasAssertions() + stubDataServices() + vi.mocked(getSVMData).mockReturnValue(neverResolves()) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).not.toContain("Updated") + }) + + it("shows the Updated line once the initial fetch resolves", async () => { + expect.hasAssertions() + stubDataServices() + vi.mocked(getSVMData).mockResolvedValue({ + newSections: [], + newUnitOptions: [], + newUnitFaxNumbers: [], + newUnitAdminStaff: [], + }) + + const wrapper = mountPage() + await flushPromises() + + expect(wrapper.text()).toContain("Updated") + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-section-service.test.ts b/VueApp/src/Personnel/__tests__/svm-section-service.test.ts new file mode 100644 index 000000000..2f7b930f5 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-section-service.test.ts @@ -0,0 +1,31 @@ +import { svmSectionService } from "../services/svm-section-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ get: (...args: unknown[]) => mockGet(...args) }), +})) + +describe("svmSectionService()", () => { + it("returns the sections on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const sections = [ + { sectionId: 1, name: "VMDO", includeAbbrv: false, unitName: null, directorTitle: "Dean", sortOrder: 1 }, + ] + mockGet.mockResolvedValue({ success: true, result: sections }) + + const result = await svmSectionService.getSections() + + expect(result).toStrictEqual(sections) + }) + + it("normalizes a failed request to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await svmSectionService.getSections() + + expect(result).toStrictEqual([]) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/svm-unit-service.test.ts b/VueApp/src/Personnel/__tests__/svm-unit-service.test.ts new file mode 100644 index 000000000..efead17ef --- /dev/null +++ b/VueApp/src/Personnel/__tests__/svm-unit-service.test.ts @@ -0,0 +1,81 @@ +import { svmUnitService } from "../services/svm-unit-service" + +const mockGet = vi.fn<(...args: unknown[]) => unknown>() +const mockPost = vi.fn<(...args: unknown[]) => unknown>() +const mockPut = vi.fn<(...args: unknown[]) => unknown>() +const mockDel = vi.fn<(...args: unknown[]) => unknown>() +vi.mock("@/composables/ViperFetch", () => ({ + useFetch: () => ({ + get: (...args: unknown[]) => mockGet(...args), + post: (...args: unknown[]) => mockPost(...args), + put: (...args: unknown[]) => mockPut(...args), + del: (...args: unknown[]) => mockDel(...args), + }), +})) + +describe("svmUnitService()", () => { + it("returns the units for a section on success", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const units = [ + { + unitId: 10, + sectionId: 1, + name: "Dean's Office", + abbrv: "DO", + sortOrder: null, + fax: null, + section: null, + unitPersons: null, + }, + ] + mockGet.mockResolvedValue({ success: true, result: units }) + + const result = await svmUnitService.getAllUnits() + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining("svm/units")) + expect(result).toStrictEqual(units) + }) + + it("normalizes a failed request to an empty array", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockGet.mockResolvedValue({ success: false, result: null }) + + const result = await svmUnitService.getAllUnits() + + expect(result).toStrictEqual([]) + }) + + it("posts new unit data to the units endpoint", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockPost.mockResolvedValue({ success: true, result: true }) + const dto = { + fax: "", + location: "", + deanIam: "dean01", + deanPhone: "", + deanInterim: "", + deanUnitPerson: -1, + staffIam: "", + staffPhone: "", + staffInterim: "", + staffUnitPerson: -1, + } + + await svmUnitService.addUnitData(10, dto) + + expect(mockPost).toHaveBeenCalledWith(expect.stringContaining("units/10"), dto) + }) + + it("deletes a list row by its row key", async () => { + expect.hasAssertions() + vi.clearAllMocks() + mockDel.mockResolvedValue({ success: true, result: true }) + + await svmUnitService.deleteRow(7) + + expect(mockDel).toHaveBeenCalledWith(expect.stringContaining("rows/7")) + }) +}) diff --git a/VueApp/src/Personnel/__tests__/test-utils.ts b/VueApp/src/Personnel/__tests__/test-utils.ts new file mode 100644 index 000000000..c0bb86f73 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/test-utils.ts @@ -0,0 +1,26 @@ +import type { Result } from "@/composables/ViperFetch" + +/** + * Builds a full ViperFetch Result for mocking a service call. + * + * The service layer resolves to the whole Result, not just the interesting fields, so a mock + * that supplies only `success`/`result`/`errors` fails to type-check against the real return + * type. This fills in the plumbing fields the tests never assert on. + */ +function apiResult(overrides: Partial = {}): Result { + return { + result: null, + errors: [], + success: true, + pagination: null, + status: 200, + ...overrides, + } +} + +/** A failed call carrying server-supplied error messages. */ +function apiError(errors: string[]): Result { + return apiResult({ success: false, errors, status: 400 }) +} + +export { apiResult, apiError } diff --git a/VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts b/VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts new file mode 100644 index 000000000..9226b9633 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/use-add-record-dialog.test.ts @@ -0,0 +1,111 @@ +import { useAddRecordDialog } from "../composables/use-add-record-dialog" +import type { SaveOutcome } from "../composables/use-add-record-dialog" + +const { mockNotify } = vi.hoisted(() => ({ + mockNotify: vi.fn<(...args: unknown[]) => unknown>(), +})) + +vi.mock("quasar", () => ({ + useQuasar: () => ({ notify: mockNotify }), +})) + +type Form = { label: string } + +// Match the real sendSave signature rather than a loose (...args: unknown[]) mock, so a change +// to what the composable passes its save callback is a compile error here instead of a silent +// mismatch that the mock happily absorbs. +type SendSave = (form: Form, isEdit: boolean) => Promise + +function makeSendSave(outcome?: SaveOutcome) { + const mock = vi.fn() + if (outcome) { + mock.mockResolvedValue(outcome) + } + return mock +} + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +// A controllable pending promise, so a save can be held open mid-flight. The `as` cast lets +// `resolve` be filled in by the executor without a separate uninitialized declaration. +function createDeferred(): Deferred { + const deferred = {} as Deferred + // eslint-disable-next-line avoid-new -- a controllable pending promise is the point of this helper + deferred.promise = new Promise((resolve) => { + deferred.resolve = resolve + }) + return deferred +} + +function buildOptions(overrides: Partial>[0]> = {}) { + return { + editData: () => null, + emptyForm: () => ({ label: "" }), + formFromEditData: () => ({ label: "" }), + validate: () => null, + sendSave: makeSendSave({ success: true, result: { id: 1 }, errors: null }), + onSaved: vi.fn<(result: unknown) => void>(), + onClose: vi.fn<() => void>(), + ...overrides, + } +} + +describe("useAddRecordDialog()", () => { + it("surfaces the validation error and never calls sendSave when the form is invalid", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const sendSave = makeSendSave() + const { save, formError } = useAddRecordDialog(buildOptions({ validate: () => "Label is required.", sendSave })) + + await save() + + expect(sendSave).not.toHaveBeenCalled() + expect(formError.value).toBe("Label is required.") + }) + + it("notifies, saves the result, and closes the dialog on a successful save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const onSaved = vi.fn<(result: unknown) => void>() + const onClose = vi.fn<() => void>() + const sendSave = makeSendSave({ success: true, result: { id: 42 }, errors: null }) + const { save } = useAddRecordDialog(buildOptions({ sendSave, onSaved, onClose })) + + await save() + + expect(onSaved).toHaveBeenCalledWith({ id: 42 }) + expect(onClose).toHaveBeenCalledWith() + expect(mockNotify).toHaveBeenCalledWith(expect.objectContaining({ type: "positive" })) + }) + + it("surfaces the server error and leaves the dialog open on a failed save", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const onSaved = vi.fn<(result: unknown) => void>() + const onClose = vi.fn<() => void>() + const sendSave = makeSendSave({ success: false, errors: ["Phone number is already in use."] }) + const { save, formError } = useAddRecordDialog(buildOptions({ sendSave, onSaved, onClose })) + + await save() + + expect(formError.value).toBe("Phone number is already in use.") + expect(onSaved).not.toHaveBeenCalled() + expect(onClose).not.toHaveBeenCalled() + }) + + it("ignores a second save call while the first is still in flight", async () => { + expect.hasAssertions() + vi.clearAllMocks() + const deferredSave = createDeferred() + const sendSave = vi.fn(() => deferredSave.promise) + const { save } = useAddRecordDialog(buildOptions({ sendSave })) + + const firstSave = save() + await save() + + expect(sendSave).toHaveBeenCalledOnce() + + deferredSave.resolve({ success: true, result: null, errors: null }) + await firstSave + }) +}) diff --git a/VueApp/src/Personnel/components/PersonSelector.vue b/VueApp/src/Personnel/components/PersonSelector.vue new file mode 100644 index 000000000..d253e4c3b --- /dev/null +++ b/VueApp/src/Personnel/components/PersonSelector.vue @@ -0,0 +1,63 @@ + + + diff --git a/VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue b/VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue new file mode 100644 index 000000000..73bdaf6a2 --- /dev/null +++ b/VueApp/src/Personnel/components/PhoneListAddRecordDialog.vue @@ -0,0 +1,173 @@ + + + diff --git a/VueApp/src/Personnel/components/PhoneListUnitTable.vue b/VueApp/src/Personnel/components/PhoneListUnitTable.vue new file mode 100644 index 000000000..f19f7f309 --- /dev/null +++ b/VueApp/src/Personnel/components/PhoneListUnitTable.vue @@ -0,0 +1,86 @@ + + + diff --git a/VueApp/src/Personnel/components/RecordActionButton.vue b/VueApp/src/Personnel/components/RecordActionButton.vue new file mode 100644 index 000000000..0cfce5a64 --- /dev/null +++ b/VueApp/src/Personnel/components/RecordActionButton.vue @@ -0,0 +1,28 @@ + + + diff --git a/VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue b/VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue new file mode 100644 index 000000000..c2848866c --- /dev/null +++ b/VueApp/src/Personnel/components/SVMAddFrequentNumberDialog.vue @@ -0,0 +1,99 @@ + + + diff --git a/VueApp/src/Personnel/components/SVMAddRecordDialog.vue b/VueApp/src/Personnel/components/SVMAddRecordDialog.vue new file mode 100644 index 000000000..e35c70372 --- /dev/null +++ b/VueApp/src/Personnel/components/SVMAddRecordDialog.vue @@ -0,0 +1,274 @@ + + + diff --git a/VueApp/src/Personnel/components/SVMFrequentNumberTable.vue b/VueApp/src/Personnel/components/SVMFrequentNumberTable.vue new file mode 100644 index 000000000..128af6a9a --- /dev/null +++ b/VueApp/src/Personnel/components/SVMFrequentNumberTable.vue @@ -0,0 +1,73 @@ + + + diff --git a/VueApp/src/Personnel/components/SVMPhoneSectionTable.vue b/VueApp/src/Personnel/components/SVMPhoneSectionTable.vue new file mode 100644 index 000000000..418dbcbdc --- /dev/null +++ b/VueApp/src/Personnel/components/SVMPhoneSectionTable.vue @@ -0,0 +1,60 @@ + + + diff --git a/VueApp/src/Personnel/composables/phone-list-data-fetch.ts b/VueApp/src/Personnel/composables/phone-list-data-fetch.ts new file mode 100644 index 000000000..ad6b902c1 --- /dev/null +++ b/VueApp/src/Personnel/composables/phone-list-data-fetch.ts @@ -0,0 +1,89 @@ +import { phoneListUnitService } from "../services/phone-list-unit-service" +import { getSparseAugmentedViperPerson } from "./use-person-helper" +import type { QTableProps } from "quasar" +import type { PhoneListUnit, PhoneListDisplayRecord } from "../types/phone-list-phone-types" + +// Retrieve all units and associated people for the given list code (e.g., VDMO). +// The data included depends on whether this is for editing or displaying the list. +// isInternal also affects displayed columns, but the backend independently +// enforces permissions to ensure no unintended data gets through. +async function getPhoneListData(code: string, isEdit: boolean, isInternal: boolean) { + const units: PhoneListUnit[] = [] + const r = await phoneListUnitService.getUnitsByList(code) + for (const unit of r) { + const rows: PhoneListDisplayRecord[] = [] + const cols: QTableProps["columns"] = [ + { name: "name", label: "Name", field: "name", align: "left", sortable: true }, + { name: "phone", label: "Phone", field: "phone", align: "left", sortable: false }, + ] + if (isInternal || isEdit) { + cols.push({ + name: "directPhone", + label: "Direct Phone", + field: "directPhone", + align: "left", + sortable: false, + }) + } + cols.push({ name: "office", label: "Office", field: "office", align: "left", sortable: false }) + if (isEdit) { + cols.push( + { + name: "listFirst", + label: "List First", + field: "listFirst", + align: "center", + sortable: false, + }, + { + name: "edit", + label: "Edit", + field: "edit", + align: "left", + sortable: false, + }, + { + name: "delete", + label: "Delete", + field: "delete", + align: "left", + sortable: false, + }, + ) + } + for (const unitPerson of unit.phoneListUnitPersons) { + // If a person is no longer an active employee, + // person or viperPerson may be null. + // Display only active employees. + if (unitPerson.person !== null) { + if (unitPerson.person.viperPerson === null) { + unitPerson.person.viperPerson = getSparseAugmentedViperPerson() + } + rows.push({ + unitId: unit.phoneListUnitId, + unitPersonId: unitPerson.phoneListUnitPersonId, + fullName: unitPerson.person.viperPerson.fullName, + name: `${unitPerson.person.viperPerson.lastName}, ${unitPerson.person.viperPerson.firstName}`, + employeeIam: unitPerson.person.personIam, + employeeMailId: unitPerson.person.viperPerson.mailId, + phone: unitPerson.person.phone, + directPhone: unitPerson.person.directPhone, + office: unitPerson.person.office, + listFirst: unitPerson.listFirst, + unitName: unit.name, + modifiedBy: unitPerson.viperModPerson?.fullName ?? "", + modifiedDate: unitPerson.modifiedDate, + }) + } + } + units.push({ + name: unit.name, + id: unit.phoneListUnitId, + cols, + rows, + }) + } + return units +} + +export { getPhoneListData } diff --git a/VueApp/src/Personnel/composables/svm-data-fetch.ts b/VueApp/src/Personnel/composables/svm-data-fetch.ts new file mode 100644 index 000000000..59b255e1a --- /dev/null +++ b/VueApp/src/Personnel/composables/svm-data-fetch.ts @@ -0,0 +1,276 @@ +import { svmFrequentNumberService } from "../services/svm-frequent-number-service" +import { svmSectionService } from "../services/svm-section-service" +import { svmUnitService } from "../services/svm-unit-service" +import { getEmptyPhonePerson, getEmptySVMUnitPerson, getSparseAugmentedViperPerson } from "./use-person-helper" +import type { QSelectOption, QTableProps } from "quasar" +import type { + SVMFrequentNumberAPIResponse, + SVMFrequentNumberRecord, + SVMPhoneDisplayRecord, + SVMPhoneSection, + SVMSectionAPIResponse, + SVMUnitAPIResponse, + SVMUnitPerson, + UnitAdminStaff, + UnitFaxNumber, + UnitOptions, +} from "../types/svm-phone-types" + +// Creates a framework for the sections in the SVM list, accounting for different +// columns in edit mode. +async function getSections(isEdit: boolean): Promise { + const sections: SVMPhoneSection[] = [] + const r: SVMSectionAPIResponse[] = await svmSectionService.getSections() + r.forEach((result: SVMSectionAPIResponse) => { + const cols: QTableProps["columns"] = [] + let { unitName } = result + if (!unitName) { + unitName = "" + } + if (isEdit) { + cols.push( + { name: "unitName", label: unitName, field: "unitName", align: "left", sortable: true }, + { + name: "deanDirector", + label: result.directorTitle, + field: "deanDirectorDisplayName", + align: "left", + sortable: true, + }, + { + name: "adminStaff", + label: "Admin Staff", + field: "adminStaffDisplayName", + align: "left", + sortable: true, + }, + { name: "edit", label: "Edit", field: "edit", align: "left" }, + { name: "delete", label: "Delete", field: "delete", align: "left" }, + ) + } else { + cols.push({ + name: "unitName", + label: unitName, + field: "unitName", + align: "left", + sortable: true, + }) + if (result.includeAbbrv) { + cols.push({ + name: "abbreviation", + label: "Abbrv", + field: "unitAbbrv", + align: "left", + sortable: true, + }) + } + cols.push( + { name: "location", label: "Location", field: "officeLocation", align: "left", sortable: true }, + { + name: "deanDirector", + label: result.directorTitle, + field: "deanDirectorDisplayName", + align: "left", + sortable: true, + }, + { name: "dirPhone", label: "Phone", field: "deanDirectorPhone", align: "left" }, + { name: "fax", label: "Fax", field: "officeFax", align: "left" }, + { + name: "adminStaff", + label: "Admin Staff", + field: "adminStaffDisplayName", + align: "left", + sortable: true, + }, + { name: "adminPhone", label: "Phone", field: "adminStaffPhone", align: "left" }, + ) + } + sections.push({ + rows: [], + cols, + title: result.name, + id: result.sectionId, + }) + }) + return sections +} + +// Helper function to full in empty/default values for the person +// associated with an SVMUnitPerson, if needed. +function populateEmptyPerson(unitPerson: SVMUnitPerson): SVMUnitPerson { + if (unitPerson.person === null) { + unitPerson.person = getEmptyPhonePerson() + } + if (unitPerson.person.viperPerson === null) { + unitPerson.person.viperPerson = getSparseAugmentedViperPerson() + } + return unitPerson +} + +// Returns data about the given admin staff for inclusion in a row in an SVM table. +function getAdminStaffData(staff: SVMUnitPerson | null) { + if (staff === null) { + return { + adminStaffFullName: "", + adminStaffDisplayName: "", + adminStaffInterim: "", + adminStaffIam: "", + adminStaffUnitPersonId: -1, + adminStaffPhone: "", + adminStaffModifiedBy: null, + adminStaffModifiedDate: null, + } + } + let staffDisplayName = "" + // Ensures staff.person.viperPerson is not null. + const populatedStaff = populateEmptyPerson(staff) + staffDisplayName = populatedStaff.person!.viperPerson!.fullName + if (populatedStaff.interim) { + staffDisplayName += ` (${populatedStaff.interim})` + } + return { + adminStaffFullName: staff.person!.viperPerson!.fullName, + adminStaffDisplayName: staffDisplayName, + adminStaffInterim: staff.interim ?? "", + adminStaffIam: staff.person!.personIam, + adminStaffUnitPersonId: staff.unitPersonId, + adminStaffPhone: staff.person!.phone ?? "", + adminStaffModifiedBy: staff.person!.viperModPerson?.fullName ?? null, + adminStaffModifiedDate: staff.person!.modifiedDate, + } +} + +// Helper function to return the staff data data to populate into an add dialog box, +// or an empty array if no data should be populated. +function unitAdminStaffEntries( + unitId: number, + staff: SVMUnitPerson | null, + adminStaffPartialRow: ReturnType, +): UnitAdminStaff[] { + if (staff === null) { + return [] + } + return [ + { + unitId, + staffIam: adminStaffPartialRow.adminStaffIam, + staffFullName: adminStaffPartialRow.adminStaffFullName, + staffPhone: adminStaffPartialRow.adminStaffPhone, + staffInterim: adminStaffPartialRow.adminStaffInterim, + staffUnitPersonId: adminStaffPartialRow.adminStaffUnitPersonId, + }, + ] +} + +// Populates and returns the data used to populate the SVM phone list and +// to autocomplete fields when adding or editing rows. +// Autopopulated data includes a unit's fax number and admin staff. +async function getSVMData(isEdit: boolean) { + const unitOptions: UnitOptions[] = [] + const unitFaxNumbers: UnitFaxNumber[] = [] + const unitAdminStaff: UnitAdminStaff[] = [] + const [sections, allUnits] = await Promise.all([getSections(isEdit), svmUnitService.getAllUnits()]) + const unitsBySection = new Map() + for (const unit of allUnits) { + const sectionUnits = unitsBySection.get(unit.sectionId) + if (sectionUnits === undefined) { + unitsBySection.set(unit.sectionId, [unit]) + } else { + sectionUnits.push(unit) + } + } + + for (const section of sections) { + const r = unitsBySection.get(section.id) ?? [] + const units: QSelectOption[] = [] + const rows: SVMPhoneDisplayRecord[] = [] + r.forEach((result: SVMUnitAPIResponse) => { + units.push({ label: result.name ?? "", value: result.unitId.toString() }) + unitFaxNumbers.push({ unitId: result.unitId, fax: result.fax ?? "" }) + if (result.unitPersons !== null) { + const leaders: SVMUnitPerson[] = [] + let staff: SVMUnitPerson | null = null + for (const unitPerson of result.unitPersons) { + if (unitPerson.posType === "Staff") { + staff = unitPerson + } else if (unitPerson?.posType) { + leaders.push(unitPerson) + } + } + const adminStaffPartialRow = getAdminStaffData(staff) + // Lets the add dialog auto-populate the admin staff fields when adding another + // leader to a unit that already has one. + unitAdminStaff.push(...unitAdminStaffEntries(result.unitId, staff, adminStaffPartialRow)) + // The front end prevents new units from having a director but no admin staff, + // but if the listed director is no longer a current employee, + // we still want to display the admin staff and show that there is no active director. + if (leaders.length === 0 && staff !== null) { + leaders.push(getEmptySVMUnitPerson(result.unitId)) + } + for (let leader of leaders) { + // Ensures leader.person.viperPerson is not null. + leader = populateEmptyPerson(leader) + let leaderDisplayName = leader.person!.viperPerson!.fullName + if (leader.interim) { + leaderDisplayName += ` (${leader.interim})` + } + const partialRow = { + sectionName: section.title, + unitName: result.name, + unitId: result.unitId, + unitAbbrv: result.abbrv, + officeLocation: leader.office, + officeFax: result.fax, + deanDirectorFullName: leader.person!.viperPerson!.fullName, + deanDirectorDisplayName: leaderDisplayName, + deanDirectorInterim: leader.interim ?? "", + deanDirectorIam: leader.person!.personIam, + deanDirectorUnitPersonId: leader.unitPersonId, + deanDirectorPhone: leader.person!.phone ?? "", + deanDirectorModifiedBy: leader.person!.viperModPerson?.fullName ?? null, + deanDirectorModifiedDate: leader.person!.modifiedDate, + // The admin staff belongs to the unit, so the API keeps them until no + // leader row is left. Recording that here lets the delete confirmation + // name everyone it is about to remove. + isOnlyRowForUnit: leaders.length === 1, + } + // Ensure that row-key is unique by using leader's unitPersonId if it exists, + // or staff's if not. At least one should exist. If not, there are no + // people for this row, so hide it. + const entryId = leader.unitPersonId === -1 ? staff?.unitPersonId : leader.unitPersonId + if (entryId !== undefined && entryId !== -1) { + const entryIdObj = { entryId } + rows.push({ ...partialRow, ...adminStaffPartialRow, ...entryIdObj }) + } + } + } + }) + unitOptions.push({ section: section.id, units }) + section.rows = rows + } + return { + newSections: sections, + newUnitOptions: unitOptions, + newUnitFaxNumbers: unitFaxNumbers, + newUnitAdminStaff: unitAdminStaff, + } +} + +// Queries and returns the frequently called numbers displayed at the bottom +// of SVM phone list pages. +async function getFrequentlyCalledNumbers(): Promise { + const rows: SVMFrequentNumberRecord[] = [] + + const r = await svmFrequentNumberService.getFrequentNumbers() + r.forEach((result: SVMFrequentNumberAPIResponse) => { + rows.push({ + label: result.label, + phone: result.phone, + entryId: result.numberId, + }) + }) + + return rows +} + +export { getSVMData, getFrequentlyCalledNumbers } diff --git a/VueApp/src/Personnel/composables/use-add-record-dialog.ts b/VueApp/src/Personnel/composables/use-add-record-dialog.ts new file mode 100644 index 000000000..cb97d21b0 --- /dev/null +++ b/VueApp/src/Personnel/composables/use-add-record-dialog.ts @@ -0,0 +1,110 @@ +import { computed, ref, watch } from "vue" +import { useQuasar } from "quasar" +import type { Ref } from "vue" + +interface SaveOutcome { + success: boolean + result?: any + errors: string[] | null +} + +interface UseAddRecordDialogOptions { + editData: () => TEditData | null | undefined + /** Watched to reset the form when the enclosing context (e.g. unit/section) changes. Omit if there is none. */ + resetOn?: () => unknown + emptyForm: () => TForm + formFromEditData: () => TForm + validate: (form: TForm) => string | null + sendSave: (form: TForm, isEdit: boolean) => Promise + onSaved: (result: any) => void + onClose: () => void + /** Used in the default save-failure message: "Failed to save/upload {recordLabel}". */ + recordLabel?: string +} + +/** + * Shared submit lifecycle for the phone-area "add/edit record" dialogs (PhoneList, SVM, + * SVM frequent numbers). + */ +export function useAddRecordDialog({ + editData, + resetOn, + emptyForm, + formFromEditData, + validate, + sendSave, + onSaved, + onClose, + recordLabel = "phone record", +}: UseAddRecordDialogOptions) { + const $q = useQuasar() + + const saving = ref(false) + const formError = ref("") + const isEdit = computed(() => editData() !== undefined && editData() !== null) + + const form = ref(isEdit.value ? formFromEditData() : emptyForm()) as Ref + + watch(editData, () => { + form.value = formFromEditData() + }) + if (resetOn) { + watch(resetOn, () => { + form.value = emptyForm() + }) + } + + function resetForm() { + form.value = emptyForm() + formError.value = "" + } + + function onValidationError() { + formError.value = "Please complete the required fields before saving." + } + + function reportSaveError(res: { errors: string[] | null }) { + formError.value = res.errors?.[0] ?? `Failed to ${isEdit.value ? "save" : "upload"} ${recordLabel}` + } + + async function save() { + // The saving flag flips synchronously before the first await, so a second submit - a + // double click, or Enter while the button is already spinning - returns here instead + // of sending the record twice. + if (saving.value) { + return + } + formError.value = "" + + const validationError = validate(form.value) + if (validationError) { + reportSaveError({ errors: [validationError] }) + return + } + + saving.value = true + const res = await sendSave(form.value, isEdit.value) + saving.value = false + + if (!res.success) { + reportSaveError(res) + return + } + + $q.notify({ type: "positive", message: isEdit.value ? "Phone record updated" : "Phone record created" }) + onSaved(res.result) + onClose() + } + + return { + form, + saving, + formError, + isEdit, + save, + resetForm, + onValidationError, + } +} + +export type { SaveOutcome } diff --git a/VueApp/src/Personnel/composables/use-person-helper.ts b/VueApp/src/Personnel/composables/use-person-helper.ts new file mode 100644 index 000000000..e0dfad859 --- /dev/null +++ b/VueApp/src/Personnel/composables/use-person-helper.ts @@ -0,0 +1,50 @@ +import type { PhonePerson, AugmentedViperPerson } from "../types/phone-types" +import type { SVMUnitPerson } from "../types/svm-phone-types" + +// Helper functions to populate empty or nearly-empty AugmentedViperPersons, +// PhonePersons, and SVMUnitPerson. +function getEmptySVMUnitPerson(unitId: number): SVMUnitPerson { + return { + unitPersonId: -1, + unitId, + personIam: "", + office: "", + posType: "", + interim: "", + modifiedDate: null, + modifiedBy: "", + unit: null, + person: getEmptyPhonePerson(), + viperModPerson: null, + } +} + +function getEmptyPhonePerson(): PhonePerson { + return { + personIam: "", + phone: "", + directPhone: "", + office: "", + modifiedDate: null, + modifiedBy: "", + unitPersons: null, + phoneListUnitPersons: null, + viperPerson: getSparseAugmentedViperPerson(), + viperModPerson: getSparseAugmentedViperPerson(), + } +} + +function getSparseAugmentedViperPerson(fullName = "", iamId = ""): AugmentedViperPerson { + return { + personId: -1, + firstName: "", + lastName: "", + fullName, + iamId, + currentEmployee: true, + mailId: "", + phoneData: null, + } +} + +export { getEmptySVMUnitPerson, getEmptyPhonePerson, getSparseAugmentedViperPerson } diff --git a/VueApp/src/Personnel/index.html b/VueApp/src/Personnel/index.html new file mode 100644 index 000000000..93ace49e1 --- /dev/null +++ b/VueApp/src/Personnel/index.html @@ -0,0 +1,12 @@ + + + + + + VIPER - Personnel + + +
+ + + \ No newline at end of file diff --git a/VueApp/src/Personnel/pages/Home.vue b/VueApp/src/Personnel/pages/Home.vue new file mode 100644 index 000000000..a7254de06 --- /dev/null +++ b/VueApp/src/Personnel/pages/Home.vue @@ -0,0 +1,6 @@ + + diff --git a/VueApp/src/Personnel/pages/PhoneList.vue b/VueApp/src/Personnel/pages/PhoneList.vue new file mode 100644 index 000000000..89b6a6d67 --- /dev/null +++ b/VueApp/src/Personnel/pages/PhoneList.vue @@ -0,0 +1,114 @@ + + + diff --git a/VueApp/src/Personnel/pages/PhoneListMaintain.vue b/VueApp/src/Personnel/pages/PhoneListMaintain.vue new file mode 100644 index 000000000..828e29052 --- /dev/null +++ b/VueApp/src/Personnel/pages/PhoneListMaintain.vue @@ -0,0 +1,158 @@ + + + diff --git a/VueApp/src/Personnel/pages/SVMPhones.vue b/VueApp/src/Personnel/pages/SVMPhones.vue new file mode 100644 index 000000000..05f7e0353 --- /dev/null +++ b/VueApp/src/Personnel/pages/SVMPhones.vue @@ -0,0 +1,67 @@ + + + diff --git a/VueApp/src/Personnel/pages/SVMPhonesMaintain.vue b/VueApp/src/Personnel/pages/SVMPhonesMaintain.vue new file mode 100644 index 000000000..c8766732d --- /dev/null +++ b/VueApp/src/Personnel/pages/SVMPhonesMaintain.vue @@ -0,0 +1,188 @@ + + + diff --git a/VueApp/src/Personnel/personnel.ts b/VueApp/src/Personnel/personnel.ts new file mode 100644 index 000000000..0ef55b5c5 --- /dev/null +++ b/VueApp/src/Personnel/personnel.ts @@ -0,0 +1,10 @@ +import { bootstrapSpa } from "@/shared/bootstrap-spa" +import { router } from "./router" +import App from "./App.vue" + +bootstrapSpa({ + areaPath: "/Personnel", + appComponent: App, + router, + provides: { apiURL: import.meta.env.VITE_API_URL }, +}) diff --git a/VueApp/src/Personnel/router/index.ts b/VueApp/src/Personnel/router/index.ts new file mode 100644 index 000000000..ee4f72c53 --- /dev/null +++ b/VueApp/src/Personnel/router/index.ts @@ -0,0 +1,58 @@ +import { createSpaRouter } from "@/shared/create-spa-router" +import { routes } from "./routes" +import { useRequireLogin } from "@/composables/RequireLogin" +import { useUserStore } from "@/store/UserStore" +import { useFetch } from "@/composables/ViperFetch" +import { checkHasOnePermission } from "@/composables/CheckPagePermission" + +const router = createSpaRouter(routes) + +// Dedup latch: reuse in-flight fetch so concurrent navigations don't fire multiple requests +let phonePermissionsPromise: Promise | null = null + +async function loadPhonePermissions() { + try { + const userStore = useUserStore() + const existingPermissions = userStore.userInfo?.permissions ?? [] + const { get } = useFetch() + const apiUrl = import.meta.env.VITE_API_URL + const evalPerms = await get(`${apiUrl}loggedInUser/permissions?prefix=SVMSecure.PhoneLists`) + if (evalPerms.success && Array.isArray(evalPerms.result)) { + userStore.setPermissions([...existingPermissions, ...evalPerms.result]) + } + } finally { + // Reset latch so future session changes refetch instead of reusing the old resolved promise + phonePermissionsPromise = null + } +} + +router.beforeEach(async (to, from) => { + const userStore = useUserStore() + + // Skip re-authentication for in-app navigations (tab switches, course-to-course). + // The user is already logged in and permissions are loaded; re-calling requireLogin + // Would overwrite the permission array and cause a visible flash. + const isInternalNavigation = from.matched.length > 0 && userStore.isLoggedIn + if (!isInternalNavigation) { + const { requireLogin } = useRequireLogin(to) + const loginResult = await requireLogin(true, "SVMSecure.Personnel") + if (loginResult !== null && !loginResult) { + return false + } + + // PhoneList permissions are in a separate area, so they aren't loaded by requireLogin + if (!phonePermissionsPromise) { + phonePermissionsPromise = loadPhonePermissions() + } + await phonePermissionsPromise + } + + if (to.meta.permissions !== null && to.meta.permissions !== undefined) { + const hasPerm = checkHasOnePermission(to.meta.permissions as string[]) + if (!hasPerm) { + return { name: "PersonnelHome" } + } + } +}) + +export { router } diff --git a/VueApp/src/Personnel/router/routes.ts b/VueApp/src/Personnel/router/routes.ts new file mode 100644 index 000000000..9de63e03c --- /dev/null +++ b/VueApp/src/Personnel/router/routes.ts @@ -0,0 +1,57 @@ +import ViperLayout from "@/layouts/ViperLayout.vue" +//Import ViperLayoutSimple from '@/layouts/ViperLayoutSimple.vue' + +const routes = [ + { + path: "/Personnel/", + alias: "/Personnel/Home", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/Home.vue"), + name: "PersonnelHome", + }, + // Unit phone lists are addressed by their stable PhoneList.Code, so a new list is a row in + // phones.PhoneList plus a nav entry rather than another pair of near-identical pages. + { + path: "/Personnel/PhoneList/:code", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/PhoneList.vue"), + name: "PhoneList", + }, + { + // No meta.permissions: the required role is the list's own MaintainRole, which is not + // known until the list is fetched. The page redirects if canMaintain comes back false, + // and the API rejects writes independently. + path: "/Personnel/PhoneList/:code/Maintain", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/PhoneListMaintain.vue"), + name: "MaintainPhoneList", + }, + // The legacy paths so existing links and bookmarks keep working. + { + path: "/Personnel/VMDOPhones", + redirect: { name: "PhoneList", params: { code: "VMDO" } }, + }, + { + path: "/Personnel/VMDOPhonesMaintain", + redirect: { name: "MaintainPhoneList", params: { code: "VMDO" } }, + }, + { + path: "/Personnel/SVMPhones", + meta: { layout: ViperLayout, allowUnAuth: false }, + component: () => import("@/Personnel/pages/SVMPhones.vue"), + name: "SchoolwidePhones", + }, + { + path: "/Personnel/SVMPhonesMaintain", + meta: { layout: ViperLayout, allowUnAuth: false, permissions: ["SVMSecure.PhoneLists.SVMMaintain"] }, + component: () => import("@/Personnel/pages/SVMPhonesMaintain.vue"), + name: "MaintainSchoolwidePhones", + }, + { + path: "/:catchAll(.*)*", + meta: { layout: ViperLayout }, + component: () => import("@/pages/Error404.vue"), + }, +] + +export { routes } diff --git a/VueApp/src/Personnel/services/phone-list-modified-date-service.ts b/VueApp/src/Personnel/services/phone-list-modified-date-service.ts new file mode 100644 index 000000000..432d808ae --- /dev/null +++ b/VueApp/src/Personnel/services/phone-list-modified-date-service.ts @@ -0,0 +1,19 @@ +import { useFetch } from "@/composables/ViperFetch" + +const { get } = useFetch() + +/** + * Service for requesting the most recent modification date for the unit + * phone lists. + */ +class PhoneListModifiedDateService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/phonelist` + + async getModifiedDate(code: string): Promise { + const r = await get(`${this.baseUrl}/${encodeURIComponent(code)}/modifiedDate`) + return r.result ?? null + } +} + +const phoneListModifiedDateService = new PhoneListModifiedDateService() +export { phoneListModifiedDateService } diff --git a/VueApp/src/Personnel/services/phone-list-service.ts b/VueApp/src/Personnel/services/phone-list-service.ts new file mode 100644 index 000000000..c244d3ba7 --- /dev/null +++ b/VueApp/src/Personnel/services/phone-list-service.ts @@ -0,0 +1,27 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { PhoneListInfo } from "../types/phone-list-phone-types" + +const { get } = useFetch() + +/** + * Service for PhoneList metadata. + */ +class PhoneListService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/phonelist` + + /** + * Resolves a list by its stable code. Returns null when the code is unknown or the request + * fails, so callers can notify and bail rather than rendering an empty list as if it were + * a list with no entries. + */ + async getPhoneListInfo(code: string): Promise { + const r = await get(`${this.baseUrl}/${encodeURIComponent(code)}`) + if (!r.success || !r.result) { + return null + } + return r.result as PhoneListInfo + } +} + +const phoneListService = new PhoneListService() +export { phoneListService } diff --git a/VueApp/src/Personnel/services/phone-list-unit-service.ts b/VueApp/src/Personnel/services/phone-list-unit-service.ts new file mode 100644 index 000000000..5f8ba40df --- /dev/null +++ b/VueApp/src/Personnel/services/phone-list-unit-service.ts @@ -0,0 +1,40 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { PhoneListUnitAPIResponse, PhoneListUnitPersonDTO } from "../types/phone-list-phone-types" + +const { get, post, put, del } = useFetch() + +/** + * Service for PhoneListUnit. Every call is scoped to a list code, which the API resolves to a + * list and then uses for its own permission check. + */ +class PhoneListUnitService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/phonelist` + + private listUrl(code: string) { + return `${this.baseUrl}/${encodeURIComponent(code)}` + } + + async getUnitsByList(code: string): Promise { + const r = await get(`${this.listUrl(code)}/units`) + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as PhoneListUnitAPIResponse[] + } + + async addUnitPersonData(code: string, formData: PhoneListUnitPersonDTO) { + return await post(`${this.listUrl(code)}/unitPerson`, formData) + } + + async updateUnitPersonData(code: string, unitPersonId: number, formData: PhoneListUnitPersonDTO) { + return await put(`${this.listUrl(code)}/unitPerson/${unitPersonId}`, formData) + } + + async deleteUnitPersonData(code: string, deletionUnitPersonId: number) { + return await del(`${this.listUrl(code)}/unitPerson/${deletionUnitPersonId}`) + } +} + +const phoneListUnitService = new PhoneListUnitService() +export { phoneListUnitService } diff --git a/VueApp/src/Personnel/services/phone-person-options-service.ts b/VueApp/src/Personnel/services/phone-person-options-service.ts new file mode 100644 index 000000000..3b4853cdc --- /dev/null +++ b/VueApp/src/Personnel/services/phone-person-options-service.ts @@ -0,0 +1,16 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { AugmentedViperPerson } from "../types/phone-types" + +/** + * Phone option-list lookups for people. Returns null on a + * failed request (vs an empty array) so callers can tell "no matches" from "the fetch failed". + */ +const { get, createUrlSearchParams } = useFetch() +const optionsUrl = `${import.meta.env.VITE_API_URL}phones/people` + +async function searchPeopleOptions(search: string, listCode: string = ""): Promise { + const res = await get(`${optionsUrl}?${createUrlSearchParams({ search, listCode })}`) + return res.success ? (res.result as AugmentedViperPerson[]) : null +} + +export { searchPeopleOptions } diff --git a/VueApp/src/Personnel/services/svm-frequent-number-service.ts b/VueApp/src/Personnel/services/svm-frequent-number-service.ts new file mode 100644 index 000000000..e7c78490f --- /dev/null +++ b/VueApp/src/Personnel/services/svm-frequent-number-service.ts @@ -0,0 +1,36 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { SVMFrequentNumberAPIResponse, SVMFrequentNumberRecord } from "../types/svm-phone-types" + +const { get, post, put, del } = useFetch() + +/** + * Service for SVM Frequent Number API calls. + */ +class SVMFrequentNumberService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm/frequentnumbers` + + async getFrequentNumbers(): Promise { + const r = await get(this.baseUrl) + + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as SVMFrequentNumberAPIResponse[] + } + + async addFrequentNumber(formData: SVMFrequentNumberRecord) { + return await post(this.baseUrl, formData) + } + + async updateFrequentNumber(entryId: number, formData: SVMFrequentNumberRecord) { + return await put(`${this.baseUrl}/${entryId}`, formData) + } + + async deleteFrequentNumber(entryId: number) { + return await del(`${this.baseUrl}/${entryId}`) + } +} + +const svmFrequentNumberService = new SVMFrequentNumberService() +export { svmFrequentNumberService } diff --git a/VueApp/src/Personnel/services/svm-modified-date-service.ts b/VueApp/src/Personnel/services/svm-modified-date-service.ts new file mode 100644 index 000000000..6d1ed1566 --- /dev/null +++ b/VueApp/src/Personnel/services/svm-modified-date-service.ts @@ -0,0 +1,19 @@ +import { useFetch } from "@/composables/ViperFetch" + +const { get } = useFetch() + +/** + * Service for requesting the most recent modification date for the SVM + * phone list. + */ +class SVMModifiedDateService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm/modifiedDate` + + async getModifiedDate(): Promise { + const r = await get(this.baseUrl) + return r.result ?? null + } +} + +const svmModifiedDateService = new SVMModifiedDateService() +export { svmModifiedDateService } diff --git a/VueApp/src/Personnel/services/svm-section-service.ts b/VueApp/src/Personnel/services/svm-section-service.ts new file mode 100644 index 000000000..08427cc2a --- /dev/null +++ b/VueApp/src/Personnel/services/svm-section-service.ts @@ -0,0 +1,24 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { SVMSectionAPIResponse } from "../types/svm-phone-types" + +const { get } = useFetch() + +/** + * Service for SVMSection API calls. + */ +class SVMSectionService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm/sections` + + async getSections(): Promise { + const r = await get(this.baseUrl) + + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as SVMSectionAPIResponse[] + } +} + +const svmSectionService = new SVMSectionService() +export { svmSectionService } diff --git a/VueApp/src/Personnel/services/svm-unit-service.ts b/VueApp/src/Personnel/services/svm-unit-service.ts new file mode 100644 index 000000000..a605de72c --- /dev/null +++ b/VueApp/src/Personnel/services/svm-unit-service.ts @@ -0,0 +1,43 @@ +import { useFetch } from "@/composables/ViperFetch" +import type { SVMUnitAPIResponse, SVMUnitNumberDTO } from "../types/svm-phone-types" + +const { get, put, post, del } = useFetch() + +/** + * Service for SVMUnit and related API calls. + */ +class SVMUnitService { + private baseUrl = `${import.meta.env.VITE_API_URL}phones/svm` + + /** + * Every unit on the list. The page renders all sections together, so these come back in one + * request and are grouped by sectionId client-side. + */ + async getAllUnits(): Promise { + const r = await get(`${this.baseUrl}/units`) + const results = r.result + if (!results || results.length === 0) { + return [] + } + return results as SVMUnitAPIResponse[] + } + + async addUnitData(unitId: number, formData: SVMUnitNumberDTO) { + return await post(`${this.baseUrl}/units/${unitId}`, formData) + } + + async updateUnitData(unitId: number, formData: SVMUnitNumberDTO) { + return await put(`${this.baseUrl}/units/${unitId}`, formData) + } + + /** + * Deletes one row of the SVM list. The server owns which underlying UnitPerson records that + * covers, so the caller passes only the row key the table renders. + */ + async deleteRow(entryId: number) { + return await del(`${this.baseUrl}/rows/${entryId}`) + } +} + +const svmUnitService = new SVMUnitService() +export { svmUnitService } diff --git a/VueApp/src/Personnel/types/phone-list-phone-types.ts b/VueApp/src/Personnel/types/phone-list-phone-types.ts new file mode 100644 index 000000000..1a478b743 --- /dev/null +++ b/VueApp/src/Personnel/types/phone-list-phone-types.ts @@ -0,0 +1,77 @@ +/** + * Types for department Phone Lists. + */ + +import type { QTableProps } from "quasar" +import type { PhonePerson, ViperPerson } from "./phone-types" + +type PhoneListInfo = { + phoneListId: number + code: string + name: string + canMaintain: boolean + canViewDirectPhone: boolean +} + +type PhoneListUnitPersonDTO = { + unitId: number + office: string + employeeIam: string + phone: string + directPhone: string + listFirst: boolean +} + +type PhoneListUnitPerson = { + phoneListUnitPersonId: number + phoneListUnitId: number + personIam: string + listFirst: boolean + phoneListUnit: null + person: PhonePerson | null + modifiedBy: string | null + modifiedDate: Date | null + viperModPerson: ViperPerson | null +} + +type PhoneListUnitAPIResponse = { + phoneListUnitId: number + phoneListId: number + name: string + sortOrder: number | null + // Key returned by API but always null for this use case. + phoneList: null + phoneListUnitPersons: PhoneListUnitPerson[] +} + +type PhoneListDisplayRecord = { + fullName: string + name: string + employeeIam?: string + employeeMailId: string + phone: string | null + directPhone?: string | null + office: string | null + listFirst: boolean + unitPersonId: number + unitId: number + unitName: string + modifiedBy: string | null + modifiedDate: Date | null +} + +type PhoneListUnit = { + name: string + id: number + cols: QTableProps["columns"] + rows: PhoneListDisplayRecord[] +} + +export type { + PhoneListInfo, + PhoneListUnitAPIResponse, + PhoneListUnitPerson, + PhoneListUnitPersonDTO, + PhoneListUnit, + PhoneListDisplayRecord, +} diff --git a/VueApp/src/Personnel/types/phone-types.ts b/VueApp/src/Personnel/types/phone-types.ts new file mode 100644 index 000000000..8a63f37b1 --- /dev/null +++ b/VueApp/src/Personnel/types/phone-types.ts @@ -0,0 +1,40 @@ +/** + * Types for general phone records reports. + */ + +type ViperPerson = { + personId: number + firstName: string + lastName: string + fullName: string + iamId: string + currentEmployee: boolean + mailId: string +} + +type PhonePerson = { + personIam: string + phone: string | null + directPhone: string | null + office: string | null + modifiedDate: Date | null + modifiedBy: string | null + // Returned by the API but not populated for this use case. + unitPersons: null + phoneListUnitPersons: null + viperPerson: ViperPerson | null + viperModPerson: ViperPerson | null +} + +type AugmentedViperPerson = { + personId: number + firstName: string + lastName: string + fullName: string + iamId: string + currentEmployee: boolean + mailId: string + phoneData: PhonePerson | null +} + +export type { ViperPerson, PhonePerson, AugmentedViperPerson } diff --git a/VueApp/src/Personnel/types/svm-phone-types.ts b/VueApp/src/Personnel/types/svm-phone-types.ts new file mode 100644 index 000000000..fc17e6dde --- /dev/null +++ b/VueApp/src/Personnel/types/svm-phone-types.ts @@ -0,0 +1,137 @@ +/** + * Types for the SVM Phone List. + */ + +import type { QSelectOption, QTableProps } from "quasar" +import type { ViperPerson, PhonePerson } from "./phone-types" + +type SVMPhoneDisplayRecord = { + sectionName: string | null + unitName: string | null + unitId: number | null + unitAbbrv: string | null + officeLocation: string | null + officeFax: string | null + deanDirectorFullName: string | null + deanDirectorDisplayName: string | null + deanDirectorInterim: string | null + deanDirectorIam: string | null + deanDirectorUnitPersonId: number | null + deanDirectorPhone: string | null + deanDirectorModifiedDate: Date | null + deanDirectorModifiedBy: string | null + adminStaffFullName: string | null + adminStaffDisplayName: string | null + adminStaffInterim: string | null + adminStaffIam: string | null + adminStaffUnitPersonId: number | null + adminStaffPhone: string | null + adminStaffModifiedDate: Date | null + adminStaffModifiedBy: string | null + entryId: number + /** + * True when this is the only row the unit produces. Deleting it therefore takes the admin + * staff with it, since no other row would be left to list them. + */ + isOnlyRowForUnit: boolean +} + +type SVMFrequentNumberRecord = { + label: string + phone: string + entryId: number +} + +type SVMPhoneSection = { + title: string + id: number + cols: QTableProps["columns"] + rows: SVMPhoneDisplayRecord[] +} + +type SVMSectionAPIResponse = { + sectionId: number + name: string + includeAbbrv: boolean + unitName: string | null + directorTitle: string + sortOrder: number +} + +type SVMUnitPerson = { + unitPersonId: number + unitId: number + personIam: string + office: string | null + posType: string | null + interim: string | null + modifiedDate: Date | null + modifiedBy: string | null + unit: null + person: PhonePerson | null + viperModPerson: ViperPerson | null +} + +type SVMUnitAPIResponse = { + unitId: number + sectionId: number + name: string | null + abbrv: string | null + sortOrder: number | null + fax: string | null + section: null + unitPersons: SVMUnitPerson[] | null +} + +type SVMFrequentNumberAPIResponse = { + numberId: number + label: string + phone: string + sortOrder: number | null +} + +type UnitOptions = { + section: number + units: QSelectOption[] +} + +type UnitFaxNumber = { + unitId: number + fax: string +} + +type UnitAdminStaff = { + unitId: number + staffIam: string + staffFullName: string + staffPhone: string + staffInterim: string + staffUnitPersonId: number +} + +type SVMUnitNumberDTO = { + fax: string + location: string + deanIam: string + deanPhone: string + deanInterim: string + deanUnitPerson: number + staffIam: string + staffPhone: string + staffInterim: string + staffUnitPerson: number +} + +export type { + SVMPhoneDisplayRecord, + SVMFrequentNumberRecord, + SVMPhoneSection, + SVMSectionAPIResponse, + SVMUnitPerson, + SVMUnitAPIResponse, + SVMFrequentNumberAPIResponse, + UnitOptions, + UnitFaxNumber, + UnitAdminStaff, + SVMUnitNumberDTO, +} diff --git a/VueApp/src/components/RecordFormDialog.vue b/VueApp/src/components/RecordFormDialog.vue new file mode 100644 index 000000000..e6fa78b90 --- /dev/null +++ b/VueApp/src/components/RecordFormDialog.vue @@ -0,0 +1,113 @@ + + + diff --git a/VueApp/src/components/__tests__/record-form-dialog.test.ts b/VueApp/src/components/__tests__/record-form-dialog.test.ts new file mode 100644 index 000000000..5aed2dd17 --- /dev/null +++ b/VueApp/src/components/__tests__/record-form-dialog.test.ts @@ -0,0 +1,130 @@ +import { mount, flushPromises } from "@vue/test-utils" +import { Quasar } from "quasar" +import RecordFormDialog from "../RecordFormDialog.vue" + +/** + * RecordFormDialog is the shared shell behind PhoneListAddRecordDialog, SVMAddRecordDialog, and + * SVMAddFrequentNumberDialog. This file tests functionality involving + * closing and canceling these dialogs. + */ + +function mountDialog(props: Partial["$props"]> = {}) { + return mount(RecordFormDialog, { + props: { + modelValue: true, + titleId: "test-dialog-title", + title: "Test Dialog", + isEdit: false, + saving: false, + formError: "", + submitLabel: "Upload", + ...props, + }, + global: { plugins: [Quasar] }, + attachTo: document.body, + }) +} + +function findButtonByLabel(wrapper: ReturnType, label: string) { + return wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("label") === label) +} + +// QDialog teleports to a Quasar-managed portal outside the mounted wrapper, and its @keydown.escape +// listener sits on the teleported [role="dialog"] element - a plain VTU .trigger() on the wrapper +// doesn't reach it, so dispatch a real KeyboardEvent there instead. +async function pressEscape(): Promise { + const dialogEl = document.querySelector('[role="dialog"]') + dialogEl?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })) + await flushPromises() +} + +describe("recordFormDialog.vue - close behavior", () => { + it("emits update:modelValue false when Cancel is clicked, with no confirmClose guard", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + + await findButtonByLabel(wrapper, "Cancel")!.trigger("click") + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("emits update:modelValue false when the header close button is clicked", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + + const closeButton = wrapper.findAllComponents({ name: "QBtn" }).find((btn) => btn.props("icon") === "close") + expect(closeButton).toBeTruthy() + await closeButton!.trigger("click") + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("stays open when confirmClose resolves false", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const confirmClose = vi.fn<() => Promise>().mockResolvedValue(false) + const wrapper = mountDialog({ confirmClose }) + await flushPromises() + + await findButtonByLabel(wrapper, "Cancel")!.trigger("click") + + expect(confirmClose).toHaveBeenCalledOnce() + expect(wrapper.emitted("update:modelValue")).toBeFalsy() + }) + + it("closes when confirmClose resolves true", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const confirmClose = vi.fn<() => Promise>().mockResolvedValue(true) + const wrapper = mountDialog({ confirmClose }) + await flushPromises() + + await findButtonByLabel(wrapper, "Cancel")!.trigger("click") + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("closes on Escape without a confirmClose guard", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + + await pressEscape() + + expect(wrapper.emitted("update:modelValue")).toStrictEqual([[false]]) + }) + + it("stays open on Escape when confirmClose resolves false", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const confirmClose = vi.fn<() => Promise>().mockResolvedValue(false) + const wrapper = mountDialog({ confirmClose }) + await flushPromises() + + await pressEscape() + + expect(confirmClose).toHaveBeenCalledOnce() + expect(wrapper.emitted("update:modelValue")).toBeFalsy() + }) + + it("resets form validation and emits hide when the dialog reports hide", async () => { + expect.hasAssertions() + document.body.innerHTML = "" + const wrapper = mountDialog() + await flushPromises() + const resetValidation = vi.fn<() => void>() + // FormRef is the mounted QForm instance; stub its resetValidation to confirm onHide calls it, + // without needing to actually fail validation first. + wrapper.findComponent({ name: "QForm" }).vm.resetValidation = resetValidation + + await wrapper.findComponent({ name: "QDialog" }).vm.$emit("hide") + + expect(resetValidation).toHaveBeenCalledOnce() + expect(wrapper.emitted("hide")).toBeTruthy() + }) +}) diff --git a/VueApp/src/composables/ViperFetch.ts b/VueApp/src/composables/ViperFetch.ts index 34888227f..ed87db70a 100644 --- a/VueApp/src/composables/ViperFetch.ts +++ b/VueApp/src/composables/ViperFetch.ts @@ -228,9 +228,11 @@ async function postForBlob( if (contentDisposition) { // Try to extract filename from Content-Disposition header // Format: attachment; filename="filename.ext" or attachment; filename=filename.ext - const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/) - if (filenameMatch && filenameMatch[1]) { - filename = filenameMatch[1].replaceAll(/['"]/g, "") + const filenameMatch = contentDisposition.match( + /filename[^;=\n]*=(?(?['"]).*?\k|[^;\n]*)/u, + ) + if (filenameMatch?.groups?.filename) { + filename = filenameMatch.groups.filename.replaceAll(/['"]/gu, "") } } @@ -303,3 +305,4 @@ function downloadBlob(blob: Blob, filename: string): void { } export { useFetch, postForBlob, downloadBlob, HTTP_STATUS } +export type { Result, Pagination } diff --git a/VueApp/src/composables/__tests__/use-person-search.test.ts b/VueApp/src/composables/__tests__/use-person-search.test.ts new file mode 100644 index 000000000..9c8d790cc --- /dev/null +++ b/VueApp/src/composables/__tests__/use-person-search.test.ts @@ -0,0 +1,82 @@ +import { usePersonSearch } from "../use-person-search" + +type Person = { iamId: string; fullName: string } + +type Deferred = { promise: Promise; resolve: (value: T) => void } + +// A controllable pending promise, for simulating a search that hasn't resolved yet. The `as` +// cast lets `resolve` be filled in by the executor without a separate uninitialized declaration. +function createDeferred(): Deferred { + const deferred = {} as Deferred + // eslint-disable-next-line avoid-new -- a controllable pending promise is the point of this helper + deferred.promise = new Promise((resolve) => { + deferred.resolve = resolve + }) + return deferred +} + +function applyUpdate(fn: () => void): void { + fn() +} + +function runFilter(searchPeople: (val: string, update: (fn: () => void) => void) => Promise, val: string) { + return searchPeople(val, applyUpdate) +} + +describe("usePersonSearch()", () => { + it("clears options without calling search when the term is below two characters", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>() + const { searchPeople, options, loading } = usePersonSearch(search) + options.value = [{ iamId: "a", fullName: "Existing Person" }] + + await runFilter(searchPeople, "a") + + expect(search).not.toHaveBeenCalled() + expect(options.value).toStrictEqual([]) + expect(loading.value).toBeFalsy() + }) + + it("sets loading and populates options for a valid search", async () => { + expect.hasAssertions() + const results: Person[] = [{ iamId: "person01", fullName: "Amy Smith" }] + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(results) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, " ab ") + + expect(search).toHaveBeenCalledWith("ab") + expect(options.value).toStrictEqual(results) + }) + + it("falls back to an empty list when search resolves null", async () => { + expect.hasAssertions() + const search = vi.fn<(value: string) => Promise>().mockResolvedValue(null) + const { searchPeople, options } = usePersonSearch(search) + + await runFilter(searchPeople, "ab") + + expect(options.value).toStrictEqual([]) + }) + + it("discards a slower, earlier response that resolves after a newer search", async () => { + expect.hasAssertions() + const first = createDeferred() + const second = createDeferred() + const search = vi + .fn<(value: string) => Promise>() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + const { searchPeople, options } = usePersonSearch(search) + + const firstFilter = runFilter(searchPeople, "first") + const secondFilter = runFilter(searchPeople, "second") + + second.resolve([{ iamId: "second", fullName: "Second Result" }]) + await secondFilter + first.resolve([{ iamId: "first", fullName: "First Result" }]) + await firstFilter + + expect(options.value).toStrictEqual([{ iamId: "second", fullName: "Second Result" }]) + }) +}) diff --git a/VueApp/src/composables/use-person-search.ts b/VueApp/src/composables/use-person-search.ts new file mode 100644 index 000000000..56634b78c --- /dev/null +++ b/VueApp/src/composables/use-person-search.ts @@ -0,0 +1,41 @@ +import { ref } from "vue" + +/** + * Debounced, out-of-order-safe server search for a QSelect's @filter handler. Shared by every + * PersonSelector variant (CMS, Personnel): the search/race-guard logic is identical across them, + * only the search function and result type differ per caller. + */ +function usePersonSearch(search: (value: string) => Promise) { + const options = ref([]) + const loading = ref(false) + // Guards against out-of-order responses: only the latest search may update options + let searchSeq = 0 + + async function searchPeople(val: string, update: (fn: () => void) => void) { + if (val.trim().length < 2) { + // Invalidate any in-flight search too, or its late response would repopulate + // the options we just cleared. + searchSeq += 1 + loading.value = false + update(() => { + options.value = [] + }) + return + } + searchSeq += 1 + const seq = searchSeq + loading.value = true + const result = await search(val.trim()) + if (seq !== searchSeq) { + return + } + loading.value = false + update(() => { + options.value = result ?? [] + }) + } + + return { options, loading, searchPeople } +} + +export { usePersonSearch } diff --git a/VueApp/vueapp.esproj b/VueApp/vueapp.esproj index 8513a0c6b..1c514331e 100644 --- a/VueApp/vueapp.esproj +++ b/VueApp/vueapp.esproj @@ -18,7 +18,9 @@ + + - \ No newline at end of file + diff --git a/test/Classes/Utilities/PersonSearchHelperTests.cs b/test/Classes/Utilities/PersonSearchHelperTests.cs new file mode 100644 index 000000000..ecadafa00 --- /dev/null +++ b/test/Classes/Utilities/PersonSearchHelperTests.cs @@ -0,0 +1,137 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Classes.Utilities; + +namespace Viper.test.Classes.Utilities; + +/// +/// Tests for PersonSearchHelper, the shared "search current people by partial name" query shape +/// used by both the CMS file/permission pickers and the Personnel phone directory. A regression +/// here affects every autocomplete built on it. +/// +public class PersonSearchHelperTests +{ + private sealed class Person + { + public required string LastName { get; set; } + public required string FirstName { get; set; } + public string LoginId { get; set; } = ""; + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("a")] + [InlineData(" a ")] + public void Normalize_ReturnsNull_WhenBelowMinimumLength(string? search) + { + Assert.Null(PersonSearchHelper.Normalize(search)); + } + + [Fact] + public void Normalize_ReturnsTrimmedValue_WhenAtOrAboveMinimumLength() + { + var result = PersonSearchHelper.Normalize(" ab "); + + Assert.Equal("ab", result); + } + + [Fact] + public void NameMatches_MatchesLastCommaFirstForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "mith, A"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_MatchesFirstSpaceLastForm() + { + var people = new[] { new Person { LastName = "Smith", FirstName = "Amy" } }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Amy Sm"); + + var results = people.Where(predicate).ToList(); + + Assert.Single(results); + } + + [Fact] + public void NameMatches_ExcludesNonMatchingPeople() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy" }, + new Person { LastName = "Jones", FirstName = "Bob" }, + }.AsQueryable(); + var predicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + + var results = people.Where(predicate).ToList(); + + var match = Assert.Single(results); + Assert.Equal("Smith", match.LastName); + } + + [Fact] + public void OrderAndCap_OrdersByLastNameThenFirstName_AndCapsToMaxResults() + { + var people = Enumerable.Range(0, 30) + .Select(i => new Person { LastName = $"Person{i:D2}", FirstName = "X" }) + .Reverse() + .AsQueryable(); + + var results = PersonSearchHelper.OrderAndCap(people, p => p.LastName, p => p.FirstName).ToList(); + + Assert.Equal(PersonSearchHelper.MaxResults, results.Count); + Assert.Equal("Person00", results[0].LastName); + Assert.Equal("Person24", results[^1].LastName); + } + + [Fact] + public void Or_IncludesRecordsMatchingEitherPredicate() + { + var people = new[] + { + new Person { LastName = "Smith", FirstName = "Amy", LoginId = "asmith" }, + new Person { LastName = "Jones", FirstName = "Bob", LoginId = "bjones" }, + }.AsQueryable(); + var namePredicate = PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "Smith"); + var combined = namePredicate.Or(p => p.LoginId == "bjones"); + + var results = people.Where(combined).ToList(); + + Assert.Equal(2, results.Count); + } + + private sealed class SearchTestContext(DbContextOptions options) : DbContext(options) + { + public DbSet People => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + => modelBuilder.Entity().HasKey(p => p.LastName); + } + + [Fact] + public void NameMatches_EmitsASqlParameter_RatherThanALiteral() + { + // These autocompletes fire per keystroke, so a term embedded as a literal would give every + // distinct search its own query plan. The literal form also drops the ESCAPE clause, which + // is what stops a typed % or _ from being treated as a wildcard. + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=none;Database=none;Trusted_Connection=True;") + .Options; + using var context = new SearchTestContext(options); + + var sql = context.People + .Where(PersonSearchHelper.NameMatches(p => p.LastName, p => p.FirstName, "smith")) + .ToQueryString(); + + // Assert on the predicate, not the whole string: ToQueryString prefixes a DECLARE that + // spells the value out for copy-paste even when the query itself is parameterized. + Assert.Contains("LIKE @", sql, StringComparison.Ordinal); + Assert.DoesNotContain("LIKE N'", sql, StringComparison.Ordinal); + Assert.Contains("ESCAPE", sql, StringComparison.Ordinal); + } +} diff --git a/test/Personnel/PhoneListServiceTests.cs b/test/Personnel/PhoneListServiceTests.cs new file mode 100644 index 000000000..0401a9277 --- /dev/null +++ b/test/Personnel/PhoneListServiceTests.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListService, the entry point every list-scoped request resolves a list +/// through. Lookup is by Code rather than Name so that renaming a list for display cannot +/// break the routes and API paths that address it. +/// +public sealed class PhoneListServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListService _service; + + public PhoneListServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneListService(_context); + } + + public void Dispose() => _context.Dispose(); + + private void SeedLists() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetListByCode_ReturnsMatchingList() + { + SeedLists(); + + var result = await _service.GetListByCode("OTHER", TestContext.Current.CancellationToken); + + Assert.Equal(2, result.PhoneListId); + Assert.Equal("Some Other Unit", result.Name); + } + + [Fact] + public async Task GetListByCode_ResolvesIndependentlyOfDisplayName() + { + SeedLists(); + var list = await _context.PhoneList.SingleAsync(l => l.Code == "VMDO", TestContext.Current.CancellationToken); + list.Name = "Office of the Dean"; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _service.GetListByCode("VMDO", TestContext.Current.CancellationToken); + + Assert.Equal(1, result.PhoneListId); + } + + [Fact] + public async Task GetListByCode_Throws_WhenCodeNotFound() + { + await Assert.ThrowsAsync( + () => _service.GetListByCode("NOPE", TestContext.Current.CancellationToken)); + } +} diff --git a/test/Personnel/PhoneListUnitControllerTests.cs b/test/Personnel/PhoneListUnitControllerTests.cs new file mode 100644 index 000000000..e42d2aaa0 --- /dev/null +++ b/test/Personnel/PhoneListUnitControllerTests.cs @@ -0,0 +1,234 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListUnitController. Beyond the InvalidOperationException-to-400 +/// mapping, these cover the authorization model: write access is the role named by the target +/// list's own MaintainRole column, so holding one list's role must grant nothing on another, +/// and a record id from one list must not be reachable through another list's route. +/// +public sealed class PhoneListUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string OtherRole = "SVMSecure.PhoneLists.OtherMaintain"; + + public PhoneListUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonesPermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListUnitController(phoneListService, unitService, permissionsService); + + // Two lists, each with its own unit and its own maintain role. + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = OtherRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "person01", Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// Grants the caller exactly one maintain role. + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private void AddUnitPersonRow(int unitPersonId, int unitId) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private static PhoneListUnitDataRequest Request(int unitId) => new() + { + UnitId = unitId, + EmployeeIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }; + + [Fact] + public async Task GetUnits_ReturnsOk_WithUnitsForTheNamedList() + { + var result = await _controller.GetUnits("VMDO", TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal("Front Office", Assert.Single(units).Name); + } + + [Fact] + public async Task GetUnits_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetUnits("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsOk_ForAMaintainerOfThatList() + { + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_WithoutTheRoleForThatList() + { + var result = await _controller.AddUnitPersonData("VMDO", Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task AddUnitPersonData_IsForbidden_ForAMaintainerOfADifferentList() + { + // Holding VMDOMaintain must not confer write access to the OTHER list, which is what a + // hard-coded permission attribute on the endpoint would have allowed. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("OTHER", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitPersonData_ReturnsBadRequest_WhenTheUnitBelongsToAnotherList() + { + // Unit 2 is on the OTHER list; routing through VMDO must not reach it. + GrantRole(VmdoRole); + + var result = await _controller.AddUnitPersonData("VMDO", Request(2), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.PhoneListUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 999, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitPersonData_ReturnsBadRequest_WhenTheRecordBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.UpdateUnitPersonData("VMDO", 5, Request(1), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsBadRequest_WhenNotFound() + { + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitPersonData_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPersonRow(unitPersonId: 1, unitId: 1); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task DeleteUnitPersonData_LeavesTheRecordAlone_WhenItBelongsToAnotherList() + { + AddUnitPersonRow(unitPersonId: 5, unitId: 2); + GrantRole(VmdoRole); + + var result = await _controller.DeleteUnitPersonData("VMDO", 5, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var untouched = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { 5 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouched); + Assert.True(untouched.IsActive); + } +} diff --git a/test/Personnel/PhoneListUnitServiceTests.cs b/test/Personnel/PhoneListUnitServiceTests.cs new file mode 100644 index 000000000..1f0ba7548 --- /dev/null +++ b/test/Personnel/PhoneListUnitServiceTests.cs @@ -0,0 +1,306 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneListUnitService, focused on the direct-phone visibility rule: +/// a caller may only see DirectPhone for a list if they hold the list's maintain +/// permission, OR they are themselves an active member of that list. +/// +public sealed class PhoneListUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListUnitService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhoneListUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + // rapsContext is unused when IUserHelper.HasPermission is mocked directly, + // so a bare substitute (no seeded roles) is sufficient here. + var rapsContext = Substitute.For(); + var permissionsService = new PhonesPermissionsService(rapsContext, _userHelper); + + _service = new PhoneListUnitService(_context, _userHelper, permissionsService); + + SeedList(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + private void SeedList() + { + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Dean's Office" }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "listedperson", + FirstName = "Listed", + LastName = "Person", + FullName = "Listed Person", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "listedperson", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + Office = "Room 100", + }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = "listedperson", + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + [Fact] + public async Task GetPhoneListUnits_MasksDirectPhone_WhenCallerHasNoAccess() + { + // Caller has neither the maintain permission nor a membership row on this list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).PhoneListUnitPersons); + Assert.Equal("530-555-1000", person.Person.Phone); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task GetPhoneListUnits_ShowsDirectPhone_WhenCallerIsListMember() + { + // No maintain permission, but the caller is themselves an active member of the list - + // membership alone should be enough to unlock direct numbers for that list. + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "", DirectPhone = "", Office = "" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetPhoneListUnits(TestList(), TestContext.Current.CancellationToken); + + var person = Assert.Single(units.Single().PhoneListUnitPersons, p => p.PersonIam == "listedperson"); + Assert.Equal("530-555-2000", person.Person.DirectPhone); + } + + [Fact] + public async Task AddUnitPersonData_UnsetsPreviousListFirst_WhenNewPersonIsMarkedFirst() + { + var existingFirstPerson = Assert.Single(_context.PhoneListUnitPerson); + existingFirstPerson.ListFirst = true; + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "newperson", + FirstName = "New", + LastName = "Person", + FullName = "New Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "newperson", + Phone = "530-555-5000", + DirectPhone = "530-555-6000", + Office = "Room 300", + ListFirst = true, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + Assert.False(existingFirstPerson.ListFirst); + var newPerson = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "newperson", TestContext.Current.CancellationToken); + Assert.True(newPerson.ListFirst); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceForSamePerson_UpsertsInsteadOfDuplicating() + { + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + DirectPhone = "530-555-9001", + Office = "Room 900", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var associations = await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "listedperson" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Single(associations); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9000", phonePerson.Phone); + Assert.Equal("530-555-9001", phonePerson.DirectPhone); + Assert.Equal("Room 900", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_TrimsWhitespace_ForANewPerson() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "paddedperson", + FirstName = "Padded", + LastName = "Person", + FullName = "Padded Person", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedperson ", + Phone = " 530-555-9500 ", + DirectPhone = " 530-555-9501 ", + Office = " Room 950 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "paddedperson", TestContext.Current.CancellationToken); + Assert.Equal("530-555-9500", phonePerson.Phone); + Assert.Equal("Room 950", phonePerson.Office); + } + + [Fact] + public async Task AddUnitPersonData_CalledTwiceWithAPaddedIam_UpsertsInsteadOfDuplicating() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 4, + IamId = "paddedtwice", + FirstName = "Padded", + LastName = "Twice", + FullName = "Padded Twice", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The existing-row lookup has to trim the same way the insert does. Matching a padded + // request against the trimmed PersonIam already stored finds nothing, so every resubmit + // would add another association row for the same person and unit. + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = " paddedtwice ", + Phone = " 530-555-9600 ", + DirectPhone = " 530-555-9601 ", + Office = " Room 960 ", + ListFirst = false, + }; + + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + await _service.AddUnitPersonData(1, request, TestContext.Current.CancellationToken); + + var association = Assert.Single(await _context.PhoneListUnitPerson + .Where(p => p.PersonIam == "paddedtwice" && p.PhoneListUnitId == 1) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.True(association.IsActive); + Assert.Equal("paddedtwice", association.PersonIam); + } + + [Fact] + public async Task DeleteUnitPersonData_SoftDeletes_KeepsRowButMarksInactive() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + + var stillExists = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + Assert.Equal(CallerIam, stillExists.ModifiedBy); + } + + [Fact] + public async Task DeleteUnitPersonData_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitPersonData(1, 9999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task EditingAnAlreadyDeletedRecord_ReportsItAsRemoved_RatherThanResurrectingIt() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + await _service.DeleteUnitPersonData(1, unitPerson.PhoneListUnitPersonId, TestContext.Current.CancellationToken); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-9000", + }; + + // A maintainer whose page predates someone else's delete. Saving must not bring the row + // back, and the message becomes an error banner, so it is worded for that reader. + var ex = await Assert.ThrowsAsync( + () => _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + + var stillDeleted = await _context.PhoneListUnitPerson + .FindAsync(new object?[] { unitPerson.PhoneListUnitPersonId }, TestContext.Current.CancellationToken); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + } +} diff --git a/test/Personnel/PhonePersonLookupServiceTests.cs b/test/Personnel/PhonePersonLookupServiceTests.cs new file mode 100644 index 000000000..0ce4a466b --- /dev/null +++ b/test/Personnel/PhonePersonLookupServiceTests.cs @@ -0,0 +1,157 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhonePersonLookupService: GetPhonePeople implements the same +/// permission-based DirectPhone masking as PhoneListUnitService, but as an independent code +/// path used by the person-picker autocomplete, so a regression there wouldn't be caught by +/// the PhoneListUnitService tests. GetViperCurrentEmployees layers PersonSearchHelper on top +/// of the CurrentEmployee filter. +/// +public sealed class PhonePersonLookupServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonLookupService _service; + + private const string MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + private const string CallerIam = "caller01"; + + public PhonePersonLookupServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonesPermissionsService(rapsContext, _userHelper); + _service = new PhonePersonLookupService(_context, permissionsService); + + _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + /// The seeded list, as the controller would hand it to the service. + private PhoneList TestList() => + _context.PhoneList.Single(l => l.PhoneListId == 1); + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenNoListSupplied() + { + var results = await _service.GetPhonePeople(["person01"], ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_MasksDirectPhone_WhenCallerLacksMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(false); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_ShowsDirectPhone_WhenCallerHasMaintainAccessToList() + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), MaintainRole).Returns(true); + + var results = await _service.GetPhonePeople(["person01"], TestList(), ct: TestContext.Current.CancellationToken); + + var person = Assert.Single(results); + Assert.Equal("530-555-2000", person.DirectPhone); + } + + [Fact] + public async Task GetPhonePeople_IgnoresBlankAndWhitespaceIamIds() + { + var results = await _service.GetPhonePeople( + ["person01", "", " ", null!], + ct: TestContext.Current.CancellationToken); + + Assert.Single(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ReturnsEmpty_WhenSearchBelowMinimumLength() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("a", TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetViperCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.AddRange( + new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Amy", + LastName = "Smith", + FullName = "Amy Smith", + CurrentEmployee = true, + }, + new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Amy", + LastName = "Smithson", + FullName = "Amy Smithson", + CurrentEmployee = false, + } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetViperCurrentEmployees("Smith", TestContext.Current.CancellationToken); + + var match = Assert.Single(results); + Assert.Equal("person01", match.IamId); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs new file mode 100644 index 000000000..c124be591 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberControllerTests.cs @@ -0,0 +1,101 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller wiring tests for PhoneSVMFrequentNumberController: verifies the +/// InvalidOperationException-to-400 mapping used across the phones endpoints when +/// a maintain action targets a row that doesn't exist (or was already removed). +/// +public sealed class PhoneSVMFrequentNumberControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMFrequentNumberController _controller; + + public PhoneSVMFrequentNumberControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMFrequentNumberController(new PhoneSVMFrequentNumberService(_context, userHelper)); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task UpdateFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + var result = await _controller.UpdateFrequentNumber(999, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteFrequentNumber(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteFrequentNumber_ReturnsOk_AndSoftDeletes_WhenFound() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } + + [Fact] + public async Task GetFrequentNumbers_ReturnsOnlyActiveNumbers() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Active Line", Phone = "1", IsActive = true }, + new SVMFrequentNumber { NumberId = 2, Label = "Retired Line", Phone = "2", IsActive = false } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetFrequentNumbers(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var numbers = Assert.IsAssignableFrom>(okResult.Value); + Assert.Equal(["Active Line"], numbers.Select(n => n.Label)); + } +} diff --git a/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs new file mode 100644 index 000000000..6814819f6 --- /dev/null +++ b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs @@ -0,0 +1,116 @@ +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMFrequentNumberService, focused on the soft-delete +/// convention (rows are marked inactive rather than removed, so ModifiedDate keeps +/// tracking when the list last changed) and the SQL Server 2016-safe null-last +/// SortOrder ordering. +/// +public sealed class PhoneSVMFrequentNumberServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneSVMFrequentNumberService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMFrequentNumberServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMFrequentNumberService(_context, _userHelper); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task AddFrequentNumber_SetsIsActiveTrue_AndModifiedMetadata() + { + var request = new SVMFrequentNumberRequest { Label = "Front Desk", Phone = "530-555-1000" }; + + await _service.AddFrequentNumber(request, TestContext.Current.CancellationToken); + + var saved = await _context.SVMFrequentNumber + .SingleAsync(n => n.Label == "Front Desk", TestContext.Current.CancellationToken); + Assert.True(saved.IsActive); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + } + + [Fact] + public async Task DeleteFrequentNumber_SoftDeletes_ExcludesRowFromGetSVMFrequentNumbers() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Pharmacy", + Phone = "530-555-2000", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken); + + var stillExists = await _context.SVMFrequentNumber + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + + var activeNumbers = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + Assert.Empty(activeNumbers); + } + + [Fact] + public async Task DeleteFrequentNumber_Throws_WhenAlreadyInactive() + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Retired Line", + Phone = "530-555-3000", + IsActive = false, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await Assert.ThrowsAsync( + () => _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task GetSVMFrequentNumbers_OrdersRowsWithNoSortOrderLast() + { + _context.SVMFrequentNumber.AddRange( + new SVMFrequentNumber { NumberId = 1, Label = "Zebra Unsorted", Phone = "1", IsActive = true, SortOrder = null }, + new SVMFrequentNumber { NumberId = 2, Label = "Pharmacy", Phone = "2", IsActive = true, SortOrder = 2 }, + new SVMFrequentNumber { NumberId = 3, Label = "Front Desk", Phone = "3", IsActive = true, SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMFrequentNumbers(TestContext.Current.CancellationToken); + + Assert.Equal(["Front Desk", "Pharmacy", "Zebra Unsorted"], results.Select(r => r.Label)); + } +} diff --git a/test/Personnel/PhoneSVMSectionServiceTests.cs b/test/Personnel/PhoneSVMSectionServiceTests.cs new file mode 100644 index 000000000..dfd484fa8 --- /dev/null +++ b/test/Personnel/PhoneSVMSectionServiceTests.cs @@ -0,0 +1,51 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMSectionService, covering the SQL Server 2016-safe null-last +/// SortOrder ordering convention shared with PhoneListUnitService and +/// PhoneSVMFrequentNumberService. +/// +public sealed class PhoneSVMSectionServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionService _service; + + public PhoneSVMSectionServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + _service = new PhoneSVMSectionService(_context); + } + + public void Dispose() => _context.Dispose(); + + [Fact] + public async Task GetSVMSections_ReturnsEmptyList_WhenNoSectionsExist() + { + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Empty(results); + } + + [Fact] + public async Task GetSVMSections_OrdersRowsWithNoSortOrderLast() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Zebra Unsorted", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Registrar", SortOrder = 2 }, + new SVMSection { SectionId = 3, Name = "Dean's Office", SortOrder = 1 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await _service.GetSVMSections(TestContext.Current.CancellationToken); + + Assert.Equal(["Dean's Office", "Registrar", "Zebra Unsorted"], results.Select(r => r.Name)); + } +} diff --git a/test/Personnel/PhoneSVMUnitServiceTests.cs b/test/Personnel/PhoneSVMUnitServiceTests.cs new file mode 100644 index 000000000..3a87ad664 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitServiceTests.cs @@ -0,0 +1,516 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Unit tests for PhoneSVMUnitService, covering the two places its row handling is asymmetric. +/// DeleteUnitRow: a row is a leader plus the unit-wide admin staff, so deleting it removes the +/// leader and then the staff only once no other row still lists them - in one transaction, since +/// as separate per-record requests the pair could half-apply. +/// AddOrUpdateUnitData: one method serves both POST and PUT, so add and edit are distinguished +/// only by DeanUnitPerson/StaffUnitPerson - unset (-1) means "add another person to this unit" +/// and leaves existing rows alone, while a real id means "replace the person on that row" and +/// must deactivate it even though the incoming DeanIam/StaffIam no longer names its occupant. +/// +public sealed class PhoneSVMUnitServiceTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitService _service; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitServiceTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _service = new PhoneSVMUnitService(_context, userHelper); + } + + public void Dispose() => _context.Dispose(); + + private void SeedUnit() + { + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "dean01", Phone = "530-555-1000" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = "staff01", Phone = "530-555-2000" }); + _context.SaveChanges(); + } + + + [Fact] + public async Task DeleteUnitRow_SoftDeletesTheLeader_AndTheStaffItWasTheLastRowFor() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // One call, not one per underlying record: the caller names the row, the service decides + // which records that covers. + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_KeepsTheStaff_WhenAnotherLeaderRowStillListsThem() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 3, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 3 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.True(staffRow.IsActive); + + var survivingLeader = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(survivingLeader); + Assert.True(survivingLeader.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_RemovesTheStaff_WhenNamedForAStaffOnlyRow() + { + SeedUnit(); + // A unit with staff but no active leader renders one row keyed by the staff record, so + // that id is what the delete arrives with. + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var staffRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(staffRow); + Assert.False(staffRow.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_LeavesOtherUnitsAlone() + { + SeedUnit(); + _context.SVMUnit.Add(new SVMUnit { UnitId = 2, SectionId = 1, Name = "Another Unit" }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 2, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var otherUnitStaff = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(otherUnitStaff); + Assert.True(otherUnitStaff.IsActive); + } + + [Fact] + public async Task DeleteUnitRow_Throws_WhenNotFound() + { + await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(999, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_Throws_WhenUnitNotFound() + { + var request = new SVMUnitDataRequest { DeanIam = "dean01", DeanPhone = "530-555-1000" }; + + await Assert.ThrowsAsync( + () => _service.AddOrUpdateUnitData(999, request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesPreviousUnitPeople_AndAddsNewActiveRows() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Fax = " 530-555-9999 ", + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1111", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var oldRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(oldRow); + Assert.False(oldRow.IsActive); + + var newRow = await _context.SVMUnitPerson + .SingleAsync(p => p.IsActive && p.PersonIam == "dean01", TestContext.Current.CancellationToken); + Assert.Equal("Dean", newRow.PosType); + + var unit = await _context.SVMUnit.FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.Equal("530-555-9999", unit!.Fax); + } + + [Fact] + public async Task GetSVMUnits_AlwaysBlanksDirectPhone() + { + SeedUnit(); + var phonePerson = await _context.PhonePerson + .SingleAsync(p => p.PersonIam == "dean01", TestContext.Current.CancellationToken); + phonePerson.DirectPhone = "530-555-4000"; + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "dean01", + FirstName = "Dean", + LastName = "Person", + FullName = "Dean Person", + CurrentEmployee = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var units = await _service.GetSVMUnits(TestContext.Current.CancellationToken); + + var person = Assert.Single(Assert.Single(units).UnitPersons); + Assert.Equal("", person.Person.DirectPhone); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheDeanIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // Editing the row for dean01 and choosing dean02 instead. The outgoing person is + // identified only by DeanUnitPerson, since DeanIam now names the incoming person. + var request = new SVMUnitDataRequest + { + Fax = "530-555-9999", + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeLeader = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean02", activeLeader.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_DeactivatesTheEditedRow_WhenTheStaffIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "staff02", + StaffPhone = "530-555-2002", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var replacedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(replacedRow); + Assert.False(replacedRow.IsActive); + + var activeStaff = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("staff02", activeStaff.PersonIam); + } + + [Fact] + public async Task AddOrUpdateUnitData_LeavesOtherLeaderRowsActive_WhenOneLeaderIsReplaced() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "dean02", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // A unit legitimately has several leader rows; replacing one must not disturb the rest. + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean03", + DeanPhone = "530-555-1113", + DeanUnitPerson = 1, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var untouchedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(untouchedRow); + Assert.True(untouchedRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean02"); + Assert.Contains(activeLeaders, p => p.PersonIam == "dean03"); + } + + [Fact] + public async Task AddOrUpdateUnitData_KeepsExistingLeaders_WhenUnitPersonIdsAreUnset() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + // The add path leaves DeanUnitPerson/StaffUnitPerson at their -1 default, which is what + // separates "add another leader to this unit" from "replace the person on this row". + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean02", + DeanPhone = "530-555-1112", + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var existingRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 1 }, TestContext.Current.CancellationToken); + Assert.NotNull(existingRow); + Assert.True(existingRow.IsActive); + + var activeLeaders = await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType != "Staff") + .ToListAsync(TestContext.Current.CancellationToken); + Assert.Equal(2, activeLeaders.Count); + } + + [Fact] + public async Task AddOrUpdateUnitData_RemovesStaff_WhenClearedFromTheEditedRow() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var request = new SVMUnitDataRequest + { + Location = "Room 500", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + DeanUnitPerson = 1, + StaffIam = "", + StaffUnitPerson = 2, + }; + + await _service.AddOrUpdateUnitData(1, request, TestContext.Current.CancellationToken); + + var clearedRow = await _context.SVMUnitPerson + .FindAsync(new object?[] { 2 }, TestContext.Current.CancellationToken); + Assert.NotNull(clearedRow); + Assert.False(clearedRow.IsActive); + + Assert.Empty(await _context.SVMUnitPerson + .Where(p => p.UnitId == 1 && p.IsActive && p.PosType == "Staff") + .ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task DeleteUnitRow_ReportsAnAlreadyDeletedRow_AsRemoved() + { + SeedUnit(); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + }); + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 2, + UnitId = 1, + PersonIam = "staff01", + PosType = "Staff", + IsActive = true, + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + await _service.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + // A maintainer whose page predates someone else's delete. Deleting again must say so + // rather than silently repeating the cascade over rows that are already gone. + var ex = await Assert.ThrowsAsync( + () => _service.DeleteUnitRow(1, TestContext.Current.CancellationToken)); + Assert.Equal("That record has already been removed.", ex.Message); + } +} diff --git a/web/Areas/CMS/Controllers/CMSOptionsController.cs b/web/Areas/CMS/Controllers/CMSOptionsController.cs index 1a7419353..549f059a6 100644 --- a/web/Areas/CMS/Controllers/CMSOptionsController.cs +++ b/web/Areas/CMS/Controllers/CMSOptionsController.cs @@ -4,6 +4,8 @@ using Viper.Areas.RAPS.Services; using Viper.Classes; using Viper.Classes.SQLContext; +using Viper.Classes.Utilities; +using Viper.Models.AAUD; using Web.Authorization; namespace Viper.Areas.CMS.Controllers @@ -65,22 +67,23 @@ public async Task>> GetPermissions(CancellationToken c [HttpGet("people")] public async Task>> SearchPeople(string search, CancellationToken ct = default) { - if (string.IsNullOrWhiteSpace(search) || search.Trim().Length < 2) + var normalizedSearch = PersonSearchHelper.Normalize(search); + if (normalizedSearch == null) { return new List(); } - search = search.Trim(); - return await _aaudContext.AaudUsers + var namePredicate = PersonSearchHelper + .NameMatches(u => u.DisplayLastName, u => u.DisplayFirstName, normalizedSearch) + .Or(u => u.LoginId != null && u.LoginId.Contains(normalizedSearch)) + .Or(u => u.MailId != null && u.MailId.Contains(normalizedSearch)); + + var query = _aaudContext.AaudUsers .AsNoTracking() .Where(u => u.Current != 0 && u.IamId != null) - .Where(u => (u.DisplayLastName + ", " + u.DisplayFirstName).Contains(search) - || (u.DisplayFirstName + " " + u.DisplayLastName).Contains(search) - || (u.LoginId != null && u.LoginId.Contains(search)) - || (u.MailId != null && u.MailId.Contains(search))) - .OrderBy(u => u.DisplayLastName) - .ThenBy(u => u.DisplayFirstName) - .Take(25) + .Where(namePredicate); + + return await PersonSearchHelper.OrderAndCap(query, u => u.DisplayLastName, u => u.DisplayFirstName) .Select(u => new CmsPersonOption { IamId = u.IamId!, diff --git a/web/Areas/Personnel/Controllers/PhoneListController.cs b/web/Areas/Personnel/Controllers/PhoneListController.cs new file mode 100644 index 000000000..d7236cacc --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListController.cs @@ -0,0 +1,48 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist")] + [Permission(Allow = "SVMSecure")] + public class PhoneListController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonesPermissionsService phonesPermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonesPermissionsService _phonesPermissionsService = phonesPermissionsService; + + /// + /// Everything a client needs before it fetches rows: + /// Returns the list's display name plus this caller's permissions. + /// Returned together to reduce API calls on the front end. + /// The backend continues to enforce permissions, but the front end + /// knows what data to expect and display based on these results. + /// + [HttpGet("{code}")] + public async Task> GetListInfo(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + return Ok(new PhoneListInfo + { + PhoneListId = list.PhoneListId, + Code = list.Code, + Name = list.Name, + CanMaintain = _phonesPermissionsService.CanMaintainList(list), + CanViewDirectPhone = await _phoneListUnitService.CanViewDirectPhone(list, ct), + }); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs new file mode 100644 index 000000000..a0ee9926a --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListModifiedDateController.cs @@ -0,0 +1,36 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/phonelist/{code}/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneListModifiedDateController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + + /// + /// Returns the latest modification date of a UnitPerson in this list. + /// Includes deleted rows. + /// + [HttpGet] + public async Task> GetLastModifiedDate(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var unitPersonDate = await _phoneListUnitService.GetUnitPersonModifiedDate(list.PhoneListId, ct); + return Ok(unitPersonDate); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListUnitController.cs b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs new file mode 100644 index 000000000..579dfc895 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs @@ -0,0 +1,132 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + /// + /// Unit and unit-person endpoints for a phone list, addressed by the list's stable Code. + /// Write access is the role named by that list's MaintainRole column, so each list + /// can have separate permissions. + /// + [Route("/api/phones/phonelist/{code}")] + [Permission(Allow = "SVMSecure")] + public class PhoneListUnitController( + PhoneListService phoneListService, + PhoneListUnitService phoneListUnitService, + PhonesPermissionsService phonesPermissionsService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; + private readonly PhonesPermissionsService _phonesPermissionsService = phonesPermissionsService; + + /// + /// Resolves the list named in the route and confirms the caller may edit it. Returns the + /// list id on success, or the ActionResult to return to the caller on failure. + /// + private async Task<(int ListId, ActionResult? Failure)> ResolveListForMaintain(string code, CancellationToken ct) + { + PhoneList list; + try + { + list = await _phoneListService.GetListByCode(code, ct); + } + catch (InvalidOperationException ex) + { + return (0, NotFound(ex.Message)); + } + if (!_phonesPermissionsService.CanMaintainList(list)) + { + return (0, Forbid()); + } + return (list.PhoneListId, null); + } + + /// + /// Retrieves the PhoneListUnits associated with a given list code, including the PhoneListUnitPersons + /// in that unit. + /// + [HttpGet("units")] + public async Task>> GetUnits(string code, CancellationToken ct = default) + { + try + { + var list = await _phoneListService.GetListByCode(code, ct); + var results = await _phoneListUnitService.GetPhoneListUnits(list, ct); + return Ok(results); + } + catch (InvalidOperationException ex) + { + return NotFound(ex.Message); + } + } + + /// + /// Adds a unit person to a given list, provided the user has appropriate permissions. + /// + [HttpPost("unitPerson")] + public async Task AddUnitPersonData(string code, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.AddUnitPersonData(listId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a unit person in a given list, provided the user has appropriate permissions. + /// + [HttpPut("unitPerson/{unitPersonId}")] + public async Task UpdateUnitPersonData(string code, int unitPersonId, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.UpdateUnitPersonData(listId, unitPersonId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a unit person from a given list, provided the user has appropriate permissions. + /// + [HttpDelete("unitPerson/{unitPersonId}")] + public async Task DeleteUnitPersonData(string code, int unitPersonId, CancellationToken ct = default) + { + var (listId, failure) = await ResolveListForMaintain(code, ct); + if (failure != null) + { + return failure; + } + try + { + await _phoneListUnitService.DeleteUnitPersonData(listId, unitPersonId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhonePersonController.cs b/web/Areas/Personnel/Controllers/PhonePersonController.cs new file mode 100644 index 000000000..efa54a533 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhonePersonController.cs @@ -0,0 +1,59 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/people")] + [Permission(Allow = "SVMSecure")] + public class PhonePersonController( + PhoneListService phoneListService, + PhonePersonLookupService phonePersonService) : ApiController + { + private readonly PhoneListService _phoneListService = phoneListService; + private readonly PhonePersonLookupService _phonePersonService = phonePersonService; + + /// + /// Person picker for the phone-record dialogs. Only returns direct numbers if the user + /// can edit the current list (and so has access to the data). + /// + [HttpGet] + public async Task>> GetCurrentEmployees(string search, string? listCode = null, CancellationToken ct = default) + { + PhoneList? list = null; + if (!string.IsNullOrWhiteSpace(listCode)) + { + try + { + list = await _phoneListService.GetListByCode(listCode, ct); + } + catch (InvalidOperationException) + { + list = null; + } + } + + List viperResults = await _phonePersonService.GetViperCurrentEmployees(search, ct); + List iamIds = []; + foreach (ViperPerson result in viperResults) + { + iamIds.Add(result.IamId); + } + List phoneResults = await _phonePersonService.GetPhonePeople(iamIds, list, ct); + Dictionary mergedResultsDict = []; + foreach (ViperPerson result in viperResults) + { + mergedResultsDict[result.IamId] = PersonnelMapper.ToAugmentedViperPerson(result); + } + List matchingResults = [.. phoneResults.Where(x => mergedResultsDict.ContainsKey(x.PersonIam))]; + + foreach (PhonePerson result in matchingResults) + { + mergedResultsDict[result.PersonIam].AddPhoneData(result); + } + return Ok(mergedResultsDict.Values.ToList()); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs new file mode 100644 index 000000000..94aa79f84 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMFrequentNumberController.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/frequentnumbers")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMFrequentNumberController(PhoneSVMFrequentNumberService phoneSVMFrequentNumberService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + + /// + /// Gets the list of frequently called numbers for the SVM Phone List. + /// + [HttpGet] + public async Task>> GetFrequentNumbers(CancellationToken ct = default) + { + var results = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbers(ct); + return Ok(results); + } + + /// + /// Adds a frequently called number to the SVM Phone List. + /// + [HttpPost] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddFrequentNumber(SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.AddFrequentNumber(request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates a frequently called number in the SVM Phone List. + /// + [HttpPut("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateFrequentNumber(int entryId, SVMFrequentNumberRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.UpdateFrequentNumber(entryId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes a frequently called number from the SVM Phone List. + /// + [HttpDelete("{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteFrequentNumber(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMFrequentNumberService.DeleteFrequentNumber(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs new file mode 100644 index 000000000..28407ab24 --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/modifiedDate")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMModifiedDateController( + PhoneSVMFrequentNumberService phoneSVMFrequentNumberService, + PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMFrequentNumberService _phoneSVMFrequentNumberService = phoneSVMFrequentNumberService; + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Identfies when frequent numbers were last modified. + /// + [HttpGet] + public async Task> GetLastModifiedDate(CancellationToken ct = default) + { + var frequentNumberDate = await _phoneSVMFrequentNumberService.GetSVMFrequentNumbersModifiedDate(ct); + var unitPersonDate = await _phoneSVMUnitService.GetSVMUnitPersonModifiedDate(ct); + if (frequentNumberDate == null || unitPersonDate != null && unitPersonDate > frequentNumberDate) + { + return Ok(unitPersonDate); + } + return Ok(frequentNumberDate); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs new file mode 100644 index 000000000..5fa4113fd --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMSectionController.cs @@ -0,0 +1,29 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm/sections")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMSectionController(PhoneSVMSectionService phoneSVMSectionService) : ApiController + { + private readonly PhoneSVMSectionService _phoneSVMSectionService = phoneSVMSectionService; + + /// + /// Gets the sections to include in the SVM Phone List. + /// + [HttpGet] + public async Task>> GetSections(CancellationToken ct = default) + { + var results = await _phoneSVMSectionService.GetSVMSections(ct); + if (results == null || results.Count == 0) + { + return NotFound("No sections for the SVM Phone List were found."); + } + return Ok(results); + } + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs new file mode 100644 index 000000000..759e7a2ba --- /dev/null +++ b/web/Areas/Personnel/Controllers/PhoneSVMUnitController.cs @@ -0,0 +1,84 @@ +using Microsoft.AspNetCore.Mvc; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes; +using Web.Authorization; + +namespace Viper.Areas.Personnel.Controllers +{ + [Route("/api/phones/svm")] + [Permission(Allow = "SVMSecure")] + public class PhoneSVMUnitController(PhoneSVMUnitService phoneSVMUnitService) : ApiController + { + private readonly PhoneSVMUnitService _phoneSVMUnitService = phoneSVMUnitService; + + /// + /// Gets all units for every section in the SVM Phone List. + /// + [HttpGet("units")] + public async Task>> GetUnits(CancellationToken ct = default) + { + var results = await _phoneSVMUnitService.GetSVMUnits(ct); + return Ok(results); + } + + /// + /// Adds data to a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPost("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task AddUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Updates data in a unit for the SVM Phone List. + /// This affects both SVMUnit and SVMUnitPerson. + /// + [HttpPut("units/{unitId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task UpdateUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.AddOrUpdateUnitData(unitId, request, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + /// + /// Deletes one row of the SVM list, identified by the row key the list renders. + /// This may delete multiple SVMUnitPerson. + /// Handled this way to match the end user experience and wrap multiple + /// deletions in a transaction. + /// + [HttpDelete("rows/{entryId}")] + [Permission(Allow = "SVMSecure.PhoneLists.SVMMaintain")] + public async Task DeleteUnitRow(int entryId, CancellationToken ct = default) + { + try + { + await _phoneSVMUnitService.DeleteUnitRow(entryId, ct); + return Ok(true); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + } +} diff --git a/web/Areas/Personnel/Models/AugmentedViperPerson.cs b/web/Areas/Personnel/Models/AugmentedViperPerson.cs new file mode 100644 index 000000000..fbf094c30 --- /dev/null +++ b/web/Areas/Personnel/Models/AugmentedViperPerson.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for combining ViperPerson and PhonePerson results for name searches. + /// + public class AugmentedViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public string IamId { get; set; } = string.Empty; + public required bool CurrentEmployee { get; set; } = false; + public string MailId { get; set; } = string.Empty; + public PhonePerson? PhoneData { get; set; } = null; + + public void AddPhoneData(PhonePerson phonePerson) + { + this.PhoneData = phonePerson; + } + } +} diff --git a/web/Areas/Personnel/Models/PersonnelMapper.cs b/web/Areas/Personnel/Models/PersonnelMapper.cs new file mode 100644 index 000000000..71797c45b --- /dev/null +++ b/web/Areas/Personnel/Models/PersonnelMapper.cs @@ -0,0 +1,13 @@ +using Riok.Mapperly.Abstractions; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Mapperly mapper to create an AugmentedViperPerson from a ViperPerson. + /// + [Mapper(RequiredMappingStrategy = RequiredMappingStrategy.None)] + public static partial class PersonnelMapper + { + public static partial AugmentedViperPerson ToAugmentedViperPerson(ViperPerson source); + } +} diff --git a/web/Areas/Personnel/Models/PhoneList.cs b/web/Areas/Personnel/Models/PhoneList.cs new file mode 100644 index 000000000..f56a35a8f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneList.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneList, + /// a (typically unit/department-level) grouping for phone numbers. + /// + public class PhoneList + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListId { get; set; } + + /** + * Stable lookup key used in routes and API paths (e.g. "VMDO"). + * Allows changing Name without breaking links. + */ + public required string Code { get; set; } + public required string Name { get; set; } + public required string MaintainRole { get; set; } + + public virtual ICollection PhoneListUnits { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListInfo.cs b/web/Areas/Personnel/Models/PhoneListInfo.cs new file mode 100644 index 000000000..18b30abad --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListInfo.cs @@ -0,0 +1,16 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// What a client needs to render a phone list before it fetches any rows: the display name + /// plus the caller's own capabilities. Allows the client to render correctly + /// based on the permissions that will be enforced on the back end. + /// + public class PhoneListInfo + { + public int PhoneListId { get; set; } + public required string Code { get; set; } + public required string Name { get; set; } + public bool CanMaintain { get; set; } + public bool CanViewDirectPhone { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnit.cs b/web/Areas/Personnel/Models/PhoneListUnit.cs new file mode 100644 index 000000000..ff0b73b9f --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnit.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnit, + /// a grouping within a phone list. + /// + public class PhoneListUnit + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitId { get; set; } + public required int PhoneListId { get; set; } + public required string Name { get; set; } + public int? SortOrder { get; set; } + + public virtual PhoneList PhoneList { get; set; } = null!; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs new file mode 100644 index 000000000..1adfc6015 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitDataRequests.cs @@ -0,0 +1,15 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in unit-specific phone list tables. + /// + public class PhoneListUnitDataRequest + { + public required int UnitId { get; set; } + public string Office { get; set; } = ""; + public required string EmployeeIam { get; set; } + public string Phone { get; set; } = ""; + public string DirectPhone { get; set; } = ""; + public bool ListFirst { get; set; } = false; + } +} diff --git a/web/Areas/Personnel/Models/PhoneListUnitPerson.cs b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs new file mode 100644 index 000000000..133b30d00 --- /dev/null +++ b/web/Areas/Personnel/Models/PhoneListUnitPerson.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.PhoneListUnitPerson, + /// connecting people to a given unit for a phone list. + /// + public class PhoneListUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int PhoneListUnitPersonId { get; set; } + public required int PhoneListUnitId { get; set; } + public required string PersonIam { get; set; } + public required bool ListFirst { get; set; } + public required bool IsActive { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual PhoneListUnit PhoneListUnit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/PhonePerson.cs b/web/Areas/Personnel/Models/PhonePerson.cs new file mode 100644 index 000000000..73c182cc4 --- /dev/null +++ b/web/Areas/Personnel/Models/PhonePerson.cs @@ -0,0 +1,21 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.Person. + /// Ties a person to phone number and office data. + /// + public class PhonePerson + { + public required string PersonIam { get; set; } + public string? Phone { get; set; } + public string? DirectPhone { get; set; } + public string? Office { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ICollection PhoneListUnitPersons { get; set; } = []; + public virtual ViperPerson? ViperPerson { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumber.cs b/web/Areas/Personnel/Models/SVMFrequentNumber.cs new file mode 100644 index 000000000..0e1da9e46 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumber.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents data from phones.SVMFrequentNumber. + /// Provides a spot for additional important phone + /// numbers not tied to a specific person. + /// + public class SVMFrequentNumber + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int NumberId { get; set; } + public required string Label { get; set; } + public required string Phone { get; set; } + public int? SortOrder { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + public bool IsActive { get; set; } + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs new file mode 100644 index 000000000..89a365e84 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMFrequentNumberRequests.cs @@ -0,0 +1,11 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM frequently called numbers table. + /// + public class SVMFrequentNumberRequest + { + public required string Label { get; set; } = ""; + public required string Phone { get; set; } = ""; + } +} diff --git a/web/Areas/Personnel/Models/SVMSection.cs b/web/Areas/Personnel/Models/SVMSection.cs new file mode 100644 index 000000000..4901b625b --- /dev/null +++ b/web/Areas/Personnel/Models/SVMSection.cs @@ -0,0 +1,18 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMSection, + /// a grouping for the SVM Phone List. + /// + public class SVMSection + { + public required int SectionId { get; set; } + public string? Name { get; set; } + public bool? IncludeAbbrv { get; set; } + public string? UnitName { get; set; } + public string? DirectorTitle { get; set; } + public int? SortOrder { get; set; } + + public virtual ICollection Units { get; set; } = []; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnit.cs b/web/Areas/Personnel/Models/SVMUnit.cs new file mode 100644 index 000000000..77459a484 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnit.cs @@ -0,0 +1,22 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnit, + /// a department, unit, or dean's office. + /// + public class SVMUnit + { + public required int UnitId { get; set; } + public required int SectionId { get; set; } + public string? Name { get; set; } + public string? Abbrv { get; set; } + public int? SortOrder { get; set; } + public string? Fax { get; set; } + public string? ModifiedBy { get; set; } + public DateTime? ModifiedDate { get; set; } + + public virtual SVMSection Section { get; set; } = null!; + public virtual ICollection UnitPersons { get; set; } = []; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitDataRequests.cs b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs new file mode 100644 index 000000000..d6e64cf08 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitDataRequests.cs @@ -0,0 +1,19 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Class for form data to create or edit a row in the SVM table. + /// + public class SVMUnitDataRequest + { + public string Fax { get; set; } = ""; + public string Location { get; set; } = ""; + public string DeanIam { get; set; } = ""; + public string DeanPhone { get; set; } = ""; + public string DeanInterim { get; set; } = ""; + public int DeanUnitPerson { get; set; } = -1; + public string StaffIam { get; set; } = ""; + public string StaffPhone { get; set; } = ""; + public string StaffInterim { get; set; } = ""; + public int StaffUnitPerson { get; set; } = -1; + } +} diff --git a/web/Areas/Personnel/Models/SVMUnitPerson.cs b/web/Areas/Personnel/Models/SVMUnitPerson.cs new file mode 100644 index 000000000..fa5c08863 --- /dev/null +++ b/web/Areas/Personnel/Models/SVMUnitPerson.cs @@ -0,0 +1,27 @@ +using System.ComponentModel.DataAnnotations.Schema; + +namespace Viper.Areas.Personnel.Models +{ + /// + /// Represents an entry from phones.SVMUnitPerson, + /// connecting people in leadership and admin roles to a + /// given unit. + /// + public class SVMUnitPerson + { + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public int UnitPersonId { get; set; } + public required int UnitId { get; set; } + public required string PersonIam { get; set; } + public string? Office { get; set; } + public string? PosType { get; set; } + public string? Interim { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + public bool IsActive { get; set; } + + public virtual SVMUnit Unit { get; set; } = null!; + public virtual PhonePerson Person { get; set; } = null!; + public virtual ViperPerson? ViperModPerson { get; set; } + } +} diff --git a/web/Areas/Personnel/Models/ViperPerson.cs b/web/Areas/Personnel/Models/ViperPerson.cs new file mode 100644 index 000000000..9b955ffd4 --- /dev/null +++ b/web/Areas/Personnel/Models/ViperPerson.cs @@ -0,0 +1,17 @@ +namespace Viper.Areas.Personnel.Models +{ + /// + /// Read-only entity for accessing users.Person table within PhonesDbContext. + /// Used for joining to get employee names without cross-context queries. + /// + public class ViperPerson + { + public int PersonId { get; set; } + public string FirstName { get; set; } = string.Empty; + public string LastName { get; set; } = string.Empty; + public string FullName { get; set; } = string.Empty; + public required string IamId { get; set; } + public required bool CurrentEmployee { get; set; } = false; + public string MailId { get; set; } = string.Empty; + } +} diff --git a/web/Areas/Personnel/PhonesDbContext.cs b/web/Areas/Personnel/PhonesDbContext.cs new file mode 100644 index 000000000..aeeca70b0 --- /dev/null +++ b/web/Areas/Personnel/PhonesDbContext.cs @@ -0,0 +1,216 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel; + +/// +/// Entity Framework DbContext for the Personnel phone numbers system. +/// All tables are in the [phones] schema in the VIPER database. +/// +public class PhonesDbContext : DbContext +{ + public PhonesDbContext(DbContextOptions options) : base(options) + { + } + + // Core data tables + public virtual DbSet PhonePerson { get; set; } + public virtual DbSet SVMSection { get; set; } + public virtual DbSet SVMUnit { get; set; } + public virtual DbSet SVMUnitPerson { get; set; } + public virtual DbSet SVMFrequentNumber { get; set; } + public virtual DbSet PhoneList { get; set; } + public virtual DbSet PhoneListUnit { get; set; } + public virtual DbSet PhoneListUnitPerson { get; set; } + + // Read-only cross-schema reference (users schema in same database) + public virtual DbSet ViperPerson { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // PhonePerson (phones.Person) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PersonIam); + entity.ToTable("Person", schema: "phones"); + + entity.Property(e => e.PersonIam).HasColumnName("PersonIam"); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.DirectPhone).HasColumnName("DirectPhone").HasMaxLength(25); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(100); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy"); + + // Cross-schema FK to users.Person + entity.HasOne(e => e.ViperPerson) + .WithMany() + .HasForeignKey(e => e.PersonIam) + .HasPrincipalKey(e => e.IamId) + .IsRequired(true); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMSection (phones.SVMSection) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SectionId); + entity.ToTable("SVMSection", schema: "phones"); + + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.IncludeAbbrv).HasColumnName("IncludeAbbrv"); + entity.Property(e => e.UnitName).HasColumnName("UnitName").HasMaxLength(50); + entity.Property(e => e.DirectorTitle).HasColumnName("DirectorTitle").HasMaxLength(50); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + }); + + // SVMUnit (phones.SVMUnit) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitId); + entity.ToTable("SVMUnit", schema: "phones"); + + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.SectionId).HasColumnName("SectionId"); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.Fax).HasColumnName("Fax").HasMaxLength(25); + entity.Property(e => e.Abbrv).HasColumnName("Abbrv").HasMaxLength(20); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + + entity.HasOne(e => e.Section) + .WithMany(s => s.Units) + .HasForeignKey(e => e.SectionId); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMUnitPerson (phones.SVMUnitPerson) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.UnitPersonId); + entity.ToTable("SVMUnitPerson", schema: "phones"); + + entity.Property(e => e.UnitPersonId).HasColumnName("UnitPersonId"); + entity.Property(e => e.UnitId).HasColumnName("UnitId"); + entity.Property(e => e.PersonIam).HasColumnName("PersonIam").HasMaxLength(10); + entity.Property(e => e.Office).HasColumnName("Office").HasMaxLength(50); + entity.Property(e => e.PosType).HasColumnName("PosType").HasMaxLength(25); + entity.Property(e => e.Interim).HasColumnName("Interim").HasMaxLength(10); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.Unit) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.UnitId); + + entity.HasOne(e => e.Person) + .WithMany(s => s.UnitPersons) + .HasForeignKey(e => e.PersonIam); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // SVMFrequentNumber (phones.SVMFrequentNumber) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.NumberId); + entity.ToTable("SVMFrequentNumber", schema: "phones"); + + entity.Property(e => e.NumberId).HasColumnName("NumberId"); + entity.Property(e => e.Label).HasColumnName("Label").HasMaxLength(100); + entity.Property(e => e.Phone).HasColumnName("Phone").HasMaxLength(25); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // PhoneList (phones.PhoneList) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PhoneListId); + entity.ToTable("PhoneList", schema: "phones"); + + entity.Property(e => e.Code).HasColumnName("Code").HasMaxLength(20); + entity.HasIndex(e => e.Code).IsUnique(); + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.MaintainRole).HasColumnName("MaintainRole").HasMaxLength(100); + }); + + // PhoneListUnit (phones.PhoneListUnit) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PhoneListUnitId); + entity.ToTable("PhoneListUnit", schema: "phones"); + + entity.Property(e => e.Name).HasColumnName("Name").HasMaxLength(100); + entity.Property(e => e.PhoneListId).HasColumnName("PhoneListId"); + entity.Property(e => e.SortOrder).HasColumnName("SortOrder"); + + entity.HasOne(e => e.PhoneList) + .WithMany(s => s.PhoneListUnits) + .HasForeignKey(e => e.PhoneListId); + }); + + // PhoneListUnitPerson (phones.PhoneListUnitPerson) + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PhoneListUnitPersonId); + entity.ToTable("PhoneListUnitPerson", schema: "phones"); + + entity.Property(e => e.PhoneListUnitId).HasColumnName("PhoneListUnitId"); + entity.Property(e => e.PersonIam).HasColumnName("PersonIam"); + entity.Property(e => e.ListFirst).HasColumnName("ListFirst"); + entity.Property(e => e.IsActive).HasColumnName("IsActive"); + entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); + entity.Property(e => e.ModifiedDate).HasColumnName("ModifiedDate"); + + entity.HasOne(e => e.PhoneListUnit) + .WithMany(s => s.PhoneListUnitPersons) + .HasForeignKey(e => e.PhoneListUnitId); + + entity.HasOne(e => e.Person) + .WithMany(s => s.PhoneListUnitPersons) + .HasForeignKey(e => e.PersonIam); + + entity.HasOne(e => e.ViperModPerson) + .WithMany() + .HasForeignKey(e => e.ModifiedBy) + .HasPrincipalKey(e => e.IamId); + }); + + // ViperPerson (users.Person) - read-only cross-schema reference + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PersonId); + entity.ToTable("Person", schema: "users"); + + entity.Property(e => e.FirstName).HasMaxLength(30); + entity.Property(e => e.LastName).HasMaxLength(60); + entity.Property(e => e.FullName).HasMaxLength(91); + entity.Property(e => e.IamId).HasMaxLength(10); + entity.Property(e => e.MailId); + entity.Property(e => e.CurrentEmployee); + }); + } +} diff --git a/web/Areas/Personnel/Scripts/MigratePhoneListsData.cs b/web/Areas/Personnel/Scripts/MigratePhoneListsData.cs new file mode 100644 index 000000000..9bdcac3e7 --- /dev/null +++ b/web/Areas/Personnel/Scripts/MigratePhoneListsData.cs @@ -0,0 +1,1294 @@ +// ============================================ +// Script: MigratePhoneListsData.cs +// Description: Migrate data from the legacy PhoneLists database into the phones schema +// ============================================ +// Transforms both legacy phone-directory features into their normalized Viper 2 shape: +// - SVM school-wide list: SVM_Phones_Sections/dvtUnit/SVM_Phones -> SVMSection/SVMUnit/SVMUnitPerson +// - VMDO Dean's Office list: VMDOUnits/VMDOPeople -> PhoneList/PhoneListUnit/PhoneListUnitPerson +// - phones.Person is shared by both, and is the only place office/direct-phone data lands +// - SVMFrequentNumber has no legacy table and is seeded from constants below +// +// Run `analysis` first - this script re-checks its structural assertions as pre-flight guards +// and aborts rather than writing if an environment's data violates them. +// ============================================ +// USAGE: +// dotnet run -- migrate-data (dry run: everything rolls back) +// dotnet run -- migrate-data Production (dry run on Production data: everything rolls back) +// dotnet run -- migrate-data --apply (writes; requires typing DELETE to confirm) +// ============================================ + +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using Microsoft.Data.SqlClient; + +namespace Viper.Areas.Personnel.Scripts +{ + public class MigratePhoneListsData + { + /// Per-section display metadata that has no legacy column and must be supplied here. + private sealed record SectionMetadata(bool IncludeAbbrv, string DirectorTitle, string UnitName); + + // Keyed by the legacy SVM_Phones_Sections.Section value, as confirmed against the real data. + // The dry run prints what each section resolved to and warns unless exactly one Dean and one + // Director come out, since a mis-keyed entry here would fall through to the default silently. + private static readonly Dictionary SectionMetadataByName = + new(StringComparer.OrdinalIgnoreCase) + { + ["Dean's Office"] = new(false, "Dean", "Units"), + ["Departments"] = new(true, "Chair", "Departments"), + ["Units"] = new(true, "Director", "Units"), + ["Executive Committee"] = new(false, "Chair", "Executive Committee"), + }; + + private static readonly SectionMetadata DefaultSectionMetadata = new(false, "Chair", "Units"); + + private const string SectionNameSuffix = " Phone Information"; + + /// No legacy table backs these; they are environment-independent. + private static readonly (string Label, string Phone, int SortOrder)[] FrequentNumbers = + [ + ("Health Sciences Library", "2-1162", 1), + ("Health Science Bookstore", "2-3369", 2), + ("HYPP", "2-2211", 3), + ("CAHFS, Tulare", "559-688-7543", 4), + ("CAHFS, San Bernardino", "909-383-4287", 5), + ("CAHFS, Turlock", "209-634-5837", 6), + ("Large Animal Clinic", "2-0290, 2-9815", 7), + ("Small Animal Clinic", "2-1393", 8), + ]; + + private const string VmdoListCode = "VMDO"; + private const string VmdoListName = "Dean's Office"; + private const string VmdoListMaintainRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + /// Matches the HasMaxLength(25) on Fax, Phone, and DirectPhone. + private const int PhoneFieldMaxLength = 25; + + private sealed record SvmPhoneRow( + int SectionId, int UnitId, int? UnitOrder, string? Fax, string? Location, + string? DeanMothraId, string? DirPhone, string? InterimDirector, + string? AdminMothraId, string? AdminPhone, string? InterimAdmin, + DateTime? DateMod, string? WhoMod); + + private sealed record DvtUnitRow(int SectionId, int UnitId, string UnitName, string? Abbreviation); + + private sealed record VmdoPersonRow( + int? UnitId, string? MothraId, string? PublicNum, string? DirectNum, + string? Office, bool ListFirst, DateTime? Updated); + + /// Accumulates one shared phones.Person row from both legacy sources. + private sealed class PersonAccumulator + { + /// Keyed by normalized form so equivalent numbers collapse; value keeps the fuller raw form. + public Dictionary Phones { get; } = new(StringComparer.OrdinalIgnoreCase); + public string? DirectPhone { get; set; } + public string? Office { get; set; } + public DateTime? ModifiedDate { get; set; } + public string? ModifiedBy { get; set; } + } + + private readonly List _overflowReports = []; + private readonly List _faxConflictReports = []; + private readonly List _widthViolations = []; + private readonly Dictionary<(string Table, string Column), int> _columnWidths = []; + private int _whoModAsMothraId; + private int _whoModAsLoginId; + private int _whoModUnresolved; + + private Dictionary _personLookup = new(); + private Dictionary _loginIdLookup = new(); + + public static void Run(string[] args) + { + bool executeMode = args.Contains("--apply"); + bool isDryRun = !executeMode; + var stopwatch = Stopwatch.StartNew(); + + Console.WriteLine("============================================"); + Console.WriteLine("Migrating PhoneLists into the phones schema"); + Console.WriteLine($"Start Time: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($"Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}"); + Console.WriteLine("============================================"); + Console.WriteLine(); + + if (isDryRun) + { + Console.WriteLine("DRY-RUN MODE: the migration is previewed, then rolled back."); + Console.WriteLine(" No permanent changes are made. To migrate for real, add --apply"); + Console.WriteLine(); + } + + try + { + // Config and connectivity are settled before the confirmation prompt: there is no + // point asking anyone to authorise a destructive run that cannot reach a database. + var configuration = PhoneListsScriptHelper.LoadConfiguration(); + string viperConnectionString = PhoneListsScriptHelper.GetConnectionString(configuration, "VIPER"); + string legacyConnectionString = PhoneListsScriptHelper.GetConnectionString(configuration, "PhoneLists"); + + Console.WriteLine($"Target: {PhoneListsScriptHelper.GetServerAndDatabase(viperConnectionString)}"); + Console.WriteLine($"Source: {PhoneListsScriptHelper.GetServerAndDatabase(legacyConnectionString)}"); + Console.WriteLine(); + + if (!VerifyPrerequisites(viperConnectionString, legacyConnectionString)) + { + Console.WriteLine("ERROR: Prerequisites not met. Exiting."); + Environment.Exit(1); + return; + } + + if (!isDryRun && !ConfirmDestructiveRun()) + { + return; + } + + new MigratePhoneListsData().Execute(viperConnectionString, legacyConnectionString, isDryRun); + } + catch (SqlException ex) + { + WriteFatalError(ex); + } + catch (InvalidOperationException ex) + { + WriteFatalError(ex); + } + + stopwatch.Stop(); + Console.WriteLine(); + Console.WriteLine($"Elapsed: {stopwatch.Elapsed:mm\\:ss\\.fff}"); + } + + private static bool ConfirmDestructiveRun() + { + Console.WriteLine("APPLY MODE: existing phones data for the SVM and VMDO lists will be"); + Console.WriteLine(" DELETED and rebuilt from the legacy database. This cannot be undone."); + Console.WriteLine(" Type 'DELETE' to confirm:"); + Console.Write(" > "); + string? confirmation = Console.ReadLine(); + if (!string.Equals(confirmation, "DELETE", StringComparison.Ordinal)) + { + Console.WriteLine("Migration cancelled."); + return false; + } + Console.WriteLine(); + return true; + } + + private static void WriteFatalError(Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\nERROR: {ex.Message}"); + if (ex is SqlException sqlEx) + { + Console.WriteLine($"SQL Error Number: {sqlEx.Number}"); + } + Console.WriteLine("\nStack Trace:"); + Console.WriteLine(ex.StackTrace); + Console.ResetColor(); + Environment.Exit(1); + } + + private void Execute(string viperConnectionString, string legacyConnectionString, bool isDryRun) + { + using var viperConn = new SqlConnection(viperConnectionString); + using var legacyConn = new SqlConnection(legacyConnectionString); + viperConn.Open(); + legacyConn.Open(); + + _personLookup = PhoneListsScriptHelper.BuildMothraIdToPersonLookupMap(viperConn); + _loginIdLookup = PhoneListsScriptHelper.BuildLoginIdToIamIdMap(viperConn); + Console.WriteLine($"Loaded {_personLookup.Count:N0} people from users.Person."); + Console.WriteLine(); + + var svmRows = ReadSvmPhoneRows(legacyConn); + var dvtUnits = ReadDvtUnits(legacyConn); + var vmdoPeople = ReadVmdoPeople(legacyConn); + + if (!RunPreflightGuards(svmRows, dvtUnits, vmdoPeople)) + { + Console.WriteLine("ERROR: Pre-flight guards failed. Nothing was written. Exiting."); + Environment.Exit(1); + return; + } + + using var transaction = viperConn.BeginTransaction(); + try + { + LoadColumnWidths(viperConn, transaction); + ClearExistingData(viperConn, transaction); + + MigrateSections(viperConn, transaction, legacyConn); + var unitIdMap = MigrateUnits(viperConn, transaction, dvtUnits, svmRows); + MigratePhonePersons(viperConn, transaction, svmRows, vmdoPeople); + MigrateUnitPersons(viperConn, transaction, svmRows, unitIdMap); + MigrateFrequentNumbers(viperConn, transaction); + MigrateVmdoList(viperConn, transaction, legacyConn, vmdoPeople); + + ReportDeferredFindings(); + ValidateMigration(viperConn, transaction, legacyConn); + + if (_widthViolations.Count > 0) + { + // Committing here would persist values this script had to cut down to fit. + transaction.Rollback(); + Console.WriteLine(); + Console.WriteLine("============================================"); + Console.WriteLine("ROLLED BACK: values too long for their columns (listed above)."); + Console.WriteLine("Nothing was written. Resolve those before migrating."); + Console.WriteLine("============================================"); + } + else if (isDryRun) + { + transaction.Rollback(); + Console.WriteLine(); + Console.WriteLine("============================================"); + Console.WriteLine("DRY RUN SUCCESSFUL - all changes rolled back."); + Console.WriteLine("Re-run with --apply to migrate for real."); + Console.WriteLine("============================================"); + } + else + { + transaction.Commit(); + Console.WriteLine(); + Console.WriteLine("============================================"); + Console.WriteLine("MIGRATION COMMITTED."); + Console.WriteLine("Next: exercise both phone lists in the app to confirm the data renders."); + Console.WriteLine("============================================"); + } + } + catch + { + try + { + transaction.Rollback(); + Console.WriteLine("Transaction rolled back."); + } + catch (InvalidOperationException rollbackEx) + { + Console.WriteLine($"WARNING: rollback failed: {rollbackEx.Message}"); + } + throw; + } + } + + private static bool VerifyPrerequisites(string viperConnectionString, string legacyConnectionString) + { + Console.WriteLine("Verifying prerequisites..."); + + using var viperConn = new SqlConnection(viperConnectionString); + viperConn.Open(); + + using (var cmd = new SqlCommand("SELECT COUNT(*) FROM sys.schemas WHERE name = 'phones'", viperConn)) + { + if ((int)cmd.ExecuteScalar() == 0) + { + Console.WriteLine(" ERROR: the [phones] schema does not exist in the target database."); + return false; + } + } + + string[] requiredTables = + [ + "Person", "SVMSection", "SVMUnit", "SVMUnitPerson", + "SVMFrequentNumber", "PhoneList", "PhoneListUnit", "PhoneListUnitPerson" + ]; + foreach (var table in requiredTables) + { + using var cmd = new SqlCommand( + "SELECT COUNT(*) FROM sys.tables WHERE schema_id = SCHEMA_ID('phones') AND name = @name", viperConn); + cmd.Parameters.AddWithValue("@name", table); + if ((int)cmd.ExecuteScalar() == 0) + { + Console.WriteLine($" ERROR: required table [phones].[{table}] does not exist."); + return false; + } + } + + try + { + using var legacyConn = new SqlConnection(legacyConnectionString); + legacyConn.Open(); + } + catch (SqlException ex) + { + Console.WriteLine($" ERROR: cannot connect to the legacy PhoneLists database: {ex.Message}"); + return false; + } + + Console.WriteLine(" All prerequisites met."); + Console.WriteLine(); + return true; + } + + /// + /// Re-asserts what the analysis pass proved on Development and Test. A later environment + /// whose data differs aborts here rather than silently migrating something misshapen. + /// + private bool RunPreflightGuards( + List svmRows, List dvtUnits, List vmdoPeople) + { + Console.WriteLine("Running pre-flight guards..."); + var failures = new List(); + + var unitOrderConflicts = svmRows + .GroupBy(r => (r.SectionId, r.UnitId)) + .Where(g => g.Select(r => r.UnitOrder).Distinct().Count() > 1) + .ToList(); + if (unitOrderConflicts.Count > 0) + { + failures.Add($"{unitOrderConflicts.Count} unit(s) disagree on UnitOrder - a single sort order per unit is assumed."); + } + + var rawMothraIds = svmRows.Select(r => r.DeanMothraId) + .Concat(svmRows.Select(r => r.AdminMothraId)) + .Concat(vmdoPeople.Select(p => p.MothraId)) + .ToList(); + + // An all-zero id names nobody, so those rows are dropped rather than failing the guard. + // Blanks are the ordinary "no person listed" case and are not worth counting. + var placeholders = rawMothraIds + .Count(m => !string.IsNullOrWhiteSpace(m) && !PhoneListsScriptHelper.HasMothraId(m)); + if (placeholders > 0) + { + Console.WriteLine($" {placeholders} reference(s) carry a placeholder MothraId and will not be migrated."); + } + + var allMothraIds = rawMothraIds + .Where(PhoneListsScriptHelper.HasMothraId) + .Select(m => m!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + var unresolved = allMothraIds.Where(m => !_personLookup.ContainsKey(m)).ToList(); + if (unresolved.Count > 0) + { + failures.Add($"{unresolved.Count} MothraId(s) do not resolve against users.Person: {string.Join(", ", unresolved.Take(10))}"); + } + + var orphanUnits = svmRows + .Select(r => (r.SectionId, r.UnitId)) + .Distinct() + .Where(k => !dvtUnits.Any(d => d.SectionId == k.SectionId && d.UnitId == k.UnitId)) + .ToList(); + if (orphanUnits.Count > 0) + { + failures.Add($"{orphanUnits.Count} unit(s) appear in SVM_Phones but not dvtUnit - dvtUnit is assumed to be the superset."); + } + + if (failures.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Red; + foreach (var failure in failures) + { + Console.WriteLine($" FAILED: {failure}"); + } + Console.ResetColor(); + return false; + } + + Console.WriteLine(" All guards passed."); + Console.WriteLine(); + return true; + } + + // ---------- Legacy reads ---------- + + private static List ReadSvmPhoneRows(SqlConnection legacyConn) + { + var rows = new List(); + const string sql = @" + SELECT SectionID, unitID, UnitOrder, Fax, Location, + Dean_Director_MothraID, Dir_Phone, InterimDirector, + Admin_MothraID, Phone, InterimAdmin, + Date_Mod, Who_Mod + FROM [dbo].[SVM_Phones] + WHERE SectionID IS NOT NULL AND unitID IS NOT NULL"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(new SvmPhoneRow( + reader.GetInt32(0), + reader.GetInt32(1), + reader.IsDBNull(2) ? null : reader.GetInt32(2), + ReadNullableString(reader, 3), + ReadNullableString(reader, 4), + ReadNullableString(reader, 5), + ReadNullableString(reader, 6), + ReadNullableString(reader, 7), + ReadNullableString(reader, 8), + ReadNullableString(reader, 9), + ReadNullableString(reader, 10), + reader.IsDBNull(11) ? null : reader.GetDateTime(11), + ReadNullableString(reader, 12))); + } + return rows; + } + + private static List ReadDvtUnits(SqlConnection legacyConn) + { + var rows = new List(); + const string sql = @" + SELECT dvtUnit_sectionID, dvtUnit_unitID, dvtUnit_unitName, dvtUnit_abbreviation + FROM [dbo].[dvtUnit] + WHERE dvtUnit_unitID IS NOT NULL + ORDER BY dvtUnit_sectionID, dvtUnit_unitID"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(new DvtUnitRow( + reader.GetInt32(0), + reader.GetInt32(1), + reader.GetString(2).Trim(), + ReadNullableString(reader, 3))); + } + return rows; + } + + private static List ReadVmdoPeople(SqlConnection legacyConn) + { + var rows = new List(); + const string sql = @" + SELECT vmdoPeople_unitID, vmdoPeople_mothraID, vmdoPeople_publicNum, + vmdoPeople_directNum, vmdoPeople_office, vmdoPeople_listFirst, vmdoPeople_updated + FROM [dbo].[VMDOPeople]"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + rows.Add(new VmdoPersonRow( + reader.IsDBNull(0) ? null : reader.GetInt32(0), + ReadNullableString(reader, 1), + ReadNullableString(reader, 2), + ReadNullableString(reader, 3), + ReadNullableString(reader, 4), + !reader.IsDBNull(5) && reader.GetBoolean(5), + reader.IsDBNull(6) ? null : reader.GetDateTime(6))); + } + return rows; + } + + /// Maps a null of any type - including a nullable value type - onto DBNull. + private static object ToDbValue(object? value) => value ?? DBNull.Value; + + /// + /// Legacy wraps interim status in parentheses - "(Vice)". The new schema stores the bare + /// word and the UI supplies the parentheses when rendering: SVMAddRecordDialog's + /// interimOptions pair a "(Vice)" label with a "Vice" value, and its edit form re-wraps + /// the stored value. Importing "(Vice)" verbatim would render as "((Vice))" and would not + /// match any option in the dropdown. + /// + /// Blank becomes an empty string rather than null, matching what the live app writes via + /// request.DeanInterim.Trim() when no interim status is selected. + /// + private static string NormalizeInterim(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return ""; + } + + var trimmed = value.Trim(); + if (trimmed.Length >= 2 && trimmed[0] == '(' && trimmed[^1] == ')') + { + trimmed = trimmed[1..^1].Trim(); + } + + return trimmed; + } + + /// + /// Reads the real column widths from the destination, since the phones schema was created + /// outside this repo and its physical columns are the authority, not the EF model's + /// HasMaxLength calls. Without this a too-long value surfaces only as SQL Server's + /// "String or binary data would be truncated", which names neither column nor value. + /// + private void LoadColumnWidths(SqlConnection conn, SqlTransaction tx) + { + string[] tables = + [ + "Person", "SVMSection", "SVMUnit", "SVMUnitPerson", + "SVMFrequentNumber", "PhoneList", "PhoneListUnit", "PhoneListUnitPerson" + ]; + + const string sql = @" + SELECT c.name, c.max_length, t.name AS TypeName + FROM sys.columns c + INNER JOIN sys.types t ON t.user_type_id = c.user_type_id + WHERE c.object_id = OBJECT_ID(@qualifiedTable)"; + + foreach (var table in tables) + { + using var cmd = new SqlCommand(sql, conn, tx); + cmd.Parameters.AddWithValue("@qualifiedTable", $"[phones].[{table}]"); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var column = reader.GetString(0); + var maxLength = reader.GetInt16(1); + var typeName = reader.GetString(2); + + // nvarchar/nchar report max_length in bytes; -1 means MAX, i.e. no practical limit. + int? charLimit = typeName switch + { + "nvarchar" or "nchar" => maxLength == -1 ? null : maxLength / 2, + "varchar" or "char" => maxLength == -1 ? null : maxLength, + _ => null, + }; + + if (charLimit.HasValue) + { + _columnWidths[(table, column)] = charLimit.Value; + } + } + } + } + + /// + /// Binds a string, recording any value too long for its physical column. The value is cut + /// down only so the run can continue and collect every violation in one pass - a run with + /// any violation refuses to commit, so a truncated value is never persisted. + /// + private object ToDbString(string? value, string table, string column) + { + if (value is null) + { + return DBNull.Value; + } + if (_columnWidths.TryGetValue((table, column), out var limit) && value.Length > limit) + { + _widthViolations.Add( + $"[phones].[{table}].[{column}] holds {limit} chars, got {value.Length}: '{value}'"); + return value[..limit]; + } + return value; + } + + private static string? ReadNullableString(SqlDataReader reader, int ordinal) + { + if (reader.IsDBNull(ordinal)) + { + return null; + } + var value = reader.GetString(ordinal).Trim(); + return value.Length == 0 ? null : value; + } + + // ---------- Destination clearing ---------- + + /// + /// Clears only what this migration owns. The SVM tables are cleared wholesale, but the + /// PhoneList side is scoped to the VMDO list so any other list in the table survives, and + /// phones.Person rows are removed only once nothing references them. + /// + private static void ClearExistingData(SqlConnection conn, SqlTransaction tx) + { + Console.WriteLine("Clearing existing data..."); + + ExecuteAndReport(conn, tx, @" + DELETE pup FROM [phones].[PhoneListUnitPerson] pup + INNER JOIN [phones].[PhoneListUnit] plu ON plu.PhoneListUnitId = pup.PhoneListUnitId + INNER JOIN [phones].[PhoneList] pl ON pl.PhoneListId = plu.PhoneListId + WHERE pl.Code = @code", "PhoneListUnitPerson", ("@code", VmdoListCode)); + + ExecuteAndReport(conn, tx, @" + DELETE plu FROM [phones].[PhoneListUnit] plu + INNER JOIN [phones].[PhoneList] pl ON pl.PhoneListId = plu.PhoneListId + WHERE pl.Code = @code", "PhoneListUnit", ("@code", VmdoListCode)); + + ExecuteAndReport(conn, tx, "DELETE FROM [phones].[SVMUnitPerson]", "SVMUnitPerson"); + ExecuteAndReport(conn, tx, "DELETE FROM [phones].[SVMFrequentNumber]", "SVMFrequentNumber"); + ExecuteAndReport(conn, tx, "DELETE FROM [phones].[SVMUnit]", "SVMUnit"); + ExecuteAndReport(conn, tx, "DELETE FROM [phones].[SVMSection]", "SVMSection"); + + // Only unreferenced people - another phone list may still be using a shared row. + ExecuteAndReport(conn, tx, @" + DELETE p FROM [phones].[Person] p + WHERE NOT EXISTS (SELECT 1 FROM [phones].[SVMUnitPerson] up WHERE up.PersonIam = p.PersonIam) + AND NOT EXISTS (SELECT 1 FROM [phones].[PhoneListUnitPerson] pup WHERE pup.PersonIam = p.PersonIam)", + "Person (unreferenced)"); + + foreach (var table in new[] { "SVMUnitPerson", "SVMFrequentNumber", "PhoneListUnit", "PhoneListUnitPerson" }) + { + using var cmd = new SqlCommand($"DBCC CHECKIDENT ('[phones].[{table}]', RESEED, 0)", conn, tx); + cmd.ExecuteNonQuery(); + } + + Console.WriteLine(); + } + + private static void ExecuteAndReport(SqlConnection conn, SqlTransaction tx, string sql, string label, + params (string Name, object Value)[] parameters) + { + using var cmd = new SqlCommand(sql, conn, tx); + foreach (var (name, value) in parameters) + { + cmd.Parameters.AddWithValue(name, value); + } + int deleted = cmd.ExecuteNonQuery(); + Console.WriteLine($" Cleared {deleted:N0} rows from {label}"); + } + + // ---------- Step 1: sections ---------- + + private void MigrateSections(SqlConnection conn, SqlTransaction tx, SqlConnection legacyConn) + { + Console.WriteLine("Step 1: SVMSection"); + + var sections = new List<(int SectionId, string Name, int? SortOrder, SectionMetadata Metadata)>(); + const string sql = "SELECT SectionID, Section, Priority FROM [dbo].[SVM_Phones_Sections] ORDER BY Priority"; + + using (var cmd = new SqlCommand(sql, legacyConn)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var sectionId = reader.GetInt32(0); + var legacyName = ReadNullableString(reader, 1) ?? ""; + var priority = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2); + + if (!SectionMetadataByName.TryGetValue(legacyName, out var metadata)) + { + metadata = DefaultSectionMetadata; + } + sections.Add((sectionId, legacyName + SectionNameSuffix, priority, metadata)); + } + } + + Console.WriteLine(" Resolved section metadata:"); + foreach (var s in sections) + { + Console.WriteLine($" [{s.SectionId}] {s.Name} | Abbrv={s.Metadata.IncludeAbbrv} " + + $"| DirectorTitle={s.Metadata.DirectorTitle} | UnitName={s.Metadata.UnitName}"); + } + WarnOnImplausibleSectionTitles(sections.Select(s => s.Metadata).ToList()); + + var isIdentity = PhoneListsScriptHelper.IsIdentityColumn(conn, "phones", "SVMSection", "SectionId", tx); + var insertSql = WrapForIdentityInsert(isIdentity, "SVMSection", @" + INSERT INTO [phones].[SVMSection] (SectionId, Name, IncludeAbbrv, UnitName, DirectorTitle, SortOrder) + VALUES (@SectionId, @Name, @IncludeAbbrv, @UnitName, @DirectorTitle, @SortOrder);"); + + foreach (var s in sections) + { + using var cmd = new SqlCommand(insertSql, conn, tx); + cmd.Parameters.AddWithValue("@SectionId", s.SectionId); + cmd.Parameters.AddWithValue("@Name", ToDbString(s.Name, "SVMSection", "Name")); + cmd.Parameters.AddWithValue("@IncludeAbbrv", s.Metadata.IncludeAbbrv); + cmd.Parameters.AddWithValue("@UnitName", ToDbString(s.Metadata.UnitName, "SVMSection", "UnitName")); + cmd.Parameters.AddWithValue("@DirectorTitle", ToDbString(s.Metadata.DirectorTitle, "SVMSection", "DirectorTitle")); + cmd.Parameters.AddWithValue("@SortOrder", ToDbValue(s.SortOrder)); + cmd.ExecuteNonQuery(); + } + + Console.WriteLine($" Migrated {sections.Count} sections."); + Console.WriteLine(); + } + + private static void WarnOnImplausibleSectionTitles(List metadata) + { + var deanCount = metadata.Count(m => m.DirectorTitle == "Dean"); + var directorCount = metadata.Count(m => m.DirectorTitle == "Director"); + if (deanCount == 1 && directorCount == 1) + { + return; + } + + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" WARNING: expected exactly one Dean section and one Director section, " + + $"but found {deanCount} Dean and {directorCount} Director."); + Console.WriteLine(" A legacy section name probably doesn't match a key in SectionMetadataByName,"); + Console.WriteLine(" so it fell through to the default. Correct that table before running --apply."); + Console.ResetColor(); + } + + private static string WrapForIdentityInsert(bool isIdentity, string table, string insertSql) + { + return isIdentity + ? $"SET IDENTITY_INSERT [phones].[{table}] ON;{insertSql}SET IDENTITY_INSERT [phones].[{table}] OFF;" + : insertSql; + } + + // ---------- Step 2: units ---------- + + private Dictionary<(int SectionId, int UnitId), int> MigrateUnits( + SqlConnection conn, SqlTransaction tx, List dvtUnits, List svmRows) + { + Console.WriteLine("Step 2: SVMUnit"); + + var rowsByUnit = svmRows + .GroupBy(r => (r.SectionId, r.UnitId)) + .ToDictionary(g => g.Key, g => g.ToList()); + + var isIdentity = PhoneListsScriptHelper.IsIdentityColumn(conn, "phones", "SVMUnit", "UnitId", tx); + var insertSql = WrapForIdentityInsert(isIdentity, "SVMUnit", @" + INSERT INTO [phones].[SVMUnit] (UnitId, SectionId, Name, Abbrv, SortOrder, Fax, ModifiedBy, ModifiedDate) + VALUES (@UnitId, @SectionId, @Name, @Abbrv, @SortOrder, @Fax, @ModifiedBy, @ModifiedDate);"); + + // Legacy unitID is unique only within a section, so it cannot serve as this + // single-column PK - new ids are assigned here and mapped for the UnitPerson step. + var unitIdMap = new Dictionary<(int SectionId, int UnitId), int>(); + int nextUnitId = 1; + int unitsWithoutSvmRows = 0; + + foreach (var unit in dvtUnits) + { + var key = (unit.SectionId, unit.UnitId); + var newUnitId = nextUnitId++; + unitIdMap[key] = newUnitId; + + int? sortOrder = null; + string? fax = null; + string? modifiedBy = null; + DateTime? modifiedDate = null; + + if (rowsByUnit.TryGetValue(key, out var unitRows)) + { + sortOrder = unitRows.Select(r => r.UnitOrder).FirstOrDefault(o => o.HasValue); + fax = ResolveFax(unitRows, unit.UnitName); + + var latest = unitRows.Where(r => r.DateMod.HasValue).OrderByDescending(r => r.DateMod).FirstOrDefault(); + if (latest is not null) + { + modifiedDate = latest.DateMod; + modifiedBy = ResolveModifiedBy(latest.WhoMod); + } + } + else + { + unitsWithoutSvmRows++; + } + + using var cmd = new SqlCommand(insertSql, conn, tx); + cmd.Parameters.AddWithValue("@UnitId", newUnitId); + cmd.Parameters.AddWithValue("@SectionId", unit.SectionId); + cmd.Parameters.AddWithValue("@Name", ToDbString(unit.UnitName, "SVMUnit", "Name")); + cmd.Parameters.AddWithValue("@Abbrv", ToDbString(unit.Abbreviation, "SVMUnit", "Abbrv")); + cmd.Parameters.AddWithValue("@SortOrder", ToDbValue(sortOrder)); + cmd.Parameters.AddWithValue("@Fax", ToDbString(fax, "SVMUnit", "Fax")); + cmd.Parameters.AddWithValue("@ModifiedBy", ToDbString(modifiedBy, "SVMUnit", "ModifiedBy")); + cmd.Parameters.AddWithValue("@ModifiedDate", ToDbValue(modifiedDate)); + cmd.ExecuteNonQuery(); + } + + Console.WriteLine($" Migrated {dvtUnits.Count} units ({unitsWithoutSvmRows} with no SVM_Phones rows, so no people)."); + Console.WriteLine(); + return unitIdMap; + } + + /// + /// Fax is denormalized across a unit's rows. A blank never beats a real value; genuinely + /// different values are kept together rather than one being silently dropped. + /// + private string? ResolveFax(List unitRows, string unitName) + { + var distinct = unitRows + .Select(r => r.Fax) + .Where(f => !string.IsNullOrWhiteSpace(f)) + .Select(f => f!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + if (distinct.Count > 1) + { + _faxConflictReports.Add($"{unitName}: {string.Join(" | ", distinct)}"); + } + + return CombineValues(distinct, $"Fax for unit '{unitName}'"); + } + + /// + /// Joins several distinct values into the one column that has to hold them, falling back to + /// the fullest single value rather than truncating into a malformed number. + /// + private string? CombineValues(List values, string context) + { + if (values.Count == 0) + { + return null; + } + if (values.Count == 1) + { + return values[0]; + } + + var combined = string.Join(", ", values); + if (combined.Length <= PhoneFieldMaxLength) + { + return combined; + } + + var longest = values.OrderByDescending(v => v.Length).First(); + _overflowReports.Add($"{context}: '{combined}' ({combined.Length} chars) exceeds " + + $"{PhoneFieldMaxLength}; stored '{longest}' instead."); + return longest; + } + + /// + /// The legacy schema never recorded which identifier Who_Mod holds, so try MothraId, then + /// LoginId, then give up. The bucket counts are reported so a bad guess is visible. + /// + private string? ResolveModifiedBy(string? whoMod) + { + if (string.IsNullOrWhiteSpace(whoMod)) + { + return null; + } + + var value = whoMod.Trim(); + if (_personLookup.TryGetValue(value, out var person) && person.IamId is not null) + { + _whoModAsMothraId++; + return person.IamId; + } + if (_loginIdLookup.TryGetValue(value, out var iamId)) + { + _whoModAsLoginId++; + return iamId; + } + + _whoModUnresolved++; + return null; + } + + // ---------- Step 3: shared people ---------- + + private void MigratePhonePersons( + SqlConnection conn, SqlTransaction tx, List svmRows, List vmdoPeople) + { + Console.WriteLine("Step 3: phones.Person"); + + var accumulators = new Dictionary(StringComparer.OrdinalIgnoreCase); + int skippedInactive = 0; + + foreach (var row in svmRows) + { + AccumulateSvmPerson(accumulators, row.DeanMothraId, row.DirPhone, row.DateMod, row.WhoMod, ref skippedInactive); + AccumulateSvmPerson(accumulators, row.AdminMothraId, row.AdminPhone, row.DateMod, row.WhoMod, ref skippedInactive); + } + + foreach (var person in vmdoPeople) + { + var iamId = ResolveActiveIamId(person.MothraId, ref skippedInactive); + if (iamId is null) + { + continue; + } + + var accumulator = GetOrAdd(accumulators, iamId); + AddPhone(accumulator, person.PublicNum); + + // Office and DirectPhone have no SVM analogue, so VMDO is their only source. + accumulator.DirectPhone ??= person.DirectNum; + accumulator.Office ??= person.Office; + + if (person.Updated.HasValue && (!accumulator.ModifiedDate.HasValue || person.Updated > accumulator.ModifiedDate)) + { + accumulator.ModifiedDate = person.Updated; + // VMDOPeople has no "who modified" column, so the attribution is genuinely unknown. + accumulator.ModifiedBy = null; + } + } + + const string insertSql = @" + INSERT INTO [phones].[Person] (PersonIam, Phone, DirectPhone, Office, ModifiedDate, ModifiedBy) + VALUES (@PersonIam, @Phone, @DirectPhone, @Office, @ModifiedDate, @ModifiedBy);"; + + foreach (var (iamId, accumulator) in accumulators) + { + // Empty string rather than null when a person has no number on either list, since + // that is what both of the live app's create paths write via .Trim(). + var phone = CombineValues(accumulator.Phones.Values.ToList(), $"Phone for {iamId}") ?? ""; + + using var cmd = new SqlCommand(insertSql, conn, tx); + cmd.Parameters.AddWithValue("@PersonIam", ToDbString(iamId, "Person", "PersonIam")); + cmd.Parameters.AddWithValue("@Phone", ToDbString(phone, "Person", "Phone")); + cmd.Parameters.AddWithValue("@DirectPhone", ToDbString(accumulator.DirectPhone, "Person", "DirectPhone")); + cmd.Parameters.AddWithValue("@Office", ToDbString(accumulator.Office, "Person", "Office")); + cmd.Parameters.AddWithValue("@ModifiedDate", ToDbValue(accumulator.ModifiedDate)); + cmd.Parameters.AddWithValue("@ModifiedBy", ToDbString(accumulator.ModifiedBy, "Person", "ModifiedBy")); + cmd.ExecuteNonQuery(); + } + + Console.WriteLine($" Migrated {accumulators.Count} people."); + if (skippedInactive > 0) + { + Console.WriteLine($" Skipped {skippedInactive} reference(s) to people who are not current employees."); + } + Console.WriteLine(); + } + + private void AccumulateSvmPerson( + Dictionary accumulators, string? mothraId, string? phone, + DateTime? dateMod, string? whoMod, ref int skippedInactive) + { + var iamId = ResolveActiveIamId(mothraId, ref skippedInactive); + if (iamId is null) + { + return; + } + + var accumulator = GetOrAdd(accumulators, iamId); + AddPhone(accumulator, phone); + + if (dateMod.HasValue && (!accumulator.ModifiedDate.HasValue || dateMod > accumulator.ModifiedDate)) + { + accumulator.ModifiedDate = dateMod; + accumulator.ModifiedBy = ResolveModifiedBy(whoMod); + } + } + + private static PersonAccumulator GetOrAdd(Dictionary accumulators, string iamId) + { + if (!accumulators.TryGetValue(iamId, out var accumulator)) + { + accumulator = new PersonAccumulator(); + accumulators[iamId] = accumulator; + } + return accumulator; + } + + /// + /// Collapses numbers that differ only by the omitted 75 prefix, keeping whichever spelling + /// carries more information so the stored value stays the fuller of the two. + /// + private static void AddPhone(PersonAccumulator accumulator, string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return; + } + + var value = raw.Trim(); + var key = PhoneListsScriptHelper.NormalizePhone(value); + if (!accumulator.Phones.TryGetValue(key, out var existing) || value.Length > existing.Length) + { + accumulator.Phones[key] = value; + } + } + + /// + /// Resolves a legacy MothraId to an IamId, excluding rows that name no person at all and + /// anyone no longer employed. + /// + private string? ResolveActiveIamId(string? mothraId, ref int skippedInactive) + { + if (!PhoneListsScriptHelper.HasMothraId(mothraId)) + { + return null; + } + if (!_personLookup.TryGetValue(mothraId.Trim(), out var person)) + { + return null; + } + if (!person.CurrentEmployee || person.IamId is null) + { + skippedInactive++; + return null; + } + return person.IamId; + } + + // ---------- Step 4: SVM unit people ---------- + + private void MigrateUnitPersons( + SqlConnection conn, SqlTransaction tx, List svmRows, + Dictionary<(int SectionId, int UnitId), int> unitIdMap) + { + Console.WriteLine("Step 4: SVMUnitPerson"); + + const string insertSql = @" + INSERT INTO [phones].[SVMUnitPerson] + (UnitId, PersonIam, Office, PosType, Interim, ModifiedDate, ModifiedBy, IsActive) + VALUES (@UnitId, @PersonIam, @Office, @PosType, @Interim, @ModifiedDate, @ModifiedBy, 1);"; + + int leaders = 0; + int staff = 0; + int skippedInactive = 0; + + foreach (var group in svmRows.GroupBy(r => (r.SectionId, r.UnitId))) + { + if (!unitIdMap.TryGetValue(group.Key, out var unitId)) + { + continue; + } + + var seenLeaders = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var row in group) + { + var iamId = ResolveActiveIamId(row.DeanMothraId, ref skippedInactive); + if (iamId is null || !seenLeaders.Add(iamId)) + { + continue; + } + InsertUnitPerson(conn, tx, insertSql, unitId, iamId, row.Location, "Dean", + row.InterimDirector, row.DateMod, ResolveModifiedBy(row.WhoMod)); + leaders++; + } + + // The admin staff is repeated on every row of a unit, so only one row is emitted. + // Where legacy names two, the current-employee filter picks out the live one. + // Resolving the distinct ids rather than every row keeps the skip count per + // person - the denormalization would otherwise count one inactive staffer once + // per row of their unit. + var groupRows = group.ToList(); + var distinctAdminIds = groupRows + .Select(r => r.AdminMothraId) + .Where(m => !string.IsNullOrWhiteSpace(m)) + .Select(m => m!.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + foreach (var adminMothraId in distinctAdminIds) + { + var staffIamId = ResolveActiveIamId(adminMothraId, ref skippedInactive); + if (staffIamId is null) + { + continue; + } + + var staffRow = groupRows.First(r => + string.Equals(r.AdminMothraId?.Trim(), adminMothraId, StringComparison.OrdinalIgnoreCase)); + InsertUnitPerson(conn, tx, insertSql, unitId, staffIamId, staffRow.Location, "Staff", + staffRow.InterimAdmin, staffRow.DateMod, ResolveModifiedBy(staffRow.WhoMod)); + staff++; + break; + } + } + + Console.WriteLine($" Migrated {leaders} leader row(s) and {staff} admin-staff row(s)."); + if (skippedInactive > 0) + { + Console.WriteLine($" Skipped {skippedInactive} assignment(s) for people who are not current employees."); + } + Console.WriteLine(); + } + + private void InsertUnitPerson( + SqlConnection conn, SqlTransaction tx, string insertSql, int unitId, string personIam, + string? office, string posType, string? interim, DateTime? modifiedDate, string? modifiedBy) + { + using var cmd = new SqlCommand(insertSql, conn, tx); + cmd.Parameters.AddWithValue("@UnitId", unitId); + cmd.Parameters.AddWithValue("@PersonIam", ToDbString(personIam, "SVMUnitPerson", "PersonIam")); + // Location is a per-row value that the live app writes to both the leader and the staff. + cmd.Parameters.AddWithValue("@Office", ToDbString(office, "SVMUnitPerson", "Office")); + cmd.Parameters.AddWithValue("@PosType", ToDbString(posType, "SVMUnitPerson", "PosType")); + // Normalized here rather than at each call site so neither the Dean nor the Staff path + // can miss it. + cmd.Parameters.AddWithValue( + "@Interim", ToDbString(NormalizeInterim(interim), "SVMUnitPerson", "Interim")); + cmd.Parameters.AddWithValue("@ModifiedDate", ToDbValue(modifiedDate)); + cmd.Parameters.AddWithValue("@ModifiedBy", ToDbString(modifiedBy, "SVMUnitPerson", "ModifiedBy")); + cmd.ExecuteNonQuery(); + } + + // ---------- Step 5: frequent numbers ---------- + + private void MigrateFrequentNumbers(SqlConnection conn, SqlTransaction tx) + { + Console.WriteLine("Step 5: SVMFrequentNumber"); + + const string insertSql = @" + INSERT INTO [phones].[SVMFrequentNumber] (Label, Phone, SortOrder, ModifiedBy, ModifiedDate, IsActive) + VALUES (@Label, @Phone, @SortOrder, NULL, NULL, 1);"; + + foreach (var (label, phone, sortOrder) in FrequentNumbers) + { + using var cmd = new SqlCommand(insertSql, conn, tx); + cmd.Parameters.AddWithValue("@Label", ToDbString(label, "SVMFrequentNumber", "Label")); + cmd.Parameters.AddWithValue("@Phone", ToDbString(phone, "SVMFrequentNumber", "Phone")); + cmd.Parameters.AddWithValue("@SortOrder", sortOrder); + cmd.ExecuteNonQuery(); + } + + Console.WriteLine($" Seeded {FrequentNumbers.Length} frequent numbers."); + Console.WriteLine(); + } + + // ---------- Step 6: VMDO list ---------- + + private void MigrateVmdoList( + SqlConnection conn, SqlTransaction tx, SqlConnection legacyConn, List vmdoPeople) + { + Console.WriteLine("Step 6: VMDO phone list"); + + var phoneListId = GetOrCreateVmdoList(conn, tx); + + var unitIdMap = new Dictionary(); + const string unitSql = "SELECT vmdoUnits_recordID, vmdoUnits_name FROM [dbo].[VMDOUnits]"; + var legacyUnits = new List<(int RecordId, string Name)>(); + + using (var cmd = new SqlCommand(unitSql, legacyConn)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + legacyUnits.Add((reader.GetInt32(0), reader.GetString(1).Trim())); + } + } + + // SortOrder stays null so the app's alphabetical default applies - the legacy + // ordering columns (listprocName, column) are not being carried over. + const string insertUnitSql = @" + INSERT INTO [phones].[PhoneListUnit] (PhoneListId, Name, SortOrder) + VALUES (@PhoneListId, @Name, NULL); + SELECT CAST(SCOPE_IDENTITY() AS INT);"; + + foreach (var (recordId, name) in legacyUnits) + { + using var cmd = new SqlCommand(insertUnitSql, conn, tx); + cmd.Parameters.AddWithValue("@PhoneListId", phoneListId); + cmd.Parameters.AddWithValue("@Name", ToDbString(name, "PhoneListUnit", "Name")); + unitIdMap[recordId] = (int)cmd.ExecuteScalar(); + } + + const string insertPersonSql = @" + INSERT INTO [phones].[PhoneListUnitPerson] + (PhoneListUnitId, PersonIam, ListFirst, IsActive, ModifiedBy, ModifiedDate) + VALUES (@PhoneListUnitId, @PersonIam, @ListFirst, 1, NULL, @ModifiedDate);"; + + int migrated = 0; + int skippedInactive = 0; + int skippedNoUnit = 0; + + foreach (var person in vmdoPeople) + { + var iamId = ResolveActiveIamId(person.MothraId, ref skippedInactive); + if (iamId is null) + { + continue; + } + if (person.UnitId is null || !unitIdMap.TryGetValue(person.UnitId.Value, out var phoneListUnitId)) + { + skippedNoUnit++; + continue; + } + + using var cmd = new SqlCommand(insertPersonSql, conn, tx); + cmd.Parameters.AddWithValue("@PhoneListUnitId", phoneListUnitId); + cmd.Parameters.AddWithValue("@PersonIam", ToDbString(iamId, "PhoneListUnitPerson", "PersonIam")); + cmd.Parameters.AddWithValue("@ListFirst", person.ListFirst); + // ModifiedBy stays null: VMDOPeople records when a row changed but never by whom. + cmd.Parameters.AddWithValue("@ModifiedDate", ToDbValue(person.Updated)); + cmd.ExecuteNonQuery(); + migrated++; + } + + Console.WriteLine($" Migrated {legacyUnits.Count} units and {migrated} people."); + if (skippedInactive > 0) + { + Console.WriteLine($" Skipped {skippedInactive} person(s) who are not current employees."); + } + if (skippedNoUnit > 0) + { + Console.WriteLine($" Skipped {skippedNoUnit} person(s) whose unit could not be resolved."); + } + Console.WriteLine(); + } + + private static int GetOrCreateVmdoList(SqlConnection conn, SqlTransaction tx) + { + using (var lookup = new SqlCommand( + "SELECT PhoneListId FROM [phones].[PhoneList] WHERE Code = @code", conn, tx)) + { + lookup.Parameters.AddWithValue("@code", VmdoListCode); + var existing = lookup.ExecuteScalar(); + if (existing is int existingId) + { + Console.WriteLine($" Reusing existing '{VmdoListCode}' phone list (PhoneListId={existingId})."); + return existingId; + } + } + + using var insert = new SqlCommand(@" + INSERT INTO [phones].[PhoneList] (Code, Name, MaintainRole) + VALUES (@code, @name, @role); + SELECT CAST(SCOPE_IDENTITY() AS INT);", conn, tx); + insert.Parameters.AddWithValue("@code", VmdoListCode); + insert.Parameters.AddWithValue("@name", VmdoListName); + insert.Parameters.AddWithValue("@role", VmdoListMaintainRole); + var newId = (int)insert.ExecuteScalar(); + Console.WriteLine($" Created '{VmdoListCode}' phone list (PhoneListId={newId})."); + return newId; + } + + // ---------- Reporting ---------- + + private void ReportDeferredFindings() + { + Console.WriteLine("Findings requiring review:"); + + Console.WriteLine($" Who_Mod resolved as MothraId: {_whoModAsMothraId:N0}"); + Console.WriteLine($" Who_Mod resolved as LoginId: {_whoModAsLoginId:N0}"); + Console.ForegroundColor = _whoModUnresolved == 0 ? ConsoleColor.Green : ConsoleColor.Yellow; + Console.WriteLine($" Who_Mod unresolved (stored as null): {_whoModUnresolved:N0}"); + Console.ResetColor(); + + if (_faxConflictReports.Count > 0) + { + Console.WriteLine($" Units whose rows disagreed on fax ({_faxConflictReports.Count}):"); + foreach (var report in _faxConflictReports) + { + Console.WriteLine($" {report}"); + } + } + + if (_widthViolations.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($" COLUMN WIDTH VIOLATIONS ({_widthViolations.Count}) - this run will not commit:"); + foreach (var violation in _widthViolations.Distinct()) + { + Console.WriteLine($" {violation}"); + } + Console.ResetColor(); + } + + if (_overflowReports.Count > 0) + { + Console.ForegroundColor = ConsoleColor.Yellow; + Console.WriteLine($" Combined values too long for their column ({_overflowReports.Count}) - fix these by hand afterward:"); + foreach (var report in _overflowReports) + { + Console.WriteLine($" {report}"); + } + Console.ResetColor(); + } + else + { + Console.WriteLine(" No values exceeded their column limit."); + } + + Console.WriteLine(); + } + + private static void ValidateMigration(SqlConnection conn, SqlTransaction tx, SqlConnection legacyConn) + { + Console.WriteLine("Destination row counts:"); + string[] tables = + [ + "SVMSection", "SVMUnit", "SVMUnitPerson", "SVMFrequentNumber", + "Person", "PhoneList", "PhoneListUnit", "PhoneListUnitPerson" + ]; + + foreach (var table in tables) + { + using var cmd = new SqlCommand($"SELECT COUNT(*) FROM [phones].[{table}]", conn, tx); + Console.WriteLine($" phones.{table}: {(int)cmd.ExecuteScalar():N0}"); + } + + Console.WriteLine("Legacy source row counts, for comparison:"); + foreach (var table in new[] { "SVM_Phones_Sections", "dvtUnit", "SVM_Phones", "VMDOUnits", "VMDOPeople" }) + { + using var cmd = new SqlCommand($"SELECT COUNT(*) FROM [dbo].[{table}]", legacyConn); + Console.WriteLine($" {table}: {(int)cmd.ExecuteScalar():N0}"); + } + } + } +} diff --git a/web/Areas/Personnel/Scripts/PhoneListsDataAnalysis.cs b/web/Areas/Personnel/Scripts/PhoneListsDataAnalysis.cs new file mode 100644 index 000000000..05da40f79 --- /dev/null +++ b/web/Areas/Personnel/Scripts/PhoneListsDataAnalysis.cs @@ -0,0 +1,509 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; + +namespace Viper.Areas.Personnel.Scripts +{ + public sealed record NullUnitKeyRow(int Id, string? Section, string? UnitName); + public sealed record UnitFieldDisagreement(int SectionId, int UnitId, string? UnitName, string Field, List DistinctValues); + public sealed record ValueCount(string Value, int Count); + public sealed record UnresolvedMothraId(string Table, string Column, string MothraId, string LegacyName); + public sealed record MatchedNullIamId(string Table, string Column, string MothraId, string LegacyName); + public sealed record NameMismatch(string Table, string Column, string MothraId, string LegacyName, string ResolvedName); + public sealed record PhoneConflict(string IamId, List SvmValues, List VmdoValues); + public sealed record UnitKey(int SectionId, int UnitId); + + /// + /// Flat, unscored data-quality report for the PhoneLists -> phones schema migration. + /// Every list here is meant for line-by-line human review, not automated resolution. + /// + public class AnalysisReport + { + public List NullUnitKeyRows { get; } = []; + public List UnitOrderOrFaxDisagreements { get; } = []; + public List InterimAdminValues { get; } = []; + public List InterimDirectorValues { get; } = []; + public List UnresolvedMothraIds { get; } = []; + public List MatchedButNullIamId { get; } = []; + public List NameMismatches { get; } = []; + public List CrossFeaturePhoneConflicts { get; } = []; + public int DvtUnitNullUnitIdCount { get; set; } + public List DvtUnitOnlyKeys { get; } = []; + public List SvmPhonesOnlyKeys { get; } = []; + } + + /// + /// Read-only data-quality analysis for the PhoneLists -> phones schema migration. + /// Connects to the legacy "PhoneLists" database (read-only) and "VIPER" (for the + /// users.Person lookup only) and reports every conflict/risk identified while planning + /// the migration. Writes no data anywhere - this is the dry-run pass that precedes the + /// real transform/apply script. + /// + public class PhoneListsDataAnalysis + { + private readonly string _legacyConnectionString; + private readonly string _viperConnectionString; + private readonly string _outputPath; + private readonly DateTime _analysisDate; + private readonly AnalysisReport _report = new(); + + public PhoneListsDataAnalysis(IConfiguration? configuration = null, string? outputPath = null) + { + var config = configuration ?? PhoneListsScriptHelper.LoadConfiguration(); + _viperConnectionString = PhoneListsScriptHelper.GetConnectionString(config, "VIPER"); + _legacyConnectionString = PhoneListsScriptHelper.GetConnectionString(config, "PhoneLists"); + _outputPath = PhoneListsScriptHelper.ValidateOutputPath(outputPath, "AnalysisOutput"); + _analysisDate = DateTime.Now; + + Directory.CreateDirectory(_outputPath); + } + + public static void Run(string[] args) + { + Console.WriteLine("==========================================="); + Console.WriteLine("PHONELISTS MIGRATION ANALYSIS"); + Console.WriteLine("==========================================="); + Console.WriteLine($"Analysis Started: {DateTime.Now:yyyy-MM-dd HH:mm:ss}"); + Console.WriteLine($"Environment: {Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"}"); + Console.WriteLine(); + + try + { + var analyzer = new PhoneListsDataAnalysis(); + + Console.WriteLine("Connection Configuration:"); + Console.WriteLine($" VIPER Database: {PhoneListsScriptHelper.GetServerAndDatabase(analyzer._viperConnectionString)}"); + Console.WriteLine($" PhoneLists Database: {PhoneListsScriptHelper.GetServerAndDatabase(analyzer._legacyConnectionString)}"); + Console.WriteLine(); + + analyzer.RunFullAnalysis(); + } + catch (InvalidOperationException ex) + { + WriteFatalError(ex); + } + catch (SqlException ex) + { + WriteFatalError(ex); + } + } + + private static void WriteFatalError(Exception ex) + { + Console.ForegroundColor = ConsoleColor.Red; + Console.WriteLine($"\nERROR: {ex.Message}"); + Console.WriteLine("\nStack Trace:"); + Console.WriteLine(ex.StackTrace); + Console.ResetColor(); + Environment.Exit(1); + } + + public void RunFullAnalysis() + { + using var legacyConn = new SqlConnection(_legacyConnectionString); + using var viperConn = new SqlConnection(_viperConnectionString); + legacyConn.Open(); + viperConn.Open(); + + Console.WriteLine("Building MothraId -> IamId lookup from users.Person..."); + var personLookup = PhoneListsScriptHelper.BuildMothraIdToPersonLookupMap(viperConn); + Console.WriteLine($" Loaded {personLookup.Count:N0} person records."); + Console.WriteLine(); + + Console.WriteLine("Checking for SVM_Phones rows with a null SectionID or unitID..."); + AnalyzeNullUnitKeys(legacyConn); + WriteColoredCount(" Rows with null SectionID/unitID", _report.NullUnitKeyRows.Count, isCritical: true); + Console.WriteLine(); + + Console.WriteLine("Checking unit-level UnitOrder/Fax consistency across SVM_Phones rows..."); + var unitKeys = AnalyzeUnitConsistency(legacyConn); + WriteColoredCount(" Units with disagreeing UnitOrder/Fax", _report.UnitOrderOrFaxDisagreements.Count, isCritical: false); + Console.WriteLine(); + + Console.WriteLine("Checking distinct InterimAdmin/InterimDirector values..."); + AnalyzeInterimValues(legacyConn); + Console.WriteLine($" InterimAdmin: {_report.InterimAdminValues.Count} distinct value(s)"); + Console.WriteLine($" InterimDirector: {_report.InterimDirectorValues.Count} distinct value(s)"); + Console.WriteLine(); + + Console.WriteLine("Resolving legacy MothraId references against users.Person..."); + AnalyzeMothraIdResolution(legacyConn, personLookup); + WriteColoredCount(" Unresolved MothraIds", _report.UnresolvedMothraIds.Count, isCritical: true); + WriteColoredCount(" Resolved but missing IamId", _report.MatchedButNullIamId.Count, isCritical: true); + WriteColoredCount(" Name mismatches (for review)", _report.NameMismatches.Count, isCritical: false); + Console.WriteLine(); + + Console.WriteLine("Checking for cross-feature Phone conflicts (SVM vs. VMDO)..."); + AnalyzeCrossFeaturePhoneConflicts(legacyConn, personLookup); + WriteColoredCount(" Cross-feature Phone conflicts", _report.CrossFeaturePhoneConflicts.Count, isCritical: false); + Console.WriteLine(); + + Console.WriteLine("Checking dvtUnit coverage against SVM_Phones..."); + AnalyzeDvtUnitCoverage(legacyConn, unitKeys.Keys); + WriteColoredCount(" dvtUnit rows with null unitID", _report.DvtUnitNullUnitIdCount, isCritical: false); + WriteColoredCount(" Units only in dvtUnit", _report.DvtUnitOnlyKeys.Count, isCritical: false); + WriteColoredCount(" Units only in SVM_Phones", _report.SvmPhonesOnlyKeys.Count, isCritical: false); + Console.WriteLine(); + + var reportPath = WriteTextReport(); + Console.WriteLine("==========================================="); + Console.WriteLine($"Full report written to: {reportPath}"); + Console.WriteLine("==========================================="); + } + + // Check 1: SVM_Phones rows whose (SectionID, unitID) unit key isn't fully populated. + private void AnalyzeNullUnitKeys(SqlConnection legacyConn) + { + const string sql = "SELECT ID, Section, UnitName FROM [dbo].[SVM_Phones] WHERE SectionID IS NULL OR unitID IS NULL"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var id = reader.GetInt32(0); + var section = reader.IsDBNull(1) ? null : reader.GetString(1).Trim(); + var unitName = reader.IsDBNull(2) ? null : reader.GetString(2).Trim(); + _report.NullUnitKeyRows.Add(new NullUnitKeyRow(id, section, unitName)); + } + } + + // Check 2: per-unit UnitOrder/Fax should be single-valued across all of a unit's rows + // (both are denormalized per-row in SVM_Phones, same shape as admin-staff already is). + // Returns the grouped unit keys so AnalyzeDvtUnitCoverage can reuse them. + private Dictionary<(int SectionId, int UnitId), List<(int? UnitOrder, string? Fax, string? UnitName)>> AnalyzeUnitConsistency( + SqlConnection legacyConn) + { + var groups = new Dictionary<(int SectionId, int UnitId), List<(int? UnitOrder, string? Fax, string? UnitName)>>(); + + const string sql = @" + SELECT SectionID, unitID, UnitOrder, Fax, UnitName + FROM [dbo].[SVM_Phones] + WHERE SectionID IS NOT NULL AND unitID IS NOT NULL + ORDER BY SectionID, unitID"; + + using (var cmd = new SqlCommand(sql, legacyConn)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + var sectionId = reader.GetInt32(0); + var unitId = reader.GetInt32(1); + var unitOrder = reader.IsDBNull(2) ? (int?)null : reader.GetInt32(2); + var fax = reader.IsDBNull(3) ? null : reader.GetString(3).Trim(); + var unitName = reader.IsDBNull(4) ? null : reader.GetString(4).Trim(); + + var key = (sectionId, unitId); + if (!groups.TryGetValue(key, out var rows)) + { + rows = []; + groups[key] = rows; + } + rows.Add((unitOrder, fax, unitName)); + } + } + + foreach (var (key, rows) in groups) + { + var distinctOrders = rows.Select(r => r.UnitOrder).Distinct().ToList(); + if (distinctOrders.Count > 1) + { + _report.UnitOrderOrFaxDisagreements.Add(new UnitFieldDisagreement( + key.SectionId, key.UnitId, rows[0].UnitName, "UnitOrder", + distinctOrders.Select(o => o?.ToString() ?? "").ToList())); + } + + var distinctFaxes = rows.Select(r => r.Fax ?? "").Distinct().ToList(); + if (distinctFaxes.Count > 1) + { + _report.UnitOrderOrFaxDisagreements.Add(new UnitFieldDisagreement( + key.SectionId, key.UnitId, rows[0].UnitName, "Fax", + distinctFaxes.Select(f => f.Length == 0 ? "" : f).ToList())); + } + } + + return groups; + } + + // Check 3: surface the distinct values actually present, rather than assume they map + // cleanly onto SVMUnitPerson.Interim's Acting/Interim/Vice enum. + private void AnalyzeInterimValues(SqlConnection legacyConn) + { + _report.InterimAdminValues.AddRange(GetDistinctValueCounts(legacyConn, "InterimAdmin")); + _report.InterimDirectorValues.AddRange(GetDistinctValueCounts(legacyConn, "InterimDirector")); + } + + private static List GetDistinctValueCounts(SqlConnection legacyConn, string column) + { + var results = new List(); + var sql = $"SELECT {column}, COUNT(*) FROM [dbo].[SVM_Phones] GROUP BY {column}"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var value = reader.IsDBNull(0) ? "" : reader.GetString(0).Trim(); + var count = reader.GetInt32(1); + results.Add(new ValueCount(value.Length == 0 ? "" : value, count)); + } + return results; + } + + // Checks 4-6: resolve every legacy MothraId reference against users.Person, and for + // each one report exactly one of: unresolved, resolved-but-no-IamId, or a name mismatch. + private void AnalyzeMothraIdResolution(SqlConnection legacyConn, Dictionary personLookup) + { + CheckMothraIdColumn(legacyConn, "SVM_Phones", "Dean_Director_MothraID", "Dean_Director", personLookup); + CheckMothraIdColumn(legacyConn, "SVM_Phones", "Admin_MothraID", "Admin_Staff", personLookup); + CheckMothraIdColumn(legacyConn, "VMDOPeople", "vmdoPeople_mothraID", + "CONCAT(vmdoPeople_firstName, ' ', vmdoPeople_lastName)", personLookup); + } + + private void CheckMothraIdColumn( + SqlConnection legacyConn, + string table, + string mothraIdColumn, + string nameExpression, + Dictionary personLookup) + { + // `table` doubles as the plain label used in the report, so qualify it only here. + var sql = $@" + SELECT {mothraIdColumn}, {nameExpression} AS LegacyName + FROM [dbo].[{table}] + WHERE {mothraIdColumn} IS NOT NULL AND RTRIM({mothraIdColumn}) <> ''"; + + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var mothraId = reader.GetString(0).Trim(); + var legacyName = reader.IsDBNull(1) ? "" : reader.GetString(1).Trim(); + + // An all-zero placeholder names nobody. The migration drops those rows rather than + // failing on them, so reporting them as unresolved would be a false blocker. + if (!PhoneListsScriptHelper.HasMothraId(mothraId)) + { + continue; + } + + if (!personLookup.TryGetValue(mothraId, out var person)) + { + _report.UnresolvedMothraIds.Add(new UnresolvedMothraId(table, mothraIdColumn, mothraId, legacyName)); + continue; + } + + if (person.IamId is null) + { + _report.MatchedButNullIamId.Add(new MatchedNullIamId(table, mothraIdColumn, mothraId, legacyName)); + continue; + } + + if (!NamesMatch(legacyName, person.FullName)) + { + _report.NameMismatches.Add(new NameMismatch(table, mothraIdColumn, mothraId, legacyName, person.FullName)); + } + } + } + + private static bool NamesMatch(string legacyName, string resolvedName) + { + return string.Equals(NormalizeName(legacyName), NormalizeName(resolvedName), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeName(string name) + { + return Regex.Replace(name.Trim(), @"\s+", " "); + } + + // Check 7: the same person can appear in both legacy sources, each with their own Phone + // value, but both now feed the single shared phones.Person.Phone column. + private void AnalyzeCrossFeaturePhoneConflicts(SqlConnection legacyConn, Dictionary personLookup) + { + var svmPhones = new Dictionary>(StringComparer.OrdinalIgnoreCase); + var vmdoPhones = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + const string svmSql = @" + SELECT Dean_Director_MothraID AS MothraId, Dir_Phone AS Phone + FROM [dbo].[SVM_Phones] + WHERE Dean_Director_MothraID IS NOT NULL AND RTRIM(Dean_Director_MothraID) <> '' + UNION ALL + SELECT Admin_MothraID, Phone + FROM [dbo].[SVM_Phones] + WHERE Admin_MothraID IS NOT NULL AND RTRIM(Admin_MothraID) <> ''"; + CollectPhonesByIamId(legacyConn, svmSql, personLookup, svmPhones); + + const string vmdoSql = @" + SELECT vmdoPeople_mothraID AS MothraId, vmdoPeople_publicNum AS Phone + FROM [dbo].[VMDOPeople] + WHERE vmdoPeople_mothraID IS NOT NULL AND RTRIM(vmdoPeople_mothraID) <> ''"; + CollectPhonesByIamId(legacyConn, vmdoSql, personLookup, vmdoPhones); + + foreach (var (iamId, svmValues) in svmPhones) + { + if (!vmdoPhones.TryGetValue(iamId, out var vmdoValues)) + { + continue; + } + + var allValues = new HashSet(svmValues, StringComparer.OrdinalIgnoreCase); + allValues.UnionWith(vmdoValues); + if (allValues.Count > 1) + { + _report.CrossFeaturePhoneConflicts.Add(new PhoneConflict(iamId, svmValues.ToList(), vmdoValues.ToList())); + } + } + } + + private static void CollectPhonesByIamId( + SqlConnection legacyConn, + string sql, + Dictionary personLookup, + Dictionary> destination) + { + using var cmd = new SqlCommand(sql, legacyConn); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var mothraId = reader.GetString(0).Trim(); + var phone = reader.IsDBNull(1) ? "" : reader.GetString(1).Trim(); + + if (phone.Length == 0 || !personLookup.TryGetValue(mothraId, out var person) || person.IamId is null) + { + continue; + } + + if (!destination.TryGetValue(person.IamId, out var values)) + { + values = new HashSet(StringComparer.OrdinalIgnoreCase); + destination[person.IamId] = values; + } + values.Add(phone); + } + } + + // Check 8 (optional/cheap): sanity-checks whether dvtUnit is safe to drop from the + // eventual transform, since everything it has is otherwise duplicated in SVM_Phones. + private void AnalyzeDvtUnitCoverage(SqlConnection legacyConn, IEnumerable<(int SectionId, int UnitId)> svmPhoneUnitKeys) + { + const string countSql = "SELECT COUNT(*) FROM [dbo].[dvtUnit] WHERE dvtUnit_unitID IS NULL"; + using (var countCmd = new SqlCommand(countSql, legacyConn)) + { + _report.DvtUnitNullUnitIdCount = (int)countCmd.ExecuteScalar(); + } + + var dvtUnitKeys = new HashSet<(int SectionId, int UnitId)>(); + const string sql = @" + SELECT DISTINCT dvtUnit_sectionID, dvtUnit_unitID + FROM [dbo].[dvtUnit] + WHERE dvtUnit_unitID IS NOT NULL"; + + using (var cmd = new SqlCommand(sql, legacyConn)) + using (var reader = cmd.ExecuteReader()) + { + while (reader.Read()) + { + dvtUnitKeys.Add((reader.GetInt32(0), reader.GetInt32(1))); + } + } + + var svmKeys = new HashSet<(int SectionId, int UnitId)>(svmPhoneUnitKeys); + + _report.DvtUnitOnlyKeys.AddRange(dvtUnitKeys.Except(svmKeys).Select(k => new UnitKey(k.SectionId, k.UnitId))); + _report.SvmPhonesOnlyKeys.AddRange(svmKeys.Except(dvtUnitKeys).Select(k => new UnitKey(k.SectionId, k.UnitId))); + } + + private static void WriteColoredCount(string label, int count, bool isCritical) + { + Console.ForegroundColor = count == 0 ? ConsoleColor.Green : (isCritical ? ConsoleColor.Red : ConsoleColor.Yellow); + Console.WriteLine($"{label}: {count:N0}"); + Console.ResetColor(); + } + + private string WriteTextReport() + { + var sb = new StringBuilder(); + sb.AppendLine("PhoneLists Migration - Data Quality Analysis"); + sb.AppendLine($"Generated: {_analysisDate:yyyy-MM-dd HH:mm:ss}"); + sb.AppendLine(); + + sb.AppendLine("== SVM_Phones rows with null SectionID/unitID =="); + foreach (var row in _report.NullUnitKeyRows) + { + sb.AppendLine($" ID={row.Id} Section='{row.Section}' UnitName='{row.UnitName}'"); + } + sb.AppendLine(); + + sb.AppendLine("== Units with disagreeing UnitOrder/Fax =="); + foreach (var d in _report.UnitOrderOrFaxDisagreements) + { + sb.AppendLine($" SectionId={d.SectionId} UnitId={d.UnitId} UnitName='{d.UnitName}' " + + $"Field={d.Field} Values=[{string.Join(", ", d.DistinctValues)}]"); + } + sb.AppendLine(); + + sb.AppendLine("== InterimAdmin distinct values =="); + foreach (var v in _report.InterimAdminValues) + { + sb.AppendLine($" '{v.Value}': {v.Count}"); + } + sb.AppendLine(); + + sb.AppendLine("== InterimDirector distinct values =="); + foreach (var v in _report.InterimDirectorValues) + { + sb.AppendLine($" '{v.Value}': {v.Count}"); + } + sb.AppendLine(); + + sb.AppendLine("== Unresolved MothraIds =="); + foreach (var u in _report.UnresolvedMothraIds) + { + sb.AppendLine($" [{u.Table}.{u.Column}] MothraId='{u.MothraId}' LegacyName='{u.LegacyName}'"); + } + sb.AppendLine(); + + sb.AppendLine("== Resolved MothraIds with missing IamId =="); + foreach (var m in _report.MatchedButNullIamId) + { + sb.AppendLine($" [{m.Table}.{m.Column}] MothraId='{m.MothraId}' LegacyName='{m.LegacyName}'"); + } + sb.AppendLine(); + + sb.AppendLine("== Name mismatches (legacy name vs. users.Person.FullName) =="); + foreach (var n in _report.NameMismatches) + { + sb.AppendLine($" [{n.Table}.{n.Column}] MothraId='{n.MothraId}' Legacy='{n.LegacyName}' Resolved='{n.ResolvedName}'"); + } + sb.AppendLine(); + + sb.AppendLine("== Cross-feature Phone conflicts (SVM vs. VMDO) =="); + foreach (var c in _report.CrossFeaturePhoneConflicts) + { + sb.AppendLine($" IamId={c.IamId} SVM=[{string.Join(", ", c.SvmValues)}] VMDO=[{string.Join(", ", c.VmdoValues)}]"); + } + sb.AppendLine(); + + sb.AppendLine("== dvtUnit coverage =="); + sb.AppendLine($" dvtUnit rows with null unitID: {_report.DvtUnitNullUnitIdCount}"); + sb.AppendLine(" Units only in dvtUnit:"); + foreach (var k in _report.DvtUnitOnlyKeys) + { + sb.AppendLine($" SectionId={k.SectionId} UnitId={k.UnitId}"); + } + sb.AppendLine(" Units only in SVM_Phones:"); + foreach (var k in _report.SvmPhonesOnlyKeys) + { + sb.AppendLine($" SectionId={k.SectionId} UnitId={k.UnitId}"); + } + + var fileName = $"PhoneListsAnalysis_{_analysisDate:yyyyMMdd_HHmmss}.txt"; + var path = Path.Join(_outputPath, fileName); + File.WriteAllText(path, sb.ToString()); + return path; + } + } +} diff --git a/web/Areas/Personnel/Scripts/PhoneListsMigration.csproj b/web/Areas/Personnel/Scripts/PhoneListsMigration.csproj new file mode 100644 index 000000000..56e45bfee --- /dev/null +++ b/web/Areas/Personnel/Scripts/PhoneListsMigration.csproj @@ -0,0 +1,27 @@ + + + + Exe + net10.0 + enable + PhoneListsMigration + Viper.Areas.Personnel.Scripts + + + + + + + + + + + + + + + + + + + diff --git a/web/Areas/Personnel/Scripts/PhoneListsScriptHelper.cs b/web/Areas/Personnel/Scripts/PhoneListsScriptHelper.cs new file mode 100644 index 000000000..a71655490 --- /dev/null +++ b/web/Areas/Personnel/Scripts/PhoneListsScriptHelper.cs @@ -0,0 +1,293 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text.RegularExpressions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Configuration; +using Amazon; +using Amazon.Extensions.NETCore.Setup; + +namespace Viper.Areas.Personnel.Scripts +{ + /// + /// A person resolved from users.Person by legacy MothraId. IamId is nullable because + /// the column itself is nullable in users.Person - the analysis script treats a + /// resolved-but-null IamId as its own reportable case rather than a defensive corner case. + /// + public sealed record PersonLookup(string? IamId, string FullName, bool CurrentEmployee); + + /// + /// Shared utilities for the PhoneLists data migration scripts. + /// + public static class PhoneListsScriptHelper + { + public static string GetApplicationRoot() + { + var currentDir = Directory.GetCurrentDirectory(); + + if (currentDir.Contains("Scripts")) + { + currentDir = Path.GetFullPath(Path.Join(currentDir, "..", "..", "..")); + } + + if (!File.Exists(Path.Join(currentDir, "appsettings.json"))) + { + var parentDir = Path.GetFullPath(Path.Join(currentDir, "..", "..")); + if (File.Exists(Path.Join(parentDir, "appsettings.json"))) + { + currentDir = parentDir; + } + } + + return currentDir; + } + + public static string GetConnectionString(IConfiguration configuration, string name, bool readOnly = true) + { + var connectionString = configuration.GetConnectionString(name); + + if (string.IsNullOrEmpty(connectionString)) + { + // Name the environment: the checked-in appsettings hold empty placeholders, so a + // missing value almost always means AWS Parameter Store had nothing for THIS + // environment - which is easy to miss when another environment resolved fine. + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + throw new InvalidOperationException( + $"{name} database connection string not found in configuration for environment '{environment}'. " + + $"It resolves from AWS Parameter Store (/{environment} or /Shared); the checked-in " + + $"appsettings.{environment}.json holds only an empty placeholder." + ); + } + + // SECURITY: Automatically add ApplicationIntent=ReadOnly to the "PhoneLists" connection + // string to prevent accidental modifications to the legacy database during analysis. + if (name.Equals("PhoneLists", StringComparison.OrdinalIgnoreCase) && readOnly) + { + var builder = new SqlConnectionStringBuilder(connectionString); + + if (builder.ApplicationIntent != ApplicationIntent.ReadOnly) + { + builder.ApplicationIntent = ApplicationIntent.ReadOnly; + connectionString = builder.ConnectionString; + Console.WriteLine(" Added ApplicationIntent=ReadOnly to PhoneLists connection for safety"); + } + } + + return connectionString; + } + + public static string GetServerAndDatabase(string connectionString) + { + try + { + var builder = new SqlConnectionStringBuilder(connectionString); + return $"{builder.DataSource}/{builder.InitialCatalog}"; + } + catch (ArgumentException ex) + { + return $"Could not parse connection string: {ex.Message}"; + } + catch (FormatException ex) + { + return $"Could not parse connection string: {ex.Message}"; + } + } + + /// + /// Loads configuration from appsettings.json files and AWS Parameter Store. + /// Falls back gracefully to appsettings.json only if AWS is unavailable. + /// + public static IConfiguration LoadConfiguration() + { + var environment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Development"; + var appRoot = GetApplicationRoot(); + + Console.WriteLine($"Loading configuration for environment: {environment}"); + Console.WriteLine($"Configuration root: {appRoot}"); + + var builder = new ConfigurationBuilder() + .SetBasePath(appRoot) + .AddJsonFile("appsettings.json", optional: false, reloadOnChange: false) + .AddJsonFile($"appsettings.{environment}.json", optional: true, reloadOnChange: false) + .AddEnvironmentVariables(); + + try + { + AWSOptions awsOptions = new() + { + Region = RegionEndpoint.USWest1 + }; + + builder.AddSystemsManager("/" + environment, awsOptions) + .AddSystemsManager("/Shared", awsOptions); + + Console.WriteLine($"Successfully connected to AWS Parameter Store for environment: {environment}"); + } + catch (Amazon.Runtime.AmazonServiceException ex) + { + Console.WriteLine($"Warning: Could not connect to AWS Parameter Store: {ex.Message}"); + Console.WriteLine("Continuing with appsettings.json configuration only."); + } + catch (Amazon.Runtime.AmazonClientException ex) + { + Console.WriteLine($"Warning: Could not connect to AWS Parameter Store: {ex.Message}"); + Console.WriteLine("Continuing with appsettings.json configuration only."); + } + catch (ArgumentException ex) + { + Console.WriteLine($"Warning: AWS configuration error: {ex.Message}"); + Console.WriteLine("Continuing with appsettings.json configuration only."); + } + + return builder.Build(); + } + + public static string ValidateOutputPath(string? outputPath, string defaultSubfolder) + { + if (string.IsNullOrWhiteSpace(defaultSubfolder) + || Path.IsPathRooted(defaultSubfolder) + || defaultSubfolder.Contains("..")) + { + throw new InvalidOperationException( + $"Default subfolder must be a non-empty, relative path without path traversal. Value: '{defaultSubfolder}'"); + } + + if (string.IsNullOrWhiteSpace(outputPath)) + { + return Path.Join(Directory.GetCurrentDirectory(), defaultSubfolder); + } + + var fullPath = Path.GetFullPath(outputPath); + + var currentDir = Directory.GetCurrentDirectory(); + var relative = Path.GetRelativePath(currentDir, fullPath); + if (Path.IsPathRooted(relative) + || relative.Equals("..", StringComparison.OrdinalIgnoreCase) + || relative.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidOperationException( + $"Output path must be within the current directory. " + + $"Current directory: {currentDir}, Requested path: {fullPath}"); + } + + return fullPath; + } + + /// + /// Builds a MothraId -> PersonLookup map from users.Person, for resolving legacy + /// PhoneLists identity references onto the IamId the new phones.Person schema keys on. + /// Unlike Effort's PersonId-keyed map, this targets IamId since that's what + /// phones.Person.PersonIam actually is a foreign key to. + /// + public static Dictionary BuildMothraIdToPersonLookupMap(SqlConnection viperConnection) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + const string sql = @" + SELECT MothraId, IamId, FullName, CurrentEmployee + FROM [users].[Person] + WHERE MothraId IS NOT NULL"; + + using var cmd = new SqlCommand(sql, viperConnection); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var mothraId = reader.GetString(0).Trim(); + var iamId = reader.IsDBNull(1) ? null : reader.GetString(1); + var fullName = reader.IsDBNull(2) ? "" : reader.GetString(2); + var currentEmployee = !reader.IsDBNull(3) && reader.GetBoolean(3); + + map[mothraId] = new PersonLookup(iamId, fullName, currentEmployee); + } + + return map; + } + + /// + /// Builds a LoginId -> IamId map from users.Person. Used as the fallback when a legacy + /// Who_Mod value doesn't resolve as a MothraId - the legacy schema never documented which + /// identifier it stored, and Effort's audit_ModBy turned out to hold a mix of both. + /// + public static Dictionary BuildLoginIdToIamIdMap(SqlConnection viperConnection) + { + var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + + const string sql = @" + SELECT LoginId, IamId + FROM [users].[Person] + WHERE LoginId IS NOT NULL AND IamId IS NOT NULL"; + + using var cmd = new SqlCommand(sql, viperConnection); + using var reader = cmd.ExecuteReader(); + while (reader.Read()) + { + var loginId = reader.GetString(0).Trim(); + var iamId = reader.GetString(1); + + // First mapping wins, in case of duplicate LoginIds. + if (!map.ContainsKey(loginId)) + { + map[loginId] = iamId; + } + } + + return map; + } + + /// + /// Reports whether a destination column is a physical IDENTITY column. The phones schema + /// was created outside this repo (no EF migrations, no checked-in DDL), so whether the + /// caller-supplied PKs need SET IDENTITY_INSERT can only be determined at runtime. + /// + public static bool IsIdentityColumn(SqlConnection connection, string schema, string table, string column, + SqlTransaction? transaction = null) + { + const string sql = @" + SELECT c.is_identity + FROM sys.columns c + WHERE c.object_id = OBJECT_ID(@qualifiedTable) AND c.name = @column"; + + using var cmd = new SqlCommand(sql, connection, transaction); + cmd.Parameters.AddWithValue("@qualifiedTable", $"[{schema}].[{table}]"); + cmd.Parameters.AddWithValue("@column", column); + + var result = cmd.ExecuteScalar(); + return result is bool isIdentity && isIdentity; + } + + /// + /// Reports whether a legacy MothraId names a person at all. Both a blank and an all-zero + /// placeholder mean "nobody listed" - neither resolves against users.Person, and neither + /// is a data problem worth failing the migration or reporting on. + /// + public static bool HasMothraId([NotNullWhen(true)] string? mothraId) + { + return !string.IsNullOrWhiteSpace(mothraId) && mothraId.Trim().TrimStart('0').Length > 0; + } + + /// + /// Collapses the two ways the legacy lists write the same local number: the 530 area code + /// that VMDO spells out and SVM omits, and the campus-wide "75" prefix that short + /// extensions drop. 530-752-0123, 752-0123, and 2-0123 all normalize alike. Anything not + /// in one of those shapes is returned unchanged. + /// + public static string NormalizePhone(string phone) + { + var trimmed = Regex.Replace(phone.Trim(), @"^530-", ""); + return Regex.IsMatch(trimmed, @"^\d-\d{4}$") ? "75" + trimmed : trimmed; + } + + /// + /// Writes a progress message to the console if the current count is at the specified interval. + /// + public static void ShowProgress(int current, int total, int interval = 5000, string itemName = "records") + { + if (current % interval == 0) + { + int percent = total > 0 ? current * 100 / total : 0; + Console.WriteLine($" Processing: {current:N0} / {total:N0} {itemName} ({percent}%)..."); + } + } + } +} diff --git a/web/Areas/Personnel/Scripts/Program.cs b/web/Areas/Personnel/Scripts/Program.cs new file mode 100644 index 000000000..795b99d2c --- /dev/null +++ b/web/Areas/Personnel/Scripts/Program.cs @@ -0,0 +1,58 @@ +using System; +using System.Linq; + +namespace Viper.Areas.Personnel.Scripts +{ + /// + /// Entry point for PhoneLists migration and data scripts. + /// Routes to different operations based on command line args: + /// - analysis: Run read-only data-quality analysis against the legacy PhoneLists database + /// - migrate-data: Transform and load the legacy data into the phones schema + /// + public class Program + { + public static int Main(string[] args) + { + if (args.Length == 0) + { + ShowUsage(); + return 1; + } + + var command = args[0].ToLowerInvariant(); + var commandArgs = args.Skip(1).ToArray(); + + switch (command) + { + case "analysis": + PhoneListsDataAnalysis.Run(commandArgs); + return 0; + + case "migrate-data": + MigratePhoneListsData.Run(commandArgs); + return 0; + + default: + Console.WriteLine($"Unknown command: {command}"); + ShowUsage(); + return 1; + } + } + + private static void ShowUsage() + { + Console.WriteLine("PhoneLists Migration Toolkit"); + Console.WriteLine(); + Console.WriteLine("Usage: dotnet run -- [options]"); + Console.WriteLine(); + Console.WriteLine("Commands:"); + Console.WriteLine(" analysis Run read-only data-quality analysis against legacy PhoneLists database"); + Console.WriteLine(" migrate-data Migrate legacy PhoneLists data into the phones schema"); + Console.WriteLine(); + Console.WriteLine("Examples:"); + Console.WriteLine(" dotnet run -- analysis"); + Console.WriteLine(" dotnet run -- migrate-data (dry run, rolls back)"); + Console.WriteLine(" dotnet run -- migrate-data --apply (writes permanently)"); + } + } +} diff --git a/web/Areas/Personnel/Scripts/RunAnalysis.bat b/web/Areas/Personnel/Scripts/RunAnalysis.bat new file mode 100644 index 000000000..96df042a6 --- /dev/null +++ b/web/Areas/Personnel/Scripts/RunAnalysis.bat @@ -0,0 +1,88 @@ +@echo off +REM ================================================================ +REM PhoneLists Migration Toolkit - Data Analysis Runner +REM ================================================================ +REM Usage: RunAnalysis.bat [environment] +REM +REM Examples: +REM RunAnalysis.bat (uses Development config) +REM RunAnalysis.bat Test (uses Test config) +REM RunAnalysis.bat Production (uses Production config) +REM +REM NOTE: Make sure the PhoneLists connection string is set for the target +REM environment's appsettings (or AWS Parameter Store) before running: +REM "ConnectionStrings": { +REM "VIPER": "existing connection...", +REM "PhoneLists": "Server=YOUR_SERVER;Database=PhoneLists;Trusted_Connection=true;" +REM } +REM ================================================================ + +echo. +echo ==================================================== +echo PHONELISTS DATA MIGRATION ANALYSIS +echo ==================================================== +echo. + +REM Set environment +set ASPNETCORE_ENVIRONMENT=Development +if not "%~1"=="" set ASPNETCORE_ENVIRONMENT=%1 + +echo Environment: %ASPNETCORE_ENVIRONMENT% +echo Using application configuration from appsettings.json +echo. + +REM Check if .NET is installed +dotnet --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: .NET SDK not found. Please install .NET SDK 10.0 or later + echo Download from: https://dotnet.microsoft.com/download + pause + exit /b 1 +) + +REM Check if project file exists +if not exist "PhoneListsMigration.csproj" ( + echo ERROR: PhoneListsMigration.csproj not found! + echo Make sure you're running this from the Scripts folder. + pause + exit /b 1 +) + +REM Restore dependencies and build +echo Installing/updating dependencies... +dotnet restore +if %errorlevel% neq 0 ( + echo ERROR: Failed to restore dependencies + pause + exit /b 1 +) + +echo Compiling analysis script... +dotnet build -c Release +if %errorlevel% neq 0 ( + echo ERROR: Failed to compile script + echo Check for compilation errors above + pause + exit /b 1 +) + +REM Run the analysis +echo. +echo Running analysis... +echo. + +dotnet run --project PhoneListsMigration.csproj --configuration Release -- analysis + +echo. +if %errorlevel% equ 0 ( + echo ==================================================== + echo Analysis completed successfully! + echo Check the AnalysisOutput folder for detailed reports. + echo ==================================================== +) else ( + echo ==================================================== + echo Analysis failed. Check error messages above. + echo ==================================================== +) +echo. +pause diff --git a/web/Areas/Personnel/Scripts/RunMigrateData.bat b/web/Areas/Personnel/Scripts/RunMigrateData.bat new file mode 100644 index 000000000..3f3c8c1e0 --- /dev/null +++ b/web/Areas/Personnel/Scripts/RunMigrateData.bat @@ -0,0 +1,101 @@ +@echo off +REM ================================================================ +REM PhoneLists Migration Toolkit - Data Migration Runner +REM ================================================================ +REM Usage: RunMigrateData.bat [environment] [--apply] +REM +REM Examples: +REM RunMigrateData.bat (dry run, Development config) +REM RunMigrateData.bat --apply (apply, Development config) +REM RunMigrateData.bat Test (dry run, Test config) +REM RunMigrateData.bat Test --apply (apply, Test config) +REM RunMigrateData.bat Production --apply (apply, Production config) +REM +REM Run RunAnalysis.bat first - this script re-checks its structural +REM assertions as pre-flight guards and aborts rather than writing if +REM the target environment's data violates them. +REM ================================================================ + +setlocal + +REM Parse arguments: --apply is a flag, anything else names the environment. +REM Compared directly rather than pattern-matched - an earlier findstr version +REM silently took --apply as the environment name, which left every connection +REM string empty because AWS was then asked for parameters under /--apply. +set ASPNETCORE_ENVIRONMENT=Development +set SCRIPT_ARGS= + +:parse +if "%~1"=="" goto parsed +if /i "%~1"=="--apply" (set SCRIPT_ARGS= --apply) else (set ASPNETCORE_ENVIRONMENT=%~1) +shift +goto parse +:parsed + +echo. +echo ================================================================ +echo PHONELISTS DATA MIGRATION +echo ================================================================ +echo. +echo Environment: %ASPNETCORE_ENVIRONMENT% +echo. +echo Available options: +echo [no args] DRY-RUN MODE - previews the migration, then rolls back (safe) +echo --apply APPLY MODE - writes permanently, requires typing DELETE +echo. +echo ================================================================ +echo. + +REM Check if .NET is installed +dotnet --version >nul 2>&1 +if %errorlevel% neq 0 ( + echo ERROR: .NET SDK not found. Please install .NET SDK 10.0 or later + echo Download from: https://dotnet.microsoft.com/download + pause + exit /b 1 +) + +REM Check if project file exists +if not exist "PhoneListsMigration.csproj" ( + echo ERROR: PhoneListsMigration.csproj not found! + echo Make sure you're running this from the Scripts folder. + pause + exit /b 1 +) + +echo Installing/updating dependencies... +dotnet restore +if %errorlevel% neq 0 ( + echo ERROR: Failed to restore dependencies + pause + exit /b 1 +) + +echo Compiling migration script... +dotnet build -c Release +if %errorlevel% neq 0 ( + echo ERROR: Failed to compile script + echo Check for compilation errors above + pause + exit /b 1 +) + +echo. +echo Running migration... +echo. + +dotnet run --project PhoneListsMigration.csproj --configuration Release -- migrate-data%SCRIPT_ARGS% + +echo. +if %errorlevel% equ 0 ( + echo ================================================================ + echo Migration run completed. + echo Review the output above before proceeding. + echo ================================================================ +) else ( + echo ================================================================ + echo Migration failed. Check error messages above. + echo ================================================================ +) +echo. +pause diff --git a/web/Areas/Personnel/Services/PhoneListService.cs b/web/Areas/Personnel/Services/PhoneListService.cs new file mode 100644 index 000000000..628f8936a --- /dev/null +++ b/web/Areas/Personnel/Services/PhoneListService.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel.Services +{ + public class PhoneListService + { + private readonly PhonesDbContext _context; + public PhoneListService(PhonesDbContext context) + { + _context = context; + } + + /// + /// Resolves a list by its stable Code (e.g. "VMDO"). Every list-scoped request enters + /// through here, so the list the caller named is the same one used for the permission + /// check and the data query. + /// + public async Task GetListByCode(string code, CancellationToken ct = default) + { + var list = await _context.PhoneList + .AsNoTracking() + .FirstOrDefaultAsync(t => t.Code == code, ct); + if (list == null) + { + throw new InvalidOperationException("Phone list not found"); + } + return list; + } + } +} diff --git a/web/Areas/Personnel/Services/PhoneListUnitService.cs b/web/Areas/Personnel/Services/PhoneListUnitService.cs new file mode 100644 index 000000000..be6145492 --- /dev/null +++ b/web/Areas/Personnel/Services/PhoneListUnitService.cs @@ -0,0 +1,280 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel.Services +{ + public class PhoneListUnitService + { + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonesPermissionsService _phonesPermissionsService; + public PhoneListUnitService( + PhonesDbContext context, + IUserHelper userHelper, + PhonesPermissionsService phonesPermissionsService + ) + { + _context = context; + _userHelper = userHelper; + _phonesPermissionsService = phonesPermissionsService; + } + + /// + /// Direct numbers are visible to the maintainers of a list and to the people on the list + /// itself; everyone else with SVMSecure sees them blanked. + /// + public async Task CanViewDirectPhone(PhoneList list, CancellationToken ct = default) + { + var userIam = _userHelper.GetCurrentUser()?.IamId; + if (string.IsNullOrWhiteSpace(userIam)) + { + return false; + } + if (_phonesPermissionsService.CanMaintainList(list)) + { + return true; + } + return await _context.PhoneListUnitPerson + .AsNoTracking() + .AnyAsync(t => t.IsActive && t.PersonIam == userIam && t.PhoneListUnit.PhoneListId == list.PhoneListId, ct); + } + + /// + /// Confirms a unit belongs to the list the request was routed through, so a caller who + /// maintains one list cannot reach the units of another list by passing its unit id. + /// + private async Task VerifyUnitInList(int listId, int unitId, CancellationToken ct) + { + var unitInList = await _context.PhoneListUnit + .AsNoTracking() + .AnyAsync(t => t.PhoneListUnitId == unitId && t.PhoneListId == listId, ct); + if (!unitInList) + { + throw new InvalidOperationException("Unit not found in this phone list"); + } + } + + /// + /// Loads an active unit-person row, confirming it belongs to the list the request was + /// routed through. Returns the row so callers avoid a second lookup. + /// + private async Task GetUnitPersonInList(int listId, int unitPersonId, CancellationToken ct) + { + var unitPerson = await _context.PhoneListUnitPerson + .FirstOrDefaultAsync( + t => t.PhoneListUnitPersonId == unitPersonId && t.PhoneListUnit.PhoneListId == listId && t.IsActive, + ct); + if (unitPerson == null) + { + throw new InvalidOperationException("That record has already been removed."); + } + return unitPerson; + } + + /// + /// Get all units, and the people in them, for a phone list. + /// + public async Task> GetPhoneListUnits(PhoneList list, CancellationToken ct = default) + { + // Only users in a phone list may see direct numbers for that list. + bool isInternal = await CanViewDirectPhone(list, ct); + int listId = list.PhoneListId; + var allUnits = await _context.PhoneListUnit + .AsNoTracking() + .Where(t => t.PhoneListId == listId) + .Select(t => new PhoneListUnit + { + PhoneListUnitId = t.PhoneListUnitId, + PhoneListId = t.PhoneListId, + Name = t.Name, + SortOrder = t.SortOrder, + PhoneListUnitPersons = t.PhoneListUnitPersons + .Where(p => p.IsActive && p.Person.ViperPerson != null) + .Select(p => new PhoneListUnitPerson + { + PhoneListUnitPersonId = p.PhoneListUnitPersonId, + PhoneListUnitId = p.PhoneListUnitId, + PersonIam = p.PersonIam, + ListFirst = p.ListFirst, + IsActive = p.IsActive, + ModifiedDate = p.ModifiedDate, + ModifiedBy = p.ModifiedBy, + Person = new PhonePerson + { + PersonIam = p.Person.PersonIam, + Phone = p.Person.Phone, + DirectPhone = isInternal ? p.Person.DirectPhone : "", + Office = p.Person.Office, + ModifiedDate = p.Person.ModifiedDate, + ModifiedBy = p.Person.ModifiedBy, + ViperPerson = p.Person.ViperPerson, + ViperModPerson = p.Person.ViperModPerson, + }, + ViperModPerson = p.ViperModPerson + }) + .OrderByDescending(p => p.ListFirst) + .ThenBy(p => p.Person.ViperPerson!.LastName) + .ThenBy(p => p.Person.ViperPerson!.FirstName) + .ToList() + }) + .OrderBy(t => t.SortOrder == null) + .ThenBy(t => t.SortOrder) + .ThenBy(t => t.Name) + .ToListAsync(ct); + + // Need to deduplicate rows caused by multiple records for the same person + // in the Users table. Due to nested selects, this must be done client-side + // instead of server-side. + foreach (var unit in allUnits) + { + unit.PhoneListUnitPersons = [.. unit.PhoneListUnitPersons.DistinctBy(p => p.Person.PersonIam)]; + } + + return allUnits; + } + + /// + /// Gets the most recent date that a UnitPerson in this list was modified. + /// + public async Task GetUnitPersonModifiedDate(int listId, CancellationToken ct = default) + { + var results = _context.PhoneListUnitPerson + .AsNoTracking() + .Where(t => t.ModifiedDate != null && t.PhoneListUnit.PhoneListId == listId) + .OrderByDescending(t => t.ModifiedDate); + var lastModifiedRecord = await results.FirstOrDefaultAsync(ct); + return lastModifiedRecord?.ModifiedDate; + } + + /// + /// Updates a PhonePerson if they exist, or adds them otherwise. + /// + private async Task AddOrUpdatePhonePerson( + PhoneListUnitDataRequest request, + string? userIam, + DateTime updateTimestamp, + CancellationToken ct = default + ) + { + var modifiedPerson = await _context.PhonePerson.FindAsync(new object?[] { request.EmployeeIam.Trim() }, ct); + if (modifiedPerson == null) + { + modifiedPerson = new PhonePerson + { + PersonIam = request.EmployeeIam.Trim(), + Phone = request.Phone.Trim(), + DirectPhone = request.DirectPhone.Trim(), + Office = request.Office.Trim(), + ModifiedDate = updateTimestamp, + ModifiedBy = userIam, + }; + await _context.PhonePerson.AddAsync(modifiedPerson, ct); + } + else + { + modifiedPerson.Phone = request.Phone.Trim(); + modifiedPerson.DirectPhone = request.DirectPhone.Trim(); + modifiedPerson.Office = request.Office.Trim(); + modifiedPerson.ModifiedBy = userIam; + modifiedPerson.ModifiedDate = updateTimestamp; + } + await _context.SaveChangesAsync(ct); + } + + /// + /// Updates the ListFirst column for this list's UnitPersons, as only 1 + /// may have this flag at one time. + /// + private async Task UpdateListFirst(int unitId, CancellationToken ct = default) + { + var oldFirstPersons = await _context.PhoneListUnitPerson + .Where(t => t.ListFirst && t.IsActive && t.PhoneListUnitId == unitId) + .ToListAsync(ct); + foreach (var oldFirstPerson in oldFirstPersons) + { + // Don't update the Modified By or Date for these records, + // since this may result in confusing front end information. + oldFirstPerson.ListFirst = false; + } + await _context.SaveChangesAsync(ct); + } + + /// + /// Adds data about a UnitPerson, which also updates or creates a PhonePerson. + /// + public async Task AddUnitPersonData(int listId, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + await VerifyUnitInList(listId, request.UnitId, ct); + using var transaction = await _context.Database.BeginTransactionAsync(ct); + var userIam = _userHelper.GetCurrentUser()?.IamId; + var updateTimestamp = DateTime.Now; + await AddOrUpdatePhonePerson(request, userIam, updateTimestamp, ct); + + if (request.ListFirst) + { + await UpdateListFirst(request.UnitId, ct); + } + var modifiedUnitPerson = await _context.PhoneListUnitPerson + .Where(p => p.IsActive && p.PhoneListUnitId == request.UnitId && p.PersonIam == request.EmployeeIam.Trim()) + .FirstOrDefaultAsync(ct); + if (modifiedUnitPerson == null) + { + var newPhoneListPerson = new PhoneListUnitPerson + { + PhoneListUnitId = request.UnitId, + PersonIam = request.EmployeeIam.Trim(), + ListFirst = request.ListFirst, + IsActive = true, + ModifiedBy = userIam, + ModifiedDate = updateTimestamp + }; + await _context.PhoneListUnitPerson.AddAsync(newPhoneListPerson, ct); + } + else + { + modifiedUnitPerson.PersonIam = request.EmployeeIam.Trim(); + modifiedUnitPerson.ListFirst = request.ListFirst; + modifiedUnitPerson.ModifiedBy = userIam; + modifiedUnitPerson.ModifiedDate = updateTimestamp; + } + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + + /// + /// Updates data about a UnitPerson, which also updates the PhonePerson. + /// + public async Task UpdateUnitPersonData(int listId, int unitPersonId, PhoneListUnitDataRequest request, CancellationToken ct = default) + { + var modifiedPhoneListPerson = await GetUnitPersonInList(listId, unitPersonId, ct); + using var transaction = await _context.Database.BeginTransactionAsync(ct); + var userIam = _userHelper.GetCurrentUser()?.IamId; + var updateTimestamp = DateTime.Now; + + await AddOrUpdatePhonePerson(request, userIam, updateTimestamp, ct); + if (request.ListFirst) + { + // Clear the flag on the unit the record actually lives in, not the one the + // request claims, so a mismatched UnitId cannot unset the entry of another unit. + await UpdateListFirst(modifiedPhoneListPerson.PhoneListUnitId, ct); + } + modifiedPhoneListPerson.ListFirst = request.ListFirst; + modifiedPhoneListPerson.ModifiedBy = userIam; + modifiedPhoneListPerson.ModifiedDate = updateTimestamp; + await _context.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + } + + /// + /// Delete data about a UnitPerson, though not the corresponding PhonePerson. + /// + public async Task DeleteUnitPersonData(int listId, int unitPersonId, CancellationToken ct = default) + { + var personToDelete = await GetUnitPersonInList(listId, unitPersonId, ct); + personToDelete.ModifiedBy = _userHelper.GetCurrentUser()?.IamId; + personToDelete.ModifiedDate = DateTime.Now; + personToDelete.IsActive = false; + await _context.SaveChangesAsync(ct); + } + } +} diff --git a/web/Areas/Personnel/Services/PhonePermissionsService.cs b/web/Areas/Personnel/Services/PhonePermissionsService.cs new file mode 100644 index 000000000..4bdd7c4cc --- /dev/null +++ b/web/Areas/Personnel/Services/PhonePermissionsService.cs @@ -0,0 +1,32 @@ +using Viper.Classes.SQLContext; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel.Services +{ + public class PhonesPermissionsService( + RAPSContext rapsContext, + IUserHelper userHelper + ) + { + private readonly RAPSContext _rapsContext = rapsContext; + private readonly IUserHelper _userHelper = userHelper; + + /// + /// Whether the caller may edit the given list. The role comes from the list's own + /// MaintainRole column rather than a hard-coded constant, so a new list is a row in + /// phones.PhoneList and its role, not a code change. + /// + /// Takes the resolved list rather than an id: every caller has already loaded it to route + /// the request, and an id-based overload would have to fetch the same row a second time. + /// + public bool CanMaintainList(PhoneList list) + { + var user = _userHelper.GetCurrentUser(); + if (user == null) + { + return false; + } + return _userHelper.HasPermission(_rapsContext, user, list.MaintainRole); + } + } +} diff --git a/web/Areas/Personnel/Services/PhonePersonLookupService.cs b/web/Areas/Personnel/Services/PhonePersonLookupService.cs new file mode 100644 index 000000000..d48dcf359 --- /dev/null +++ b/web/Areas/Personnel/Services/PhonePersonLookupService.cs @@ -0,0 +1,71 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; +using Viper.Classes.Utilities; + +namespace Viper.Areas.Personnel.Services +{ + public class PhonePersonLookupService(PhonesDbContext context, PhonesPermissionsService phonesPermissionsService) + { + private readonly PhonesDbContext _context = context; + private readonly PhonesPermissionsService _phonePermissionsService = phonesPermissionsService; + + /// + /// Retrieves the PhonePerson records associated with a list of iam IDs. + /// + public async Task> GetPhonePeople(List iamIds, PhoneList? list = null, CancellationToken ct = default) + { + // Avoid returning direct numbers except to users with permissions to access them. + // This data should only be returned for queries tied to a list for which the user + // has maintain permissions. + bool canAccessDirectNumber = list != null && _phonePermissionsService.CanMaintainList(list); + + List cleanedIamIds = [.. iamIds.Where(x => !string.IsNullOrWhiteSpace(x))]; + + var searchResults = await _context.PhonePerson + .AsNoTracking() + // May contain up to 25 IDs, so use EF.Parameter for better caching. + .Where(t => EF.Parameter(cleanedIamIds).Contains(t.PersonIam)) + .Select(t => new PhonePerson + { + PersonIam = t.PersonIam, + Phone = t.Phone, + DirectPhone = canAccessDirectNumber ? t.DirectPhone : "", + Office = t.Office, + ModifiedDate = t.ModifiedDate, + ModifiedBy = t.ModifiedBy + }) + .ToListAsync(ct); + + return searchResults; + } + + /// + /// Get current employees. Optionally may filter by a partial name match. + /// Limits the results to 25 at most. + /// + public async Task> GetViperCurrentEmployees(string? search = null, CancellationToken ct = default) + { + search = PersonSearchHelper.Normalize(search); + if (search == null) + { + return []; + } + + var query = _context.ViperPerson + .AsNoTracking() + .Where(t => t.CurrentEmployee) + .Where(PersonSearchHelper.NameMatches(t => t.LastName, t => t.FirstName, search)) + .Select(t => new ViperPerson + { + IamId = t.IamId, + FirstName = t.FirstName, + LastName = t.LastName, + FullName = t.FullName, + CurrentEmployee = t.CurrentEmployee, + MailId = t.MailId + }); + + return await PersonSearchHelper.OrderAndCap(query, t => t.LastName, t => t.FirstName).ToListAsync(ct); + } + } +} diff --git a/web/Areas/Personnel/Services/PhoneSVMFrequentNumberService.cs b/web/Areas/Personnel/Services/PhoneSVMFrequentNumberService.cs new file mode 100644 index 000000000..8914e7099 --- /dev/null +++ b/web/Areas/Personnel/Services/PhoneSVMFrequentNumberService.cs @@ -0,0 +1,119 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel.Services +{ + public class PhoneSVMFrequentNumberService(PhonesDbContext context, IUserHelper userHelper) + { + private readonly PhonesDbContext _context = context; + private readonly IUserHelper _userHelper = userHelper; + + /// + /// Get all frequent numbers for the SVM phone list. + /// + public async Task> GetSVMFrequentNumbers(CancellationToken ct = default) + { + return await _context.SVMFrequentNumber + .AsNoTracking() + .Where(t => t.IsActive) + .OrderBy(t => t.SortOrder == null) + .ThenBy(t => t.SortOrder) + .ThenBy(t => t.Label) + .ToListAsync(ct); + } + + public async Task GetSVMFrequentNumbersModifiedDate(CancellationToken ct = default) + { + var results = _context.SVMFrequentNumber + .AsNoTracking() + .Where(t => t.ModifiedDate != null) + .OrderByDescending(t => t.ModifiedDate); + var lastModifiedRecord = await results.FirstOrDefaultAsync(ct); + return lastModifiedRecord?.ModifiedDate; + } + + /// + /// Adds a row to the SVM frequent numbers list. + /// + /// + public async Task AddFrequentNumber(SVMFrequentNumberRequest request, CancellationToken ct = default) + { + var userIam = _userHelper.GetCurrentUser()?.IamId; + if (string.IsNullOrWhiteSpace(request.Label)) + { + throw new InvalidOperationException("Location must not be empty."); + } + if (string.IsNullOrWhiteSpace(request.Phone)) + { + throw new InvalidOperationException("Phone Number must not be empty."); + } + var frequentNumber = new SVMFrequentNumber + { + Label = request.Label.Trim(), + Phone = request.Phone.Trim(), + ModifiedBy = userIam, + ModifiedDate = DateTime.Now, + IsActive = true + }; + await _context.SVMFrequentNumber.AddAsync(frequentNumber, ct); + await _context.SaveChangesAsync(ct); + } + + /// + /// Updates a row in the SVM frequently called numbers list. + /// + public async Task UpdateFrequentNumber(int entryId, SVMFrequentNumberRequest request, CancellationToken ct = default) + { + var userIam = _userHelper.GetCurrentUser()?.IamId; + if (string.IsNullOrWhiteSpace(request.Label)) + { + throw new InvalidOperationException("Location must not be empty."); + } + if (string.IsNullOrWhiteSpace(request.Phone)) + { + throw new InvalidOperationException("Phone Number must not be empty."); + } + var frequentNumber = await _context.SVMFrequentNumber.FindAsync(new object?[] { entryId }, ct); + if (frequentNumber != null && frequentNumber.IsActive) + { + frequentNumber.Label = request.Label.Trim(); + frequentNumber.Phone = request.Phone.Trim(); + frequentNumber.ModifiedBy = userIam; + frequentNumber.ModifiedDate = DateTime.Now; + await _context.SaveChangesAsync(ct); + } + else + { + throw new InvalidOperationException("Frequent number not found"); + } + } + + /// + /// Removes a frequently called number. + /// + public async Task DeleteFrequentNumber(int entryId, CancellationToken ct = default) + { + if (entryId <= 0) + { + throw new InvalidOperationException("Frequent number is already deleted"); + } + + var userIam = _userHelper.GetCurrentUser()?.IamId; + var numberToDelete = await _context.SVMFrequentNumber.FindAsync(new object?[] { entryId }, ct); + if (numberToDelete != null && numberToDelete.IsActive) + { + // Instead of deleting the record, mark it as inactive. + // This allows more accurate tracking of the time the list + // was last modified. + numberToDelete.IsActive = false; + numberToDelete.ModifiedBy = userIam; + numberToDelete.ModifiedDate = DateTime.Now; + await _context.SaveChangesAsync(ct); + } + else + { + throw new InvalidOperationException("Frequent number is already deleted"); + } + } + } +} diff --git a/web/Areas/Personnel/Services/PhoneSVMSectionService.cs b/web/Areas/Personnel/Services/PhoneSVMSectionService.cs new file mode 100644 index 000000000..6539d005e --- /dev/null +++ b/web/Areas/Personnel/Services/PhoneSVMSectionService.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel.Services +{ + public class PhoneSVMSectionService + { + private readonly PhonesDbContext _context; + public PhoneSVMSectionService(PhonesDbContext context) + { + _context = context; + } + + /// + /// Get all Sections for the SVM phone list. + /// + public async Task> GetSVMSections(CancellationToken ct = default) + { + var allSections = await _context.SVMSection + .AsNoTracking() + .OrderBy(t => t.SortOrder == null) + .ThenBy(t => t.SortOrder) + .ThenBy(t => t.Name) + .ToListAsync(ct); + + return allSections; + } + } +} diff --git a/web/Areas/Personnel/Services/PhoneSVMUnitService.cs b/web/Areas/Personnel/Services/PhoneSVMUnitService.cs new file mode 100644 index 000000000..d92b8e5dd --- /dev/null +++ b/web/Areas/Personnel/Services/PhoneSVMUnitService.cs @@ -0,0 +1,332 @@ +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel.Models; + +namespace Viper.Areas.Personnel.Services +{ + public class PhoneSVMUnitService + { + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + public PhoneSVMUnitService(PhonesDbContext context, IUserHelper userHelper) + { + _context = context; + _userHelper = userHelper; + } + + /// + /// Get every Unit on the SVM phone list, with the people in each. + /// + /// Returned in one call rather than per section: the page renders all sections at once, + /// so fetching per section made the load cost scale with the number of sections for data + /// that is always wanted together. Callers group by SectionId. + /// + public async Task> GetSVMUnits(CancellationToken ct = default) + { + // Do not return inactive SVMUnitPerson records. + // These are present for a more accurate modification date value. + var sectionUnits = await _context.SVMUnit + .AsNoTracking() + .Select(t => new SVMUnit + { + UnitId = t.UnitId, + SectionId = t.SectionId, + Name = t.Name, + Abbrv = t.Abbrv, + SortOrder = t.SortOrder, + Fax = t.Fax, + ModifiedBy = t.ModifiedBy, + ModifiedDate = t.ModifiedDate, + UnitPersons = t.UnitPersons + .Where(up => up.IsActive) + .Select(up => new SVMUnitPerson + { + UnitPersonId = up.UnitPersonId, + UnitId = up.UnitId, + PersonIam = up.PersonIam, + Office = up.Office, + PosType = up.PosType, + Interim = up.Interim, + ModifiedDate = up.ModifiedDate, + ModifiedBy = up.ModifiedBy, + IsActive = up.IsActive, + Person = new PhonePerson + { + PersonIam = up.Person.PersonIam, + Phone = up.Person.Phone, + // This data is never needed for SVM lists. + DirectPhone = "", + Office = up.Person.Office, + ModifiedDate = up.Person.ModifiedDate, + ModifiedBy = up.Person.ModifiedBy, + ViperPerson = up.Person.ViperPerson, + ViperModPerson = up.Person.ViperModPerson + }, + ViperModPerson = up.ViperModPerson + }) + .ToList(), + ViperModPerson = t.ViperModPerson + }) + .OrderBy(t => t.SectionId) + .ThenBy(t => t.SortOrder == null) + .ThenBy(t => t.SortOrder) + .ThenBy(t => t.Name) + .ToListAsync(ct); + + // Need to deduplicate rows caused by multiple records for the same person + // in the Users table. Due to nested selects, this must be done client-side + // instead of server-side. + foreach (var unit in sectionUnits) + { + unit.UnitPersons = [.. unit.UnitPersons.DistinctBy(p => p.Person.PersonIam)]; + } + + return sectionUnits; + } + + /// + /// Gets the most recent date an SVMUnitPerson record was modified. + /// + public async Task GetSVMUnitPersonModifiedDate(CancellationToken ct = default) + { + var results = _context.SVMUnitPerson + .AsNoTracking() + .Where(t => t.ModifiedDate != null) + .OrderByDescending(t => t.ModifiedDate); + var lastModifiedRecord = await results.FirstOrDefaultAsync(ct); + return lastModifiedRecord?.ModifiedDate; + } + + /// + /// Creates a PhonePerson if it doesn't exist, or updates if it does. + /// + private async Task AddOrUpdatePhonePerson( + string? userIam, + DateTime updateTimestamp, + string personIam, + string phone, + CancellationToken ct = default + ) + { + var phonePerson = await _context.PhonePerson.FindAsync(new object?[] { personIam.Trim() }, ct); + if (phonePerson == null) + { + phonePerson = new PhonePerson + { + PersonIam = personIam.Trim(), + Phone = phone.Trim(), + ModifiedDate = updateTimestamp, + ModifiedBy = userIam, + }; + await _context.PhonePerson.AddAsync(phonePerson, ct); + } + else + { + phonePerson.Phone = phone.Trim(); + phonePerson.ModifiedDate = updateTimestamp; + phonePerson.ModifiedBy = userIam; + } + await _context.SaveChangesAsync(ct); + } + + /// + /// Creates or updates both a dean/director and an admin staff + /// member based on the data in request. + /// + private async Task AddOrUpdatePhonePeople( + string? userIam, + DateTime updateTimestamp, + SVMUnitDataRequest request, + CancellationToken ct = default + ) + { + if (!string.IsNullOrWhiteSpace(request.DeanIam)) + { + await AddOrUpdatePhonePerson( + userIam, + updateTimestamp, + request.DeanIam, + request.DeanPhone, + ct + ); + } + if (!string.IsNullOrWhiteSpace(request.StaffIam)) + { + await AddOrUpdatePhonePerson( + userIam, + updateTimestamp, + request.StaffIam, + request.StaffPhone, + ct + ); + } + } + + /// + /// Updates UnitPerson records for both a dean/director + /// and admin staff. + /// + private async Task UpdateUnitPeople( + string? userIam, + DateTime updateTimestamp, + int unitId, + SVMUnitDataRequest request, + CancellationToken ct = default + ) + { + // If the new employees are already in the unit, + // replace their records with the new ones. + // Additionally, disable the records that were edited + // on the front end. For a row add, the UnitPerson value will be -1 + // and not match any records. For edits, UnitPerson values + // represent the rows being replaced. + var oldUnitPeople = await _context.SVMUnitPerson.Where( + p => p.UnitId == unitId && + ( + p.PersonIam == request.DeanIam.Trim() || + p.PersonIam == request.StaffIam.Trim() || + p.UnitPersonId == request.DeanUnitPerson || + p.UnitPersonId == request.StaffUnitPerson + ) && + p.IsActive + ) + .ToListAsync(ct); + foreach (SVMUnitPerson unitPerson in oldUnitPeople) + { + unitPerson.ModifiedBy = userIam; + unitPerson.ModifiedDate = updateTimestamp; + unitPerson.IsActive = false; + } + await _context.SaveChangesAsync(ct); + + if (!string.IsNullOrWhiteSpace(request.DeanIam)) + { + var newDeanUnitPerson = new SVMUnitPerson + { + UnitId = unitId, + PersonIam = request.DeanIam.Trim(), + Office = request.Location.Trim(), + PosType = "Dean", + Interim = request.DeanInterim.Trim(), + ModifiedDate = updateTimestamp, + ModifiedBy = userIam, + IsActive = true + }; + await _context.SVMUnitPerson.AddAsync(newDeanUnitPerson, ct); + await _context.SaveChangesAsync(ct); + } + + if (!string.IsNullOrWhiteSpace(request.StaffIam)) + { + var newStaffUnitPerson = new SVMUnitPerson + { + UnitId = unitId, + PersonIam = request.StaffIam.Trim(), + Office = request.Location.Trim(), + PosType = "Staff", + Interim = request.StaffInterim.Trim(), + ModifiedDate = updateTimestamp, + ModifiedBy = userIam, + IsActive = true + }; + await _context.SVMUnitPerson.AddAsync(newStaffUnitPerson, ct); + await _context.SaveChangesAsync(ct); + } + } + + /// + /// Adds or updates a row in the SVM list. + /// Impacts Unit, UnitPerson, and PhonePerson. + /// The differences in data in request distinguishes the behavior + /// between add and update. + /// + public async Task AddOrUpdateUnitData(int unitId, SVMUnitDataRequest request, CancellationToken ct = default) + { + using var transaction = await _context.Database.BeginTransactionAsync(ct); + var userIam = _userHelper.GetCurrentUser()?.IamId; + var updateTimestamp = DateTime.Now; + var unit = await _context.SVMUnit.FindAsync(new object?[] { unitId }, ct); + if (unit != null) + { + unit.Fax = request.Fax.Trim(); + unit.ModifiedBy = userIam; + unit.ModifiedDate = updateTimestamp; + await _context.SaveChangesAsync(ct); + await AddOrUpdatePhonePeople(userIam, updateTimestamp, request, ct); + await UpdateUnitPeople(userIam, updateTimestamp, unitId, request, ct); + } + else + { + throw new InvalidOperationException("Unit not found"); + } + await transaction.CommitAsync(ct); + } + + /// + /// Removes one row of the SVM list. A row is a dean/director plus the admin staff shared + /// by every row for that unit, so removing it deletes the leader and then the staff only + /// once no other row still lists them. Both happen in one transaction: as two separate + /// requests they could half-apply, leaving the caller unable to tell which part landed. + /// + /// entryId is the row key the list renders: the leader UnitPerson, or the admin staff + /// for a unit that has staff but no active leader. Both cases reduce to the same rule, + /// so there is no branch on which kind of row was named. + /// + /// Leaves SVMUnit and PhonePerson unchanged. + /// + public async Task DeleteUnitRow(int entryId, CancellationToken ct = default) + { + // Only an active row can be deleted. The message reaches the user as an error banner, + // and through the UI the only way to miss is to act on a row another maintainer just + // deleted, so it is worded for that reader. + var rowEntry = await _context.SVMUnitPerson + .FirstOrDefaultAsync(p => p.UnitPersonId == entryId && p.IsActive, ct); + if (rowEntry == null) + { + throw new InvalidOperationException("That record has already been removed."); + } + + var userIam = _userHelper.GetCurrentUser()?.IamId; + var updateTimestamp = DateTime.Now; + using var transaction = await _context.Database.BeginTransactionAsync(ct); + + void SoftDelete(SVMUnitPerson unitPerson) + { + unitPerson.ModifiedBy = userIam; + unitPerson.ModifiedDate = updateTimestamp; + unitPerson.IsActive = false; + } + + if (rowEntry.PosType != "Staff") + { + SoftDelete(rowEntry); + } + await _context.SaveChangesAsync(ct); + + // The admin staff entry belongs to the unit, not to this row, so it survives as long + // as any leader row still lists it. + var leadersRemain = await _context.SVMUnitPerson + .AsNoTracking() + .AnyAsync( + p => p.UnitId == rowEntry.UnitId && + p.PosType != null && + p.PosType != "" && + p.PosType != "Staff" && + p.IsActive, + ct + ); + if (!leadersRemain) + { + var staffEntries = await _context.SVMUnitPerson + .Where(p => p.UnitId == rowEntry.UnitId && p.PosType == "Staff" && p.IsActive) + .ToListAsync(ct); + foreach (var staffEntry in staffEntries) + { + SoftDelete(staffEntry); + } + await _context.SaveChangesAsync(ct); + } + + await transaction.CommitAsync(ct); + } + } +} diff --git a/web/Classes/Utilities/PersonSearchHelper.cs b/web/Classes/Utilities/PersonSearchHelper.cs new file mode 100644 index 000000000..1835e7190 --- /dev/null +++ b/web/Classes/Utilities/PersonSearchHelper.cs @@ -0,0 +1,102 @@ +using System.Linq.Expressions; + +namespace Viper.Classes.Utilities; + +/// +/// Shared query shape for "search current people by partial name" autocomplete endpoints +/// (phone directory, CMS file/permission pickers): trim + minimum-length guard, match on +/// "Last, First" or "First Last" containing the search term, ordered by last/first name, +/// capped to a page of results. Callers supply their own DbSet/entity and last/first name +/// property selectors; additional match fields (e.g. login id) can be OR'd in via . +/// +public static class PersonSearchHelper +{ + public const int MinSearchLength = 2; + public const int MaxResults = 25; + + /// + /// Trims a search term and validates its length. Returns null if the term is too short to + /// search on, signalling the caller should skip the query and return an empty result. + /// + public static string? Normalize(string? search) + { + search = search?.Trim(); + return string.IsNullOrEmpty(search) || search.Length < MinSearchLength ? null : search; + } + + /// + /// Builds the "Last, First" / "First Last" contains-match predicate for a search term. + /// lastName/firstName must be plain property accessors (e.g. t => t.LastName). + /// + public static Expression> NameMatches( + Expression> lastName, + Expression> firstName, + string search) + { + var param = Expression.Parameter(typeof(T), "p"); + var last = Expression.Property(param, PropertyName(lastName)); + var first = Expression.Property(param, PropertyName(firstName)); + // Read the term off a holder rather than embedding it with Expression.Constant. EF renders + // a bare constant as a SQL literal (LIKE N'%smith%'), which gives every distinct term its + // own query plan and skips the ESCAPE clause, so a typed % or _ acts as a wildcard. A field + // access on a captured object is the shape a C# closure produces, and EF parameterizes it. + var searchTerm = Expression.Field(Expression.Constant(new SearchTerm(search)), nameof(SearchTerm.Value)); + var containsMethod = typeof(string).GetMethod(nameof(string.Contains), [typeof(string)])!; + var concatMethod = typeof(string).GetMethod(nameof(string.Concat), [typeof(string), typeof(string), typeof(string)])!; + + var lastCommaFirst = Expression.Call(concatMethod, last, Expression.Constant(", "), first); + var firstSpaceLast = Expression.Call(concatMethod, first, Expression.Constant(" "), last); + + var body = Expression.OrElse( + Expression.Call(lastCommaFirst, containsMethod, searchTerm), + Expression.Call(firstSpaceLast, containsMethod, searchTerm)); + + return Expression.Lambda>(body, param); + } + + /// + /// Orders by last name then first name and caps to . + /// + public static IQueryable OrderAndCap( + IQueryable query, + Expression> lastName, + Expression> firstName) + => query.OrderBy(lastName).ThenBy(firstName).Take(MaxResults); + + /// + /// ORs an additional match condition (e.g. login id / mail id) onto a predicate built by + /// , rebinding it onto the same parameter. + /// + public static Expression> Or( + this Expression> predicate, + Expression> other) + { + var rebound = new ParameterRebinder(other.Parameters[0], predicate.Parameters[0]).Visit(other.Body); + return Expression.Lambda>(Expression.OrElse(predicate.Body, rebound), predicate.Parameters[0]); + } + + private static string PropertyName(Expression> selector) + { + if (selector.Body is MemberExpression member) + { + return member.Member.Name; + } + throw new ArgumentException("Selector must be a simple property accessor.", nameof(selector)); + } + + /// + /// Holder whose field the predicate reads the search term from, so EF treats it as a captured + /// closure variable and emits a SQL parameter instead of a literal. Must stay a field, not a + /// property: EF parameterizes both, but the field mirrors what the compiler generates. + /// + private sealed class SearchTerm(string value) + { + public readonly string Value = value; + } + + private sealed class ParameterRebinder(ParameterExpression from, ParameterExpression to) : ExpressionVisitor + { + protected override Expression VisitParameter(ParameterExpression node) + => node == from ? to : base.VisitParameter(node); + } +} diff --git a/web/Program.cs b/web/Program.cs index 126ab15c8..a4ddf87a9 100644 --- a/web/Program.cs +++ b/web/Program.cs @@ -31,6 +31,7 @@ using Viper.Areas.Effort; using Viper.Areas.Effort.Data; using Viper.Areas.Effort.Services.Harvest; +using Viper.Areas.Personnel; using Viper.Classes; using Viper.Classes.HealthChecks; using Viper.Classes.Scheduler; @@ -52,7 +53,7 @@ } // Centralized SPA application names to avoid duplication -string[] VueAppNames = { "CAHFS", "ClinicalScheduler", "CMS", "Computing", "CTS", "Effort", "Students" }; +string[] VueAppNames = { "CAHFS", "ClinicalScheduler", "CMS", "Computing", "CTS", "Effort", "Students", "Personnel" }; var builder = WebApplication.CreateBuilder(args); string awsCredentialsFilePath = Directory.GetCurrentDirectory() + "\\awscredentials.xml"; @@ -222,6 +223,8 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db RegisterDbContext("SIS"); // Effort tables are in the VIPER database's [effort] schema. RegisterDbContext("VIPER"); + // Phone tables are in the VIPER database's [phones] schema. + RegisterDbContext("VIPER"); RegisterDbContext("EvalHarvest"); // Register UserHelper service (must be before Scrutor to take precedence) @@ -264,6 +267,7 @@ void RegisterDbContext(string connectionStringKey) where TContext : Db "Viper.Areas.Students.Services", "Viper.Areas.Curriculum.Services", "Viper.Areas.Effort.Services", + "Viper.Areas.Personnel.Services", "Viper.Areas.CMS.Services" ) .Where(type => type.Name.EndsWith("Service") || type.Name.EndsWith("Validator"))) diff --git a/web/Viper.csproj b/web/Viper.csproj index c692ac2b1..ee948ffbf 100644 --- a/web/Viper.csproj +++ b/web/Viper.csproj @@ -21,6 +21,10 @@ + + + + diff --git a/web/appsettings.Development.json b/web/appsettings.Development.json index 293fdde6e..f415c13d6 100644 --- a/web/appsettings.Development.json +++ b/web/appsettings.Development.json @@ -15,6 +15,7 @@ "Dictionary": "", "Effort": "", "EvalHarvest": "", + "PhoneLists": "", "RAPS": "", "SIS": "", "VIPER": "" diff --git a/web/appsettings.Production.json b/web/appsettings.Production.json index 8b96ae2a0..650465d3e 100644 --- a/web/appsettings.Production.json +++ b/web/appsettings.Production.json @@ -14,6 +14,7 @@ "Dictionary": "", "Effort": "", "EvalHarvest": "", + "PhoneLists": "", "RAPS": "", "SIS": "", "VIPER": "" diff --git a/web/appsettings.Test.json b/web/appsettings.Test.json index 9f0e2b4c5..f85638bb9 100644 --- a/web/appsettings.Test.json +++ b/web/appsettings.Test.json @@ -14,6 +14,7 @@ "Dictionary": "", "Effort": "", "EvalHarvest": "", + "PhoneLists": "", "RAPS": "", "SIS": "", "VIPER": "" From 3283e1b4f8754cb396fb6a33d43181cbf84bd583 Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Tue, 25 Aug 2026 14:24:38 -0700 Subject: [PATCH 2/3] VPR-64 feat(phone): improve test coverage and fix incorrect class name --- test/Personnel/PhoneListControllerTests.cs | 165 ++++++++++++++ .../PhoneListModifiedDateControllerTests.cs | 140 ++++++++++++ .../Personnel/PhoneListUnitControllerTests.cs | 2 +- test/Personnel/PhoneListUnitServiceTests.cs | 102 ++++++++- test/Personnel/PhonePersonControllerTests.cs | 204 +++++++++++++++++ .../PhonePersonLookupServiceTests.cs | 2 +- .../PhoneSVMFrequentNumberServiceTests.cs | 85 ++++++++ .../PhoneSVMModifiedDateControllerTests.cs | 130 +++++++++++ .../PhoneSVMSectionControllerTests.cs | 61 ++++++ test/Personnel/PhoneSVMUnitControllerTests.cs | 206 ++++++++++++++++++ .../Controllers/PhoneListController.cs | 6 +- .../Controllers/PhoneListUnitController.cs | 6 +- .../Services/PhoneListUnitService.cs | 8 +- .../Services/PhonePermissionsService.cs | 2 +- .../Services/PhonePersonLookupService.cs | 4 +- 15 files changed, 1107 insertions(+), 16 deletions(-) create mode 100644 test/Personnel/PhoneListControllerTests.cs create mode 100644 test/Personnel/PhoneListModifiedDateControllerTests.cs create mode 100644 test/Personnel/PhonePersonControllerTests.cs create mode 100644 test/Personnel/PhoneSVMModifiedDateControllerTests.cs create mode 100644 test/Personnel/PhoneSVMSectionControllerTests.cs create mode 100644 test/Personnel/PhoneSVMUnitControllerTests.cs diff --git a/test/Personnel/PhoneListControllerTests.cs b/test/Personnel/PhoneListControllerTests.cs new file mode 100644 index 000000000..dfeeb5e00 --- /dev/null +++ b/test/Personnel/PhoneListControllerTests.cs @@ -0,0 +1,165 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListController. GetListInfo is what the client renders from before +/// it fetches any rows, so the two capability flags it reports have to match what the write and +/// read endpoints actually enforce: CanMaintain follows the list's own MaintainRole, and +/// CanViewDirectPhone is deliberately broader - a member of the list sees direct numbers without +/// being able to edit it. A flag that overstated either would show the client controls the API +/// then refuses. +/// +public sealed class PhoneListControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhoneListController _controller; + + private const string CallerIam = "caller01"; + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhoneListControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); + + _controller = new PhoneListController(phoneListService, unitService, permissionsService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = CallerIam, Phone = "530-555-1000" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + /// Puts the caller on the list itself, which is not the same as maintaining it. + private void AddCallerToList() + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = 1, + PhoneListUnitId = 1, + PersonIam = CallerIam, + ListFirst = false, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task GetInfo(string code) + { + var result = await _controller.GetListInfo(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsType(okResult.Value); + } + + [Fact] + public async Task GetListInfo_ReturnsTheListIdentity() + { + var info = await GetInfo("VMDO"); + + Assert.Equal(1, info.PhoneListId); + Assert.Equal("VMDO", info.Code); + Assert.Equal("Dean's Office", info.Name); + } + + [Fact] + public async Task GetListInfo_ReportsNoCapabilities_ForACallerWithNeitherRoleNorMembership() + { + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsBothCapabilities_ForAMaintainer() + { + GrantRole(VmdoRole); + + var info = await GetInfo("VMDO"); + + Assert.True(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReportsDirectPhoneOnly_ForAMemberWhoCannotMaintain() + { + // Membership is what grants the direct-number view, so the two flags have to move + // independently: reporting CanMaintain here would offer edit controls the API refuses. + AddCallerToList(); + + var info = await GetInfo("VMDO"); + + Assert.False(info.CanMaintain); + Assert.True(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_IgnoresMembershipOfAnotherList() + { + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + AddCallerToList(); + + var info = await GetInfo("OTHER"); + + Assert.False(info.CanViewDirectPhone); + } + + [Fact] + public async Task GetListInfo_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetListInfo("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListModifiedDateControllerTests.cs b/test/Personnel/PhoneListModifiedDateControllerTests.cs new file mode 100644 index 000000000..f97e34392 --- /dev/null +++ b/test/Personnel/PhoneListModifiedDateControllerTests.cs @@ -0,0 +1,140 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneListModifiedDateController, the endpoint clients poll to decide +/// whether their cached copy of a list is stale. Two properties matter: deleted rows still count +/// (a removal is a change the client has to pick up, and soft-deleted rows are the only record of +/// it), and the date is scoped to the list named in the route. +/// +public sealed class PhoneListModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneListModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneListModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, userHelper); + var phoneListService = new PhoneListService(_context); + var unitService = new PhoneListUnitService(_context, userHelper, permissionsService); + + _controller = new PhoneListModifiedDateController(phoneListService, unitService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = "SVMSecure.PhoneLists.VMDOMaintain", + }); + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 2, + Code = "OTHER", + Name = "Some Other Unit", + MaintainRole = "SVMSecure.PhoneLists.OtherMaintain", + }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 1, PhoneListId = 1, Name = "Front Office" }); + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = 2, PhoneListId = 2, Name = "Other Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddRow(int unitPersonId, int unitId, DateTime? modifiedDate, bool isActive = true) + { + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = "person01", + ListFirst = false, + IsActive = isActive, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate(string code) + { + var result = await _controller.GetLastModifiedDate(code, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheMostRecentDate() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_CountsDeletedRows() + { + // A removal is the change most likely to matter to a client holding stale rows, and the + // soft-deleted row is the only record that it happened. + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 1, Newer, isActive: false); + + Assert.Equal(Newer, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_IgnoresAnotherListsRows() + { + AddRow(unitPersonId: 1, unitId: 1, Older); + AddRow(unitPersonId: 2, unitId: 2, Newer); + + Assert.Equal(Older, await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNothingHasBeenModified() + { + AddRow(unitPersonId: 1, unitId: 1, modifiedDate: null); + + Assert.Null(await GetDate("VMDO")); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNotFound_ForAnUnknownCode() + { + var result = await _controller.GetLastModifiedDate("NOPE", TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneListUnitControllerTests.cs b/test/Personnel/PhoneListUnitControllerTests.cs index e42d2aaa0..cea934723 100644 --- a/test/Personnel/PhoneListUnitControllerTests.cs +++ b/test/Personnel/PhoneListUnitControllerTests.cs @@ -49,7 +49,7 @@ public PhoneListUnitControllerTests() }); var rapsContext = Substitute.For(); - var permissionsService = new PhonesPermissionsService(rapsContext, _userHelper); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); var phoneListService = new PhoneListService(_context); var unitService = new PhoneListUnitService(_context, _userHelper, permissionsService); diff --git a/test/Personnel/PhoneListUnitServiceTests.cs b/test/Personnel/PhoneListUnitServiceTests.cs index 1f0ba7548..e7a89769d 100644 --- a/test/Personnel/PhoneListUnitServiceTests.cs +++ b/test/Personnel/PhoneListUnitServiceTests.cs @@ -47,7 +47,7 @@ public PhoneListUnitServiceTests() // rapsContext is unused when IUserHelper.HasPermission is mocked directly, // so a bare substitute (no seeded roles) is sufficient here. var rapsContext = Substitute.For(); - var permissionsService = new PhonesPermissionsService(rapsContext, _userHelper); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); _service = new PhoneListUnitService(_context, _userHelper, permissionsService); @@ -60,6 +60,30 @@ public PhoneListUnitServiceTests() private PhoneList TestList() => _context.PhoneList.Single(l => l.PhoneListId == 1); + private void AddUnit(int unitId, string name) + { + _context.PhoneListUnit.Add(new PhoneListUnit { PhoneListUnitId = unitId, PhoneListId = 1, Name = name }); + _context.SaveChanges(); + } + + /// Puts a person on a unit, with the phone row the read paths expect them to have. + private void AddMember(int unitPersonId, int unitId, string personIam, bool listFirst) + { + _context.PhonePerson.Add(new PhonePerson { PersonIam = personIam, Phone = "530-555-0000" }); + _context.PhoneListUnitPerson.Add(new PhoneListUnitPerson + { + PhoneListUnitPersonId = unitPersonId, + PhoneListUnitId = unitId, + PersonIam = personIam, + ListFirst = listFirst, + IsActive = true, + }); + _context.SaveChanges(); + } + + private async Task FindMember(int unitPersonId) => + await _context.PhoneListUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + private void SeedList() { _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); @@ -258,6 +282,82 @@ public async Task AddUnitPersonData_CalledTwiceWithAPaddedIam_UpsertsInsteadOfDu Assert.Equal("paddedtwice", association.PersonIam); } + [Fact] + public async Task UpdateUnitPersonData_UpdatesThePhonePerson_AndModifiedMetadata() + { + var unitPerson = Assert.Single(_context.PhoneListUnitPerson); + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = " 530-555-7000 ", + DirectPhone = " 530-555-7001 ", + Office = " Room 700 ", + }; + + await _service.UpdateUnitPersonData( + 1, unitPerson.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + var phonePerson = await _context.PhonePerson + .FindAsync(new object?[] { "listedperson" }, TestContext.Current.CancellationToken); + Assert.NotNull(phonePerson); + Assert.Equal("530-555-7000", phonePerson.Phone); + Assert.Equal("530-555-7001", phonePerson.DirectPhone); + Assert.Equal("Room 700", phonePerson.Office); + Assert.Equal(CallerIam, unitPerson.ModifiedBy); + Assert.NotNull(unitPerson.ModifiedDate); + } + + [Fact] + public async Task UpdateUnitPersonData_ClearsListFirstOnTheRecordsOwnUnit_NotTheRequestedOne() + { + // The request carries a UnitId, but the record already knows which unit it lives in. Only + // the record's own unit may be cleared: taking the caller's word for it would let a + // mismatched UnitId unset the first-listed entry of an unrelated unit. + AddUnit(unitId: 2, name: "Other Office"); + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + AddMember(unitPersonId: 20, unitId: 2, personIam: "otherunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 2, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = true, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.True(target.ListFirst); + Assert.False((await FindMember(10))!.ListFirst); + Assert.True((await FindMember(20))!.ListFirst); + } + + [Fact] + public async Task UpdateUnitPersonData_LeavesTheExistingFirstEntry_WhenListFirstIsNotSet() + { + AddMember(unitPersonId: 10, unitId: 1, personIam: "sameunitfirst", listFirst: true); + var target = await _context.PhoneListUnitPerson + .SingleAsync(p => p.PersonIam == "listedperson", TestContext.Current.CancellationToken); + + var request = new PhoneListUnitDataRequest + { + UnitId = 1, + EmployeeIam = "listedperson", + Phone = "530-555-7000", + ListFirst = false, + }; + + await _service.UpdateUnitPersonData( + 1, target.PhoneListUnitPersonId, request, TestContext.Current.CancellationToken); + + Assert.False(target.ListFirst); + Assert.True((await FindMember(10))!.ListFirst); + } + [Fact] public async Task DeleteUnitPersonData_SoftDeletes_KeepsRowButMarksInactive() { diff --git a/test/Personnel/PhonePersonControllerTests.cs b/test/Personnel/PhonePersonControllerTests.cs new file mode 100644 index 000000000..cd692c5b1 --- /dev/null +++ b/test/Personnel/PhonePersonControllerTests.cs @@ -0,0 +1,204 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Classes.SQLContext; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhonePersonController, the person picker behind the phone-record dialogs. +/// The controller does the merge itself rather than delegating it: two independent queries (people +/// from users.Person, phone rows from phones.Person) are joined in memory, so the cases worth +/// pinning are the ones the join can get wrong - a person with no phone row, a phone row with no +/// matching person - plus the direct-number masking, which depends on a list code supplied by the +/// caller and so must fail closed when that code is absent or bogus. +/// +public sealed class PhonePersonControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly IUserHelper _userHelper; + private readonly PhonePersonController _controller; + + private const string VmdoRole = "SVMSecure.PhoneLists.VMDOMaintain"; + + public PhonePersonControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _userHelper = Substitute.For(); + _userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + var rapsContext = Substitute.For(); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); + var phoneListService = new PhoneListService(_context); + var lookupService = new PhonePersonLookupService(_context, permissionsService); + + _controller = new PhonePersonController(phoneListService, lookupService); + + _context.PhoneList.Add(new PhoneList + { + PhoneListId = 1, + Code = "VMDO", + Name = "Dean's Office", + MaintainRole = VmdoRole, + }); + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 1, + IamId = "person01", + FirstName = "Ada", + LastName = "Smithers", + FullName = "Ada Smithers", + CurrentEmployee = true, + MailId = "asmithers", + }); + _context.PhonePerson.Add(new PhonePerson + { + PersonIam = "person01", + Phone = "530-555-1000", + DirectPhone = "530-555-2000", + }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void GrantRole(string role) + { + _userHelper.HasPermission(Arg.Any(), Arg.Any(), role).Returns(true); + } + + private async Task> Search(string search, string? listCode = null) + { + var result = await _controller.GetCurrentEmployees(search, listCode, TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetCurrentEmployees_MergesPhoneDataOntoTheMatchedPerson() + { + var results = await Search("Smithers"); + + var person = Assert.Single(results); + Assert.Equal("person01", person.IamId); + Assert.Equal("Ada Smithers", person.FullName); + Assert.NotNull(person.PhoneData); + Assert.Equal("530-555-1000", person.PhoneData.Phone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_WhenNoListIsNamed() + { + var results = await Search("Smithers"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForANonMaintainer() + { + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsDirectPhone_ForAMaintainerOfTheNamedList() + { + GrantRole(VmdoRole); + + var results = await Search("Smithers", "VMDO"); + + Assert.Equal("530-555-2000", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_MasksDirectPhone_ForAnUnknownListCode() + { + // An unresolvable code drops to "no list", not "no permission check": holding the role + // must not be enough on its own, since the code is caller-supplied. + GrantRole(VmdoRole); + + var results = await Search("Smithers", "NOPE"); + + Assert.Equal("", Assert.Single(results).PhoneData?.DirectPhone); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsThePerson_WhenTheyHaveNoPhoneRow() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 2, + IamId = "person02", + FirstName = "Bo", + LastName = "Smithfield", + FullName = "Bo Smithfield", + CurrentEmployee = true, + MailId = "bsmithfield", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithfield"); + + var person = Assert.Single(results); + Assert.Equal("person02", person.IamId); + Assert.Null(person.PhoneData); + } + + [Fact] + public async Task GetCurrentEmployees_IgnoresPhoneRowsWithNoMatchingPerson() + { + // phones.Person outlives users.Person entries, so an orphaned phone row must not + // materialize as a pickable person. + _context.PhonePerson.Add(new PhonePerson { PersonIam = "ghost01", Phone = "530-555-9999" }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var results = await Search("Smithers"); + + Assert.Equal("person01", Assert.Single(results).IamId); + } + + [Fact] + public async Task GetCurrentEmployees_ExcludesFormerEmployees() + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = 3, + IamId = "person03", + FirstName = "Cy", + LastName = "Smithson", + FullName = "Cy Smithson", + CurrentEmployee = false, + MailId = "csmithson", + }); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + Assert.Empty(await Search("Smithson")); + } + + [Fact] + public async Task GetCurrentEmployees_ReturnsEmpty_ForASearchTermBelowTheMinimumLength() + { + Assert.Empty(await Search("S")); + } +} diff --git a/test/Personnel/PhonePersonLookupServiceTests.cs b/test/Personnel/PhonePersonLookupServiceTests.cs index 0ce4a466b..8b458288e 100644 --- a/test/Personnel/PhonePersonLookupServiceTests.cs +++ b/test/Personnel/PhonePersonLookupServiceTests.cs @@ -45,7 +45,7 @@ public PhonePersonLookupServiceTests() }); var rapsContext = Substitute.For(); - var permissionsService = new PhonesPermissionsService(rapsContext, _userHelper); + var permissionsService = new PhonePermissionsService(rapsContext, _userHelper); _service = new PhonePersonLookupService(_context, permissionsService); _context.PhoneList.Add(new PhoneList { PhoneListId = 1, Code = "VMDO", Name = "Dean's Office", MaintainRole = MaintainRole }); diff --git a/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs index 6814819f6..629f64bf2 100644 --- a/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs +++ b/test/Personnel/PhoneSVMFrequentNumberServiceTests.cs @@ -46,6 +46,21 @@ public PhoneSVMFrequentNumberServiceTests() public void Dispose() => _context.Dispose(); + private void SeedNumber(string label, string phone, bool isActive = true) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = label, + Phone = phone, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private async Task FindNumber(int numberId) => + await _context.SVMFrequentNumber.FindAsync(new object?[] { numberId }, TestContext.Current.CancellationToken); + [Fact] public async Task AddFrequentNumber_SetsIsActiveTrue_AndModifiedMetadata() { @@ -99,6 +114,76 @@ await Assert.ThrowsAsync( () => _service.DeleteFrequentNumber(1, TestContext.Current.CancellationToken)); } + [Fact] + public async Task UpdateFrequentNumber_OverwritesFields_AndModifiedMetadata() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + Assert.Equal(CallerIam, saved.ModifiedBy); + Assert.NotNull(saved.ModifiedDate); + Assert.True(saved.IsActive); + } + + [Fact] + public async Task UpdateFrequentNumber_TrimsWhitespace() + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = " Reception ", Phone = " 530-555-4000 " }; + + await _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken); + + var saved = await FindNumber(1); + Assert.NotNull(saved); + Assert.Equal("Reception", saved.Label); + Assert.Equal("530-555-4000", saved.Phone); + } + + [Theory] + [InlineData("", "530-555-4000", "Location must not be empty.")] + // Whitespace-only rather than empty: the guard is IsNullOrWhiteSpace, and a bare "" would + // still pass if it were ever weakened to IsNullOrEmpty. + [InlineData(" ", "530-555-4000", "Location must not be empty.")] + [InlineData("Reception", "", "Phone Number must not be empty.")] + [InlineData("Reception", " ", "Phone Number must not be empty.")] + public async Task UpdateFrequentNumber_Throws_ForBlankFields(string label, string phone, string expectedMessage) + { + SeedNumber(label: "Front Desk", phone: "530-555-1000"); + var request = new SVMFrequentNumberRequest { Label = label, Phone = phone }; + + var ex = await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + Assert.Equal(expectedMessage, ex.Message); + var unchanged = await FindNumber(1); + Assert.NotNull(unchanged); + Assert.Equal("Front Desk", unchanged.Label); + } + + [Fact] + public async Task UpdateFrequentNumber_Throws_WhenTheRowWasAlreadyDeleted() + { + // A maintainer whose page predates someone else's delete. Editing must not resurrect the + // row, which the IsActive half of the guard is what prevents - an id-only lookup would + // find the soft-deleted record and happily write to it. + SeedNumber(label: "Retired Line", phone: "530-555-3000", isActive: false); + var request = new SVMFrequentNumberRequest { Label = "Reception", Phone = "530-555-4000" }; + + await Assert.ThrowsAsync( + () => _service.UpdateFrequentNumber(1, request, TestContext.Current.CancellationToken)); + + var stillDeleted = await FindNumber(1); + Assert.NotNull(stillDeleted); + Assert.False(stillDeleted.IsActive); + Assert.Equal("Retired Line", stillDeleted.Label); + } + [Fact] public async Task GetSVMFrequentNumbers_OrdersRowsWithNoSortOrderLast() { diff --git a/test/Personnel/PhoneSVMModifiedDateControllerTests.cs b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs new file mode 100644 index 000000000..531370460 --- /dev/null +++ b/test/Personnel/PhoneSVMModifiedDateControllerTests.cs @@ -0,0 +1,130 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMModifiedDateController, which clients poll to decide whether their +/// cached SVM list is stale. The SVM page renders two independently-maintained datasets - frequent +/// numbers and unit people - behind one freshness date, so the endpoint has to report the later of +/// the two and stay correct when either side has never been modified. Reporting the earlier one +/// would leave a client believing its copy is current. +/// +public sealed class PhoneSVMModifiedDateControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMModifiedDateController _controller; + + private static readonly DateTime Older = new(2026, 1, 1, 9, 0, 0, DateTimeKind.Local); + private static readonly DateTime Newer = new(2026, 6, 1, 9, 0, 0, DateTimeKind.Local); + + public PhoneSVMModifiedDateControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = "caller01", + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = "caller01", + }); + + _controller = new PhoneSVMModifiedDateController( + new PhoneSVMFrequentNumberService(_context, userHelper), + new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + _context.SaveChanges(); + } + + public void Dispose() => _context.Dispose(); + + private void AddFrequentNumber(DateTime? modifiedDate) + { + _context.SVMFrequentNumber.Add(new SVMFrequentNumber + { + NumberId = 1, + Label = "Front Desk", + Phone = "530-555-1000", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private void AddUnitPerson(DateTime? modifiedDate) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = 1, + UnitId = 1, + PersonIam = "dean01", + PosType = "Dean", + IsActive = true, + ModifiedDate = modifiedDate, + }); + _context.SaveChanges(); + } + + private async Task GetDate() + { + var result = await _controller.GetLastModifiedDate(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return (DateTime?)okResult.Value; + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsNull_WhenNeitherDatasetHasBeenModified() + { + Assert.Null(await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenNoFrequentNumberHasOne() + { + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenNoUnitPersonHasOne() + { + AddFrequentNumber(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheUnitPersonDate_WhenItIsTheLater() + { + AddFrequentNumber(Older); + AddUnitPerson(Newer); + + Assert.Equal(Newer, await GetDate()); + } + + [Fact] + public async Task GetLastModifiedDate_ReturnsTheFrequentNumberDate_WhenItIsTheLater() + { + AddFrequentNumber(Newer); + AddUnitPerson(Older); + + Assert.Equal(Newer, await GetDate()); + } +} diff --git a/test/Personnel/PhoneSVMSectionControllerTests.cs b/test/Personnel/PhoneSVMSectionControllerTests.cs new file mode 100644 index 000000000..80775977e --- /dev/null +++ b/test/Personnel/PhoneSVMSectionControllerTests.cs @@ -0,0 +1,61 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMSectionController. Sections are the page's top-level grouping, so +/// the order they come back in is the order the page renders; unsorted sections fall to the end +/// alphabetically rather than jumping to the front on a null SortOrder. +/// +public sealed class PhoneSVMSectionControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMSectionController _controller; + + public PhoneSVMSectionControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + _context = new PhonesDbContext(options); + + _controller = new PhoneSVMSectionController(new PhoneSVMSectionService(_context)); + } + + public void Dispose() => _context.Dispose(); + + private async Task> GetSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + var okResult = Assert.IsType(result.Result); + return Assert.IsAssignableFrom>(okResult.Value); + } + + [Fact] + public async Task GetSections_ReturnsSortedSectionsBeforeUnsortedOnes() + { + _context.SVMSection.AddRange( + new SVMSection { SectionId = 1, Name = "Anatomy", SortOrder = null }, + new SVMSection { SectionId = 2, Name = "Dean's Office", SortOrder = 1 }, + new SVMSection { SectionId = 3, Name = "Zoology", SortOrder = 2 } + ); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var sections = await GetSections(); + + Assert.Equal(["Dean's Office", "Zoology", "Anatomy"], sections.Select(s => s.Name)); + } + + [Fact] + public async Task GetSections_ReturnsNotFound_WhenThereAreNoSections() + { + var result = await _controller.GetSections(TestContext.Current.CancellationToken); + + Assert.IsType(result.Result); + } +} diff --git a/test/Personnel/PhoneSVMUnitControllerTests.cs b/test/Personnel/PhoneSVMUnitControllerTests.cs new file mode 100644 index 000000000..8946163f4 --- /dev/null +++ b/test/Personnel/PhoneSVMUnitControllerTests.cs @@ -0,0 +1,206 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using NSubstitute; +using Viper.Areas.Personnel; +using Viper.Areas.Personnel.Controllers; +using Viper.Areas.Personnel.Models; +using Viper.Areas.Personnel.Services; +using Viper.Models.AAUD; + +namespace Viper.test.Personnel; + +/// +/// Controller tests for PhoneSVMUnitController: the read shape the SVM page renders from, and the +/// InvalidOperationException-to-400 mapping on the three maintain endpoints. Unlike the per-list +/// controllers, write access here is a fixed role on the endpoint rather than a per-row lookup, so +/// what the controller itself decides is narrower - which makes the failure mapping the thing +/// worth pinning, since a 500 here would surface to a maintainer as an unexplained error banner +/// instead of the message the service wrote for them. +/// +public sealed class PhoneSVMUnitControllerTests : IDisposable +{ + private readonly PhonesDbContext _context; + private readonly PhoneSVMUnitController _controller; + + private const string CallerIam = "caller01"; + + public PhoneSVMUnitControllerTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) + .Options; + _context = new PhonesDbContext(options); + + var userHelper = Substitute.For(); + userHelper.GetCurrentUser().Returns(new AaudUser + { + ClientId = "ucd.edu", + MothraId = CallerIam, + LastName = "Caller", + FirstName = "Test", + DisplayLastName = "Caller", + DisplayFirstName = "Test", + DisplayFullName = "Test Caller", + IamId = CallerIam, + }); + + _controller = new PhoneSVMUnitController(new PhoneSVMUnitService(_context, userHelper)); + + _context.SVMUnit.Add(new SVMUnit { UnitId = 1, SectionId = 1, Name = "Dean's Office" }); + // The read projection joins phones.Person to users.Person on a required relationship, so + // a phone row without its person is invisible to GetUnits. + AddPerson(personId: 1, "dean01", "Dinah", "Deanly", "530-555-1000"); + AddPerson(personId: 2, "staff01", "Sam", "Staffly", "530-555-2000"); + _context.SaveChanges(); + } + + private void AddPerson(int personId, string iamId, string firstName, string lastName, string phone) + { + _context.ViperPerson.Add(new ViperPerson + { + PersonId = personId, + IamId = iamId, + FirstName = firstName, + LastName = lastName, + FullName = $"{firstName} {lastName}", + CurrentEmployee = true, + }); + _context.PhonePerson.Add(new PhonePerson { PersonIam = iamId, Phone = phone }); + } + + public void Dispose() => _context.Dispose(); + + private void AddUnitPerson(int unitPersonId, string personIam, string posType, bool isActive = true) + { + _context.SVMUnitPerson.Add(new SVMUnitPerson + { + UnitPersonId = unitPersonId, + UnitId = 1, + PersonIam = personIam, + PosType = posType, + IsActive = isActive, + }); + _context.SaveChanges(); + } + + private static SVMUnitDataRequest Request() => new() + { + Fax = "530-555-3000", + Location = "Room 100", + DeanIam = "dean01", + DeanPhone = "530-555-1000", + }; + + private async Task FindUnitPerson(int unitPersonId) => + await _context.SVMUnitPerson.FindAsync(new object?[] { unitPersonId }, TestContext.Current.CancellationToken); + + [Fact] + public async Task GetUnits_ReturnsOk_WithActivePeopleOnly() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + AddUnitPerson(unitPersonId: 2, "staff01", "Staff", isActive: false); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + var units = Assert.IsAssignableFrom>(okResult.Value); + var unit = Assert.Single(units); + Assert.Equal("dean01", Assert.Single(unit.UnitPersons).PersonIam); + } + + [Fact] + public async Task GetUnits_ReturnsOk_WithAnEmptyListWhenThereAreNoUnits() + { + // An empty SVM list is a legitimate state, not a 404: the page still renders its sections. + _context.SVMUnit.RemoveRange(_context.SVMUnit); + await _context.SaveChangesAsync(TestContext.Current.CancellationToken); + + var result = await _controller.GetUnits(TestContext.Current.CancellationToken); + + var okResult = Assert.IsType(result.Result); + Assert.Empty(Assert.IsAssignableFrom>(okResult.Value)); + } + + [Fact] + public async Task AddUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.AddUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + Assert.Empty(await _context.SVMUnitPerson.ToListAsync(TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task AddUnitData_ReturnsOk_AndAddsTheLeader() + { + var result = await _controller.AddUnitData(1, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + var added = Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)); + Assert.Equal("dean01", added.PersonIam); + Assert.Equal("Dean", added.PosType); + } + + [Fact] + public async Task UpdateUnitData_ReturnsBadRequest_ForAnUnknownUnit() + { + var result = await _controller.UpdateUnitData(999, Request(), TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task UpdateUnitData_ReturnsOk_AndReplacesTheNamedRow() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + var request = Request(); + request.DeanUnitPerson = 1; + + var result = await _controller.UpdateUnitData(1, request, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var replaced = await FindUnitPerson(1); + Assert.NotNull(replaced); + Assert.False(replaced.IsActive); + Assert.Equal("Room 100", Assert.Single(await _context.SVMUnitPerson + .Where(p => p.IsActive) + .ToListAsync(TestContext.Current.CancellationToken)).Office); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenNotFound() + { + var result = await _controller.DeleteUnitRow(999, TestContext.Current.CancellationToken); + + Assert.IsType(result); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsBadRequest_WhenTheRowWasAlreadyRemoved() + { + // The realistic way to miss through the UI: two maintainers on the same list. + AddUnitPerson(unitPersonId: 1, "dean01", "Dean", isActive: false); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + var badRequest = Assert.IsType(result); + Assert.Equal("That record has already been removed.", badRequest.Value); + } + + [Fact] + public async Task DeleteUnitRow_ReturnsOk_AndSoftDeletes_WhenFound() + { + AddUnitPerson(unitPersonId: 1, "dean01", "Dean"); + + var result = await _controller.DeleteUnitRow(1, TestContext.Current.CancellationToken); + + Assert.IsType(result); + var stillExists = await FindUnitPerson(1); + Assert.NotNull(stillExists); + Assert.False(stillExists.IsActive); + } +} diff --git a/web/Areas/Personnel/Controllers/PhoneListController.cs b/web/Areas/Personnel/Controllers/PhoneListController.cs index d7236cacc..c657ad635 100644 --- a/web/Areas/Personnel/Controllers/PhoneListController.cs +++ b/web/Areas/Personnel/Controllers/PhoneListController.cs @@ -11,11 +11,11 @@ namespace Viper.Areas.Personnel.Controllers public class PhoneListController( PhoneListService phoneListService, PhoneListUnitService phoneListUnitService, - PhonesPermissionsService phonesPermissionsService) : ApiController + PhonePermissionsService phonePermissionsService) : ApiController { private readonly PhoneListService _phoneListService = phoneListService; private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; - private readonly PhonesPermissionsService _phonesPermissionsService = phonesPermissionsService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; /// /// Everything a client needs before it fetches rows: @@ -35,7 +35,7 @@ public async Task> GetListInfo(string code, Cancella PhoneListId = list.PhoneListId, Code = list.Code, Name = list.Name, - CanMaintain = _phonesPermissionsService.CanMaintainList(list), + CanMaintain = _phonePermissionsService.CanMaintainList(list), CanViewDirectPhone = await _phoneListUnitService.CanViewDirectPhone(list, ct), }); } diff --git a/web/Areas/Personnel/Controllers/PhoneListUnitController.cs b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs index 579dfc895..f7bb1a398 100644 --- a/web/Areas/Personnel/Controllers/PhoneListUnitController.cs +++ b/web/Areas/Personnel/Controllers/PhoneListUnitController.cs @@ -16,11 +16,11 @@ namespace Viper.Areas.Personnel.Controllers public class PhoneListUnitController( PhoneListService phoneListService, PhoneListUnitService phoneListUnitService, - PhonesPermissionsService phonesPermissionsService) : ApiController + PhonePermissionsService phonePermissionsService) : ApiController { private readonly PhoneListService _phoneListService = phoneListService; private readonly PhoneListUnitService _phoneListUnitService = phoneListUnitService; - private readonly PhonesPermissionsService _phonesPermissionsService = phonesPermissionsService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; /// /// Resolves the list named in the route and confirms the caller may edit it. Returns the @@ -37,7 +37,7 @@ public class PhoneListUnitController( { return (0, NotFound(ex.Message)); } - if (!_phonesPermissionsService.CanMaintainList(list)) + if (!_phonePermissionsService.CanMaintainList(list)) { return (0, Forbid()); } diff --git a/web/Areas/Personnel/Services/PhoneListUnitService.cs b/web/Areas/Personnel/Services/PhoneListUnitService.cs index be6145492..02d3d5a5a 100644 --- a/web/Areas/Personnel/Services/PhoneListUnitService.cs +++ b/web/Areas/Personnel/Services/PhoneListUnitService.cs @@ -7,16 +7,16 @@ public class PhoneListUnitService { private readonly PhonesDbContext _context; private readonly IUserHelper _userHelper; - private readonly PhonesPermissionsService _phonesPermissionsService; + private readonly PhonePermissionsService _phonePermissionsService; public PhoneListUnitService( PhonesDbContext context, IUserHelper userHelper, - PhonesPermissionsService phonesPermissionsService + PhonePermissionsService phonePermissionsService ) { _context = context; _userHelper = userHelper; - _phonesPermissionsService = phonesPermissionsService; + _phonePermissionsService = phonePermissionsService; } /// @@ -30,7 +30,7 @@ public async Task CanViewDirectPhone(PhoneList list, CancellationToken ct { return false; } - if (_phonesPermissionsService.CanMaintainList(list)) + if (_phonePermissionsService.CanMaintainList(list)) { return true; } diff --git a/web/Areas/Personnel/Services/PhonePermissionsService.cs b/web/Areas/Personnel/Services/PhonePermissionsService.cs index 4bdd7c4cc..a6d629610 100644 --- a/web/Areas/Personnel/Services/PhonePermissionsService.cs +++ b/web/Areas/Personnel/Services/PhonePermissionsService.cs @@ -3,7 +3,7 @@ namespace Viper.Areas.Personnel.Services { - public class PhonesPermissionsService( + public class PhonePermissionsService( RAPSContext rapsContext, IUserHelper userHelper ) diff --git a/web/Areas/Personnel/Services/PhonePersonLookupService.cs b/web/Areas/Personnel/Services/PhonePersonLookupService.cs index d48dcf359..a9ffb0628 100644 --- a/web/Areas/Personnel/Services/PhonePersonLookupService.cs +++ b/web/Areas/Personnel/Services/PhonePersonLookupService.cs @@ -4,10 +4,10 @@ namespace Viper.Areas.Personnel.Services { - public class PhonePersonLookupService(PhonesDbContext context, PhonesPermissionsService phonesPermissionsService) + public class PhonePersonLookupService(PhonesDbContext context, PhonePermissionsService phonePermissionsService) { private readonly PhonesDbContext _context = context; - private readonly PhonesPermissionsService _phonePermissionsService = phonesPermissionsService; + private readonly PhonePermissionsService _phonePermissionsService = phonePermissionsService; /// /// Retrieves the PhonePerson records associated with a list of iam IDs. From d4aaacb9c560eda1a5890e6cbcc70539519efe51 Mon Sep 17 00:00:00 2001 From: Benjamin Edward Niedzielski Date: Tue, 25 Aug 2026 16:55:39 -0700 Subject: [PATCH 3/3] VPR-64 feat(phone): Fix code quality errors --- .../__tests__/modified-summary.test.ts | 62 +++++++ .../phone-list-add-record-dialog.test.ts | 41 +++++ .../__tests__/router-permissions.test.ts | 89 ++++++++-- .../__tests__/svm-add-record-dialog.test.ts | 124 ++++++++++++++ .../__tests__/svm-data-fetch.test.ts | 48 ++++++ .../Personnel/components/ModifiedSummary.vue | 42 +++++ .../components/PhoneListAddRecordDialog.vue | 25 ++- .../components/SVMAddRecordDialog.vue | 104 +++++++----- .../composables/phone-list-data-fetch.ts | 88 +++++----- .../Personnel/composables/svm-data-fetch.ts | 157 ++++++++++++------ .../src/Personnel/pages/SVMPhonesMaintain.vue | 32 ++-- VueApp/src/Personnel/router/index.ts | 63 +++---- .../Utilities/PersonSearchHelperTests.cs | 2 +- .../Controllers/PhoneSVMSectionController.cs | 2 +- .../Personnel/Models/AugmentedViperPerson.cs | 4 +- .../Models/PhoneListUnitDataRequests.cs | 2 +- web/Areas/Personnel/Models/ViperPerson.cs | 2 +- web/Areas/Personnel/PhonesDbContext.cs | 2 +- 18 files changed, 670 insertions(+), 219 deletions(-) create mode 100644 VueApp/src/Personnel/__tests__/modified-summary.test.ts create mode 100644 VueApp/src/Personnel/components/ModifiedSummary.vue diff --git a/VueApp/src/Personnel/__tests__/modified-summary.test.ts b/VueApp/src/Personnel/__tests__/modified-summary.test.ts new file mode 100644 index 000000000..fbed94871 --- /dev/null +++ b/VueApp/src/Personnel/__tests__/modified-summary.test.ts @@ -0,0 +1,62 @@ +import { mount } from "@vue/test-utils" +import { Quasar } from "quasar" +import ModifiedSummary from "../components/ModifiedSummary.vue" + +/** + * ModifiedSummary renders the "