Overdue (8) is a dispatcher-assigned type, separate from the derived
past-due overlay. It takes no severity, resolves services and
completion-doc templates from the PM catalog, and filters as its own
type. The past-due flag now narrows a type filter instead of widening it,
and the dashboard breakdown partitions by stored type.
The parity test now also asserts which rows the drill-down lists: legacy
open rows (status in Status or in LegacyStatus, or none) are listed and
legacy closed, cancelled or assigned rows are not. Drops ticket keys
from comments.
The legacy create paths (WorkOrderDTOs, SyncService, the Blazor
WorkorderService) still write Status without LifecycleStatus. The board
status filter only matched LifecycleStatus. So an open unassigned row of
that kind was left out of the Unassigned tile and its drill-down list,
even though the Open tile in the same response counted it.
ApplyStatusFilter now reads a row with no LifecycleStatus by its legacy
status (LegacyStatus ?? Status), using the Phase0 backfill rules: known
text maps as LifecycleStatusMapper does, and anything else, blank
included, counts as Incomplete. The tile and /board/search share the
predicate, so the count still matches the list it opens.
A vendor could withdraw (or cancel) an uplift a dispatcher raised from the work
order. Withdraw and its cancel alias now refuse requests with createdby set,
using the portal's not-found response, and the portal read model reports
RaisedByVendor so the portal can hide Revise and Withdraw on those requests.
A work-order request stores the requested increase, not a total. Letting
the vendor revise one after changes were requested rewrote RequestedNTE
as a total while it still read as a work-order request, corrupting its
amount and the NTE it would be approved to. Revise now answers not-found
for any request the vendor did not raise and leaves the row untouched.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Work-order requests store the requested increase in RequestedNTE; vendor
portal requests store the requested NTE total. The queue Delta, the
pending and approved exposure totals, the work-order uplift list, the
board summary and the notification Delta now all read one definition
(UpliftAmount) that honours both meanings and translates to SQL.
Approving a work-order request now adds its increase to the dispatch NTE
instead of replacing the NTE with the increase; vendor requests still end
at their requested total.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The approvals header summed the whole RequestedNTE for work-order-path
requests, while each Pending row shows RequestedNTE - CurrentNTE. When a
work order already had an NTE the header overstated exposure by that NTE.
The header now sums the same Delta the rows display, over the same rows
the Pending tab lists (non-deleted request on a non-deleted dispatch).
The unused duplicate aggregate is removed so one definition remains.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The strict date range made undated open work orders disappear for every
client that predates the includeDateless flag. The frontend on main sends
the all-weeks window (2000-01-01..2099-12-31) for "no range", the
1970-01-01..2099-12-31 window for the pinned Unassigned queue, and no date
input at all for the WO# duplicate lookup. None of those send the flag, so
deploying this backend before the frontend would have dropped undated WOs
from all three flows.
includeDateless is now optional. An explicit value still wins. When it is
omitted, a request with no date input or with an all-weeks Custom window
keeps its open undated rows, as before SH-391. Any other range stays
strict. The backend and frontend can therefore deploy in either order.
AddMedia sized a file before validating its type, and ValidateSize's Unknown
arm returned the video message, so a 150 MB .exe sent as
application/octet-stream was rejected as FileTooLarge with "Videos must be
100 MB or smaller." EnsureAllowed now runs first, so an unsupported file always
reports UnsupportedMediaType, and the Unknown arm uses a type-neutral message.
The oversize controller tests now use real file headers so they pass the type
check before reaching the size cap.
Approve, reject, request-changes, expiry and escalation staged their work
order audit with WorkOrderId = dispatch.WorkOrderId ?? 0. WorkOrderAuditLogs
requires a real work order, so any uplift on a dispatch without an owning
work order failed to save: the decision returned a 500 and the request stayed
Pending, and the expiry sweep failed on it every run.
The audit now goes to the work order the uplift resolves to through the
existing owner-or-linked read that revoke already uses. When none resolves,
the status change is saved on the request and no audit row is written.
The 10-photo / 3-video cap was a check-then-insert with no lock on both
upload surfaces, so two overlapping uploads could both take the last slot.
The board media upload and the vendor portal upload now run count, insert
and save inside ExecuteWorkOrderMutationAsync. GetMediaContent maps .heic
to image/heic.
UserCanApprove only accepted roles listed in Approvals:Tier1Roles/Tier2Roles,
so an environment whose config omits Admin denied every approval surface to
admins: can-approve, per-row CanDecide, approve/reject/request-changes and
evidence download. Admin now short-circuits the check; tier-role config still
governs every other role.
Uplift creation now checks the actor's RequestUplifts permission, so the
ownership tests construct the service with the permission services and seed
their actor as an Admin.
After SH-393 the uplift dispatch resolves only through dispatches the work
order owns or links through DispatchWorkOrders. Model the shared dispatch
that way so the concurrent-insert conflict test exercises the conflict
mapping again; assertions are unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds MediaCounts (limits, current photos/videos, and the counts a new
completion version would see) so the portal can refuse an 11th photo or
4th video before uploading it. Uses the same count and replacement rule
the upload check enforces.
The Unassigned drill-down opens the board with no range, which searches
2000-01-01..2099-12-31 plus undated work. The All time count had no
bounds, so a work order dated outside that window was counted but not
listed. The count now uses the same window.
Read the duration from the MP4/MOV movie header (moov/mvhd) on both the
dispatcher media endpoint and the vendor portal completion upload, so a
direct request cannot bypass the browser check. Unreadable metadata still
never blocks an upload.
The 10-photo / 3-video limit only counted dispatcher attachments, so vendor
portal uploads could push a work order past it (and vice versa). Count the
work order's current vendor documents alongside its attachments on both the
dispatcher media endpoint and the vendor portal. A new completion version
does not count the version it replaces.
Board create left the new primary dispatch with no WorkOrderId, so uplifts
created on those work orders were written to a dispatch the work order's
uplift reads never resolve: not listed, allowance never consumed, queue WO
number blank. The same orphan made ApptDate/vendor patches fail with
"A primary dispatch is required".
- Board create backfills Dispatch.WorkOrderId after the first save.
- Uplift create resolves its dispatch through the read-side scope
(non-deleted, owned or linked); otherwise the stable
"no primary dispatch" error.
- Data-only migration assigns existing orphaned primaries to the single
work order naming them primary; idempotent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A misleading file name could move a file into a larger size class: a real
PDF or JPEG named .mp4/.mov got the 100 MB video cap on the vendor portal,
and a .jpg declared with a foreign video type got it on the media endpoint.
Classify by the same resolved type the signature check validates; the
extension only decides when no allowlisted type is known.
GET /dashboard/stats now returns unassigned: open (not Completed or
Canceled) work orders with no dispatcher in the selected period, within the
caller's server-derived account scope. It uses the same filters as the
Unassigned board search, so the Dashboard number equals the list it drills
into. A dispatcher-scoped Dashboard has no unassigned work and returns 0.
The Elastic Beanstalk nginx proxy kept its 1 MB default body limit, so every
media upload over ~1 MB got an nginx 413 before reaching the API. Ship a
.platform nginx override (120M) in the bundle and assert it in the bundle
contract.
Apply the client-confirmed contract: photos up to 10 MB (JPEG/PNG/HEIC),
videos up to 100 MB (MP4/MOV), at most 10 photos and 3 videos per work order,
with stable generic rejection messages. The request ceiling (110 MB) sits
between the per-kind caps and the proxy so oversize files get the generic
message. The vendor portal accepts the same photo/video types and caps.
An explicit date range let every undated, non-terminal work order through,
so Unassigned plus a range still returned all ~1,780 unassigned rows. The
range now matches Schedule On, or the Target Week for week-only rows
(overlap), the same date the weekly board groups by. Undated rows are added
only when the caller sends includeDateless, which the client uses for
searches with no range selected and for the Unassigned queue.