#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:
@@ -1322,7 +1322,8 @@
|
||||
"apply": "تطبيق",
|
||||
"duplicate": "تكرار",
|
||||
"replace_attendees": "تحديث الحضور",
|
||||
"done": "تم"
|
||||
"done": "تم",
|
||||
"meeting_cascade_shift": "تأجيل تلقائي بالتسلسل"
|
||||
}
|
||||
},
|
||||
"pdf": {
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user