From 82ec10cfad10ab17345a17efd1a0d2b0d950dc4d Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 25 Feb 2026 17:15:25 +0100 Subject: [PATCH] 460 import event without organiser (#579) Co-authored-by: Camille Moussu --- .../updateAttendeesAfterTimeChange.test.ts | 213 ++++++++++++++++++ .../Calendar/handlers/eventHandlers.ts | 54 +---- src/features/Events/EventUpdateModal.tsx | 2 +- .../updateAttendeesAfterTimeChange.ts | 67 ++++++ 4 files changed, 282 insertions(+), 54 deletions(-) create mode 100644 __test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts create mode 100644 src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts diff --git a/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts b/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts new file mode 100644 index 0000000..d1712d8 --- /dev/null +++ b/__test__/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.test.ts @@ -0,0 +1,213 @@ +import { CalendarEvent } from "@/features/Events/EventsTypes"; +import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; +import { userAttendee } from "@/features/User/models/attendee"; +import { userOrganiser } from "@/features/User/userDataTypes"; + +describe("updateAttendeesAfterTimeChange", () => { + const mockAttendee: userAttendee = { + cal_address: "attendee@example.com", + partstat: "ACCEPTED", + rsvp: "FALSE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cn: "attendee", + }; + + const mockOrganizer: userOrganiser = { + cn: "Organizer", + cal_address: "organizer@example.com", + }; + + const organizerAttendee: userAttendee = { + cal_address: "organizer@example.com", + partstat: "ACCEPTED", + rsvp: "FALSE", + role: "CHAIR", + cutype: "INDIVIDUAL", + cn: "organizer", + }; + + const baseEvent: CalendarEvent = { + uid: "test-event", + URL: "/calendar/667037022b752d0026472254/cal1/test-event.ics", + title: "Test Event", + calId: "667037022b752d0026472254/cal1", + start: "2025-01-15T07:00:00.000Z", + end: "2025-01-15T08:00:00.000Z", + allday: false, + organizer: mockOrganizer, + attendee: [mockAttendee, organizerAttendee], + timezone: "ETC/UTC", + }; + + describe("early returns", () => { + it("should return the event unchanged when attendee list is undefined", () => { + const event = { ...baseEvent, attendee: undefined }; + const result = updateAttendeesAfterTimeChange( + event as unknown as CalendarEvent + ); + expect(result).toBe(event); + }); + + it("should NOT bail out early when organizer is undefined", () => { + const event = { ...baseEvent, organizer: undefined }; + const result = updateAttendeesAfterTimeChange(event); + expect(result.attendee).toBeDefined(); + expect(result.attendee).toHaveLength(2); + }); + }); + + describe("without provided attendees list", () => { + it("should return attendees unchanged when timeChanged is false", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, false); + expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); + expect(result.attendee?.[0].rsvp).toBe("FALSE"); + }); + + it("should mark non-organizer attendees as NEEDS-ACTION when timeChanged is true", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, true); + const attendee = result.attendee?.find( + (a) => a.cal_address === "attendee@example.com" + ); + expect(attendee?.partstat).toBe("NEEDS-ACTION"); + expect(attendee?.rsvp).toBe("TRUE"); + }); + + it("should NOT mark the organizer as NEEDS-ACTION when timeChanged is true", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, true); + const organizer = result.attendee?.find( + (a) => a.cal_address === "organizer@example.com" + ); + expect(organizer?.partstat).toBe("ACCEPTED"); + expect(organizer?.rsvp).toBe("FALSE"); + }); + + it("should return attendees unchanged when timeChanged is undefined", () => { + const result = updateAttendeesAfterTimeChange(baseEvent); + expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); + }); + }); + + describe("with provided attendees list", () => { + const newAttendee: userAttendee = { + cal_address: "new@example.com", + partstat: "ACCEPTED", + rsvp: "FALSE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cn: "new", + }; + + it("should use existing attendee data when the address already exists in the event", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, false, [ + mockAttendee, + ]); + const attendee = result.attendee?.find( + (a) => a.cal_address === "attendee@example.com" + ); + expect(attendee?.partstat).toBe("ACCEPTED"); + }); + + it("should mark new attendee as NEEDS-ACTION when not found in existing list", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, false, [ + newAttendee, + ]); + const attendee = result.attendee?.find( + (a) => a.cal_address === "new@example.com" + ); + expect(attendee?.partstat).toBe("NEEDS-ACTION"); + expect(attendee?.rsvp).toBe("TRUE"); + }); + + it("should mark all attendees as NEEDS-ACTION when timeChanged is true", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, true, [ + mockAttendee, + newAttendee, + ]); + const nonOrganizerAttendees = result.attendee?.filter( + (a) => a.cal_address !== "organizer@example.com" + ); + nonOrganizerAttendees?.forEach((a) => { + expect(a.partstat).toBe("NEEDS-ACTION"); + expect(a.rsvp).toBe("TRUE"); + }); + }); + + it("should append organizer entry at the end of the attendee list", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, false, [ + mockAttendee, + ]); + const last = result.attendee?.[result.attendee.length - 1]; + expect(last?.cal_address).toBe("organizer@example.com"); + }); + + it("should use existing organizer attendee data when found in event attendees", () => { + const result = updateAttendeesAfterTimeChange(baseEvent, false, [ + mockAttendee, + ]); + const organizer = result.attendee?.find( + (a) => a.cal_address === "organizer@example.com" + ); + expect(organizer?.partstat).toBe("ACCEPTED"); + }); + + it("should fall back to organizer defaults when organizer is not in existing attendees", () => { + const eventWithoutOrganizerInAttendees: CalendarEvent = { + ...baseEvent, + attendee: [mockAttendee], // organizer not in attendee list + }; + const result = updateAttendeesAfterTimeChange( + eventWithoutOrganizerInAttendees, + false, + [mockAttendee] + ); + const organizer = result.attendee?.find( + (a) => a.cal_address === "organizer@example.com" + ); + expect(organizer?.role).toBe("CHAIR"); + expect(organizer?.partstat).toBe("NEEDS-ACTION"); + }); + }); + + describe("organizer undefined", () => { + const eventNoOrganizer: CalendarEvent = { + ...baseEvent, + organizer: undefined, + }; + + it("should return attendees without bailing out", () => { + const result = updateAttendeesAfterTimeChange(eventNoOrganizer); + expect(result.attendee).toHaveLength(2); + }); + + it("should mark attendees as NEEDS-ACTION when timeChanged is true", () => { + const result = updateAttendeesAfterTimeChange(eventNoOrganizer, true); + result.attendee?.forEach((a) => { + expect(a.partstat).toBe("NEEDS-ACTION"); + expect(a.rsvp).toBe("TRUE"); + }); + }); + + it("should not mutate attendees when timeChanged is false", () => { + const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false); + expect(result.attendee?.[0].partstat).toBe("ACCEPTED"); + expect(result.attendee?.[0].rsvp).toBe("FALSE"); + }); + + it("should not append an organizer entry when organizer is undefined and attendees list is provided", () => { + const newAttendee: userAttendee = { + cal_address: "new@example.com", + partstat: "ACCEPTED", + rsvp: "FALSE", + role: "REQ-PARTICIPANT", + cutype: "INDIVIDUAL", + cn: "new", + }; + const result = updateAttendeesAfterTimeChange(eventNoOrganizer, false, [ + newAttendee, + ]); + expect(result.attendee).toHaveLength(1); + expect(result.attendee?.[0].cal_address).toBe("new@example.com"); + }); + }); +}); diff --git a/src/components/Calendar/handlers/eventHandlers.ts b/src/components/Calendar/handlers/eventHandlers.ts index 6ef6e5d..867137c 100644 --- a/src/components/Calendar/handlers/eventHandlers.ts +++ b/src/components/Calendar/handlers/eventHandlers.ts @@ -10,7 +10,7 @@ import { } from "@/features/Calendars/services"; import { getEvent } from "@/features/Events/EventApi"; import { CalendarEvent } from "@/features/Events/EventsTypes"; -import { userAttendee } from "@/features/User/models/attendee"; +import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; import { createAttendee } from "@/features/User/models/attendee.mapper"; import { getDeltaInMilliseconds } from "@/utils/dateUtils"; import { @@ -258,55 +258,3 @@ export const createEventHandlers = (props: EventHandlersProps) => { handleEventResize, }; }; - -export const updateAttendeesAfterTimeChange = ( - event: CalendarEvent, - timeChanged?: boolean, - attendees?: userAttendee[] -): CalendarEvent => { - const { attendee, organizer } = event; - if (!attendee || !organizer) return event; - - const organizerAddr = organizer.cal_address; - - const markNeedsAction = (att: userAttendee): userAttendee => ({ - ...att, - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - }); - - const getExistingOrDefault = (addr: string, fallback: userAttendee) => - attendee.find((a) => a?.cal_address === addr) ?? fallback; - - if (attendees) { - const updatedAttendees = attendees.map((att) => { - const existing = getExistingOrDefault( - att.cal_address, - markNeedsAction(att) - ); - return timeChanged ? markNeedsAction(existing) : existing; - }); - - const organizerEntry = getExistingOrDefault(organizerAddr, { - ...organizer, - role: "CHAIR", - cutype: "INDIVIDUAL", - partstat: "NEEDS-ACTION", - rsvp: "TRUE", - }); - - return { - ...event, - attendee: [...updatedAttendees, organizerEntry], - }; - } - const updatedAttendees = attendee.map((att) => { - if (att.cal_address === organizerAddr) return att; - return timeChanged ? markNeedsAction(att) : att; - }); - - return { - ...event, - attendee: updatedAttendees, - }; -}; diff --git a/src/features/Events/EventUpdateModal.tsx b/src/features/Events/EventUpdateModal.tsx index f292bd4..3e7ccfe 100644 --- a/src/features/Events/EventUpdateModal.tsx +++ b/src/features/Events/EventUpdateModal.tsx @@ -1,5 +1,5 @@ import { useAppDispatch, useAppSelector } from "@/app/hooks"; -import { updateAttendeesAfterTimeChange } from "@/components/Calendar/handlers/eventHandlers"; +import { updateAttendeesAfterTimeChange } from "@/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange"; import { ResponsiveDialog } from "@/components/Dialog"; import EventFormFields from "@/components/Event/EventFormFields"; import { addDays } from "@/components/Event/utils/dateRules"; diff --git a/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts b/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts new file mode 100644 index 0000000..b419361 --- /dev/null +++ b/src/features/Events/updateEventHelpers/updateAttendeesAfterTimeChange.ts @@ -0,0 +1,67 @@ +import { userAttendee } from "@/features/User/models/attendee"; +import { CalendarEvent } from "../EventsTypes"; + +export const updateAttendeesAfterTimeChange = ( + event: CalendarEvent, + timeChanged?: boolean, + attendees?: userAttendee[] +): CalendarEvent => { + const { attendee, organizer } = event; + if (!attendee) return event; + + const organizerAddr = organizer?.cal_address; + + const markNeedsAction = (att: userAttendee): userAttendee => ({ + ...att, + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + }); + + const getExistingOrDefault = (addr: string, fallback: userAttendee) => + attendee.find((a) => a?.cal_address === addr) ?? fallback; + + if (attendees) { + const updatedAttendees = attendees.map((att) => { + const existing = getExistingOrDefault( + att.cal_address, + markNeedsAction(att) + ); + return timeChanged ? markNeedsAction(existing) : existing; + }); + + // Only append organizer entry if organizer exists + const organizerEntry = + organizer && organizerAddr + ? getExistingOrDefault(organizerAddr, { + ...organizer, + role: "CHAIR", + cutype: "INDIVIDUAL", + partstat: "NEEDS-ACTION", + rsvp: "TRUE", + }) + : null; + + const deduped = organizerEntry + ? updatedAttendees.filter( + (attendee) => attendee.cal_address !== organizerAddr + ) + : updatedAttendees; + + return { + ...event, + attendee: organizerEntry + ? [...deduped, organizerEntry] + : updatedAttendees, + }; + } + + const updatedAttendees = attendee.map((att) => { + if (organizerAddr && att.cal_address === organizerAddr) return att; + return timeChanged ? markNeedsAction(att) : att; + }); + + return { + ...event, + attendee: updatedAttendees, + }; +};