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.
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.
#144 branched from the SH-210 decision-actions commit before the SH-207/SH-208
read-contract corrections landed, so it re-implemented workOrderClosed,
attachmentCount, decidedByName, and pendingExposureTotal with stale semantics:
it projected WorkerOrderNumber (the CRM external id) instead of InternalWONumber
(what the board renders) and summed raw RequestedNTE, which double-counts the new
NTE total on vendor-portal rows across sequential approvals.
Merge origin/feat/ab/sh-210-uplift-decisions (#141, what lands) in and resolve
every conflict in #141's favour, so those fields and their granted-amount
exposure math now come from #141 rather than being duplicated here. Keep only the
two deltas #144 actually adds on top of #141:
- attachmentCount counts non-deleted UpliftEvidence documents on the dispatch,
not every completion document, so completion photos no longer inflate the chip;
- the queue read resolves the effective work order via the primary-plus-linked
(DispatchWorkOrders) convention the sibling reads use, so a dispatch linked only
through that table surfaces its WO context, closed flag, and exposure. Covered
by an in-memory test and a SQLite relational test that proves the fallback
translates to SQL.
Drop the superseded attachment-count test that asserted completion documents
count, and align the relational test to seed InternalWONumber.
GetPagedAsync resolved WorkOrderNumber/Site/Service, WorkOrderClosed, and
WorkOrderId only through Dispatch.WorkOrderId, so a dispatch linked to its
work order solely through DispatchWorkOrders surfaced blank WO context,
workOrderClosed:false, and zero exposure. Resolve the effective work order
using the same primary-plus-linked convention as GetWorkOrderIdForUpliftAsync
and GetApprovedExposureForWorkOrdersAsync via a single work-order lookup.
Break collection initializer entries onto one line each in
WorkOrderBoardRegions and WorkOrderBoardSearchTests so the changed-file
formatting gate (dotnet format --verify-no-changes) passes.
The approvals queue frontend needs four list-contract additions the read
contract PRs do not carry yet: workOrderClosed so the Approved tab can
disable Revoke on terminal work orders (mirroring the SH-196 revoke
guard), attachmentCount from non-deleted UpliftEvidence documents so the
+N chip renders, decidedByName for the Approved By column, and the
queue-wide pendingExposureTotal for the header total.
SH-320: the WO number normalizer stripped every non-digit, so a manually
entered SH placeholder (e.g. SH00001) was saved as 00000000001 on both
create and patch. Keep the SH prefix, and reject replacing a saved real
APM number with an SH placeholder.
The media allowlist refused any upload whose multipart part had an empty or
application/octet-stream Content-Type before looking at the extension or the
bytes. Browsers take that header from File.type, which mobile browsers leave
empty when the OS cannot classify a picked file, while the client-side gate
already accepts such files on extension alone. A real JPG/MP4/MOV could pass
the dialog and still be refused by the API.
Only an undetermined type now falls back to the extension. The resolved type
still goes through the SH-171 document/category rule, the extension pairing,
and the magic-byte signature check, so an octet-stream .pdf stays refused for
Completion, Before and After, and a declared type is never overridden.
Incomplete and Scheduled are derived by the server. A direct lifecycleStatus
PATCH may only restate the status derivation already produced; any other
request to move a work order into an automatic state returns the stable
AutomaticLifecycleStatus validation error and leaves status and audit untouched.
Updating this branch onto dev turned MediaRules_StillRejectPdf red, and the
test was the thing that had gone stale, not the rule. SH-171 added documents
to the SH-116 media allowlist for Extra and Aveta in 3454125 — a deliberate
widening with its own review — so asserting that media rejects a PDF outright
now contradicts shipped behaviour.
What this branch actually needs guarded is that the completion-doc allowlist
stopped there. Assert it per category instead: Completion, the category
SH-337 touches, plus Before and After, must all still refuse a PDF.
The completion-document endpoint persisted whatever file it received: the
only checks were non-null, non-empty, and a 30 MB request limit. Its sibling
media endpoint has enforced a MIME allowlist, MIME-to-extension pairing, and
a magic-byte signature check since SH-116.
Validate before the file reaches storage, so a rejected upload leaves nothing
behind. An undetermined content type is accepted only alongside a .pdf name
and a %PDF- signature, because the browser leaves File.type empty when the OS
cannot classify the file and the completion-doc dialog already allows that.