Skip to content

Commit eaf36a5

Browse files
committed
✨ feat(edit-recurrence): WIP
1 parent 3bd0438 commit eaf36a5

33 files changed

+9112
-680
lines changed

packages/backend/src/__tests__/mocks.gcal/factories/gcal.factory.ts

Lines changed: 109 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
gSchema$CalendarListEntry,
1313
gSchema$Channel,
1414
gSchema$Event,
15+
gSchema$EventBase,
1516
gSchema$Events,
1617
} from "@core/types/gcal";
1718
import {
@@ -20,7 +21,8 @@ import {
2021
isRegularGCalEvent,
2122
} from "@core/util/event/gcal.event.util";
2223
import { compassTestState } from "@backend/__tests__/helpers/mock.setup";
23-
import { mockRecurringGcalEvents } from "@backend/__tests__/mocks.gcal/factories/gcal.event.factory";
24+
import { GcalEventRRule } from "../../../event/classes/gcal.event.rrule";
25+
import { generateGcalId } from "./gcal.event.factory";
2426

2527
/**
2628
* Generates a paginated items for the Google Calendar API.
@@ -111,8 +113,14 @@ export const mockGcal = ({
111113
): GaxiosPromise<gSchema$Event> => {
112114
const testState = compassTestState();
113115
const { all: events } = testState.events.gcalEvents;
116+
const id = params.requestBody?.id ?? generateGcalId();
117+
const event = { ...params.requestBody, id } as gSchema$EventBase;
118+
const isBase = isBaseGCalEvent(event);
119+
const rrule = isBase ? new GcalEventRRule(event) : null;
120+
const instances = rrule?.instances() ?? [];
121+
const newEvents = [event, ...instances];
114122

115-
events.push(params.requestBody as WithGcalId<gSchema$Event>);
123+
events.push(...newEvents);
116124

117125
return Promise.resolve({
118126
config: options,
@@ -138,7 +146,24 @@ export const mockGcal = ({
138146
throw new Error(`Event with id ${eventId} not found`);
139147
}
140148

141-
const updatedEvent = { ...events[eventIndex], ...params.requestBody };
149+
let updatedEvent = { ...events[eventIndex], ...params.requestBody };
150+
151+
if (updatedEvent.recurrence === null) {
152+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
153+
const { recurrence, ...update } = updatedEvent;
154+
updatedEvent = update as gSchema$EventBase;
155+
156+
events
157+
.filter(
158+
({ recurringEventId }) => updatedEvent.id === recurringEventId,
159+
)
160+
.forEach((instance) => {
161+
const index = events.findIndex((e) => e.id === instance.id);
162+
if (index !== -1) {
163+
events.splice(index, 1);
164+
}
165+
});
166+
}
142167

143168
events.splice(
144169
eventIndex,
@@ -156,6 +181,68 @@ export const mockGcal = ({
156181
});
157182
},
158183
),
184+
update: jest.fn(
185+
async (
186+
params: calendar_v3.Params$Resource$Events$Update,
187+
options: MethodOptions = {},
188+
): GaxiosPromise<gSchema$Event> => {
189+
const testState = compassTestState();
190+
const { all: events } = testState.events.gcalEvents;
191+
const { eventId } = params;
192+
const eventIndex = events.findIndex((e) => e.id === eventId);
193+
194+
if (eventIndex === -1) {
195+
throw new Error(`Event with id ${eventId} not found`);
196+
}
197+
198+
let updatedEvent = {
199+
...events[eventIndex],
200+
...params.requestBody,
201+
} as gSchema$EventBase;
202+
203+
if (updatedEvent.recurrence === null) {
204+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
205+
const { recurrence, ...update } = updatedEvent;
206+
updatedEvent = update as gSchema$EventBase;
207+
208+
events
209+
.filter(
210+
({ recurringEventId }) => updatedEvent.id === recurringEventId,
211+
)
212+
.forEach((instance) => {
213+
const index = events.findIndex((e) => e.id === instance.id);
214+
if (index !== -1) {
215+
events.splice(index, 1);
216+
}
217+
});
218+
}
219+
220+
events.splice(eventIndex, 1, updatedEvent);
221+
222+
const isBase = isBaseGCalEvent(updatedEvent);
223+
const rrule = isBase ? new GcalEventRRule(updatedEvent) : null;
224+
const instances = rrule?.instances() ?? [];
225+
226+
instances.forEach((instance) => {
227+
const instanceIndex = events.findIndex((e) => e.id === instance.id);
228+
229+
if (instanceIndex !== -1) {
230+
events.splice(instanceIndex, 1, instance);
231+
} else {
232+
events.push(instance);
233+
}
234+
});
235+
236+
return Promise.resolve({
237+
config: options,
238+
statusText: "OK",
239+
status: 200,
240+
data: updatedEvent,
241+
headers: options.headers!,
242+
request: { responseURL: updatedEvent.id! },
243+
});
244+
},
245+
),
159246
delete: jest.fn(
160247
async (params: calendar_v3.Params$Resource$Events$Delete) => {
161248
const testState = compassTestState();
@@ -167,8 +254,23 @@ export const mockGcal = ({
167254
throw new Error(`Event with id ${eventId} not found`);
168255
}
169256

257+
const event = events[eventIndex]!;
258+
const isRecurring = isBaseGCalEvent(event);
259+
170260
events.splice(eventIndex, 1);
171261

262+
if (isRecurring) {
263+
// Also delete all instances of the recurring event
264+
const instanceEvents = events.filter(isInstanceGCalEvent);
265+
266+
instanceEvents.forEach((instance) => {
267+
const index = events.findIndex((e) => e.id === instance.id);
268+
if (index !== -1) {
269+
events.splice(index, 1);
270+
}
271+
});
272+
}
273+
172274
return Promise.resolve({
173275
statusText: "OK",
174276
status: 204,
@@ -211,6 +313,7 @@ export const mockGcal = ({
211313
params.maxResults ?? pageSize,
212314
params.pageToken,
213315
);
316+
214317
return {
215318
statusText: "OK",
216319
status: 200,
@@ -227,7 +330,9 @@ export const mockGcal = ({
227330

228331
if (!baseEvent) throw new Error(`Event with id ${id} not found`);
229332

230-
const { instances } = mockRecurringGcalEvents({ ...baseEvent, id });
333+
const instances = events.filter(
334+
({ recurringEventId }) => recurringEventId === id,
335+
);
231336

232337
const eventsPage = generatePaginatedGcalItems(
233338
instances,

packages/backend/src/common/services/gcal/gcal.service.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,28 @@ class GCalService {
2121
const { status, statusText } = response;
2222

2323
if (status !== 200 || statusText !== "OK") {
24-
throw new Error(message);
24+
throw error(GcalError.Unsure, message);
2525
}
2626

2727
return response;
2828
}
2929

30+
async getEvent(
31+
gcal: gCalendar,
32+
gcalEventId: string,
33+
calendarId = GCAL_PRIMARY,
34+
) {
35+
const response = await gcal.events.get({
36+
calendarId,
37+
eventId: gcalEventId,
38+
});
39+
40+
return response.data;
41+
}
42+
3043
async createEvent(gcal: gCalendar, event: gSchema$Event) {
3144
const response = await gcal.events.insert({
32-
calendarId: "primary",
45+
calendarId: GCAL_PRIMARY,
3346
requestBody: event,
3447
});
3548

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
import { ClientSession, Filter, ObjectId, WithId } from "mongodb";
2+
import { MapEvent } from "@core/mappers/map.event";
3+
import {
4+
CompassAllEvents,
5+
CompassEvent,
6+
CompassEventSchema,
7+
CompassThisAndFollowingEvent,
8+
EventUpdateSchema,
9+
Event_Core,
10+
RecurringEventUpdateScope,
11+
Schema_Event,
12+
Schema_Event_Recur_Base,
13+
Schema_Event_Recur_Instance,
14+
} from "@core/types/event.types";
15+
import { parseCompassEventDate } from "@core/util/event/event.util";
16+
import mongoService from "@backend/common/services/mongo.service";
17+
import { CompassEventRRule } from "@backend/event/classes/compass.event.rrule";
18+
19+
export class CompassEventFactory {
20+
private static async findCompassEvent(
21+
{ eventId, userId: user }: Pick<CompassEvent, "userId" | "eventId">,
22+
session?: ClientSession,
23+
throwIfNotFound = true,
24+
): Promise<WithId<Omit<Schema_Event, "_id">> | null> {
25+
const _id = new ObjectId(eventId);
26+
const filter: Filter<Omit<Schema_Event, "_id">> = { _id, user };
27+
28+
const event = await mongoService.event.findOne(filter, { session });
29+
30+
if (throwIfNotFound && !event) {
31+
throw new Error(`Compass event not found for id: ${eventId}`);
32+
}
33+
34+
return event;
35+
}
36+
37+
private static async findCompassBaseAndInstanceEvent(
38+
event: Pick<CompassEvent, "userId" | "eventId">,
39+
session?: ClientSession,
40+
): Promise<{
41+
baseEvent: WithId<Omit<Schema_Event_Recur_Base, "_id">>;
42+
instanceEvent: WithId<Omit<Schema_Event_Recur_Instance, "_id">>;
43+
}> {
44+
const { userId } = event;
45+
46+
// get instance event or throw
47+
const instanceEvent = await CompassEventFactory.findCompassEvent(
48+
event,
49+
session,
50+
);
51+
52+
const baseEventId = instanceEvent?.recurrence?.eventId?.toString();
53+
54+
if (!baseEventId) throw new Error("event is not a recurring instance");
55+
56+
// get base event in series or throw
57+
const baseEvent = await CompassEventFactory.findCompassEvent(
58+
{ userId, eventId: baseEventId },
59+
session,
60+
);
61+
62+
return {
63+
baseEvent: baseEvent as WithId<Omit<Schema_Event_Recur_Base, "_id">>,
64+
instanceEvent: instanceEvent as WithId<
65+
Omit<Schema_Event_Recur_Instance, "_id">
66+
>,
67+
};
68+
}
69+
70+
private static async genThisAndFollowingEvents(
71+
event: CompassEvent,
72+
session?: ClientSession,
73+
): Promise<CompassThisAndFollowingEvent[]> {
74+
const { baseEvent, instanceEvent } =
75+
await CompassEventFactory.findCompassBaseAndInstanceEvent(event, session);
76+
77+
const rruleOldSeries = new CompassEventRRule(baseEvent, {
78+
until: parseCompassEventDate(instanceEvent.startDate!)
79+
.subtract(1, baseEvent.isAllDay ? "day" : "milliseconds")
80+
.toDate(),
81+
});
82+
83+
const baseEventId = baseEvent._id.toString();
84+
85+
const compassBaseEventWithUntil = {
86+
...event,
87+
eventId: baseEventId,
88+
payload: {
89+
...rruleOldSeries.base(),
90+
_id: baseEventId,
91+
},
92+
} as CompassThisAndFollowingEvent;
93+
94+
const payload = EventUpdateSchema.parse(event.payload);
95+
96+
delete payload.recurrence?.eventId;
97+
98+
const rruleNewSeries = new CompassEventRRule({
99+
...MapEvent.removeIdentifyingData(instanceEvent),
100+
...(payload as Schema_Event_Recur_Base),
101+
_id: instanceEvent._id,
102+
});
103+
104+
const newBase = rruleNewSeries.base();
105+
106+
// new series
107+
const compassEvent = {
108+
...event,
109+
payload: {
110+
...newBase,
111+
_id: newBase._id.toString(),
112+
} as CompassThisAndFollowingEvent["payload"],
113+
} as CompassThisAndFollowingEvent;
114+
115+
return [compassBaseEventWithUntil, compassEvent];
116+
}
117+
118+
private static async genAllEvents(
119+
event: CompassEvent,
120+
session?: ClientSession,
121+
): Promise<CompassAllEvents[]> {
122+
const { baseEvent } =
123+
await CompassEventFactory.findCompassBaseAndInstanceEvent(event, session);
124+
125+
const eventId = baseEvent._id.toString();
126+
const payload = EventUpdateSchema.parse(event.payload);
127+
const nullRecurrence = payload.recurrence?.rule === null;
128+
129+
delete payload.startDate;
130+
delete payload.endDate;
131+
delete payload.recurrence?.eventId;
132+
133+
if (nullRecurrence) {
134+
delete (payload as Event_Core).recurrence;
135+
delete (baseEvent as unknown as Event_Core).recurrence;
136+
}
137+
138+
const compassEvent = {
139+
...event,
140+
eventId,
141+
payload: {
142+
...baseEvent,
143+
...payload,
144+
_id: eventId,
145+
} as CompassAllEvents["payload"],
146+
} as CompassAllEvents;
147+
148+
return [compassEvent];
149+
}
150+
151+
private static async genThisEvent(
152+
event: CompassEvent,
153+
session?: ClientSession,
154+
): Promise<CompassEvent[]> {
155+
const payload = event.payload as Schema_Event;
156+
const hasRRule = Array.isArray(payload.recurrence?.rule);
157+
const hasRecurringBase = !!payload.recurrence?.eventId;
158+
const isSomeday = payload.isSomeday;
159+
const nullRecurrence = payload.recurrence?.rule === null;
160+
const baseToStandaloneTransition = nullRecurrence && hasRecurringBase;
161+
const baseToSomedayTransition = isSomeday && hasRecurringBase;
162+
163+
if (baseToStandaloneTransition || baseToSomedayTransition) {
164+
return CompassEventFactory.genAllEvents(event, session);
165+
}
166+
167+
if (hasRRule && hasRecurringBase) delete payload.recurrence?.rule;
168+
if (nullRecurrence && !hasRecurringBase) delete payload.recurrence;
169+
170+
return Promise.resolve([event]);
171+
}
172+
173+
static async generateEvents(
174+
_event: CompassEvent,
175+
session?: ClientSession,
176+
): Promise<CompassEvent[]> {
177+
const event = CompassEventSchema.parse(_event);
178+
179+
switch (event.applyTo) {
180+
case RecurringEventUpdateScope.ALL_EVENTS:
181+
return CompassEventFactory.genAllEvents(event, session);
182+
case RecurringEventUpdateScope.THIS_AND_FOLLOWING_EVENTS:
183+
return CompassEventFactory.genThisAndFollowingEvents(event, session);
184+
case RecurringEventUpdateScope.THIS_EVENT:
185+
default:
186+
return CompassEventFactory.genThisEvent(event);
187+
}
188+
}
189+
}

0 commit comments

Comments
 (0)