f53f7307da
Backend - New POST /api/executive-meetings/rotate-content (zod-validated) rotates ONLY meeting content through fixed (start_time, end_time, daily_number) slots. Same-date enforced; per-meeting expectedUpdatedAt → 409 stale; incomplete day (missing visible row) → 400. - ExecutiveMeetingsRotateContentBody added in lib/api-zod (manual.ts). - 6 backend tests cover happy path, stale, different_dates, 401, 403, incomplete_day. Existing /swap-times tests still pass. Frontend (artifacts/tx-os) - Whole <tr> is now the drag handle (the dedicated GripVertical button is retired). useSortable is gated on canMutate; safeRowDragListeners filters drags whose target is an interactive descendant (button, input, edit/time cells, row-actions, bulk-select). useSortable `attributes` are spread only when canMutate so view-mode rows stay clickable (otherwise aria-disabled blocked the popover trigger). - onRowDragEnd → rotateContent(fromId, toId): optimistic patch reassigns each chronological slot's tuple to the new occupant; rolls back + toast on failure. - Quick-actions popover now contains only Postpone (#486 Move up/down buttons removed). Tests (artifacts/tx-os) - New tests/executive-meetings-row-drag.spec.mjs: drags Alpha → Charlie position by # cell, asserts rotate-content fires and slots stay anchored. - tests/executive-meetings-row-quick-actions.spec.mjs: drops up/down cases, keeps Postpone + skip-surfaces + viewer. - tests/executive-meetings-schedule-features.spec.mjs: two legacy grip drag tests rewritten to drag the row body and target /rotate-content (the legacy /reorder route + tests are intentionally untouched). Drift / notes - Architect flagged a medium-severity hardening note: rotate-content FOR UPDATE locks orderedIds but not the day-scope completeness query. Out of #489 scope; no follow-up created (proposeFollowUpTasks was already consumed on #486).
54 lines
2.3 KiB
TypeScript
54 lines
2.3 KiB
TypeScript
import { z } from "zod";
|
|
|
|
export const ExecutiveMeetingsReorderBody = z.object({
|
|
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
orderedIds: z.array(z.number().int().positive()).min(1).max(1000),
|
|
});
|
|
|
|
export type ExecutiveMeetingsReorderBodyT = z.infer<
|
|
typeof ExecutiveMeetingsReorderBody
|
|
>;
|
|
|
|
// #486: Body for POST /executive-meetings/swap-times. Used by the
|
|
// schedule row quick-actions popover (Move up / Move down) to swap
|
|
// just the (startTime, endTime) tuple between two meetings on the
|
|
// same date without changing daily numbering, so the time column
|
|
// stays visually anchored.
|
|
export const ExecutiveMeetingsSwapTimesBody = z.object({
|
|
aId: z.number().int().positive(),
|
|
bId: z.number().int().positive(),
|
|
// 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<
|
|
typeof ExecutiveMeetingsSwapTimesBody
|
|
>;
|
|
|
|
// #489: Body for POST /executive-meetings/rotate-content. Used when the
|
|
// user drags a meeting from anywhere on its row to a new position. The
|
|
// server keeps each meeting's slot (startTime, endTime, dailyNumber)
|
|
// anchored to its physical row in chronological order and rotates the
|
|
// meeting CONTENT (title, attendees, notes, color, status, merge) so
|
|
// that visible position i ends up holding the i-th tuple from the
|
|
// original chronological order.
|
|
//
|
|
// `orderedIds` is the new visible content order (same set as the day's
|
|
// non-cancelled meetings — every visible row must appear exactly once).
|
|
// `expectedUpdatedAt` carries each visible meeting's last-known
|
|
// `updatedAt` ISO string so the server can detect concurrent edits and
|
|
// return 409 `stale_meeting` like the other optimistic-locked routes.
|
|
export const ExecutiveMeetingsRotateContentBody = z.object({
|
|
meetingDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
orderedIds: z.array(z.number().int().positive()).min(2).max(1000),
|
|
expectedUpdatedAt: z.record(z.string().regex(/^\d+$/), z.string().datetime()),
|
|
});
|
|
|
|
export type ExecutiveMeetingsRotateContentBodyT = z.infer<
|
|
typeof ExecutiveMeetingsRotateContentBody
|
|
>;
|