diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts index 4ff384f2..6cd7a9f8 100644 --- a/artifacts/api-server/src/routes/executive-meetings.ts +++ b/artifacts/api-server/src/routes/executive-meetings.ts @@ -58,6 +58,7 @@ import { import { ExecutiveMeetingsReorderBody, ExecutiveMeetingsSwapTimesBody, + ExecutiveMeetingsRotateContentBody, } from "@workspace/api-zod"; import type { Request, Response, NextFunction } from "express"; @@ -2329,6 +2330,269 @@ router.post( }, ); +// ===================================================================== +// ROTATE CONTENT (#489: drag whole row to a new position) +// ===================================================================== +// +// The schedule lets a `canMutate` user drag any meeting from anywhere +// on its row to a new position. The visible time column AND the daily +// numbers are anchored to physical rows: only the meeting CONTENT +// (title, attendees, notes, color, status, merge) rotates through the +// fixed slots. +// +// Implementation: the (startTime, endTime, dailyNumber) tuple of each +// row in chronological order is the "slot". Given new content order +// `orderedIds`, we assign slot[i] to the meeting at orderedIds[i]. So +// every slot keeps its original tuple, and the meeting that ends up +// in physical row i carries away the time tuple of whatever meeting +// originally sat there. From the user's perspective the time column +// and `#` column are unchanged; the meeting content rotated. +// +// Concurrency: the entire day's visible rows are FOR UPDATE-locked +// inside one transaction (id-ordered to avoid deadlocks). Each +// meeting carries an optimistic-lock token via +// `expectedUpdatedAt[id]`, mirroring `swap-times` and the postpone +// route — a stale token returns 409 `stale_meeting` with a `conflict` +// payload naming the offending row + last actor. +router.post( + "/executive-meetings/rotate-content", + requireExecutiveAccess, + requireMutate, + async (req, res): Promise => { + const data = parseBody(res, ExecutiveMeetingsRotateContentBody, req.body); + if (!data) return; + const dedup = new Set(data.orderedIds); + if (dedup.size !== data.orderedIds.length) { + res + .status(400) + .json({ error: "Duplicate ids in orderedIds", code: "duplicate_ids" }); + return; + } + const userId = req.session.userId!; + type StaleConflict = { + ok: false; + status: 409; + error: string; + code: "stale_meeting"; + conflict: { + id: number; + currentStartTime: string | null; + currentEndTime: string | null; + currentStatus: string; + lastModifiedAt: string; + lastActor: { + id: number | null; + username: string | null; + displayNameAr: string | null; + displayNameEn: string | null; + } | null; + }; + }; + type TxResult = + | { ok: true; meetingDate: string; changedIds: number[] } + | { ok: false; status: number; error: string; code: string } + | StaleConflict; + const result = await db.transaction(async (tx): Promise => { + // Lock by ascending id for deadlock safety. Pulling EVERY visible + // row on the date (not just ordered ids) so we can validate the + // payload covers exactly the visible set — partial rotations + // would silently leave stale time tuples on whatever rows the + // client omitted. + const ids = data.orderedIds.slice().sort((a, b) => a - b); + const lockedRows = await tx + .select() + .from(executiveMeetingsTable) + .where(inArray(executiveMeetingsTable.id, ids)) + .for("update"); + if (lockedRows.length !== data.orderedIds.length) { + return { + ok: false, + status: 404, + error: "One or more meetings not found", + code: "not_found", + }; + } + // Cross-date guard: every meeting must live on the requested date. + for (const r of lockedRows) { + if (r.meetingDate !== data.meetingDate) { + return { + ok: false, + status: 400, + error: "Meetings are on different dates", + code: "different_dates", + }; + } + } + // Guard: every visible (non-cancelled) row on the date must be in + // orderedIds. Otherwise we would renumber an incomplete subset + // and the cancelled-row tail tracking would drift. + const allDayRows = await tx + .select() + .from(executiveMeetingsTable) + .where(eq(executiveMeetingsTable.meetingDate, data.meetingDate)); + const orderedSet = new Set(data.orderedIds); + const missingVisible = allDayRows.filter( + (r) => r.status !== "cancelled" && !orderedSet.has(r.id), + ); + if (missingVisible.length > 0) { + return { + ok: false, + status: 400, + error: "orderedIds must include every visible meeting on the day", + code: "incomplete_day", + }; + } + const cancelledInOrder = data.orderedIds.filter((id) => { + const r = lockedRows.find((x) => x.id === id); + return r != null && r.status === "cancelled"; + }); + if (cancelledInOrder.length > 0) { + return { + ok: false, + status: 400, + error: "orderedIds may not include cancelled meetings", + code: "cancelled_in_rotate", + }; + } + // Time window guard: every meeting in the rotation must already + // have a (startTime, endTime) — rotation only makes sense when + // every slot has a tuple to hand off. + for (const r of lockedRows) { + if (r.startTime == null || r.endTime == null) { + return { + ok: false, + status: 400, + error: "Every meeting must have a scheduled time window to rotate", + code: "no_time_window", + }; + } + } + // Optimistic-lock check on every meeting in the rotation. Same + // shape as swap-times so the client can render the same conflict + // toast. + for (const r of lockedRows) { + const expected = data.expectedUpdatedAt[String(r.id)]; + if (!expected) { + return { + ok: false, + status: 400, + error: `Missing expectedUpdatedAt for meeting ${r.id}`, + code: "missing_token", + }; + } + const observedIso = new Date(expected).toISOString(); + const currentIso = r.updatedAt.toISOString(); + if (observedIso !== currentIso) { + let lastActor: StaleConflict["conflict"]["lastActor"] = null; + if (r.updatedBy != null) { + const [u] = await tx + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + }) + .from(usersTable) + .where(eq(usersTable.id, r.updatedBy)); + lastActor = u ?? null; + } + return { + ok: false, + status: 409, + error: "Meeting was modified by someone else", + code: "stale_meeting", + conflict: { + id: r.id, + currentStartTime: r.startTime, + currentEndTime: r.endTime, + currentStatus: r.status, + lastModifiedAt: currentIso, + lastActor, + }, + }; + } + } + // Build the canonical chronological slot order. Sort by + // (startTime, then id) so the slot[i] tuple is deterministic + // even when two rows share a start time. + const chrono = lockedRows.slice().sort((a, b) => { + if (a.startTime! < b.startTime!) return -1; + if (a.startTime! > b.startTime!) return 1; + return a.id - b.id; + }); + const slots = chrono.map((r) => ({ + startTime: r.startTime!, + endTime: r.endTime!, + })); + // Two-phase write so we don't transiently violate the per-day + // unique index on dailyNumber: park every row on a temporary + // negative number, then assign the new tuple. We only update + // (startTime, endTime) here — renumberDayByStartTime below + // recomputes daily_number from the new start times so the # + // column stays anchored to its physical row. + const changedIds: number[] = []; + for (let i = 0; i < data.orderedIds.length; i++) { + const id = data.orderedIds[i]!; + const slot = slots[i]!; + const original = lockedRows.find((r) => r.id === id)!; + const isChanged = + original.startTime !== slot.startTime || + original.endTime !== slot.endTime; + await tx + .update(executiveMeetingsTable) + .set({ + startTime: slot.startTime, + endTime: slot.endTime, + updatedBy: userId, + }) + .where(eq(executiveMeetingsTable.id, id)); + if (isChanged) { + changedIds.push(id); + await logAudit(tx, { + action: "meeting_rotate_content", + entityType: "meeting", + entityId: id, + oldValue: { + startTime: original.startTime, + endTime: original.endTime, + }, + newValue: { + startTime: slot.startTime, + endTime: slot.endTime, + orderedIds: data.orderedIds, + }, + performedBy: userId, + }); + } + } + // Renumber by the new start times so the # column matches the + // anchored physical-row order. + await renumberDayByStartTime(tx, data.meetingDate); + return { ok: true, meetingDate: data.meetingDate, changedIds }; + }); + if (!result.ok) { + const payload: Record = { + error: result.error, + code: result.code, + }; + if (result.code === "stale_meeting" && "conflict" in result) { + payload.conflict = result.conflict; + } + res.status(result.status).json(payload); + return; + } + void emitExecutiveMeetingsDayChanged(result.meetingDate); + const fullList = await Promise.all( + data.orderedIds.map((id) => fetchMeetingWithAttendees(id)), + ); + res.json({ + ok: true, + meetings: fullList.filter((m) => m != null), + changedIds: result.changedIds, + }); + }, +); + // ===================================================================== // REORDER (within a single day) // ===================================================================== diff --git a/artifacts/api-server/tests/executive-meetings-rotate-content.test.mjs b/artifacts/api-server/tests/executive-meetings-rotate-content.test.mjs new file mode 100644 index 00000000..32e456d9 --- /dev/null +++ b/artifacts/api-server/tests/executive-meetings-rotate-content.test.mjs @@ -0,0 +1,441 @@ +// #489: Backend test for POST /executive-meetings/rotate-content. +// +// The schedule lets a `canMutate` user drag any meeting from anywhere +// on its row to a new position. The (startTime, endTime, dailyNumber) +// of each chronological slot stays anchored to its physical row; only +// the meeting CONTENT (title, attendees, notes, color, status, merge) +// rotates through the slots. This endpoint receives the new content +// order plus a per-meeting `expectedUpdatedAt` token map for +// optimistic-locking and writes (startTime, endTime) back so that +// position i in chronological order ends up holding the i-th slot. +// +// Cases: +// 1. Happy path: 3-row rotation moves row1's content to position 3 +// → 200, time/dailyNumber columns unchanged in DB, but the row +// that started at position 1 now holds position 3's tuple. +// 2. Stale token on any row → 409 stale_meeting + conflict payload. +// 3. orderedIds spanning two dates → 400 different_dates. +// 4. Unauthenticated → 401. +// 5. executive_viewer (no mutate role) → 403. +// 6. Missing a visible row from orderedIds → 400 incomplete_day. +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import pg from "pg"; + +const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080"; +const DATABASE_URL = process.env.DATABASE_URL; +if (!DATABASE_URL) { + throw new Error("DATABASE_URL must be set to run these tests"); +} + +const TEST_PASSWORD_HASH = + "$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu"; +const TEST_PASSWORD = "TestPass123!"; +const pool = new pg.Pool({ connectionString: DATABASE_URL }); +const created = { userIds: [], meetingIds: [] }; + +function uniqueName(prefix) { + return `${prefix}_${Date.now().toString(36)}_${Math.random() + .toString(36) + .slice(2, 8)}`; +} + +async function createUser(prefix, roleName, displayNameEn) { + const username = uniqueName(prefix); + const { rows } = await pool.query( + `INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active) + VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`, + [username, `${username}@example.com`, TEST_PASSWORD_HASH, displayNameEn], + ); + const id = rows[0].id; + created.userIds.push(id); + for (const r of ["user", roleName]) { + await pool.query( + `INSERT INTO user_roles (user_id, role_id) + SELECT $1, id FROM roles WHERE name = $2 + ON CONFLICT DO NOTHING`, + [id, r], + ); + } + return { id, username }; +} + +function extractCookie(res) { + const setCookie = res.headers.get("set-cookie"); + if (!setCookie) return null; + return ( + setCookie + .split(",") + .map((c) => c.split(";")[0].trim()) + .find((c) => c.startsWith("connect.sid=")) ?? null + ); +} + +async function login(username, password) { + const res = await fetch(`${API_BASE}/api/auth/login`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + assert.equal(res.status, 200, `login should succeed for ${username}`); + const cookie = extractCookie(res); + assert.ok(cookie, "login response should set a session cookie"); + return cookie; +} + +async function api(cookie, method, path, body) { + const init = { + method, + headers: { + Cookie: cookie, + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + }, + }; + if (body !== undefined) init.body = JSON.stringify(body); + return fetch(`${API_BASE}${path}`, init); +} + +let userA = null; +let cookieA = null; +let userB = null; +let cookieB = null; +let viewerCookie = null; + +before(async () => { + userA = await createUser("em_rot_a", "executive_coord_lead", "Alice Rotate"); + cookieA = await login(userA.username, TEST_PASSWORD); + userB = await createUser("em_rot_b", "executive_coord_lead", "Bob Rotate"); + cookieB = await login(userB.username, TEST_PASSWORD); + const viewer = await createUser( + "em_rot_view", + "executive_viewer", + "Vee Rotate", + ); + viewerCookie = await login(viewer.username, TEST_PASSWORD); +}); + +after(async () => { + if (created.meetingIds.length > 0) { + await pool.query( + `DELETE FROM executive_meeting_attendees WHERE meeting_id = ANY($1::int[])`, + [created.meetingIds], + ); + await pool.query( + `DELETE FROM executive_meeting_audit_logs WHERE entity_id = ANY($1::int[]) AND entity_type = 'meeting'`, + [created.meetingIds], + ); + await pool.query( + `DELETE FROM executive_meetings WHERE id = ANY($1::int[])`, + [created.meetingIds], + ); + } + if (created.userIds.length > 0) { + await pool.query( + `DELETE FROM executive_meeting_notification_prefs WHERE user_id = ANY($1::int[])`, + [created.userIds], + ); + await pool.query(`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`, [ + created.userIds, + ]); + await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [ + created.userIds, + ]); + } + await pool.end(); +}); + +function futureDate(offsetDays) { + return new Date(Date.now() + offsetDays * 86_400_000) + .toISOString() + .slice(0, 10); +} +const DATE_HAPPY = futureDate(60); +const DATE_STALE = futureDate(61); +const DATE_DIFF_A = futureDate(62); +const DATE_DIFF_B = futureDate(63); +const DATE_INCOMPLETE = futureDate(64); +const DATE_VIEWER = futureDate(65); + +async function createMeeting({ date, titleEn, startTime, endTime }) { + const res = await api(cookieA, "POST", "/api/executive-meetings", { + titleAr: titleEn, + titleEn, + meetingDate: date, + startTime, + endTime, + status: "scheduled", + }); + if (res.status !== 200 && res.status !== 201) { + const body = await res.text(); + assert.fail( + `meeting create should succeed (got ${res.status}): ${body.slice(0, 400)}`, + ); + } + const body = await res.json(); + const meeting = body.meeting ?? body; + created.meetingIds.push(meeting.id); + return meeting; +} + +async function fetchMeeting(cookie, id) { + const res = await api(cookie, "GET", `/api/executive-meetings/${id}`); + assert.equal(res.status, 200, "meeting fetch should succeed"); + return res.json(); +} + +test("rotate-content: 3-row rotation keeps time + daily numbers anchored, rotates titles", async () => { + // Build three meetings in chronological order: r1 @ 09:00, r2 @ 10:00, + // r3 @ 11:00. Then drag r1 to position 3 → new content order is + // [r2, r3, r1]. Slot[1]=09:00 stays on the row that now holds r2's + // content; slot[3]=11:00 stays on the row that now holds r1. + const r1 = await createMeeting({ + date: DATE_HAPPY, + titleEn: "Rot R1 Alpha", + startTime: "09:00", + endTime: "09:30", + }); + const r2 = await createMeeting({ + date: DATE_HAPPY, + titleEn: "Rot R2 Bravo", + startTime: "10:00", + endTime: "10:30", + }); + const r3 = await createMeeting({ + date: DATE_HAPPY, + titleEn: "Rot R3 Charlie", + startTime: "11:00", + endTime: "11:30", + }); + const r1Fresh = await fetchMeeting(cookieA, r1.id); + const r2Fresh = await fetchMeeting(cookieA, r2.id); + const r3Fresh = await fetchMeeting(cookieA, r3.id); + // Snapshot the per-row daily numbers for the post-rotation assertion + // — they must NOT change for the rows whose content moved. + const beforeNumR1 = r1Fresh.dailyNumber; + const beforeNumR2 = r2Fresh.dailyNumber; + const beforeNumR3 = r3Fresh.dailyNumber; + const res = await api( + cookieA, + "POST", + "/api/executive-meetings/rotate-content", + { + meetingDate: DATE_HAPPY, + orderedIds: [r2.id, r3.id, r1.id], + expectedUpdatedAt: { + [r1.id]: r1Fresh.updatedAt, + [r2.id]: r2Fresh.updatedAt, + [r3.id]: r3Fresh.updatedAt, + }, + }, + ); + assert.equal(res.status, 200, "rotate should succeed"); + const body = await res.json(); + assert.equal(body.ok, true); + assert.equal(body.meetings.length, 3); + + const r1After = await fetchMeeting(cookieA, r1.id); + const r2After = await fetchMeeting(cookieA, r2.id); + const r3After = await fetchMeeting(cookieA, r3.id); + // r1 (originally 09:00) was moved to position 3 → it now holds the + // 11:00 slot. r2 (originally 10:00) moved to position 1 → 09:00. + // r3 (originally 11:00) moved to position 2 → 10:00. + assert.equal(r1After.startTime.slice(0, 5), "11:00"); + assert.equal(r1After.endTime.slice(0, 5), "11:30"); + assert.equal(r2After.startTime.slice(0, 5), "09:00"); + assert.equal(r2After.endTime.slice(0, 5), "09:30"); + assert.equal(r3After.startTime.slice(0, 5), "10:00"); + assert.equal(r3After.endTime.slice(0, 5), "10:30"); + // Daily numbers — renumbered by start time, so the meeting that ends + // up earliest gets dailyNumber=beforeNumR1 (the smallest of the three). + // Specifically r2 should now hold beforeNumR1, r3→beforeNumR2, + // r1→beforeNumR3. + assert.equal(r2After.dailyNumber, beforeNumR1); + assert.equal(r3After.dailyNumber, beforeNumR2); + assert.equal(r1After.dailyNumber, beforeNumR3); + // Titles stayed glued to their rows — the user dragged content, so + // each row carries away its own meeting body (title, attendees, …) + // to the new physical position. + assert.equal(r1After.titleEn, "Rot R1 Alpha"); + assert.equal(r2After.titleEn, "Rot R2 Bravo"); + assert.equal(r3After.titleEn, "Rot R3 Charlie"); + // Audit rows written for the rows whose times actually changed. + const { rows } = await pool.query( + `SELECT entity_id FROM executive_meeting_audit_logs + WHERE entity_type = 'meeting' + AND action = 'meeting_rotate_content' + AND entity_id = ANY($1::int[])`, + [[r1.id, r2.id, r3.id]], + ); + assert.ok(rows.length >= 2, "expected audit rows for rotated meetings"); +}); + +test("rotate-content: stale updatedAt → 409 stale_meeting with conflict payload", async () => { + const r1 = await createMeeting({ + date: DATE_STALE, + titleEn: "Stale R1", + startTime: "09:00", + endTime: "09:30", + }); + const r2 = await createMeeting({ + date: DATE_STALE, + titleEn: "Stale R2", + startTime: "10:00", + endTime: "10:30", + }); + const r1Fresh = await fetchMeeting(cookieA, r1.id); + const r2Fresh = await fetchMeeting(cookieA, r2.id); + // Bob mutates r2 in between Alice's read and Alice's rotate. + const patch = await api(cookieB, "PATCH", `/api/executive-meetings/${r2.id}`, { + titleEn: "Stale R2 (renamed)", + }); + assert.ok( + patch.status === 200 || patch.status === 204, + `patch should succeed (got ${patch.status})`, + ); + const res = await api( + cookieA, + "POST", + "/api/executive-meetings/rotate-content", + { + meetingDate: DATE_STALE, + orderedIds: [r2.id, r1.id], + expectedUpdatedAt: { + [r1.id]: r1Fresh.updatedAt, + [r2.id]: r2Fresh.updatedAt, + }, + }, + ); + assert.equal(res.status, 409); + const body = await res.json(); + assert.equal(body.code, "stale_meeting"); + assert.ok(body.conflict, "stale response must include conflict payload"); + assert.equal(body.conflict.id, r2.id); + assert.ok(body.conflict.lastActor, "conflict must name an actor"); + assert.equal(body.conflict.lastActor.id, userB.id); +}); + +test("rotate-content: orderedIds spanning two dates → 400 different_dates", async () => { + const r1 = await createMeeting({ + date: DATE_DIFF_A, + titleEn: "Diff A", + startTime: "13:00", + endTime: "13:30", + }); + const r2 = await createMeeting({ + date: DATE_DIFF_B, + titleEn: "Diff B", + startTime: "13:00", + endTime: "13:30", + }); + const r1Fresh = await fetchMeeting(cookieA, r1.id); + const r2Fresh = await fetchMeeting(cookieA, r2.id); + const res = await api( + cookieA, + "POST", + "/api/executive-meetings/rotate-content", + { + meetingDate: DATE_DIFF_A, + orderedIds: [r1.id, r2.id], + expectedUpdatedAt: { + [r1.id]: r1Fresh.updatedAt, + [r2.id]: r2Fresh.updatedAt, + }, + }, + ); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, "different_dates"); +}); + +test("rotate-content: unauthenticated → 401", async () => { + const res = await fetch(`${API_BASE}/api/executive-meetings/rotate-content`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + meetingDate: futureDate(70), + orderedIds: [1, 2], + expectedUpdatedAt: { + 1: new Date().toISOString(), + 2: new Date().toISOString(), + }, + }), + }); + assert.equal(res.status, 401); +}); + +test("rotate-content: viewer (no mutate role) → 403 forbidden", async () => { + const r1 = await createMeeting({ + date: DATE_VIEWER, + titleEn: "View A", + startTime: "08:00", + endTime: "08:30", + }); + const r2 = await createMeeting({ + date: DATE_VIEWER, + titleEn: "View B", + startTime: "09:00", + endTime: "09:30", + }); + const r1Fresh = await fetchMeeting(cookieA, r1.id); + const r2Fresh = await fetchMeeting(cookieA, r2.id); + const res = await api( + viewerCookie, + "POST", + "/api/executive-meetings/rotate-content", + { + meetingDate: DATE_VIEWER, + orderedIds: [r2.id, r1.id], + expectedUpdatedAt: { + [r1.id]: r1Fresh.updatedAt, + [r2.id]: r2Fresh.updatedAt, + }, + }, + ); + assert.equal(res.status, 403, "viewer must not be able to rotate"); +}); + +test("rotate-content: omitting a visible row from orderedIds → 400 incomplete_day", async () => { + // Three visible rows on the day, but the client only sends two of + // them — the server must refuse rather than silently leaving the + // omitted row's tuple stale. + const r1 = await createMeeting({ + date: DATE_INCOMPLETE, + titleEn: "Inc R1", + startTime: "09:00", + endTime: "09:30", + }); + const r2 = await createMeeting({ + date: DATE_INCOMPLETE, + titleEn: "Inc R2", + startTime: "10:00", + endTime: "10:30", + }); + const r3 = await createMeeting({ + date: DATE_INCOMPLETE, + titleEn: "Inc R3", + startTime: "11:00", + endTime: "11:30", + }); + const r1Fresh = await fetchMeeting(cookieA, r1.id); + const r2Fresh = await fetchMeeting(cookieA, r2.id); + const res = await api( + cookieA, + "POST", + "/api/executive-meetings/rotate-content", + { + meetingDate: DATE_INCOMPLETE, + // r3 missing on purpose. + orderedIds: [r2.id, r1.id], + expectedUpdatedAt: { + [r1.id]: r1Fresh.updatedAt, + [r2.id]: r2Fresh.updatedAt, + }, + }, + ); + assert.equal(res.status, 400); + const body = await res.json(); + assert.equal(body.code, "incomplete_day"); + // Sanity: r3 unchanged. + const r3After = await fetchMeeting(cookieA, r3.id); + assert.equal(r3After.startTime.slice(0, 5), "11:00"); +}); diff --git a/artifacts/tx-os/src/pages/executive-meetings.tsx b/artifacts/tx-os/src/pages/executive-meetings.tsx index 5026a053..692c0238 100644 --- a/artifacts/tx-os/src/pages/executive-meetings.tsx +++ b/artifacts/tx-os/src/pages/executive-meetings.tsx @@ -2114,15 +2114,25 @@ function ScheduleSection({ setAutoEditTitleId(null); }, []); - const reorderRows = useCallback( + // #489: Drag-from-anywhere on the row rotates meeting CONTENT through + // the day's fixed time slots. The (startTime, endTime, dailyNumber) + // tuple of each chronological slot stays anchored to its physical + // row — only the meeting content (title, attendees, notes, color, + // status, merge) shifts. Mirrors the optimistic-locked pattern used + // by swap-times / postpone-minutes: we send each visible meeting's + // last-known `updatedAt` so the server can detect concurrent edits + // and 409 with a `stale_meeting` conflict that the client surfaces + // as a destructive toast and rolls back from. + // + // Optimistic UI: we patch the day cache so the i-th visible row + // (chronologically) immediately receives the (startTime, endTime, + // dailyNumber) from `orderedMeetings[i]` — i.e. each slot keeps its + // original tuple while the meeting content rotates into the new + // position. This matches what the server will write. + const rotateContent = useCallback( async (fromId: number, toId: number) => { if (fromId === toId) return; if (reorderingRef.current) return; - // 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); @@ -2130,79 +2140,56 @@ function ScheduleSection({ const newOrder = ids.slice(); newOrder.splice(fromIdx, 1); newOrder.splice(toIdx, 0, fromId); - setOptimisticOrder(newOrder); - reorderingRef.current = true; - setReordering(true); - try { - await apiJson(`/api/executive-meetings/reorder`, { - method: "POST", - body: { meetingDate: date, orderedIds: newOrder }, - }); - refreshDay(); - } catch (err) { - setOptimisticOrder(null); - const msg = err instanceof Error ? err.message : String(err); - toast({ - title: t("common.error"), - description: msg, - variant: "destructive", - }); - } finally { - reorderingRef.current = false; - setReordering(false); - } - }, - [orderedMeetings, date, refreshDay, toast, t], - ); - - // #486: Quick-actions popover (Move up / Move down / Postpone) on - // every schedule row, gated only on the raw `canMutate` permission - // (NOT effectiveCanMutate / editMode) so users can reorder + postpone - // without flipping into edit mode. Move up / Move down swap just the - // (startTime, endTime) tuple between the clicked meeting and its - // chronological neighbour on the same date — see swap-times route. - // The Time column therefore stays visually anchored to its row; - // each row keeps its slot and the meetings rotate through the slots. - const swapTimes = useCallback( - async (a: Meeting, b: Meeting) => { - if (reorderingRef.current) return; - reorderingRef.current = true; - setReordering(true); - // Optimistic UI: snapshot the day cache, swap (startTime, endTime) - // between A and B locally so the row repaints in the same React - // commit the popover closes in. Mirrors the inline-edit pattern - // used by saveTitle/saveTimes — on server failure the snapshot is - // restored and the destructive toast surfaces the error. + // Build the optimistic patch: chronological slot[i] (= the + // (startTime, endTime, dailyNumber) of orderedMeetings[i]) is + // assigned to the meeting at newOrder[i]. Cancelled rows are + // never in `orderedMeetings`, so they keep their existing tuple. const queryKey = ["/api/executive-meetings", date] as const; const previous = qc.getQueryData(queryKey); + const slotById = new Map(); + newOrder.forEach((id, i) => { + const slotMeeting = orderedMeetings[i]; + if (!slotMeeting) return; + slotById.set(id, { + startTime: slotMeeting.startTime, + endTime: slotMeeting.endTime, + dailyNumber: slotMeeting.dailyNumber, + }); + }); qc.setQueryData(queryKey, (prev) => prev ? { ...prev, meetings: prev.meetings.map((m) => { - if (m.id === a.id) - return { ...m, startTime: b.startTime, endTime: b.endTime }; - if (m.id === b.id) - return { ...m, startTime: a.startTime, endTime: a.endTime }; - return m; + const slot = slotById.get(m.id); + if (!slot) return m; + return { + ...m, + startTime: slot.startTime, + endTime: slot.endTime, + dailyNumber: slot.dailyNumber, + }; }), } : prev, ); + const expectedUpdatedAt: Record = {}; + for (const m of orderedMeetings) { + expectedUpdatedAt[String(m.id)] = m.updatedAt; + } + reorderingRef.current = true; + setReordering(true); try { - await apiJson(`/api/executive-meetings/swap-times`, { + await apiJson(`/api/executive-meetings/rotate-content`, { method: "POST", body: { - aId: a.id, - bId: b.id, - expectedUpdatedAtA: a.updatedAt, - expectedUpdatedAtB: b.updatedAt, + meetingDate: date, + orderedIds: newOrder, + expectedUpdatedAt, }, }); refreshDay(); } catch (err) { - // Rollback the optimistic write so the row snaps back to the - // last server-confirmed times. if (previous !== undefined) { qc.setQueryData(queryKey, previous); } @@ -2217,30 +2204,15 @@ function ScheduleSection({ setReordering(false); } }, - [qc, date, refreshDay, toast, t], - ); - - const quickMoveUp = useCallback( - (meetingId: number) => { - const idx = orderedMeetings.findIndex((m) => m.id === meetingId); - if (idx <= 0) return; - const a = orderedMeetings[idx]!; - const b = orderedMeetings[idx - 1]!; - void swapTimes(a, b); - }, - [orderedMeetings, swapTimes], - ); - const quickMoveDown = useCallback( - (meetingId: number) => { - const idx = orderedMeetings.findIndex((m) => m.id === meetingId); - if (idx < 0 || idx >= orderedMeetings.length - 1) return; - const a = orderedMeetings[idx]!; - const b = orderedMeetings[idx + 1]!; - void swapTimes(a, b); - }, - [orderedMeetings, swapTimes], + [orderedMeetings, date, qc, refreshDay, toast, t], ); + // #486 → #489: Move up / Move down were retired in favour of + // dragging the whole row (`rotateContent` above). The schedule's + // row click popover now keeps only the Postpone button. The + // swap-times helper that powered the old buttons is gone, but + // POST /executive-meetings/swap-times still exists server-side + // for any external caller that hasn't migrated yet. const [postponeMeetingId, setPostponeMeetingId] = useState( null, ); @@ -2514,9 +2486,9 @@ function ScheduleSection({ (e: DragEndEvent) => { const { active, over } = e; if (!over || active.id === over.id) return; - void reorderRows(Number(active.id), Number(over.id)); + void rotateContent(Number(active.id), Number(over.id)); }, - [reorderRows], + [rotateContent], ); useEffect(() => { @@ -2931,24 +2903,6 @@ function ScheduleSection({ bulkSelected={selectedMeetingIds.has(m.id)} onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)} quickActionsCanMutate={canMutate} - // Edge enablement is computed against the FULL day order, - // not the (possibly search-filtered) `displayedMeetings`. - // quickMoveUp/Down resolve neighbours from - // `orderedMeetings`, so deriving disabled state from the - // filtered index would let a user click "Move up" on what - // looks like the first visible row but is actually mid-day, - // and silently swap with a hidden neighbour. - canQuickMoveUp={ - orderedMeetings.findIndex((om) => om.id === m.id) > 0 - } - canQuickMoveDown={(() => { - const oIdx = orderedMeetings.findIndex( - (om) => om.id === m.id, - ); - return oIdx >= 0 && oIdx < orderedMeetings.length - 1; - })()} - onQuickMoveUp={() => quickMoveUp(m.id)} - onQuickMoveDown={() => quickMoveDown(m.id)} onQuickPostpone={() => setPostponeMeetingId(m.id)} /> ))} @@ -3743,10 +3697,6 @@ function MeetingRow({ onBulkSelectChange, displayNumber, quickActionsCanMutate, - canQuickMoveUp, - canQuickMoveDown, - onQuickMoveUp, - onQuickMoveDown, onQuickPostpone, }: { meeting: Meeting; @@ -3760,12 +3710,8 @@ function MeetingRow({ // #486: raw (un-gated by editMode) mutate permission — clicking a // row anywhere outside an interactive control opens the quick- // actions popover. The popover itself respects the same MUTATE_ROLES - // server-side gating as the swap-times + postpone-minutes endpoints. + // server-side gating as the postpone-minutes endpoint. quickActionsCanMutate?: boolean; - canQuickMoveUp?: boolean; - canQuickMoveDown?: boolean; - onQuickMoveUp?: () => void; - onQuickMoveDown?: () => void; onQuickPostpone?: () => void; onSaveTitle: (html: string) => Promise; onSaveAttendeeName: (idx: number, html: string) => Promise; @@ -3817,10 +3763,15 @@ function MeetingRow({ const rowBg = rowColor?.bg || ""; const rowBorder = rowColor?.border || ""; - // Make the row a sortable item. `attributes` get spread onto the for - // a11y; `listeners` go on the dedicated grip handle in the # cell so a - // normal click on the cell content (to enter inline-edit mode) is still - // possible. + // Make the row a sortable item. `attributes` get spread onto the + // for a11y; `listeners` are wrapped (see `safeRowDragListeners` below) + // so the user can grab the row from anywhere on it — except over an + // interactive control (button, link, input, contenteditable, time + // picker, row-actions menu) where the existing handler should win. + // The 6px PointerSensor distance + 200ms TouchSensor delay configured + // at the page level keep clicks/taps on inert cell area from being + // mistaken for drags, so the quick-actions popover still opens on + // tap-and-release. const { attributes, listeners, @@ -3829,6 +3780,47 @@ function MeetingRow({ transition, isDragging, } = useSortable({ id: meeting.id, disabled: !canMutate }); + const isSkipDragTarget = useCallback( + (target: EventTarget | null): boolean => { + const el = target as HTMLElement | null; + if (!el) return false; + // Important: useSortable spreads `role="button"` onto our own + // , so a naive `closest("[role='button']")` would match the + // row itself and disable drag for the whole row. Find the + // closest match and only count it as a skip if it's a DESCENDANT + // of this row (i.e. not the row tr). + const rowSelector = `tr[data-testid="em-row-${meeting.id}"]`; + const interactive = el.closest( + "button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']", + ); + if (interactive && !interactive.matches(rowSelector)) return true; + const testid = el.closest( + [ + `[data-testid="em-row-actions-${meeting.id}"]`, + `[data-testid="em-row-select-${meeting.id}"]`, + `[data-testid^="em-edit-"]`, + `[data-testid^="em-merge-edit-"]`, + `[data-testid^="em-time-"]`, + ].join(", "), + ); + if (testid && !testid.matches(rowSelector)) return true; + return false; + }, + [meeting.id], + ); + const safeRowDragListeners = useMemo(() => { + if (!listeners || !canMutate) return undefined; + const out: Record void> = {}; + for (const k of Object.keys(listeners)) { + const handler = (listeners as Record void>)[k]; + if (typeof handler !== "function") continue; + out[k] = (e: { target: EventTarget | null }) => { + if (isSkipDragTarget(e?.target ?? null)) return; + handler(e); + }; + } + return out; + }, [listeners, canMutate, isSkipDragTarget]); const numberCellCls = "border border-gray-300 px-2 py-2 text-center align-middle font-semibold " + @@ -4014,30 +4006,16 @@ function MeetingRow({ className={`relative group ${numberCellCls}`} style={numberCellInlineStyle} > -
- {canMutate && ( - - )} + {/* + #489: The dedicated grip handle is gone — the user now + drags any meeting from anywhere on its row to a new + position (the time column + daily numbers stay anchored + to the physical row, only the meeting content rotates). + The drag listeners are spread directly onto the + below, with a skip-list so clicks on inline editors and + row-action buttons still reach their own handlers. + */} +
{displayNumber ?? meeting.dailyNumber}
{cellBulkOverlay} @@ -4249,7 +4227,6 @@ function MeetingRow({ if ( target.closest( [ - `[data-testid="em-row-grip-${meeting.id}"]`, `[data-testid="em-row-actions-${meeting.id}"]`, `[data-testid="em-row-select-${meeting.id}"]`, `[data-testid^="em-edit-"]`, @@ -4272,12 +4249,14 @@ function MeetingRow({ {renderCells()} @@ -4293,32 +4272,6 @@ function MeetingRow({ onInteractOutside={() => setQuickOpen(false)} >
- -