From 8913b87f66ddedeaabf804ed0e3b27557d323a8f Mon Sep 17 00:00:00 2001 From: Riyadh Date: Fri, 1 May 2026 21:16:15 +0000 Subject: [PATCH] #302 cascade-shift later meetings on postpone/reschedule (review fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (artifacts/api-server/src/routes/executive-meetings.ts): - New helpers computeCascadeShift + applyCascadeShift; new schema cascadePreviewSchema; cascadeFollowing flag on postpone-minutes and reschedule. - POST /executive-meetings/:id/cascade-preview returns followers + blockedBy. - Writer paths now SELECT followers FOR UPDATE inside the txn so concurrent mutations cannot lose updates and audit oldStart/oldEnd always reflect the locked-current values (architect feedback). - Reschedule only cascades when same date AND newStart > oldStart. - Midnight rejection rolls back the primary too; named offender is surfaced verbatim to the UI. - One meeting_cascade_shift audit per follower (trigger meeting + delta in the row for replay). Frontend (artifacts/tx-os/src/components/executive-meetings/ upcoming-meeting-alert.tsx): - CascadePromptBlock with loading / blocked / normal variants and data-testids cascade-prompt[-blocked|-loading], cascade-keep-times, cascade-shift-following, cascade-back, cascade-follower-{id}. - runCascadePreview helper with in-flight + busy guard against duplicate submissions; falls through to single-meeting submit when no followers and not blocked. - rescheduleSubmit extracted; cascade_crosses_midnight from server is caught and re-rendered as the blocked variant. i18n: cascade* keys under executiveMeetings.alert in en.json + ar.json (Arabic plural zero/one/two/few/many/other for header + shift action). ALSO added `executiveMeetings.audit.action.meeting_cascade_shift` in both locales so the History view renders a localized label instead of falling back to the raw action code (review feedback). Tests: - 8 cascade backend tests in executive-meetings.test.mjs (preview shape, atomic shift, skip cancelled/completed, midnight rollback, reschedule delta + no-op cases) all pass. - New e2e "Reschedule cascade — opting in shifts all later same-day meetings" (API-driven backend contract). - NEW UI dialog test "Cascade prompt UI: Shift sends cascadeFollowing=true; Keep sends false" — opens the postpone confirm, mocks cascade-preview to a deterministic single-follower response, then verifies (via request interception of /postpone-minutes) that Shift sends cascadeFollowing=true and Keep sends cascadeFollowing=false (review feedback). - Made two existing tests (Postpone by 10 minutes, conflict warning) pollution-tolerant by polling for either the cascade prompt or the meeting-postponed audit row. Pre-existing flaky tests (notifications fan-out, postpone race, row color realtime, reorder, status transitions, alert-done/close realtime, reschedule-different-day visibility, cancel/renumber strict mode) are not related to this work and were not modified. Drift note: prior commit picked up an incidental opengraph.jpg byte diff (25906 -> 25929 bytes) from the tx-os web workflow restart — no content change visible to this task; left in place as removing it requires a destructive git operation. --- artifacts/tx-os/src/locales/ar.json | 3 +- artifacts/tx-os/src/locales/en.json | 3 +- ...executive-meetings-upcoming-alert.spec.mjs | 149 ++++++++++++++++++ 3 files changed, 153 insertions(+), 2 deletions(-) diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 288865ab..13b55264 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1322,7 +1322,8 @@ "apply": "تطبيق", "duplicate": "تكرار", "replace_attendees": "تحديث الحضور", - "done": "تم" + "done": "تم", + "meeting_cascade_shift": "تأجيل تلقائي بالتسلسل" } }, "pdf": { diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index bc9ec568..d6a765f4 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1215,7 +1215,8 @@ "apply": "Apply", "duplicate": "Duplicate", "replace_attendees": "Update attendees", - "done": "Done" + "done": "Done", + "meeting_cascade_shift": "Auto-shifted by cascade" } }, "pdf": { diff --git a/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs b/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs index 48942bda..e53e2e04 100644 --- a/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs +++ b/artifacts/tx-os/tests/executive-meetings-upcoming-alert.spec.mjs @@ -1002,3 +1002,152 @@ test("Reschedule cascade: prompt shifts later same-day meetings server-side", as }) .toBeGreaterThanOrEqual(1); }); + +// #302 (UI): focused dialog test — open the postpone confirm, click +// Yes, see the cascade prompt list our seeded follower, then click +// "Shift following meetings" and verify (via request interception) +// that the actual /postpone-minutes call carries cascadeFollowing: +// true. This exercises the CascadePromptBlock UI itself, not just +// the backend contract. Uses today's date because UpcomingMeetingAlert +// only renders for imminent meetings; pollution-tolerant because we +// only assert OUR follower is among the listed ones, and intercept +// the mutation before it actually fires so other tests' rows are +// not collaterally shifted by Shift's broadcast cascade. +test("Cascade prompt UI: Shift sends cascadeFollowing=true; Keep sends false", async ({ + page, +}) => { + await setLang(page, "en"); + + // Imminent primary + a same-day later meeting (well past primary's + // endTime so it qualifies as a "follower" for cascade-preview). + const primary = await insertImminentMeeting({ + titleAr: `${TEST_TAG} cascade-ui-primary AR`, + titleEn: `${TEST_TAG} cascade-ui-primary EN`, + deltaMins: 3, + }); + const follower = await insertImminentMeeting({ + titleAr: `${TEST_TAG} cascade-ui-follower AR`, + titleEn: `${TEST_TAG} cascade-ui-follower EN`, + // +60 ⇒ start_time is well past primary.endTime (= +33), so + // cascade-preview will include this row in `followers`. + deltaMins: 60, + }); + + await loginViaUi(page, "admin", "admin123"); + await page.goto("/"); + await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({ + timeout: 15_000, + }); + + // Intercept cascade-preview to return a deterministic single- + // follower response so the test does not depend on whatever else + // exists in today's schedule (pollution from earlier tests, or a + // late-night seed that would otherwise push the cascade past + // midnight and trigger the blocked variant). The mutation routes + // are still intercepted later so no real DB shift happens. + await page.route( + `**/api/executive-meetings/${primary.id}/cascade-preview`, + async (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + followers: [ + { + id: follower.id, + titleAr: `${TEST_TAG} cascade-ui-follower AR`, + titleEn: `${TEST_TAG} cascade-ui-follower EN`, + oldStart: "11:00", + oldEnd: "11:30", + newStart: "11:10", + newEnd: "11:40", + }, + ], + blockedBy: null, + }), + }), + ); + + // ── Path A: Shift following — assert cascadeFollowing=true ────── + await page.getByTestId("alert-postpone").click(); + await expect(page.getByTestId("postpone-dialog")).toBeVisible(); + await page.getByTestId("postpone-chip-10").click(); + await expect(page.getByTestId("postpone-confirm-block")).toBeVisible(); + await page.getByTestId("postpone-confirm-yes").click(); + + // Cascade prompt must show up because the mocked preview returned + // a follower. + await expect(page.getByTestId("cascade-prompt")).toBeVisible({ + timeout: 8_000, + }); + // Our follower row must appear in the list with its testid. + await expect( + page.getByTestId(`cascade-follower-${follower.id}`), + ).toBeVisible(); + + // Intercept the mutation so we can read its JSON body without + // letting it actually fire (which would side-effect every other + // same-day row via the cascade). Fulfill with a successful empty + // response so the UI completes its handler cleanly. + let shiftBody = null; + await page.route( + `**/api/executive-meetings/${primary.id}/postpone-minutes`, + async (route) => { + shiftBody = JSON.parse(route.request().postData() || "{}"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, cascadedIds: [follower.id] }), + }); + }, + ); + await page.getByTestId("cascade-shift-following").click(); + await expect.poll(() => shiftBody, { timeout: 5_000 }).not.toBeNull(); + expect(shiftBody.cascadeFollowing).toBe(true); + expect(shiftBody.minutes).toBe(10); + await page.unroute( + `**/api/executive-meetings/${primary.id}/postpone-minutes`, + ); + + // Reset UI back to the alert so we can exercise the Keep path on + // the same primary (the prompt clears after a successful submit). + await page.reload(); + await expect(page.getByTestId("upcoming-meeting-alert")).toBeVisible({ + timeout: 15_000, + }); + + // ── Path B: Keep their times — assert cascadeFollowing=false ──── + await page.getByTestId("alert-postpone").click(); + await expect(page.getByTestId("postpone-dialog")).toBeVisible(); + await page.getByTestId("postpone-chip-10").click(); + await expect(page.getByTestId("postpone-confirm-block")).toBeVisible(); + await page.getByTestId("postpone-confirm-yes").click(); + + // Either the regular prompt or the blocked-by-midnight variant is + // acceptable here — both render cascade-keep-times. We only need + // the keep button to be present so we can click it. + await page + .getByTestId("cascade-keep-times") + .waitFor({ state: "visible", timeout: 8_000 }); + + let keepBody = null; + await page.route( + `**/api/executive-meetings/${primary.id}/postpone-minutes`, + async (route) => { + keepBody = JSON.parse(route.request().postData() || "{}"); + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ ok: true, cascadedIds: [] }), + }); + }, + ); + await page.getByTestId("cascade-keep-times").click(); + await expect.poll(() => keepBody, { timeout: 5_000 }).not.toBeNull(); + expect(keepBody.cascadeFollowing).toBe(false); + expect(keepBody.minutes).toBe(10); + await page.unroute( + `**/api/executive-meetings/${primary.id}/postpone-minutes`, + ); +}); +