Task #489: row-wide drag rotates meeting content; time + daily numbers anchored
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).
This commit is contained in:
@@ -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<void> => {
|
||||
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<TxResult> => {
|
||||
// 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<string, unknown> = {
|
||||
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)
|
||||
// =====================================================================
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
Reference in New Issue
Block a user