From fad1dfe4c56d733b5be56b93344f4d1e76d97b1d Mon Sep 17 00:00:00 2001 From: Camille Moussu <66134347+Eriikah@users.noreply.github.com> Date: Wed, 11 Feb 2026 11:20:10 +0100 Subject: [PATCH] [#352] update exceptions on all update without reseting them (#510) * [#352] update exceptions on all update without reseting them * [#352] added tests --- __test__/features/Events/EventApi.test.tsx | 508 ++++++++++++++++++ .../EventUpdateModal.recurring.test.tsx | 1 + .../Calendars/services/updateSeriesAsync.ts | 2 +- src/features/Calendars/types/CalendarData.ts | 18 +- src/features/Events/EventApi.ts | 92 +--- src/features/Events/api/updateSeries.ts | 263 +++++++++ 6 files changed, 792 insertions(+), 92 deletions(-) create mode 100644 src/features/Events/api/updateSeries.ts diff --git a/__test__/features/Events/EventApi.test.tsx b/__test__/features/Events/EventApi.test.tsx index 2a21831..2a37a38 100644 --- a/__test__/features/Events/EventApi.test.tsx +++ b/__test__/features/Events/EventApi.test.tsx @@ -1,3 +1,4 @@ +import { updateSeries } from "@/features/Events/api/updateSeries"; import { deleteEvent, importEventFromFile, @@ -271,4 +272,511 @@ describe("eventApi", () => { expect(body.participants).toBeUndefined(); }); }); + + describe("updateSeries", () => { + const recurringEventICS = `BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Test//EN +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +DTEND:20240201T110000Z +RRULE:FREQ=DAILY +SUMMARY:Old title +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +DTEND:20240202T110000Z +SUMMARY:Old title +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + beforeEach(() => { + jest.clearAllMocks(); + + (api.get as jest.Mock).mockResolvedValue({ + text: jest.fn().mockResolvedValue(recurringEventICS), + }); + + (api as jest.Mock).mockResolvedValue({ ok: true }); + }); + + it("propagates summary changes to overrides", async () => { + await updateSeries( + { ...mockEvent, title: "New title" } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const summary = override[1].find(([k]: any) => k === "summary"); + expect(summary[3]).toBe("New title"); + }); + + it("does not remove recurrence-id from overrides", async () => { + await updateSeries(mockEvent as any, undefined, false); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + expect(override).toBeDefined(); + }); + + it("increments SEQUENCE on overrides when metadata changes", async () => { + await updateSeries( + { ...mockEvent, title: "Changed title" } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const sequence = override[1].find(([k]: any) => k === "sequence"); + expect(sequence[3]).toBe(2); + }); + + it("propagates description, location, class, and transp changes", async () => { + const icsWithMultipleFields = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +DTEND:20240201T110000Z +RRULE:FREQ=DAILY +SUMMARY:Title +DESCRIPTION:Old description +LOCATION:Old location +CLASS:PUBLIC +TRANSP:OPAQUE +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +DTEND:20240202T110000Z +SUMMARY:Title +DESCRIPTION:Old description +LOCATION:Old location +CLASS:PUBLIC +TRANSP:OPAQUE +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithMultipleFields), + }); + + await updateSeries( + { + ...mockEvent, + description: "New description", + location: "New location", + class: "PRIVATE", + transp: "TRANSPARENT", + } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const description = override[1].find(([k]: any) => k === "description"); + const location = override[1].find(([k]: any) => k === "location"); + const classField = override[1].find(([k]: any) => k === "class"); + const transp = override[1].find(([k]: any) => k === "transp"); + + expect(description[3]).toBe("New description"); + expect(location[3]).toBe("New location"); + expect(classField[3]).toBe("PRIVATE"); + expect(transp[3]).toBe("TRANSPARENT"); + }); + + it("propagates organizer changes", async () => { + const icsWithOrganizer = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +RRULE:FREQ=DAILY +SUMMARY:Title +ORGANIZER;CN=Alice:mailto:alice@example.com +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +SUMMARY:Title +ORGANIZER;CN=Alice:mailto:alice@example.com +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithOrganizer), + }); + + await updateSeries( + { + ...mockEvent, + organizer: { cn: "Bob", cal_address: "bob@example.com" }, + } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const organizer = override[1].find(([k]: any) => k === "organizer"); + expect(organizer[3]).toBe("mailto:bob@example.com"); + expect(organizer[1].cn).toBe("Bob"); + }); + + it("propagates attendee changes", async () => { + const icsWithAttendees = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +RRULE:FREQ=DAILY +SUMMARY:Title +ATTENDEE;CN=Alice:mailto:alice@example.com +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +SUMMARY:Title +ATTENDEE;CN=Alice:mailto:alice@example.com +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithAttendees), + }); + + await updateSeries( + { + ...mockEvent, + attendee: [ + { + cn: "Bob", + cal_address: "bob@example.com", + partstat: "NEEDS-ACTION", + cutype: "INDIVIDUAL", + role: "REQ-PARTICIPANT", + rsvp: "TRUE", + }, + { + cn: "Charlie", + cal_address: "charlie@example.com", + partstat: "NEEDS-ACTION", + cutype: "INDIVIDUAL", + role: "REQ-PARTICIPANT", + rsvp: "TRUE", + }, + ], + } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const attendees = override[1].filter(([k]: any) => k === "attendee"); + expect(attendees).toHaveLength(2); + expect(attendees[0][3]).toBe("mailto:bob@example.com"); + expect(attendees[1][3]).toBe("mailto:charlie@example.com"); + }); + + it("propagates x-openpaas-videoconference changes", async () => { + const icsWithVideo = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +RRULE:FREQ=DAILY +SUMMARY:Title +X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +SUMMARY:Title +X-OPENPAAS-VIDEOCONFERENCE:https://meet.old.com +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsWithVideo), + }); + + await updateSeries( + { + ...mockEvent, + x_openpass_videoconference: "https://meet.new.com", + } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const videoconf = override[1].find( + ([k]: any) => k === "x-openpaas-videoconference" + ); + expect(videoconf[3]).toBe("https://meet.new.com"); + }); + + it("works with multiple override instances", async () => { + const icsMultipleOverrides = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +DTEND:20240201T110000Z +RRULE:FREQ=DAILY +SUMMARY:Old +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +DTEND:20240202T110000Z +SUMMARY:Old +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240203T100000Z +DTSTART:20240203T100000Z +DTEND:20240203T110000Z +SUMMARY:Old +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsMultipleOverrides), + }); + + await updateSeries( + { ...mockEvent, title: "New title" } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const overrides = vevents.filter(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + expect(overrides).toHaveLength(2); + overrides.forEach((override) => { + const summary = override[1].find(([k]: any) => k === "summary"); + const sequence = override[1].find(([k]: any) => k === "sequence"); + expect(summary[3]).toBe("New title"); + expect(sequence[3]).toBe(2); + }); + }); + + it("adds sequence field when missing and metadata changes", async () => { + const icsNoSequence = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +DTEND:20240201T110000Z +RRULE:FREQ=DAILY +SUMMARY:Old +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +DTEND:20240202T110000Z +SUMMARY:Old +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsNoSequence), + }); + + await updateSeries( + { ...mockEvent, title: "New title" } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const sequence = override[1].find(([k]: any) => k === "sequence"); + expect(sequence[3]).toBe(1); + }); + + it("preserves override-specific fields when propagating metadata", async () => { + const icsCustomFields = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +DTEND:20240201T110000Z +RRULE:FREQ=DAILY +SUMMARY:Old +SEQUENCE:1 +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T140000Z +DTEND:20240202T150000Z +SUMMARY:Old +DESCRIPTION:custom-value +SEQUENCE:1 +END:VEVENT +END:VCALENDAR +`; + + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(icsCustomFields), + }); + + await updateSeries( + { ...mockEvent, title: "New title" } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const summary = override[1].find(([k]: any) => k === "summary"); + const dtstart = override[1].find(([k]: any) => k === "dtstart"); + const dtend = override[1].find(([k]: any) => k === "dtend"); + const description = override[1].find(([k]: any) => k === "description"); + const recurrenceId = override[1].find( + ([k]: any) => k === "recurrence-id" + ); + // Summary should be updated + expect(summary[3]).toBe("New title"); + // Override-specific time should be preserved + expect(dtstart[3]).toBe("2024-02-02T14:00:00Z"); + expect(dtend[3]).toBe("2024-02-02T15:00:00Z"); + // Custom field should be preserved + expect(description[3]).toBe("custom-value"); + // Recurrence-id should be preserved + expect(recurrenceId[3]).toBe("2024-02-02T10:00:00Z"); + }); + + const recurringWithAlarmICS = `BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:event1 +DTSTART:20240201T100000Z +RRULE:FREQ=DAILY +SUMMARY:Old +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT15M +END:VALARM +END:VEVENT +BEGIN:VEVENT +UID:event1 +RECURRENCE-ID:20240202T100000Z +DTSTART:20240202T100000Z +SUMMARY:Old +BEGIN:VALARM +ACTION:DISPLAY +TRIGGER:-PT15M +END:VALARM +END:VEVENT +END:VCALENDAR +`; + + it("replaces VALARM on overrides when alarm changes", async () => { + (api.get as jest.Mock).mockResolvedValueOnce({ + text: jest.fn().mockResolvedValue(recurringWithAlarmICS), + }); + + await updateSeries( + { ...mockEvent, alarm: { action: "EMAIL", trigger: "-PT30M" } } as any, + undefined, + false + ); + + const body = JSON.parse((api as jest.Mock).mock.calls[0][1].body); + const vevents = body[2].filter(([n]: any) => n === "vevent"); + + const override = vevents.find(([, props]: any) => + props.some(([k]: any) => k === "recurrence-id") + ); + + const alarms = override[2].filter(([n]: any) => n === "valarm"); + + expect(alarms).toHaveLength(1); + const action = alarms[0][1].find(([k]: any) => k === "action"); + const trigger = alarms[0][1].find(([k]: any) => k === "trigger"); + expect(action[3]).toBe("EMAIL"); + expect(trigger[3]).toBe("-PT30M"); + }); + }); }); diff --git a/__test__/features/Events/EventUpdateModal.recurring.test.tsx b/__test__/features/Events/EventUpdateModal.recurring.test.tsx index 5454fe0..2105dc2 100644 --- a/__test__/features/Events/EventUpdateModal.recurring.test.tsx +++ b/__test__/features/Events/EventUpdateModal.recurring.test.tsx @@ -9,6 +9,7 @@ import { renderWithProviders } from "../../utils/Renderwithproviders"; jest.mock("@/features/Events/EventApi"); jest.mock("@/features/Calendars/CalendarApi"); +jest.mock("@/features/Events/api/updateSeries"); describe("EventUpdateModal - Recurring Event 'Edit All' Handling", () => { const mockOnClose = jest.fn(); diff --git a/src/features/Calendars/services/updateSeriesAsync.ts b/src/features/Calendars/services/updateSeriesAsync.ts index 8eed05a..54dd3a8 100644 --- a/src/features/Calendars/services/updateSeriesAsync.ts +++ b/src/features/Calendars/services/updateSeriesAsync.ts @@ -1,4 +1,4 @@ -import { updateSeries } from "@/features/Events/EventApi"; +import { updateSeries } from "@/features/Events/api/updateSeries"; import { CalendarEvent } from "@/features/Events/EventsTypes"; import { toRejectedError } from "@/utils/errorUtils"; import { createAsyncThunk } from "@reduxjs/toolkit"; diff --git a/src/features/Calendars/types/CalendarData.ts b/src/features/Calendars/types/CalendarData.ts index 705d262..28eafe8 100644 --- a/src/features/Calendars/types/CalendarData.ts +++ b/src/features/Calendars/types/CalendarData.ts @@ -22,17 +22,12 @@ export type VObjectValue = // VObject property tuple export type VObjectProperty = [ string, - Record, - string | Array, + Record, + string, VObjectValue, ]; -export type VCalComponent = [ - string, - VObjectProperty[], - VCalComponent[], - ...unknown[], -]; +export type VCalComponent = [string, VObjectProperty[], VCalComponent[]?]; export interface Organizer { cn?: string; @@ -44,12 +39,7 @@ export interface CalendarItem { _links: CalDavLink; etag: string; status: number; - data: [ - "vcalendar", - Array< - VObjectProperty | [string, VObjectProperty[], unknown[]] // vevent array - >, - ]; + data: VCalComponent; } // Main calendar data diff --git a/src/features/Events/EventApi.ts b/src/features/Events/EventApi.ts index 238f399..fd658e8 100644 --- a/src/features/Events/EventApi.ts +++ b/src/features/Events/EventApi.ts @@ -1,8 +1,13 @@ import { api } from "@/utils/apiUtils"; -import { resolveTimezoneId, convertEventDateTimeToISO } from "@/utils/timezone"; +import { convertEventDateTimeToISO, resolveTimezoneId } from "@/utils/timezone"; import { TIMEZONES } from "@/utils/timezone-data"; import ICAL from "ical.js"; import { CalDavItem } from "../Calendars/api/types"; +import { + VCalComponent, + VObjectProperty, + VObjectValue, +} from "../Calendars/types/CalendarData"; import { SearchEventsResponse } from "../Search/types/SearchEventsResponse"; import { CalendarEvent } from "./EventsTypes"; import { @@ -12,23 +17,6 @@ import { parseCalendarEvent, } from "./eventUtils"; -type JCalValue = string | number | boolean | null; - -type JCalParams = Record; - -type JCalProperty = [ - name: string, - params: JCalParams, - type: string, - value: JCalValue, -]; - -type JCalComponent = [ - name: string, - properties: JCalProperty[], - components?: JCalComponent[], -]; - export async function reportEvent( event: CalendarEvent, match: { start: string; end: string } @@ -61,7 +49,7 @@ export async function getEvent(event: CalendarEvent, isMaster?: boolean) { let targetVevent; if (isMaster) { targetVevent = vevents.find( - ([, props]: JCalComponent) => + ([, props]: VCalComponent) => !props.find(([k]) => k.toLowerCase() === "recurrence-id") ); if (!targetVevent) { @@ -222,7 +210,7 @@ export const deleteEventInstance = async (event: CalendarEvent) => { // Find the master VEVENT const masterIndex = vevents.findIndex( - ([, props]: JCalComponent) => + ([, props]: VCalComponent) => !props.find(([k]) => k.toLowerCase() === "recurrence-id") ); @@ -235,9 +223,9 @@ export const deleteEventInstance = async (event: CalendarEvent) => { const masterProps = vevents[masterIndex][1]; // Check if this date is already in EXDATE (avoid duplicates) - const normalizeRecurrenceId = (id: JCalValue) => + const normalizeRecurrenceId = (id: VObjectValue) => String(id ?? "").replace(/Z$/, ""); - const isDuplicate = masterProps.some((prop: JCalProperty) => { + const isDuplicate = masterProps.some((prop: VObjectProperty) => { if (prop[0].toLowerCase() === "exdate" && prop[3]) { return ( normalizeRecurrenceId(prop[3]) === normalizeRecurrenceId(exdateValue) @@ -256,7 +244,7 @@ export const deleteEventInstance = async (event: CalendarEvent) => { vevents[masterIndex][1] = masterProps; // Remove the override instance if it exists (in case it was an override being deleted) - const filteredVevents = vevents.filter(([, props]: JCalComponent) => { + const filteredVevents = vevents.filter(([, props]: VCalComponent) => { const recurrenceIdProp = props.find( ([k]) => k.toLowerCase() === "recurrence-id" ); @@ -294,12 +282,12 @@ export const updateSeriesPartstat = async ( const vevents = await getAllRecurrentEvent(event); // Update PARTSTAT in ALL VEVENTs (master + exceptions) - const updatedVevents = vevents.map((vevent: JCalComponent) => { + const updatedVevents = vevents.map((vevent: VCalComponent) => { const properties = vevent[1]; - const updatedProperties = properties.map((prop: JCalProperty) => { + const updatedProperties = properties.map((prop: VObjectProperty) => { // Find ATTENDEE properties if (prop[0] === "attendee") { - const calAddress = prop[3]; + const calAddress = prop[3] as string; // Check if this is the target attendee if (calAddress.toLowerCase().includes(attendeeEmail.toLowerCase())) { // Update PARTSTAT parameter @@ -330,57 +318,7 @@ export const updateSeriesPartstat = async ( }); }; -export const updateSeries = async ( - event: CalendarEvent, - calOwnerEmail?: string, - removeOverrides: boolean = true -) => { - const vevents = await getAllRecurrentEvent(event); - const masterIndex = vevents.findIndex( - ([, props]: JCalComponent) => - !props.find(([k]) => k.toLowerCase() === "recurrence-id") - ); - if (masterIndex === -1) { - throw new Error("No master VEVENT found for this series"); - } - const rrule = vevents[masterIndex][1].find(([k]: string[]) => k === "rrule"); - - const tzid = event.timezone; - - const updatedMaster = makeVevent(event, tzid, calOwnerEmail, true); - const newRrule = updatedMaster[1].find(([k]: string[]) => k === "rrule"); - if (!newRrule) { - updatedMaster[1].push(rrule); - } - - const timezoneData = TIMEZONES.zones[event.timezone]; - const vtimezone = makeTimezone(timezoneData, event); - - let finalVevents; - if (removeOverrides) { - // When date/time/timezone/repeat rules changed, remove all override instances - finalVevents = [updatedMaster]; - } else { - // When only properties changed, keep override instances - vevents[masterIndex] = updatedMaster; - finalVevents = vevents; - } - - const newJCal = [ - "vcalendar", - [], - [...finalVevents, vtimezone.component.jCal], - ]; - return api(`dav${event.URL}`, { - method: "PUT", - body: JSON.stringify(newJCal), - headers: { - "content-type": "text/calendar; charset=utf-8", - }, - }); -}; - -async function getAllRecurrentEvent(event: CalendarEvent) { +export async function getAllRecurrentEvent(event: CalendarEvent) { const response = await api.get(`dav${event.URL}`); const eventData = await response.text(); const jcal = ICAL.parse(eventData); diff --git a/src/features/Events/api/updateSeries.ts b/src/features/Events/api/updateSeries.ts new file mode 100644 index 0000000..add5a4c --- /dev/null +++ b/src/features/Events/api/updateSeries.ts @@ -0,0 +1,263 @@ +import { api } from "@/utils/apiUtils"; +import { TIMEZONES } from "@/utils/timezone-data"; +import { + VCalComponent, + VObjectProperty, +} from "../../Calendars/types/CalendarData"; +import { getAllRecurrentEvent } from "../EventApi"; +import { CalendarEvent } from "../EventsTypes"; +import { makeTimezone, makeVevent } from "../eventUtils"; + +const METADATA_FIELDS = [ + "summary", + "description", + "location", + "class", + "transp", + "attendee", + "organizer", + "x-openpaas-videoconference", +] as const; + +// Helper function to get field values from props +const getFieldValues = (props: VObjectProperty[], fieldName: string) => { + return props.filter(([k]) => k.toLowerCase() === fieldName.toLowerCase()); +}; + +// Helper function to find a single field value from props +const findFieldValue = (props: VObjectProperty[], fieldName: string) => { + return props.find(([k]) => k.toLowerCase() === fieldName.toLowerCase()); +}; + +// Helper function to serialize for comparison +const serialize = (values: VObjectProperty[] | VCalComponent[]) => { + return JSON.stringify(values); +}; + +// Helper function to filter components by name +const filterComponentsByName = (components: VCalComponent[], name: string) => { + return components.filter( + ([componentName]) => componentName.toLowerCase() === name.toLowerCase() + ); +}; + +// Detect which metadata fields changed between old and new master +const detectChangedMetadataFields = ( + oldMasterProps: VObjectProperty[], + newMasterProps: VObjectProperty[] +) => { + const changedFields = new Map(); + + METADATA_FIELDS.forEach((fieldName) => { + const oldValues = getFieldValues(oldMasterProps, fieldName); + const newValues = getFieldValues(newMasterProps, fieldName); + + if (serialize(oldValues) !== serialize(newValues)) { + changedFields.set(fieldName.toLowerCase(), newValues); + } + }); + + return changedFields; +}; + +// Check if VALARM component changed between old and new master +const detectValarmChanges = ( + oldMaster: VCalComponent, + updatedMaster: VCalComponent +) => { + const oldMasterComponents = oldMaster[2] || []; + const newMasterComponents = updatedMaster[2] || []; + + const oldValarm = filterComponentsByName(oldMasterComponents, "valarm"); + const newValarm = filterComponentsByName(newMasterComponents, "valarm"); + + const valarmChanged = serialize(oldValarm) !== serialize(newValarm); + + return { valarmChanged, newValarm }; +}; + +// Apply changed metadata fields to a vevent's properties +const applyMetadataChanges = ( + props: VObjectProperty[], + changedFields: Map +) => { + let newProps = [...props]; + + changedFields.forEach((newValues, fieldNameLower) => { + // Remove old values of this changed field from exception + const filteredProps = newProps.filter( + ([k]) => k.toLowerCase() !== fieldNameLower + ); + + // Add new values from updated master + newProps = [...filteredProps, ...newValues]; + }); + + return newProps; +}; + +// Increment the sequence number in properties +const incrementSequenceNumber = (props: VObjectProperty[]) => { + const newProps = [...props]; + const sequenceIndex = newProps.findIndex( + ([k]) => k.toLowerCase() === "sequence" + ); + + if (sequenceIndex !== -1) { + const currentSequence = parseInt( + (newProps[sequenceIndex][3] as string) || "0", + 10 + ); + newProps[sequenceIndex] = [ + newProps[sequenceIndex][0], + newProps[sequenceIndex][1], + newProps[sequenceIndex][2], + currentSequence + 1, + ]; + } else { + newProps.push(["sequence", {}, "integer", 1]); + } + + return newProps; +}; + +// Update VALARM components if they changed +const updateValarmComponents = ( + components: VCalComponent[], + newValarm: VCalComponent[] +) => { + return components + .filter(([name]) => name.toLowerCase() !== "valarm") + .concat(newValarm); +}; + +// Update a single vevent with metadata changes +const updateVeventWithMetadataChanges = ( + vevent: VCalComponent, + index: number, + masterIndex: number, + updatedMaster: VCalComponent, + changedFields: Map, + valarmChanged: boolean, + newValarm: VCalComponent[] +): VCalComponent => { + if (index === masterIndex) { + return updatedMaster; + } + + const [veventType, props, components = []] = vevent; + + // Apply metadata changes + let newProps = applyMetadataChanges(props, changedFields); + + // Increment sequence number if any changes were made + if (changedFields.size > 0 || valarmChanged) { + newProps = incrementSequenceNumber(newProps); + } + + // Handle VALARM component updates + let updatedComponents = components; + if (valarmChanged) { + updatedComponents = updateValarmComponents(components, newValarm); + } + + return [veventType, newProps, updatedComponents]; +}; + +// Update all vevents with metadata changes while preserving overrides +const updateVeventsPreservingOverrides = ( + vevents: VCalComponent[], + oldMaster: VCalComponent, + updatedMaster: VCalComponent, + masterIndex: number +) => { + const oldMasterProps = oldMaster[1]; + const newMasterProps = updatedMaster[1]; + + // Detect which fields changed in the master + const changedFields = detectChangedMetadataFields( + oldMasterProps, + newMasterProps + ); + + // Check if VALARM component changed + const { valarmChanged, newValarm } = detectValarmChanges( + oldMaster, + updatedMaster + ); + + // Update all vevents + return vevents.map((vevent, index) => + updateVeventWithMetadataChanges( + vevent, + index, + masterIndex, + updatedMaster, + changedFields, + valarmChanged, + newValarm + ) + ); +}; + +export const updateSeries = async ( + event: CalendarEvent, + calOwnerEmail?: string, + removeOverrides: boolean = true +) => { + const vevents = (await getAllRecurrentEvent(event)) as VCalComponent[]; + const masterIndex = vevents.findIndex( + ([, props]) => !findFieldValue(props, "recurrence-id") + ); + + if (masterIndex === -1) { + throw new Error("No master VEVENT found for this series"); + } + + const rrule = findFieldValue(vevents[masterIndex][1], "rrule"); + const tzid = event.timezone; + const oldMaster = vevents[masterIndex]; + + const updatedMaster = makeVevent( + event, + tzid, + calOwnerEmail, + true + ) as VCalComponent; + const newRrule = findFieldValue(updatedMaster[1], "rrule"); + if (!newRrule && rrule) { + updatedMaster[1].push(rrule!); + } + + const timezoneData = TIMEZONES.zones[event.timezone]; + const vtimezone = makeTimezone(timezoneData, event); + + let finalVevents: VCalComponent[]; + + if (removeOverrides) { + // When date/time/timezone/repeat rules changed, remove all override instances + finalVevents = [updatedMaster]; + } else { + // When only properties changed, keep override instances and update their metadata + finalVevents = updateVeventsPreservingOverrides( + vevents, + oldMaster, + updatedMaster, + masterIndex + ); + } + + const newJCal = [ + "vcalendar", + [], + [...finalVevents, vtimezone.component.jCal], + ]; + + return api(`dav${event.URL}`, { + method: "PUT", + body: JSON.stringify(newJCal), + headers: { + "content-type": "text/calendar; charset=utf-8", + }, + }); +};