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(!/