From e7f877280204740583a6bde9a4f50d669ba66daa Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 10:56:17 -0300 Subject: [PATCH 1/3] fix(work-orders): count detail media for slide-over completion gate Detail info omits MediaCount, so the slide-over closability gate always reported a missing photo on Reactive/Emergency work orders. Refs SH-339. --- .../mappers/work-order-board-detail-mapper.ts | 27 ++++++++++- .../work-order-board-detail-mapper.test.ts | 45 +++++++++++++++++++ 2 files changed, 70 insertions(+), 2 deletions(-) diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index e2ae7411..90aba625 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -6,7 +6,10 @@ import type { WorkOrderBoardMediaItem, } from "@/domain/work-orders/types/work-order-board-detail"; import { BOARD_MEDIA_CATEGORY_API } from "@/domain/work-orders/types/work-order-board-detail"; -import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; +import type { + CompletionDocStatus, + WorkOrderTableRow, +} from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderAuditEntry, WorkOrderComment, @@ -269,6 +272,24 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M })); } +/** + * `GET workorders/{id}/detail` info omits the board row's `MediaCount`, so the table-row mapper + * defaults it to 0 and the slide-over closability gate would report "At least 1 photo" even + * after an upload (SH-339). Keep an explicit count when present; otherwise count the same + * set the board counts — stored (non-legacy) attachments. + */ +function resolveDetailMediaCount( + infoRaw: unknown, + info: WorkOrderTableRow, + media: WorkOrderBoardMediaItem[], +): number { + const record = asRecord(infoRaw); + if (typeof (record.mediaCount ?? record.MediaCount) === "number") { + return info.mediaCount; + } + return media.filter((item) => !item.isLegacy).length; +} + export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { const root = asRecord(raw); const infoRaw = root.info ?? root.Info ?? root; @@ -283,8 +304,10 @@ export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { : []; const media = Array.isArray(mediaRaw) ? mediaRaw.map(mapBoardDetailMediaItem) : []; + const info = mapWorkOrderTableRow(infoRaw); + return { - info: mapWorkOrderTableRow(infoRaw), + info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, info, media) }, completion: mapCompletion(completionRaw ?? {}), comments, audit, diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index 7f34bcc8..b393f716 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -6,6 +6,10 @@ import { mapBoardDetailMediaItem, mapWorkOrderBoardDetail, } from "@/domain/work-orders/mappers/work-order-board-detail-mapper"; +import { + getClosabilityGaps, + tableRowToClosabilityInput, +} from "@/domain/work-orders/utils/wo-closability"; const SAMPLE_DETAIL = { info: { @@ -279,3 +283,44 @@ describe("mapWorkOrderBoardDetail", () => { expect(completionForm.get("file")).toBeInstanceOf(File); }); }); + +describe("mapWorkOrderBoardDetail media count (SH-339)", () => { + const reactiveInfo = { ...SAMPLE_DETAIL.info, workOrderType: 6 }; + + it("derives mediaCount from stored media when detail info omits it", () => { + const detail = mapWorkOrderBoardDetail({ ...SAMPLE_DETAIL, info: reactiveInfo }); + + expect(detail.info.type).toBe("Reactive"); + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + + it("does not count legacy URL-column media, matching the board row count", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: reactiveInfo, + media: [ + { + id: -1, + category: 1, + url: "https://cdn.example.com/legacy.jpg", + fileName: "legacy.jpg", + isLegacy: true, + }, + ], + }); + + expect(detail.info.mediaCount).toBe(0); + }); + + it("keeps an explicit info mediaCount", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 5 }, + }); + + expect(detail.info.mediaCount).toBe(5); + }); +}); From a75543342d9ad4f67b97a3dd6e76564e47a935da Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 16:56:26 -0300 Subject: [PATCH 2/3] chore(work-orders): remove ticket key from mapper docs --- .../work-orders/mappers/work-order-board-detail-mapper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 90aba625..144f5e3f 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -275,7 +275,7 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M /** * `GET workorders/{id}/detail` info omits the board row's `MediaCount`, so the table-row mapper * defaults it to 0 and the slide-over closability gate would report "At least 1 photo" even - * after an upload (SH-339). Keep an explicit count when present; otherwise count the same + * after an upload. Keep an explicit count when present; otherwise count the same * set the board counts — stored (non-legacy) attachments. */ function resolveDetailMediaCount( From a9d8416f0f3b805c2154b5806888c1b0f8bccc73 Mon Sep 17 00:00:00 2001 From: Codex Review Integration Date: Wed, 16 Sep 2026 18:05:50 -0300 Subject: [PATCH 3/3] fix(work-orders): prefer stored media over a zero detail media count The detail endpoint's info inherits MediaCount from the board row DTO but the service never sets it, so it serializes as 0. The previous guard trusted any numeric value and returned that 0, leaving the slide-over completion gate reporting "At least 1 photo" even after an upload. Take the larger of the reported count and the stored (non-legacy) attachments so a positive API count still wins while a spurious or omitted 0 no longer masks present media. Dropping the unused mapped-row argument also removes a flat-payload path that would have counted legacy items. --- .../mappers/work-order-board-detail-mapper.ts | 29 +++++++------------ .../work-order-board-detail-mapper.test.ts | 12 ++++++++ 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts index 144f5e3f..54b333ed 100644 --- a/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts +++ b/src/domain/work-orders/mappers/work-order-board-detail-mapper.ts @@ -6,10 +6,7 @@ import type { WorkOrderBoardMediaItem, } from "@/domain/work-orders/types/work-order-board-detail"; import { BOARD_MEDIA_CATEGORY_API } from "@/domain/work-orders/types/work-order-board-detail"; -import type { - CompletionDocStatus, - WorkOrderTableRow, -} from "@/domain/work-orders/types/work-order-table-row"; +import type { CompletionDocStatus } from "@/domain/work-orders/types/work-order-table-row"; import type { WorkOrderAuditEntry, WorkOrderComment, @@ -273,21 +270,17 @@ export function boardMediaItemsToMediaFiles(items: WorkOrderBoardMediaItem[]): M } /** - * `GET workorders/{id}/detail` info omits the board row's `MediaCount`, so the table-row mapper - * defaults it to 0 and the slide-over closability gate would report "At least 1 photo" even - * after an upload. Keep an explicit count when present; otherwise count the same - * set the board counts — stored (non-legacy) attachments. + * `GET workorders/{id}/detail` info serializes the board row's `MediaCount` as 0 — the detail + * DTO inherits the field but the service never sets it — so trusting it verbatim would leave the + * slide-over closability gate reporting "At least 1 photo" even after an upload. Take the larger + * of the reported count and the stored (non-legacy) attachments: a positive API count still wins, + * but a spurious (or omitted) 0 can no longer mask media that are actually present. */ -function resolveDetailMediaCount( - infoRaw: unknown, - info: WorkOrderTableRow, - media: WorkOrderBoardMediaItem[], -): number { +function resolveDetailMediaCount(infoRaw: unknown, media: WorkOrderBoardMediaItem[]): number { const record = asRecord(infoRaw); - if (typeof (record.mediaCount ?? record.MediaCount) === "number") { - return info.mediaCount; - } - return media.filter((item) => !item.isLegacy).length; + const explicit = record.mediaCount ?? record.MediaCount; + const nonLegacyCount = media.filter((item) => !item.isLegacy).length; + return Math.max(typeof explicit === "number" ? explicit : 0, nonLegacyCount); } export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { @@ -307,7 +300,7 @@ export function mapWorkOrderBoardDetail(raw: unknown): WorkOrderBoardDetail { const info = mapWorkOrderTableRow(infoRaw); return { - info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, info, media) }, + info: { ...info, mediaCount: resolveDetailMediaCount(infoRaw, media) }, completion: mapCompletion(completionRaw ?? {}), comments, audit, diff --git a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts index b393f716..fb24f6b5 100644 --- a/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts +++ b/src/test/domain/work-orders/mappers/work-order-board-detail-mapper.test.ts @@ -297,6 +297,18 @@ describe("mapWorkOrderBoardDetail media count (SH-339)", () => { ); }); + it("prefers stored media when detail info reports an explicit zero", () => { + const detail = mapWorkOrderBoardDetail({ + ...SAMPLE_DETAIL, + info: { ...reactiveInfo, mediaCount: 0 }, + }); + + expect(detail.info.mediaCount).toBe(2); + expect(getClosabilityGaps(tableRowToClosabilityInput(detail.info))).not.toContain( + "At least 1 photo", + ); + }); + it("does not count legacy URL-column media, matching the board row count", () => { const detail = mapWorkOrderBoardDetail({ ...SAMPLE_DETAIL,