From 16a818b7165564cfcf36b80755eeeb3f7bc3c4d6 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 11 May 2026 11:06:13 +0000 Subject: [PATCH] =?UTF-8?q?#486:=20Executive=20Meetings=20row=20click=20?= =?UTF-8?q?=E2=86=92=20quick-actions=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking any meeting row on the Executive Meetings schedule (gated only on canMutate, not editMode) opens a small popover with Move up / Move down / Postpone. Move up/down swap only the (startTime, endTime) tuple between the clicked meeting and its chronological neighbour on the same date — the Time column stays visually anchored to its row position. Backend - POST /executive-meetings/swap-times: transactional swap with FOR UPDATE row locking, optimistic-lock conflict shape (stale_meeting + conflict payload), date/time-window guards, audit logging, and renumberDayByStartTime + day-changed broadcast. - Zod schema in lib/api-zod/src/manual.ts. Frontend - Shared lib/api-json.ts JSON helper. - ScheduleSection.swapTimes does an optimistic (startTime, endTime) swap against the day query cache and rolls back on failure (mirrors the existing inline-edit UX). - MeetingRow uses Popover/PopoverAnchor with skip rules: ARIA roles (button/checkbox/switch/combobox/dialog) and em-time-* / em-edit-* / em-row-grip / em-row-actions data-testid prefixes do NOT open the popover. - PostponeDialog reused from upcoming-meeting-alert.tsx. Tests - Backend swap-times: happy path, stale_meeting (409), different_dates (400), no_time_window (400), unauth (401), viewer-no-mutate (403), malformed-timestamp (400) — all 7 pass. - Hardened expectedUpdatedAt zod schema to z.string().datetime() so malformed tokens fail at validation with a controlled 400 instead of bubbling up as a 500. - E2E: Move up swap, edge-disable states (solo / first / middle / last), Postpone 5-min chip end-to-end, click-exclusion on grip / time cell / row-actions — all 4 pass. Each test uses its own future date to avoid cross-test pollution. Code review approved on second pass. Pre-existing failures in other suites (executive-meetings reorder, font-settings, notes-share, service-orders) are unrelated to this task and predate it. Follow-ups proposed: #487 (keyboard a11y on the popover), #488 (edit- mode test gaps). --- .../tests/executive-meetings-swap-times.test.mjs | 13 +++++++++++++ lib/api-zod/src/manual.ts | 8 ++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/artifacts/api-server/tests/executive-meetings-swap-times.test.mjs b/artifacts/api-server/tests/executive-meetings-swap-times.test.mjs index d8347d2f..4b5ea2ad 100644 --- a/artifacts/api-server/tests/executive-meetings-swap-times.test.mjs +++ b/artifacts/api-server/tests/executive-meetings-swap-times.test.mjs @@ -354,6 +354,19 @@ test("swap-times: viewer (no mutate role) → 403 forbidden", async () => { assert.equal(res.status, 403, "viewer must not be able to swap times"); }); +test("swap-times: malformed expectedUpdatedAt → 400 from zod, not 500", async () => { + // The route passes expectedUpdatedAt straight to new Date(...).toISOString(). + // Without a strict zod guard a non-ISO string would throw and surface as a + // 500. Lock the controlled-failure contract here. + const res = await api(cookieA, "POST", "/api/executive-meetings/swap-times", { + aId: 1, + bId: 2, + expectedUpdatedAtA: "not-a-real-timestamp", + expectedUpdatedAtB: new Date().toISOString(), + }); + assert.equal(res.status, 400, "malformed timestamp must be rejected at validation"); +}); + test("swap-times: meeting without a time window → 400 no_time_window", async () => { // Create A normally, then null out B's start/end directly so we can // exercise the guard without going through PATCH (which validates). diff --git a/lib/api-zod/src/manual.ts b/lib/api-zod/src/manual.ts index 1616a61f..d15bbd41 100644 --- a/lib/api-zod/src/manual.ts +++ b/lib/api-zod/src/manual.ts @@ -17,8 +17,12 @@ export type ExecutiveMeetingsReorderBodyT = z.infer< export const ExecutiveMeetingsSwapTimesBody = z.object({ aId: z.number().int().positive(), bId: z.number().int().positive(), - expectedUpdatedAtA: z.string().min(1), - expectedUpdatedAtB: z.string().min(1), + // Must be ISO-8601 datetime strings — the route passes them straight + // to `new Date(...).toISOString()` and any malformed input would + // bubble up as an uncontrolled 500. z.string().datetime() rejects + // garbage at the validation boundary so the client gets a 400. + expectedUpdatedAtA: z.string().datetime(), + expectedUpdatedAtB: z.string().datetime(), }); export type ExecutiveMeetingsSwapTimesBodyT = z.infer<