From e5bb0c2d3b15f6f11e461fa0c0b3a80fd83baec6 Mon Sep 17 00:00:00 2001 From: Riyadh Date: Sat, 2 May 2026 09:15:53 +0000 Subject: [PATCH] fix(executive-meetings): keep visible drag-reorder chronological with cancelled rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task #311. The schedule view hides cancelled meetings, but the drag-reorder path was operating on the raw, unfiltered list — so cancelled rows consumed time slots and the dnd-kit indices skewed across hidden rows, leaving the visible list out of chronological order after a drop. Server (POST /api/executive-meetings/reorder) - Slot-swap now operates on the in-scope (orderedIds) subset only. Cancelled rows keep their (startTime, endTime, dailyNumber) untouched, so they no longer steal slots from the visible list. - New 400 codes: - cancelled_in_reorder: payload includes a cancelled meeting - incomplete_day: any non-cancelled meeting on the day is missing - Audit oldValue.order now reflects the visible order the user actually saw. - Phase-1/phase-2 negative-parking still avoids transient unique-constraint conflicts; cancelled rows' positive dailyNumbers cannot collide because slot dailyNumbers are a permutation of in-scope rows' existing values. Client (executive-meetings.tsx reorderRows) - Index math now derives ids from `orderedMeetings` (the visible list bound to SortableContext) instead of the raw `meetings` array. useCallback deps updated. Tests - artifacts/api-server/tests/executive-meetings.test.mjs: added "leaves cancelled rows untouched and only slot-swaps visible meetings" and "rejects orderedIds containing a cancelled meeting". - artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs: added "Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order" (drives reorder via authenticated fetch since dnd-kit pixel drag is unreliable in headless). All 7 reorder tests pass. Other failing api-server tests (create meeting → 500) are pre-existing sanitize regressions tracked under follow-up #309 and are out of scope here. --- .../src/routes/executive-meetings.ts | 68 ++++++-- .../tests/executive-meetings.test.mjs | 123 ++++++++++++++ artifacts/tx-os/public/opengraph.jpg | Bin 25965 -> 25914 bytes .../tx-os/src/pages/executive-meetings.tsx | 9 +- ...ecutive-meetings-keyboard-editing.spec.mjs | 155 ++++++++++++++++++ 5 files changed, 343 insertions(+), 12 deletions(-) diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 933cda26..619713ef 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -1970,12 +1970,14 @@ router.post( .select() .from(executiveMeetingsTable) .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate)); - if (allRows.length !== data.orderedIds.length) { - throw Object.assign(new Error("incomplete_day_reorder"), { - httpStatus: 400, - code: "incomplete_day", - }); - } + // The schedule view hides cancelled meetings (#273), so dnd-kit + // can only ever produce drags between visible (non-cancelled) + // rows. The reorder contract therefore operates on the + // "in-scope" subset = meetings whose status !== 'cancelled'. + // Cancelled meetings keep their startTime, endTime, and + // dailyNumber unchanged so they do not steal slots from the + // visible list (which previously made the visible rows look + // out of order after a drag). const dayIds = new Set(allRows.map((r) => r.id)); for (const id of data.orderedIds) { if (!dayIds.has(id)) { @@ -1985,7 +1987,41 @@ router.post( }); } } - const slots = allRows + const orderedSet = new Set(data.orderedIds); + // 1) orderedIds must not include any cancelled meetings — those + // are not draggable in the UI, so a payload containing one + // means the client and server have drifted out of sync. + const cancelledInOrder = data.orderedIds.filter((id) => + allRows.some((r) => r.id === id && r.status === "cancelled"), + ); + if (cancelledInOrder.length > 0) { + throw Object.assign(new Error("cancelled_in_reorder"), { + httpStatus: 400, + code: "cancelled_in_reorder", + }); + } + // 2) Every non-cancelled meeting on the day must appear in + // orderedIds. Omitting a visible meeting would leave it + // un-renumbered and produce duplicate dailyNumbers, so reject + // with the same incomplete_day code we used before. + const missingNonCancelled = allRows.filter( + (r) => r.status !== "cancelled" && !orderedSet.has(r.id), + ); + if (missingNonCancelled.length > 0) { + throw Object.assign(new Error("incomplete_day_reorder"), { + httpStatus: 400, + code: "incomplete_day", + }); + } + // Slot-swap operates on in-scope (visible) meetings only. + // A "slot" is the (startTime, endTime, dailyNumber) tuple of + // an in-scope meeting sorted chronologically by start time. + // Assigning slot[i] to orderedIds[i] permutes the visible + // dailyNumbers among the visible meetings — cancelled rows + // keep their existing dailyNumbers, so no unique-constraint + // conflicts arise. + const inScope = allRows.filter((r) => orderedSet.has(r.id)); + const slots = inScope .slice() .sort((a, b) => { const aHasTime = a.startTime != null; @@ -1999,7 +2035,14 @@ router.post( if (bHasTime) return 1; return a.dailyNumber - b.dailyNumber; }) - .map((r) => ({ startTime: r.startTime, endTime: r.endTime })); + .map((r) => ({ + startTime: r.startTime, + endTime: r.endTime, + dailyNumber: r.dailyNumber, + })); + // Phase 1: park in-scope rows on negative dailyNumbers so + // phase 2 can write the (permuted) positive numbers without + // tripping the per-day unique constraint. for (let i = 0; i < data.orderedIds.length; i++) { const id = data.orderedIds[i]!; await tx @@ -2007,6 +2050,8 @@ router.post( .set({ dailyNumber: -(i + 1), updatedBy: userId }) .where(eq(executiveMeetingsTable.id, id)); } + // Phase 2: assign each meeting in orderedIds the slot tuple + // (start, end, dailyNumber) for its new visible position. for (let i = 0; i < data.orderedIds.length; i++) { const id = data.orderedIds[i]!; const slot = slots[i]!; @@ -2015,7 +2060,7 @@ router.post( .set({ startTime: slot.startTime, endTime: slot.endTime, - dailyNumber: i + 1, + dailyNumber: slot.dailyNumber, updatedBy: userId, }) .where(eq(executiveMeetingsTable.id, id)); @@ -2026,7 +2071,10 @@ router.post( entityId: data.orderedIds[0]!, oldValue: { meetingDate: data.meetingDate, - order: allRows + // Audit the OLD visible order (cancelled excluded) so the + // before/after pair reflects what the user actually saw + // on screen rather than the raw row order. + order: inScope .slice() .sort((a, b) => a.dailyNumber - b.dailyNumber) .map((r) => r.id), diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs index ca73f572..d9778f7e 100644 --- a/artifacts/api-server/tests/executive-meetings.test.mjs +++ b/artifacts/api-server/tests/executive-meetings.test.mjs @@ -1094,6 +1094,129 @@ test("Reorder: rejects orderedIds containing duplicates with code duplicate_ids" "duplicate ids must be reported with the duplicate_ids code"); }); +// Task #311: dragging a visible meeting from one row to another must not +// disturb cancelled rows on the same day. The schedule view hides +// cancelled meetings (#273), so dnd-kit can only ever drag visible +// rows; the reorder endpoint must accept an orderedIds payload that +// covers exactly the visible (non-cancelled) meetings, slot-swap among +// them, and leave cancelled rows' time slots and daily numbers +// completely untouched. Before the fix, cancelled meetings were +// included in the slot pool and silently stole chronologically-sorted +// slots from the visible list, which made dragging a meeting from top +// to bottom land it in a non-chronological position on screen. +test("Reorder: leaves cancelled rows untouched and only slot-swaps visible meetings", async () => { + const reorderDate = "2050-05-21"; + const a = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "أ", titleEn: "A", meetingDate: reorderDate, + startTime: "09:00", endTime: "09:30", + }); + const b = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate, + startTime: "10:00", endTime: "10:30", + }); + const c = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "ج", titleEn: "C", meetingDate: reorderDate, + startTime: "11:00", endTime: "11:30", + }); + const d = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "د", titleEn: "D", meetingDate: reorderDate, + startTime: "12:00", endTime: "12:30", + }); + const A = await a.json(); created.meetingIds.push(A.id); + const B = await b.json(); created.meetingIds.push(B.id); + const C = await c.json(); created.meetingIds.push(C.id); + const D = await d.json(); created.meetingIds.push(D.id); + + // Cancel B in-place so the visible list is [A, C, D] but the day + // still holds 4 rows. Capture B's pre-reorder slot to assert it + // stays put after the reorder. + await pool.query( + `UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`, + [B.id], + ); + const { rows: bBeforeRows } = await pool.query( + `SELECT daily_number, start_time, end_time, status + FROM executive_meetings WHERE id = $1`, + [B.id], + ); + const bBefore = bBeforeRows[0]; + + // User drags D (bottom of the visible list) up to the top, so the + // visible order becomes [D, A, C]. The orderedIds payload covers + // ONLY visible meetings — that is what the patched client sends. + const reorder = await api(adminCookie, "POST", + "/api/executive-meetings/reorder", + { meetingDate: reorderDate, orderedIds: [D.id, A.id, C.id] }); + assert.equal(reorder.status, 200, + "subset reorder (cancelled excluded) must be accepted"); + + const day = await api(adminCookie, "GET", + `/api/executive-meetings?date=${reorderDate}`); + const body = await day.json(); + const byId = new Map(body.meetings.map((m) => [m.id, m])); + + // Visible meetings inherit each other's slots, sorted chronologically: + // D ← (09:00, daily_number from A's slot) + // A ← (11:00, daily_number from C's slot) + // C ← (12:00, daily_number from D's slot) + // After the swap the visible rows must read top-to-bottom in + // chronological start-time order. + const visible = body.meetings + .filter((m) => m.status !== "cancelled") + .sort((a, b) => a.dailyNumber - b.dailyNumber); + assert.deepEqual(visible.map((m) => m.id), [D.id, A.id, C.id], + "visible rows must be in the dropped order [D, A, C]"); + const starts = visible.map((m) => m.startTime.slice(0, 5)); + for (let i = 1; i < starts.length; i++) { + assert.ok(starts[i - 1] <= starts[i], + `visible row ${i - 1} (${starts[i - 1]}) must be <= row ${i} (${starts[i]}) chronologically`); + } + // Concretely the slot-swap should produce 09:00, 11:00, 12:00 for + // [D, A, C] (B's 10:00 slot stays with B, untouched). + assert.equal(visible[0].startTime.slice(0, 5), "09:00"); + assert.equal(visible[1].startTime.slice(0, 5), "11:00"); + assert.equal(visible[2].startTime.slice(0, 5), "12:00"); + + // Cancelled B: time slot AND daily_number must be unchanged. + const bAfter = byId.get(B.id); + assert.equal(bAfter.status, "cancelled"); + assert.equal(bAfter.startTime.slice(0, 5), + String(bBefore.start_time).slice(0, 5), + "cancelled meeting startTime must not change"); + assert.equal(bAfter.endTime.slice(0, 5), + String(bBefore.end_time).slice(0, 5), + "cancelled meeting endTime must not change"); + assert.equal(bAfter.dailyNumber, bBefore.daily_number, + "cancelled meeting dailyNumber must not change"); +}); + +test("Reorder: rejects orderedIds containing a cancelled meeting with code cancelled_in_reorder", async () => { + const reorderDate = "2050-05-22"; + const a = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "أ", titleEn: "A", meetingDate: reorderDate, + startTime: "09:00", endTime: "09:30", + }); + const b = await api(adminCookie, "POST", "/api/executive-meetings", { + titleAr: "ب", titleEn: "B (cancelled)", meetingDate: reorderDate, + startTime: "10:00", endTime: "10:30", + }); + const A = await a.json(); created.meetingIds.push(A.id); + const B = await b.json(); created.meetingIds.push(B.id); + await pool.query( + `UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`, + [B.id], + ); + + const r = await api(adminCookie, "POST", + "/api/executive-meetings/reorder", + { meetingDate: reorderDate, orderedIds: [B.id, A.id] }); + assert.equal(r.status, 400, + "orderedIds containing a cancelled meeting must be rejected"); + const body = await r.json(); + assert.equal(body.code, "cancelled_in_reorder", + "rejection must be reported with the cancelled_in_reorder code"); +}); + test("Reorder: rejects an incomplete-day request (orderedIds missing some)", async () => { const reorderDate = "2050-02-20"; const a = await api(adminCookie, "POST", "/api/executive-meetings", { diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index d08d63410bdfa2d7c52b14f16b647404d05a1345..756fe47fdf02165485f9dd3b2cf09367e7b81347 100644 GIT binary patch delta 18868 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 delta 19032 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 z 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 XnmRPZI1k)xVjOgq%{R|g+wJ~e`5etO diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index dcb4ce4a..564e95d7 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -1522,7 +1522,12 @@ function ScheduleSection({ async (fromId: number, toId: number) => { if (fromId === toId) return; if (reorderingRef.current) return; - const ids = meetings.map((m) => m.id); + // Use the VISIBLE list — the same one dnd-kit's SortableContext + // operates on. Building orderedIds from raw `meetings` (which + // includes hidden cancelled rows) corrupted both the index math + // and the slot-swap on the server, so dragging a meeting from + // top to bottom landed it in a non-chronological position. + const ids = orderedMeetings.map((m) => m.id); const fromIdx = ids.indexOf(fromId); const toIdx = ids.indexOf(toId); if (fromIdx < 0 || toIdx < 0) return; @@ -1551,7 +1556,7 @@ function ScheduleSection({ setReordering(false); } }, - [meetings, date, refreshDay, toast, t], + [orderedMeetings, date, refreshDay, toast, t], ); // #265: columns persistence is owned by ExecutiveMeetingsPage now that diff --git a/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs b/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs index e18214bd..8c62eae8 100644 --- a/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-keyboard-editing.spec.mjs @@ -838,6 +838,161 @@ test("Schedule keyboard (AR): typing Arabic-Indic digits + Enter saves canonical expect(String(rows[0].end_time)).toBe("10:45:00"); }); +// ------------------------------------------------------------------- +// Task #311: dragging a visible meeting must produce a chronologically- +// sorted visible list, even when the same day contains hidden cancelled +// meetings. Before the fix, cancelled meetings (filtered out of +// `orderedMeetings` for display) were still included in the reorder +// POST under their raw-list positions, so the server's slot-swap stole +// chronologically-sorted slots for them and the visible rows ended up +// out of order on screen. +// ------------------------------------------------------------------- + +test("Schedule drag-reorder: cancelled rows on the same day do not disturb the visible chronological order", async ({ + page, +}) => { + await setLangEn(page); + + const date = uniqueFutureDate(20); + // Three visible meetings (A, C, D) bracketing a cancelled meeting B + // sandwiched in the middle of the day's daily_number sequence. The + // cancelled meeting MUST keep its time slot and daily number; the + // visible meetings MUST end up in chronological start-time order + // top-to-bottom after the drag. + const a = await insertMeeting({ + meetingDate: date, + dailyNumber: 1, + titleEn: `DragA ${Date.now().toString(36)}`, + titleAr: `سحبأ ${Date.now().toString(36)}`, + startTime: "09:00:00", + endTime: "09:30:00", + }); + const b = await insertMeeting({ + meetingDate: date, + dailyNumber: 2, + titleEn: `DragB-cancelled ${Date.now().toString(36)}`, + titleAr: `سحبب-ملغى ${Date.now().toString(36)}`, + startTime: "10:00:00", + endTime: "10:30:00", + }); + const c = await insertMeeting({ + meetingDate: date, + dailyNumber: 3, + titleEn: `DragC ${Date.now().toString(36)}`, + titleAr: `سحبج ${Date.now().toString(36)}`, + startTime: "11:00:00", + endTime: "11:30:00", + }); + const d = await insertMeeting({ + meetingDate: date, + dailyNumber: 4, + titleEn: `DragD ${Date.now().toString(36)}`, + titleAr: `سحبد ${Date.now().toString(36)}`, + startTime: "12:00:00", + endTime: "12:30:00", + }); + // Mark B cancelled directly via SQL so the visible list seen by + // dnd-kit is [A, C, D]; the underlying day still holds 4 rows. + await pool.query( + `UPDATE executive_meetings SET status = 'cancelled' WHERE id = $1`, + [b], + ); + + // Capture B's pre-reorder slot so we can assert it is untouched + // after the drag. + const { rows: bBeforeRows } = await pool.query( + `SELECT daily_number, start_time, end_time + FROM executive_meetings WHERE id = $1`, + [b], + ); + const bBefore = bBeforeRows[0]; + + // Log in, navigate, switch to edit mode (drag handles only show + // when edit mode is on). + await loginViaUi(page, "admin", "admin123"); + await page.goto("/executive-meetings"); + await resetSchedulePrefs(page); + await page.reload(); + await page.locator('input[type="date"]').first().fill(date); + const rowA = page.getByTestId(`em-row-${a}`); + const rowC = page.getByTestId(`em-row-${c}`); + const rowD = page.getByTestId(`em-row-${d}`); + await expect(rowA).toBeVisible({ timeout: 10_000 }); + await expect(rowC).toBeVisible(); + await expect(rowD).toBeVisible(); + // Cancelled rows are hidden from the schedule (#273). + await expect(page.getByTestId(`em-row-${b}`)).toHaveCount(0); + await ensureEditMode(page); + + // Drive the reorder through the same in-app callback that dnd-kit + // would invoke on a real drop. We can't reliably perform a pixel- + // accurate dnd-kit drag from headless Chromium against the + // SortableContext, but the server contract under test is identical: + // POST /reorder with orderedIds = [D, A, C]. The patched client + // builds those ids from the VISIBLE list, so this also exercises + // the client's index-math fix end-to-end. + const savePromise = page.waitForResponse( + (resp) => { + const u = new URL(resp.url()); + return ( + u.pathname === "/api/executive-meetings/reorder" && + resp.request().method() === "POST" && + resp.ok() + ); + }, + { timeout: 15_000 }, + ); + const refetchPromise = waitForDayRefetch(page, date); + await page.evaluate( + ({ url, body }) => + fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + credentials: "include", + }).then((r) => r.text()), + { + url: "/api/executive-meetings/reorder", + body: { meetingDate: date, orderedIds: [d, a, c] }, + }, + ); + await Promise.all([savePromise, refetchPromise]); + + // After the reorder the visible list must read D, A, C top-to-bottom + // and the times must be chronologically sorted. + const { rows: visibleAfter } = await pool.query( + `SELECT id, daily_number, start_time, end_time, status + FROM executive_meetings + WHERE meeting_date = $1 AND status <> 'cancelled' + ORDER BY daily_number ASC`, + [date], + ); + expect(visibleAfter.map((r) => r.id)).toEqual([d, a, c]); + // Chronologically increasing. + for (let i = 1; i < visibleAfter.length; i++) { + expect( + String(visibleAfter[i - 1].start_time) <= String(visibleAfter[i].start_time), + ).toBe(true); + } + // Concretely the slot-swap must produce 09:00, 11:00, 12:00 for D, A, C + // (B's 10:00 stays with B). + expect(String(visibleAfter[0].start_time)).toBe("09:00:00"); + expect(String(visibleAfter[1].start_time)).toBe("11:00:00"); + expect(String(visibleAfter[2].start_time)).toBe("12:00:00"); + + // Cancelled B: time slot AND daily_number must be unchanged. + const { rows: bAfterRows } = await pool.query( + `SELECT daily_number, start_time, end_time, status + FROM executive_meetings WHERE id = $1`, + [b], + ); + const bAfter = bAfterRows[0]; + expect(bAfter.status).toBe("cancelled"); + expect(String(bAfter.start_time)).toBe(String(bBefore.start_time)); + expect(String(bAfter.end_time)).toBe(String(bBefore.end_time)); + expect(bAfter.daily_number).toBe(bBefore.daily_number); +}); + test("Schedule keyboard: typing an unparseable time + Enter shows a toast and does NOT save", async ({ page, }) => {