#302 cascade-shift later meetings on postpone/reschedule (review fixes)

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.
This commit is contained in:
Riyadh
2026-05-01 21:16:15 +00:00
parent 7f63cf0b2b
commit 8913b87f66
3 changed files with 153 additions and 2 deletions
+2 -1
View File
@@ -1322,7 +1322,8 @@
"apply": "تطبيق",
"duplicate": "تكرار",
"replace_attendees": "تحديث الحضور",
"done": "تم"
"done": "تم",
"meeting_cascade_shift": "تأجيل تلقائي بالتسلسل"
}
},
"pdf": {
+2 -1
View File
@@ -1215,7 +1215,8 @@
"apply": "Apply",
"duplicate": "Duplicate",
"replace_attendees": "Update attendees",
"done": "Done"
"done": "Done",
"meeting_cascade_shift": "Auto-shifted by cascade"
}
},
"pdf": {
@@ -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`,
);
});