VPR-64 feat(phone): schoolwide and unit phone lists - #323
Conversation
Bundle ReportChanges will increase total bundle size by 50.13kB (2.19%) ⬆️. This is within the configured threshold ✅ Detailed changes
Affected Assets, Files, and Routes:view changes for bundle: viper-frontend-esmAssets Changed:
Files in
Files in
|
|
@coderabbitai full review |
|
There was a problem hiding this comment.
Pull request overview
This PR migrates the schoolwide (SVM) and Dean's Office (VMDO) phone lists from Viper 1 into a new Personnel area, backed by a new normalized phones schema in the VIPER database. Viewing requires basic SVMSecure permission, while a new SVMSecure.PhoneLists.SVMMaintain permission (and per-list MaintainRole) gates editing. It adds EF Core models/services/controllers plus a full Vue 3/Quasar SPA, and refactors shared person-search logic into a reusable PersonSearchHelper used by both CMS and Personnel.
Changes:
- New
phonesschema +PhonesDbContext, EF models, area services and/api/phones/...controllers with dynamic per-list maintain permissions and direct-number masking. - New Personnel Vue SPA (lists, maintenance views, person selector, record dialogs) plus data-migration scripts from the legacy PhoneList database.
- Shared
PersonSearchHelperextracted and adopted by CMS'sSearchPeople, forcing EF parameterization (ESCAPE clause) to prevent per-term query plans and%/_wildcard injection.
Reviewed changes
Copilot reviewed 121 out of 122 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| web/Viper.csproj | Excludes Areas\Personnel\Scripts\** (separate migration project) from the web build, mirroring the Effort area. |
| web/Program.cs | Registers PhonesDbContext, adds Personnel SPA name and the Personnel services namespace to Scrutor registration. |
| web/Classes/Utilities/PersonSearchHelper.cs | New shared expression-tree helper for name-search autocomplete with parameterized Contains matching. |
| web/Areas/Personnel/Services/PhoneSVMSectionService.cs | Read-only query for SVM sections, ordered with null-safe sort. |
| web/Areas/Personnel/Services/PhoneSVMFrequentNumberService.cs | CRUD + soft-delete for SVM frequent numbers, with modified-date tracking. |
| web/Areas/Personnel/Services/PhonePersonLookupService.cs | Looks up phone people by IAM IDs (direct number masked unless maintainer) and current-employee search. |
| web/Areas/Personnel/Services/PhonePermissionsService.cs | Resolves edit permission from the list's MaintainRole column. |
| web/Areas/Personnel/Controllers/PhonePersonController.cs | Person-picker endpoint merging Viper and phone data; uses foreach/Add where .Select() is preferred. |
| web/Areas/Personnel/Controllers/PhoneSVMModifiedDateController.cs | Returns latest SVM modified date; contains a comment typo ("Identfies"). |
| web/Areas/Personnel/Models/*, VueApp/src/Personnel/** | New EF models/DTOs/Mapperly mapper and the Personnel Vue SPA (services, composables, components, tests). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
rlorenzo
left a comment
There was a problem hiding this comment.
Solid work, and the parts that are easy to get wrong are right: ResolveListForMaintain, VerifyUnitInList, and GetUnitPersonInList each re-scope by list rather than trusting the id in the request, with a test proving one list's role grants nothing on another. I ran the branch against dev, so the inline notes are reproductions. Four things block deployment, none of them in the code:
- The DDL won't run.
CREATE SCHEMA Inventory;should bephones, so everyCREATE TABLE [phones].[...]fails. FourALTER TABLE [phones].[SVMUnitPerson] CHECK CONSTRAINTstatements also name the wrong table, and three run before that table exists. - The DDL is missing the unique index on
PhoneList.Code, and dev already has it.UX_PhoneList_Codewas added to dev by hand, so Production won't get it and a duplicate code would resolve arbitrarily, including for the permission check. - A permission is missing from the steps. VMDO's MaintainRole is
SVMSecure.PhoneLists.VMDOMaintain, but onlySVMMaintainis listed, so nobody could maintain VMDO. - The pages aren't reachable from the nav.
MainNav.cs:29andMiniNav/Default.cshtml:105-109still point Personnel at VIPER 1, thoughApp.vuesetshighlighted-top-nav="Personnel".
Also Home.vue needs a personnel-home CMS record per environment, or redirected non-maintainers land on a blank page. Everything else is inline, tagged minor where it's a nit rather than a fix I'd hold the PR for.
I've addressed 1 and 2 in the DDL in the description. 3 is a non-issue - SVMSecure.PhoneLists.VMDOMaintain is a legacy permission, present in all environments, and assigned to users with no changes to scope. SVMMaintain is being added to address a legacy security issue.
|
|
Tagging @JasonRobertFrancis mostly so he is aware of what I've done in Personnel as part of this task |
| // oxlint-disable-next-line unicorn/import-style | ||
| import path, { resolve } from "node:path" |
There was a problem hiding this comment.
Mixing the default and named import is what trips import-style; every other file in the repo imports path only, so no suppression is needed.
| // oxlint-disable-next-line unicorn/import-style | |
| import path, { resolve } from "node:path" | |
| import path from "node:path" |
| cahfs: resolve(import.meta.dirname, "src/CAHFS/index.html"), | ||
| clinicalscheduler: resolve(import.meta.dirname, "src/ClinicalScheduler/index.html"), | ||
| effort: resolve(import.meta.dirname, "src/Effort/index.html"), | ||
| personnel: resolve(import.meta.dirname, "src/Personnel/index.html"), |
There was a problem hiding this comment.
Same for the five entries above.
| cahfs: resolve(import.meta.dirname, "src/CAHFS/index.html"), | |
| clinicalscheduler: resolve(import.meta.dirname, "src/ClinicalScheduler/index.html"), | |
| effort: resolve(import.meta.dirname, "src/Effort/index.html"), | |
| personnel: resolve(import.meta.dirname, "src/Personnel/index.html"), | |
| cahfs: path.resolve(import.meta.dirname, "src/CAHFS/index.html"), | |
| clinicalscheduler: path.resolve(import.meta.dirname, "src/ClinicalScheduler/index.html"), | |
| effort: path.resolve(import.meta.dirname, "src/Effort/index.html"), | |
| personnel: path.resolve(import.meta.dirname, "src/Personnel/index.html"), |
| <q-item clickable> | ||
| <q-item-section> | ||
| <q-btn stretch flat label="Personnel" href="@Url.Content(ViewData["OldViperURL"] + "/personnel/default.cfm")"></q-btn> | ||
| <q-btn stretch flat label="Personnel" href="/2/Personnel")"></q-btn> |
There was a problem hiding this comment.
Hardcoded /2 breaks local dev (no PathBase): /2/Personnel is a 404 here while /Personnel is 200. The trailing )" is left over from the removed @Url.Content(...) and parses as a junk attribute.
| <q-btn stretch flat label="Personnel" href="/2/Personnel")"></q-btn> | |
| <q-btn stretch flat label="Personnel" href="@Url.Content("~/Personnel")"></q-btn> |
| <q-item clickable> | ||
| <q-item-section> | ||
| <q-btn stretch flat label="Personnel" href="@Url.Content(ViewData["OldViperURL"] + "/personnel/default.cfm")"></q-btn> | ||
| <q-btn stretch flat label="Personnel" href="/2/Personnel")"></q-btn> |
There was a problem hiding this comment.
Hardcoded /2 404s locally (no PathBase), and the trailing )" is left over from the removed @Url.Content(...). Verified on dev: this renders as href="/2/Personnel")" while MainNav renders /Personnel.
| <q-btn stretch flat label="Personnel" href="/2/Personnel")"></q-btn> | |
| <q-btn stretch flat label="Personnel" href="@Url.Content("~/Personnel")"></q-btn> |
rlorenzo
left a comment
There was a problem hiding this comment.
Re-review of d4aaacb9..dcd328cf. The earlier threads are all resolved; these are new in the delta. Items on PhoneListFilter.vue, SectionJumpLinks.vue, SVMPhones.vue and PhoneListUnitService.cs I'd hold for, the rest are marked minor. The three UI ones reproduced in the browser at 390px.
| // header above it. The height is not a constant: the field is a fixed 40px but the padding is in | ||
| // rem, and the root font-size steps from 14px to 16px at 768px. | ||
| const barRef = useTemplateRef<HTMLElement>("bar") | ||
| const { height } = useElementSize(barRef) |
There was a problem hiding this comment.
useElementSize measures the content box by default, so the published height leaves out this bar's own padding-block (line 72). Measured at 390px: --phone-list-filter-height is 207px, the bar's border box is 221px. .sticky-filter-offset is built from the variable, so every jump target lands 14px under the bar (16px at 768px and up), with focus on a hidden h2.
| const { height } = useElementSize(barRef) | |
| const { height } = useElementSize(barRef, undefined, { box: "border-box" }) |
| <template> | ||
| <!-- Nothing to navigate between with one destination, and nothing to offer when a search has | ||
| emptied them all. --> | ||
| <q-expansion-item |
There was a problem hiding this comment.
Nothing collapses this after a link is followed, and it sits inside the sticky bar, so the open panel stays pinned over the section it just jumped to (about 140px of a 390px viewport). A v-model set to false in the link's click handler closes it and lets the bar shrink back before the scroll.
| {{ errorMessage }} | ||
| </StatusBanner> | ||
|
|
||
| <template v-if="!loading && !errorMessage"> |
There was a problem hiding this comment.
This is the guard I asked for on the phone list page, where a failed read means no data. Here loadPhoneData renders whatever arrived, so a failed frequent-numbers read hides the filter and jump links over a full page of sections. v-if="!loading" is enough; the banner already sits above it.
| {{ errorMessage }} | ||
| </StatusBanner> | ||
|
|
||
| <template v-if="!loading && !errorMessage"> |
There was a problem hiding this comment.
Same as SVMPhones.vue:11: the tables below render on a partial failure, so this hides the filter over the data.
| var userIam = _userHelper.GetCurrentUser()?.IamId; | ||
| var updateTimestamp = DateTime.Now; | ||
|
|
||
| await AddOrUpdatePhonePerson(request, userIam, updateTimestamp, ct); |
There was a problem hiding this comment.
This upserts the shared phones.Person row keyed by request.EmployeeIam, but the list row's PersonIam never changes, so a PUT carrying a different (real) IAM rewrites a third party's phone, direct phone and office while the row still shows the original person. Not reachable from the dialog (v-if="!isEdit" on the picker), but VerifyPersonExists right above implies the field is honoured. Key the write off modifiedPhoneListPerson.PersonIam, or 400 on a mismatch.
| { | ||
| return BadRequest(ex.Message); | ||
| } | ||
| catch (DbUpdateException ex) |
There was a problem hiding this comment.
Minor, and my suggestion, so on me: DbUpdateException also wraps deadlock victims (1205), timeouts and dropped connections, which now return "check all field values" at Warning. With [MaxLength] and the person check in front, most of what reaches this catch is infrastructure. when (ex.InnerException is SqlException { Number: 2601 or 2627 or 547 or 8152 }) for the 400, LogError + 500 otherwise. Same in the other eight write actions.
| /// <summary> | ||
| /// Maps the phones entities to the shapes the API returns. Every property an entity has and | ||
| /// its DTO does not is named in a [MapperIgnoreSource] below, so the omissions are decisions | ||
| /// rather than oversights and a column added to an entity is a build error here before it can |
There was a problem hiding this comment.
Minor: RequiredMappingStrategy.None (line 11, the project default) turns off the unmapped-member diagnostic, so "a column added to an entity is a build error here" does not hold; the [MapperIgnoreSource] list only bites on a rename. The DTOs are what pin the wire shape, so the comment can say that instead.
| 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"); |
There was a problem hiding this comment.
Minor: only ModifiedBy in this file without the cap; lines 89, 114, 142 and 188 all have it.
| entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy"); | |
| entity.Property(e => e.ModifiedBy).HasColumnName("ModifiedBy").HasMaxLength(10); |
| } | ||
|
|
||
| [Fact] | ||
| public async Task GetUnits_SerializesWithoutTheEntityNavigationProperties() |
There was a problem hiding this comment.
Minor: the fixture seeds no unit-person rows, so phoneListUnitPersons serializes as [] and the isActive / phoneListUnit assertions never see a PhoneListUnitPersonDto. AddUnitPersonRow(1, 1) at the top makes the test pin what its comment says it pins.
| <!-- tabindex allows a jump link to land focus here, not just the viewport. --> | ||
| <h2 | ||
| :id="anchorId" | ||
| class="text-h6 q-mt-none q-mb-sm sticky-filter-offset" |
There was a problem hiding this comment.
Minor: .q-page-container .text-h6 (1rem) out-specifies .q-page-container h2 (1.2rem), so this renders a step smaller than DESIGN.md's h2.
| class="text-h6 q-mt-none q-mb-sm sticky-filter-offset" | |
| class="q-mt-none q-mb-sm sticky-filter-offset" |
This PR migrates the schoolwide and Dean's Office phone lists from Viper 1. Viewing the lists requires only basic permissions, while specific permissions allow users to edit and maintain the lists. The lists are now housed in the new Personnel area.
The migration makes the following functional changes from the Viper 1 version:
This PR also does some refactoring around Person selection and dialog boxes. There should be no end user impact to CMS, but a few files are affected.
This PR requires schema changes to the Production database:
This PR requires creating a new permission on Production: SVMSecure.PhoneLists.SVMMaintain.
This PR requires running the migration script
.\RunMigrateData.bat Productionfor a dry run, and then.\RunMigrateData.bat Production --applyto migrate data into the new schema.This PR will require a change to Viper 1 redirecting two Personnel left nav items and adding the SVMMaintain permission check to one.