diff --git a/artifacts/api-server/src/routes/executive-meetings.ts b/artifacts/api-server/src/routes/executive-meetings.ts
index c4b02efc..ee459aa5 100644
--- a/artifacts/api-server/src/routes/executive-meetings.ts
+++ b/artifacts/api-server/src/routes/executive-meetings.ts
@@ -949,8 +949,16 @@ router.post(
code: "invalid_ids",
});
}
- // Snapshot the current (start_time, end_time, daily_number) sorted
- // by current daily_number — the slots we will redistribute.
+ // Snapshot the current (start_time, end_time, daily_number) ordered
+ // by current daily_number — these are the slots we redistribute as
+ // a permutation among the affected rows. We deliberately reuse
+ // the existing daily_numbers instead of renumbering 1..N, because
+ // the day may contain meetings outside `orderedIds` (partial
+ // reorder) and renumbering would collide with the
+ // (meeting_date, daily_number) UNIQUE constraint. Reusing the
+ // affected rows' own slot values is a true permutation, so it is
+ // both contiguous within the selection and conflict-free against
+ // the rest of the day.
const slots = rows
.slice()
.sort((a, b) => a.dailyNumber - b.dailyNumber)
@@ -959,8 +967,8 @@ router.post(
endTime: r.endTime,
dailyNumber: r.dailyNumber,
}));
- const byId = new Map(rows.map((r) => [r.id, r] as const));
- // Two-phase update to avoid (date, daily_number) unique conflicts.
+ // Two-phase update to avoid (date, daily_number) unique conflicts
+ // *during* the swap.
// Phase 1: park into negative numbers.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
@@ -969,7 +977,7 @@ router.post(
.set({ dailyNumber: -(i + 1), updatedBy: userId })
.where(eq(executiveMeetingsTable.id, id));
}
- // Phase 2: assign final slot.
+ // Phase 2: assign final slot from the snapshot above.
for (let i = 0; i < data.orderedIds.length; i++) {
const id = data.orderedIds[i]!;
const slot = slots[i]!;
@@ -1017,7 +1025,7 @@ router.post(
}
seen.add(u.dailyNumber);
}
- return { updated, byId };
+ return { updated };
});
// Return the updated meetings in the new order, with attendees.
const idToFetch = data.orderedIds;
@@ -1117,7 +1125,12 @@ async function applyApprovedRequest(
break;
}
case "add_attendee": {
- const name = typeof details.name === "string" ? details.name.trim() : "";
+ const rawName = typeof details.name === "string" ? details.name.trim() : "";
+ if (!rawName) break;
+ // Same sanitization boundary as the direct attendee POST/PUT paths so
+ // an approved request cannot smuggle script/event-handler markup into
+ // the rich-text attendee.name column.
+ const name = sanitizeRichText(rawName.slice(0, 5000));
if (!name) break;
const title = typeof details.title === "string" ? details.title.slice(0, 200) : null;
const attendanceType =
@@ -1133,7 +1146,7 @@ async function applyApprovedRequest(
.where(eq(executiveMeetingAttendeesTable.meetingId, meetingId));
await tx.insert(executiveMeetingAttendeesTable).values({
meetingId,
- name: name.slice(0, 200),
+ name,
title,
attendanceType,
sortOrder: (maxOrder ?? -1) + 1,
diff --git a/artifacts/api-server/tests/executive-meetings.test.mjs b/artifacts/api-server/tests/executive-meetings.test.mjs
index 6fbd928e..6a2e8455 100644
--- a/artifacts/api-server/tests/executive-meetings.test.mjs
+++ b/artifacts/api-server/tests/executive-meetings.test.mjs
@@ -613,9 +613,51 @@ test("Reorder: POST /reorder swaps daily numbers + start times within a day", as
const cAfter = byId.get(C.id);
const bAfter = byId.get(B.id);
const aAfter = byId.get(A.id);
- // The first slot now holds C, the third now holds A.
- assert.ok(cAfter.dailyNumber < bAfter.dailyNumber);
- assert.ok(bAfter.dailyNumber < aAfter.dailyNumber);
+ // The reorder is a *permutation* of the affected rows' existing slots —
+ // C now occupies A's old slot, B keeps its slot, and A occupies C's old
+ // slot. dailyNumber values are reused exactly so that the (date, daily)
+ // UNIQUE constraint stays valid even when the day contains other
+ // meetings outside `orderedIds`.
+ assert.equal(cAfter.dailyNumber, A.dailyNumber);
+ assert.equal(bAfter.dailyNumber, B.dailyNumber);
+ assert.equal(aAfter.dailyNumber, C.dailyNumber);
+ assert.equal(cAfter.startTime.slice(0, 5), "09:00");
+ assert.equal(bAfter.startTime.slice(0, 5), "10:00");
+ assert.equal(aAfter.startTime.slice(0, 5), "11:00");
+});
+
+test("Apply request (add_attendee) sanitizes attendee.name like direct writes", async () => {
+ const m = await api(adminCookie, "POST", "/api/executive-meetings", {
+ titleAr: "اجتماع طلب", titleEn: "Request meeting", meetingDate: today,
+ });
+ const M = await m.json(); created.meetingIds.push(M.id);
+
+ // POST /api/executive-meetings/:id/requests creates a request bound to
+ // the meeting. The route validates payload via requestPayloadSchemas.
+ const reqRes = await api(adminCookie, "POST",
+ `/api/executive-meetings/${M.id}/requests`, {
+ requestType: "add_attendee",
+ requestDetails: {
+ name: 'Real
',
+ attendanceType: "internal",
+ },
+ });
+ assert.equal(reqRes.status, 201);
+ const reqRow = await reqRes.json();
+
+ // PATCH /api/executive-meetings/requests/:id with {status:"approved"}
+ // routes through applyApprovedRequest -> add_attendee.
+ const approve = await api(adminCookie, "PATCH",
+ `/api/executive-meetings/requests/${reqRow.id}`,
+ { status: "approved" });
+ assert.equal(approve.status, 200);
+
+ const det = await api(adminCookie, "GET", `/api/executive-meetings/${M.id}`);
+ const detail = await det.json();
+ const att = detail.attendees.at(-1);
+ assert.ok(/]*>Real<\/b>/i.test(att.name), " must survive");
+ assert.ok(!/