The manual POC "follow the site" clear-rule compared the edit against every
live Site contact. A work order only ever displays one of them, so editing to
a different live contact (Site has Alice primary and Bob; WO shows Alice; edit
to Bob) matched, cleared the override, and left the row showing Alice with no
audit row written — the SH-379 symptom on a different input. A work order with
a linked WorkOrderContacts POC hit the same bug when the dispatcher typed the
Site's primary: the override cleared and the linked contact showed instead.
Compare the edit against the single contact the work order actually follows —
the linked WorkOrderContacts POC, or else the Site primary (ResolvePrimary) —
matching the board projection's override -> linked -> site precedence, and
store the override whenever the edit differs from it. The create path in
WorkOrderBoardCreateService had the same any-contact rule and gets the same
fix; a supplied PocContactId that is not a live Site contact leaves no follow
target, so the typed POC is stored.
Replaces MatchesAnySiteContact with Matches(name, phone, contact); comment and
PR-body wording updated to state the followed-contact rule. Adds tests for the
second-site-contact edit, the linked-contact-differs edit, and the
second-site-contact create case.
Mobile browsers attach an unreliable Content-Type to a picked file: empty or
application/octet-stream when the OS cannot classify it, and sometimes a
foreign-but-plausible type for a supported container (video/3gpp for an .mp4,
video/x-quicktime for a .mov). The media allowlist already resolved empty and
octet-stream types from the extension, but a concrete foreign type was rejected
outright, so a real .MP4/.MOV picked on a phone passed the client dialog and was
refused by the API.
Any declared type that is not itself on the allowlist now falls back to the
extension. The extension pairing and magic-byte signature still decide, so the
accepted set of files is unchanged; an allowlisted declared type stays
authoritative and must still match its own extension.
Editing a work order's POC never reached the backend: no update path wrote
PocName/PocPhone/PocNotes, so the optimistic UI edit was lost on refetch and
the completion freeze captured the Site contact instead of the manual value,
and nothing was audited.
- Add tenant-scoped PATCH api/workorders/{id}/poc via new WorkOrderPocService
+ WorkOrderPocDataService: persists the override, stages FieldChanged audit
entries (which also write field locks so sync never overwrites a manual POC),
and enforces row-version concurrency and terminal-status read-only rules.
- Lock semantics (SH-190): a manual POC away from the Site's live contacts is
stored WO-level; an edit equal to a live Site contact (or blanking name+phone)
stores nothing so the WO follows the Site. PocCustomized exposes the state.
- Board projection, completion freeze and create now share one Site-contact
fallback (first non-deleted contact by SiteContactOrder) so a never-overridden
WO keeps following the Site, including at create when the wizard prefills it.
- Route contract baseline gains PATCH {id:int}/poc.
Adds the personal producers behind GET /api/notifications without changing its shape:
new assignments (SH-288), unanswered comments on work the user takes part in (SH-289),
@mentions, and decisions on uplifts the user requested (SH-215).
No Vendor treated any non-terminal dispatch as an assigned vendor without
checking IsDeleted, so a soft-deleted dispatch hid the work order from both
No Vendor and Vendor Conflict (the conflict query already drops deleted
dispatches). GetNoVendorAsync and the vendor-reminder assigned-work-order rule
now skip soft-deleted dispatches, keeping the asserted parity between the feed
and the reminders.
Vendor Conflict applied the section cap in vendor-sweep order, so when overlaps
exceeded the limit newer conflicts could be dropped while Count still counted
every work order. VendorConflictsAsync now orders pairs by recency before the
cap, matching the other per-work-order sections.
The uplift detail modal needs the work order's assigned dispatcher, the
requesting vendor's technician and the scheduled date. They now resolve
from the same effective work order and vendor as the existing queue row,
so the modal no longer depends on a separate work-order fetch that
account-scoped staff cannot read.
Adds GET /api/notifications, a per-user read model derived from live
work-order state: Unassigned (grouped, High), No Vendor and Aveta Missing
(per work order, Medium) and Vendor Conflict, account-scoped from claims
and ordered by section severity with a fixed reason tie-break.
Storing the submitted full name in FirstName while leaving a seeded
LastName intact made an unchanged save project a duplicated name
(e.g. "Legacy Manager Manager"). Leave FirstName/LastName untouched when
the submitted name already matches the stored first+last projection, and
otherwise split the submitted name across FirstName/LastName so renames
round-trip cleanly.
ResolveDispatcherScope now admits the Scheduler role, which owns the
viewAllDispatchersOnDashboard permission, so it can load Stats, Workload,
Performance, Regions and Trend instead of being denied.
GetTrendAsync now resolves scope via the shared ResolveDispatcherScope so a
picker DispatcherId selection scopes Trend consistently with the other
endpoints and unauthorized roles fail closed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CreateAsync checks FindByEmailAsync and then inserts, so two concurrent
creates with the same email can both pass the check and hit the unique
user-name index. That DbUpdateException was unhandled and surfaced as a
500. Catch it at the CreateAsync call and return the existing
"Email is already in use." message, matching the check-then-insert
converge pattern already used by SetOverrideCoreAsync.
Locations store State as either a two-letter postal code or a full state
name (the location list filter expands codes to both forms via
UsStateCodes.ExpandStorageValues, and dev fixtures carry State = "indiana").
DashboardRegions.Resolve only matched the two-letter key, so every location
stored as a full name fell into Unmapped/Other and the East/Central/West/
California bars undercounted.
Normalise the stored value through a new UsStateCodes.ToCode, which accepts a
code or a full name and returns the canonical postal code, before the zone
lookup. Extend the region test with full-name storage forms and add an
invariant asserting every US state and its full-name form resolves to a
canonical bucket.
ScheduledDate is persisted as a midnight calendar value (board create writes
request.ScheduledDate.Value.Date; board patch writes the parsed .Date into a
datetime2 column with no offset). GetTrendAsync treated it as a UTC instant and
converted to America/New_York, so midnight became 19:00/20:00 the previous day
and every board-scheduled work order fell into the prior bucket: a job scheduled
today read as yesterday and overdue, and the Today bucket showed zero.
Bucket by DateOnly.FromDateTime(scheduled.Date) with no conversion, matching
DashboardMetrics and WorkOrderDerivedFields, so Trend and Stats agree on the
same rows. Rewrite the daily and yearly trend tests to assert calendar-date
classification (the removed conversion had encoded the shift into their
expectations) and add a regression test that a midnight-today work order stays
in the Today bucket and is not overdue.