diff --git a/README.md b/README.md index 327fa7941a..bdacd57db5 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,10 @@ Core features include: -- [Talawa API](#talawa-api) - - [Talawa Components](#talawa-components) - - [Documentation](#documentation) - - [Installation](#installation) - - [Image Upload](#image-upload) +- [Talawa Components](#talawa-components) +- [Documentation](#documentation) +- [Installation](#installation) +- [Image Upload](#image-upload) @@ -53,4 +52,4 @@ Core features include: ## Image Upload -To enable image upload functionalities create an images folder in the root of the project \ No newline at end of file +To enable image upload functionalities create an images folder in the root of the project diff --git a/package-lock.json b/package-lock.json index 12919106f9..6c6e1d4850 100644 --- a/package-lock.json +++ b/package-lock.json @@ -52,6 +52,7 @@ "nodemailer": "^6.9.9", "pm2": "^5.2.0", "redis": "^4.6.12", + "rrule": "^2.8.1", "shortid": "^2.2.16", "typedoc-plugin-markdown": "^3.17.1", "uuid": "^9.0.0", @@ -15301,6 +15302,14 @@ "fsevents": "~2.3.2" } }, + "node_modules/rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", @@ -28483,6 +28492,14 @@ "fsevents": "~2.3.2" } }, + "rrule": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/rrule/-/rrule-2.8.1.tgz", + "integrity": "sha512-hM3dHSBMeaJ0Ktp7W38BJZ7O1zOgaFEsn41PDk+yHoEtfLV+PoJt9E9xAlZiWgf/iqEqionN0ebHFZIDAp+iGw==", + "requires": { + "tslib": "^2.4.0" + } + }, "run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", diff --git a/package.json b/package.json index eabaa960ee..eed8602574 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "nodemailer": "^6.9.9", "pm2": "^5.2.0", "redis": "^4.6.12", + "rrule": "^2.8.1", "shortid": "^2.2.16", "typedoc-plugin-markdown": "^3.17.1", "uuid": "^9.0.0", diff --git a/schema.graphql b/schema.graphql index 19d7ad4b4d..3a9565b223 100644 --- a/schema.graphql +++ b/schema.graphql @@ -253,7 +253,7 @@ type Event { createdAt: DateTime! creator: User description: String! - endDate: Date! + endDate: Date endTime: Time feedback: [Feedback!]! isPublic: Boolean! @@ -376,6 +376,13 @@ input ForgotPasswordData { userOtp: String! } +enum Frequency { + DAILY + MONTHLY + WEEKLY + YEARLY +} + enum Gender { FEMALE MALE @@ -536,7 +543,7 @@ type Mutation { createComment(data: CommentInput!, postId: ID!): Comment createDirectChat(data: createChatInput!): DirectChat! createDonation(amount: Float!, nameOfOrg: String!, nameOfUser: String!, orgId: ID!, payPalId: ID!, userId: ID!): Donation! - createEvent(data: EventInput): Event! + createEvent(data: EventInput!, recurrenceRuleData: RecurrenceRuleInput): Event! createGroupChat(data: createGroupChatInput!): GroupChat! createMember(input: UserAndOrganizationInput!): Organization! createMessageChat(data: MessageChatInput!): MessageChat! @@ -898,6 +905,12 @@ enum Recurrance { YEARLY } +input RecurrenceRuleInput { + count: Int + frequency: Frequency + weekDays: [WeekDays] +} + enum Status { ACTIVE BLOCKED @@ -1207,6 +1220,16 @@ type UsersConnectionResult { errors: [ConnectionError!]! } +enum WeekDays { + FR + MO + SA + SU + TH + TU + WE +} + input createChatInput { organizationId: ID! userIds: [ID!]! diff --git a/src/constants.ts b/src/constants.ts index b454f7e676..8745ce129a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -529,6 +529,26 @@ export const REDIS_PASSWORD = process.env.REDIS_PASSWORD; export const MILLISECONDS_IN_A_WEEK = 7 * 24 * 60 * 60 * 1000; +// recurring event frequencies +export const RECURRENCE_FREQUENCIES = ["YEARLY", "MONTHLY", "WEEKLY", "DAILY"]; + +// recurring instance generation date limit in years based on it's frequency +export const RECURRING_EVENT_INSTANCES_DAILY_LIMIT = 1; +export const RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT = 2; +export const RECURRING_EVENT_INSTANCES_MONTHLY_LIMIT = 5; +export const RECURRING_EVENT_INSTANCES_YEARLY_LIMIT = 10; + +// recurring event days +export const RECURRENCE_WEEKDAYS = [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY", +]; + export const key = ENV.ENCRYPTION_KEY as string; export const iv = crypto.randomBytes(16).toString("hex"); diff --git a/src/helpers/event/createEventHelpers/createRecurringEvent.ts b/src/helpers/event/createEventHelpers/createRecurringEvent.ts new file mode 100644 index 0000000000..4c736d9646 --- /dev/null +++ b/src/helpers/event/createEventHelpers/createRecurringEvent.ts @@ -0,0 +1,101 @@ +import type mongoose from "mongoose"; +import type { InterfaceEvent } from "../../../models"; +import { Event } from "../../../models"; +import type { MutationCreateEventArgs } from "../../../types/generatedGraphQLTypes"; +import { + generateRecurrenceRuleString, + getRecurringInstanceDates, + createRecurrenceRule, + generateRecurringEventInstances, +} from "../recurringEventHelpers"; + +/** + * This function creates the instances of a recurring event upto a certain date. + * @param args - payload of the createEvent mutation + * @param creatorId - _id of the creator + * @param organizationId - _id of the organization the events belongs to + * @remarks The following steps are followed: + * 1. Create a default recurrenceRuleData. + * 2. Generate a recurrence rule string based on the recurrenceRuleData. + * 3. Create a baseRecurringEvent on which recurring instances would be based. + * 4. Get the dates for recurring instances. + * 5. Create a recurrenceRule document. + * 6. Generate recurring instances according to the recurrence rule. + * @returns Created recurring event instance + */ + +export const createRecurringEvent = async ( + args: MutationCreateEventArgs, + creatorId: string, + organizationId: string, + session: mongoose.ClientSession +): Promise => { + const { data } = args; + let { recurrenceRuleData } = args; + + if (!recurrenceRuleData) { + // create a default weekly recurrence rule + recurrenceRuleData = { + frequency: "WEEKLY", + }; + } + + // generate a recurrence rule string which would be used to generate rrule object + // and get recurrence dates + const recurrenceRuleString = generateRecurrenceRuleString( + recurrenceRuleData, + data?.startDate, + data?.endDate + ); + + // create a base recurring event first, based on which all the + // recurring instances would be dynamically generated + const baseRecurringEvent = await Event.create( + [ + { + ...data, + recurring: true, + isBaseRecurringEvent: true, + creatorId, + admins: [creatorId], + organization: organizationId, + }, + ], + { session } + ); + + // get the dates for the recurringInstances, and the date of the last instance + // to be generated in this operation (rest would be generated dynamically during query) + const recurringInstanceDates = getRecurringInstanceDates( + recurrenceRuleString, + data.startDate, + data.endDate + ); + + // get the date for the latest created instance + const latestInstanceDate = + recurringInstanceDates[recurringInstanceDates.length - 1]; + // create a recurrenceRule document that would contain the recurrence pattern + const recurrenceRule = await createRecurrenceRule( + recurrenceRuleString, + data.startDate, + data.endDate, + organizationId, + baseRecurringEvent[0]?._id.toString(), + latestInstanceDate, + session + ); + + // generate the recurring instances and get an instance back + const recurringEventInstance = await generateRecurringEventInstances({ + data, + baseRecurringEventId: baseRecurringEvent[0]?._id.toString(), + recurrenceRuleId: recurrenceRule?._id.toString(), + recurringInstanceDates, + creatorId, + organizationId, + session, + }); + + return recurringEventInstance; +}; diff --git a/src/helpers/event/createEventHelpers/createSingleEvent.ts b/src/helpers/event/createEventHelpers/createSingleEvent.ts new file mode 100644 index 0000000000..fe9799585d --- /dev/null +++ b/src/helpers/event/createEventHelpers/createSingleEvent.ts @@ -0,0 +1,66 @@ +import type mongoose from "mongoose"; +import type { InterfaceEvent } from "../../../models"; +import { Event, EventAttendee, User } from "../../../models"; +import type { MutationCreateEventArgs } from "../../../types/generatedGraphQLTypes"; +import { cacheEvents } from "../../../services/EventCache/cacheEvents"; + +/** + * This function generates a single non-recurring event. + * @param args - the arguments provided for the createEvent mutation. + * @param creatorId - _id of the current user. + * @param organizationId - _id of the current organization. + * @remarks The following steps are followed: + * 1. Create an event document. + * 2. Associate the event with the user + * 3. Cache the event. + * @returns The created event. + */ + +export const createSingleEvent = async ( + args: MutationCreateEventArgs, + creatorId: string, + organizationId: string, + session: mongoose.ClientSession +): Promise => { + // create the single event + const createdEvent = await Event.create( + [ + { + ...args.data, + creatorId, + admins: [creatorId], + organization: organizationId, + }, + ], + { session } + ); + + // associate event with the user + await EventAttendee.create( + [ + { + userId: creatorId, + eventId: createdEvent[0]?._id, + }, + ], + { session } + ); + await User.updateOne( + { + _id: creatorId, + }, + { + $push: { + eventAdmin: createdEvent[0]?._id, + createdEvents: createdEvent[0]?._id, + registeredEvents: createdEvent[0]?._id, + }, + }, + { session } + ); + + // cache the event + await cacheEvents([createdEvent[0]]); + + return createdEvent[0]; +}; diff --git a/src/helpers/event/createEventHelpers/index.ts b/src/helpers/event/createEventHelpers/index.ts new file mode 100644 index 0000000000..ebab56acb9 --- /dev/null +++ b/src/helpers/event/createEventHelpers/index.ts @@ -0,0 +1,2 @@ +export { createSingleEvent } from "./createSingleEvent"; +export { createRecurringEvent } from "./createRecurringEvent"; diff --git a/src/helpers/event/recurringEventHelpers/createRecurrenceRule.ts b/src/helpers/event/recurringEventHelpers/createRecurrenceRule.ts new file mode 100644 index 0000000000..1f0264c499 --- /dev/null +++ b/src/helpers/event/recurringEventHelpers/createRecurrenceRule.ts @@ -0,0 +1,65 @@ +import type mongoose from "mongoose"; +import { rrulestr } from "rrule"; +import type { InterfaceRecurrenceRule } from "../../../models/RecurrenceRule"; +import { RecurrenceRule } from "../../../models/RecurrenceRule"; +import { + RECURRENCE_FREQUENCIES, + RECURRENCE_WEEKDAYS, +} from "../../../constants"; + +/** + * This function generates the recurrenceRule document. + * @param recurrenceRuleString - the rrule string containing the rules that the instances would follow. + * @param recurrenceStartDate - start date of recurrence. + * @param recurrenceEndDate - end date of recurrence. + * @param organizationId - _id of the current organization. + * @param baseRecurringEventId - _id of the base recurring event. + * @param latestInstanceDate - start date of the last instance generated during this operation. + * @remarks The following steps are followed: + * 1. Create an rrule object from the rrule string. + * 2. Get the fields for the RecurrenceRule document. + * 3. Create the RecurrenceRuleDocument. + * @returns The recurrence rule document. + */ + +export const createRecurrenceRule = async ( + recurrenceRuleString: string, + recurrenceStartDate: Date, + recurrenceEndDate: Date | null, + organizationId: string, + baseRecurringEventId: string, + latestInstanceDate: Date, + session: mongoose.ClientSession +): Promise => { + const recurrenceRuleObject = rrulestr(recurrenceRuleString); + + const { freq, byweekday } = recurrenceRuleObject.options; + + const weekDays: string[] = []; + if (byweekday) { + for (const weekday of byweekday) { + weekDays.push(RECURRENCE_WEEKDAYS[weekday]); + } + } + + const frequency = RECURRENCE_FREQUENCIES[freq]; + + const recurrenceRule = await RecurrenceRule.create( + [ + { + organizationId, + baseRecurringEventId, + recurrenceRuleString, + startDate: recurrenceStartDate, + endDate: recurrenceEndDate, + frequency, + count: recurrenceRuleObject.options.count, + weekDays, + latestInstanceDate, + }, + ], + { session } + ); + + return recurrenceRule[0].toObject(); +}; diff --git a/src/helpers/event/recurringEventHelpers/generateRecurrenceRuleString.ts b/src/helpers/event/recurringEventHelpers/generateRecurrenceRuleString.ts new file mode 100644 index 0000000000..86331a5b71 --- /dev/null +++ b/src/helpers/event/recurringEventHelpers/generateRecurrenceRuleString.ts @@ -0,0 +1,53 @@ +import type { RecurrenceRuleInput } from "../../../types/generatedGraphQLTypes"; +import { convertToRRuleDateString } from "../../../utilities/recurrenceDatesUtil"; + +/** + * This function generates the recurrence rule (rrule) string. + * @param recurrenceRuleData - the recurrenceRuleInput provided in the args. + * @param recurrenceStartDate - start date of recurrence. + * @param recurrenceEndDate - end date of recurrence. + * @remarks The following steps are followed: + * 1. Get the date strings for start and end of recurrence. + * 2. Get the recurrence rules and make a recurrenceRuleString. + * @returns The recurrence rule string that would be used to create a valid rrule object. + */ + +export const generateRecurrenceRuleString = ( + recurrenceRuleData: RecurrenceRuleInput, + recurrenceStartDate: Date, + recurrenceEndDate?: Date +): string => { + // get the start date string for rrule's "DTSTART" property + const recurrenceStartDateString = + convertToRRuleDateString(recurrenceStartDate); + + // get the end date string for rrule's "UNTIL" property + let recurrenceEndDateString = ""; + if (recurrenceEndDate) { + recurrenceEndDateString = convertToRRuleDateString(recurrenceEndDate); + } + + // destructure the recurrence rules + const { frequency, count, weekDays } = recurrenceRuleData; + + // string representing the days of the week the event would recur + const weekDaysString = weekDays?.length ? weekDays.join(",") : ""; + + // initiate recurrence rule string + let recurrenceRuleString = `DTSTART:${recurrenceStartDateString}\nRRULE:FREQ=${frequency}`; + + if (recurrenceEndDateString) { + recurrenceRuleString += `;UNTIL=${recurrenceEndDateString}`; + } + + if (count) { + // maximum number of instances to generate + recurrenceRuleString += `;COUNT=${count}`; + } + + if (weekDaysString) { + recurrenceRuleString += `;BYDAY=${weekDaysString}`; + } + + return recurrenceRuleString; +}; diff --git a/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts b/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts new file mode 100644 index 0000000000..9578924b15 --- /dev/null +++ b/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts @@ -0,0 +1,111 @@ +import type mongoose from "mongoose"; +import type { InterfaceEvent } from "../../../models"; +import { Event, EventAttendee, User } from "../../../models"; +import type { EventInput } from "../../../types/generatedGraphQLTypes"; +import { cacheEvents } from "../../../services/EventCache/cacheEvents"; + +/** + * This function generates the recurring event instances. + * @param data - the EventInput data provided in the args. + * @param baseRecurringEventId - _id of the baseRecurringEvent. + * @param recurrenceRuleId - _id of the recurrenceRule document containing the recurrence rule that the instances follow. + * @param recurringInstanceDates - the dates of the recurring instances. + * @param creatorId - _id of the creator. + * @param organizationId - _id of the current organization. + * @remarks The following steps are followed: + * 1. Generate the instances for each provided date. + * 2. Insert the documents in the database. + * 3. Associate the instances with the user. + * 4. Cache the instances. + * @returns A recurring instance generated during this operation. + */ + +interface InterfaceGenerateRecurringInstances { + data: EventInput; + baseRecurringEventId: string; + recurrenceRuleId: string; + recurringInstanceDates: Date[]; + creatorId: string; + organizationId: string; + session: mongoose.ClientSession; +} + +interface InterfaceRecurringEvent extends EventInput { + isBaseRecurringEvent: boolean; + recurrenceRuleId: string; + baseRecurringEventId: string; + creatorId: string; + admins: string[]; + organization: string; +} + +export const generateRecurringEventInstances = async ({ + data, + baseRecurringEventId, + recurrenceRuleId, + recurringInstanceDates, + creatorId, + organizationId, + session, +}: InterfaceGenerateRecurringInstances): Promise => { + const recurringInstances: InterfaceRecurringEvent[] = []; + recurringInstanceDates.map((date) => { + const createdEventInstance = { + ...data, + startDate: date, + endDate: date, + recurring: true, + isBaseRecurringEvent: false, + recurrenceRuleId, + baseRecurringEventId, + creatorId, + admins: [creatorId], + organization: organizationId, + }; + + recurringInstances.push(createdEventInstance); + }); + + //Bulk insertion in database + const recurringEventInstances = await Event.insertMany(recurringInstances, { + session, + }); + + // add eventattendee for each instance + const eventAttendees = recurringEventInstances.map( + (recurringEventInstance) => ({ + userId: creatorId, + eventId: recurringEventInstance?._id.toString(), + }) + ); + + // get event instances ids for updating user event fields to include generated instances + const eventInstanceIds = recurringEventInstances.map((instance) => + instance._id.toString() + ); + + // perform database operations + await Promise.all([ + EventAttendee.insertMany(eventAttendees, { session }), + User.updateOne( + { _id: creatorId }, + { + $push: { + eventAdmin: { $each: eventInstanceIds }, + createdEvents: { $each: eventInstanceIds }, + registeredEvents: { $each: eventInstanceIds }, + }, + }, + { session } + ), + ]); + + // cache the instances + await Promise.all( + recurringEventInstances.map((recurringEventInstance) => + cacheEvents([recurringEventInstance]) + ) + ); + + return recurringEventInstances[0]; +}; diff --git a/src/helpers/event/recurringEventHelpers/getRecurringInstanceDates.ts b/src/helpers/event/recurringEventHelpers/getRecurringInstanceDates.ts new file mode 100644 index 0000000000..d23c584d51 --- /dev/null +++ b/src/helpers/event/recurringEventHelpers/getRecurringInstanceDates.ts @@ -0,0 +1,76 @@ +import { addYears } from "date-fns"; +import { Frequency, rrulestr } from "rrule"; +import type { RRule } from "rrule"; +import { + RECURRING_EVENT_INSTANCES_DAILY_LIMIT, + RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT, + RECURRING_EVENT_INSTANCES_MONTHLY_LIMIT, + RECURRING_EVENT_INSTANCES_YEARLY_LIMIT, +} from "../../../constants"; + +/** + * This function generates the dates of recurrence for the recurring event. + * @param recurrenceRuleString - the rrule string for the recurrenceRule. + * @param recurrenceStartDate - the starting date from which we want to generate instances. + * @param eventEndDate - the end date of the event + * @param queryUptoDate - the limit date to query recurrenceRules (To be used for dynamic instance generation during queries). + * @remarks The following steps are followed: + * 1. Get the date limit for instance generation based on its recurrence frequency. + * 2. Get the dates for recurring event instances. + * @returns Dates for recurring instances to be generated during this operation. + */ + +export function getRecurringInstanceDates( + recurrenceRuleString: string, + recurrenceStartDate: Date, + eventEndDate: Date | null, + queryUptoDate: Date = recurrenceStartDate +): Date[] { + // get the rrule object + const recurrenceRuleObject: RRule = rrulestr(recurrenceRuleString); + + // get the recurrence frequency + const { freq: recurrenceFrequency } = recurrenceRuleObject.options; + + // set limitEndDate according to the recurrence frequency + // and queryUptoDate, which would default to recurrenceStartDate during createRecurringEvent mutation + // and have a specific value during queries + let limitEndDate = addYears( + queryUptoDate, + RECURRING_EVENT_INSTANCES_DAILY_LIMIT + ); + + if (recurrenceFrequency === Frequency.WEEKLY) { + limitEndDate = addYears( + queryUptoDate, + RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT + ); + } else if (recurrenceFrequency === Frequency.MONTHLY) { + limitEndDate = addYears( + queryUptoDate, + RECURRING_EVENT_INSTANCES_MONTHLY_LIMIT + ); + } else if (recurrenceFrequency === Frequency.YEARLY) { + limitEndDate = addYears( + queryUptoDate, + RECURRING_EVENT_INSTANCES_YEARLY_LIMIT + ); + } + + // if the event has no endDate + eventEndDate = eventEndDate || limitEndDate; + + // the date upto which we would generate the instances in this operation + const generateUptoDate = new Date( + Math.min(eventEndDate.getTime(), limitEndDate.getTime()) + ); + + // get the dates of recurrence + const recurringInstanceDates = recurrenceRuleObject.between( + recurrenceStartDate, + generateUptoDate, + true + ); + + return recurringInstanceDates; +} diff --git a/src/helpers/event/recurringEventHelpers/index.ts b/src/helpers/event/recurringEventHelpers/index.ts new file mode 100644 index 0000000000..43e66640d5 --- /dev/null +++ b/src/helpers/event/recurringEventHelpers/index.ts @@ -0,0 +1,4 @@ +export { generateRecurrenceRuleString } from "./generateRecurrenceRuleString"; +export { getRecurringInstanceDates } from "./getRecurringInstanceDates"; +export { createRecurrenceRule } from "./createRecurrenceRule"; +export { generateRecurringEventInstances } from "./generateRecurringEventInstances"; diff --git a/src/helpers/eventInstances/index.ts b/src/helpers/eventInstances/index.ts deleted file mode 100644 index 75a88f1a7c..0000000000 --- a/src/helpers/eventInstances/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -import * as Once from "./once"; -import * as Weekly from "./weekly"; - -export { Once, Weekly }; diff --git a/src/helpers/eventInstances/once.ts b/src/helpers/eventInstances/once.ts deleted file mode 100644 index 4a844b4841..0000000000 --- a/src/helpers/eventInstances/once.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type mongoose from "mongoose"; -import type { - InterfaceEvent, - InterfaceOrganization, - InterfaceUser, -} from "../../models"; -import { Event } from "../../models"; -import type { MutationCreateEventArgs } from "../../types/generatedGraphQLTypes"; - -export async function generateEvent( - args: Partial, - currentUser: InterfaceUser, - organization: InterfaceOrganization, - session: mongoose.ClientSession -): Promise> { - const createdEvent = await Event.create( - [ - { - ...args.data, - creatorId: currentUser._id, - admins: [currentUser._id], - organization: organization._id, - }, - ], - { session } - ); - - return createdEvent; -} diff --git a/src/helpers/eventInstances/weekly.ts b/src/helpers/eventInstances/weekly.ts deleted file mode 100644 index 716fb7dcc3..0000000000 --- a/src/helpers/eventInstances/weekly.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type mongoose from "mongoose"; -import type { - InterfaceEvent, - InterfaceOrganization, - InterfaceUser, -} from "../../models"; -import { Event } from "../../models"; -import type { MutationCreateEventArgs } from "../../types/generatedGraphQLTypes"; -import { eachDayOfInterval, format } from "date-fns"; - -interface InterfaceRecurringEvent extends MutationCreateEventArgs { - startDate: Date; - creatorId: mongoose.Types.ObjectId; - admins: mongoose.Types.ObjectId[]; - organization: mongoose.Types.ObjectId; -} - -export async function generateEvents( - args: Partial, - currentUser: InterfaceUser, - organization: InterfaceOrganization, - session: mongoose.ClientSession -): Promise { - const recurringEvents: InterfaceRecurringEvent[] = []; - const { data } = args; - - const startDate = new Date(data?.startDate); - const endDate = new Date(data?.endDate); - - const allDays = eachDayOfInterval({ start: startDate, end: endDate }); - const occurrences = allDays.filter( - (date) => date.getDay() === startDate.getDay() - ); - - occurrences.map((date) => { - const formattedDate = format(date, "yyyy-MM-dd"); - - const createdEvent = { - ...data, - startDate: new Date(formattedDate), - creatorId: currentUser._id, - admins: [currentUser._id], - organization: organization._id, - }; - - recurringEvents.push(createdEvent); - }); - - //Bulk insertion in database - const weeklyEvents = await Event.insertMany(recurringEvents, { session }); - - return Array.isArray(weeklyEvents) ? weeklyEvents : [weeklyEvents]; -} diff --git a/src/models/Event.ts b/src/models/Event.ts index 05b72ac0f5..6b073ebd19 100644 --- a/src/models/Event.ts +++ b/src/models/Event.ts @@ -2,6 +2,7 @@ import type { Types, PopulatedDoc, Document, Model } from "mongoose"; import { Schema, model, models } from "mongoose"; import type { InterfaceOrganization } from "./Organization"; import type { InterfaceUser } from "./User"; +import type { InterfaceRecurrenceRule } from "./RecurrenceRule"; /** * This is an interface representing a document for an event in the database(MongoDB). @@ -15,6 +16,10 @@ export interface InterfaceEvent { latitude: number | undefined; longitude: number; recurring: boolean; + isRecurringEventException: boolean; + isBaseRecurringEvent: boolean; + recurrenceRuleId: PopulatedDoc; + baseRecurringEventId: PopulatedDoc; allDay: boolean; startDate: string; endDate: string | undefined; @@ -40,6 +45,10 @@ export interface InterfaceEvent { * @param latitude - Latitude * @param longitude - Longitude * @param recurring - Is the event recurring + * @param isRecurringEventException - Is the event an exception to the recurring pattern it was following + * @param isBaseRecurringEvent - Is the event a true recurring event that is used for generating new instances + * @param recurrenceRuleId - Id of the recurrence rule document containing the recurrence pattern for the event + * @param baseRecurringEventId - Id of the true recurring event used for generating this instance * @param allDay - Is the event occuring all day * @param startDate - Start Date * @param endDate - End date @@ -86,6 +95,26 @@ const eventSchema = new Schema( required: true, default: false, }, + isRecurringEventException: { + type: Boolean, + required: true, + default: false, + }, + isBaseRecurringEvent: { + type: Boolean, + required: true, + default: false, + }, + recurrenceRuleId: { + type: Schema.Types.ObjectId, + ref: "RecurrenceRule", + required: false, + }, + baseRecurringEventId: { + type: Schema.Types.ObjectId, + ref: "Event", + required: false, + }, allDay: { type: Boolean, required: true, @@ -97,28 +126,28 @@ const eventSchema = new Schema( endDate: { type: Date, required: function (): () => boolean { - // @ts-ignore + // @ts-expect-error Suppressing typescript error for conditional required field return !this.allDay; }, }, startTime: { type: Date, required: function (): () => boolean { - // @ts-ignore + // @ts-expect-error Suppressing typescript error for conditional required field return !this.allDay; }, }, endTime: { type: Date, required: function (): () => boolean { - // @ts-ignore + // @ts-expect-error Suppressing typescript error for conditional required field return !this.allDay; }, }, recurrance: { type: String, required: function (): () => boolean { - // @ts-ignore + // @ts-expect-error Suppressing typescript error for conditional required field return this.recurring; }, enum: ["ONCE", "DAILY", "WEEKLY", "MONTHLY", "YEARLY"], diff --git a/src/models/RecurrenceRule.ts b/src/models/RecurrenceRule.ts new file mode 100644 index 0000000000..86ce48a621 --- /dev/null +++ b/src/models/RecurrenceRule.ts @@ -0,0 +1,101 @@ +import type { Types, PopulatedDoc, Document, Model } from "mongoose"; +import { Schema, model, models } from "mongoose"; +import type { InterfaceEvent } from "./Event"; + +/** + * This is an interface representing a document for a recurrence rule in the database(MongoDB). + */ + +export enum Frequency { + YEARLY = "YEARLY", + MONTHLY = "MONTHLY", + WEEKLY = "WEEKLY", + DAILY = "DAILY", +} + +export enum WeekDays { + SUNDAY = "SUNDAY", + MONDAY = "MONDAY", + TUESDAY = "TUESDAY", + WEDNESDAY = "WEDNESDAY", + THURSDAY = "THURSDAY", + FRIDAY = "FRIDAY", + SATURDAY = "SATURDAY", +} + +export interface InterfaceRecurrenceRule { + _id: Types.ObjectId; + organizationId: Types.ObjectId; + baseRecurringEventId: PopulatedDoc; + recurrenceRuleString: string; + startDate: Date; + endDate: Date; + frequency: Frequency; + count: number; + weekDays: WeekDays[]; + latestInstanceDate: Date; +} + +/** + * This is the Structure of the RecurringEvent + * @param organizationId - _id of the organization the evevnts following this recurrence rule belong to + * @param baseRecurringEventId - _id of the base event common to the recurrence pattern + * @param recurrenceRuleString - An rrule string representing the recurrence pattern + * @param startDate - Start date of the recurrence pattern (not necessarily the startDate of the first recurring instance) + * @param endDate - Start date of the recurrence pattern (not necessarily the startDate of the last recurring instance) + * @param frequency - Frequency of recurrence + * @param count - Number of recurring instances + * @param weekDays - Array containing the days of the week the recurring instance occurs + * @param latestInstanceDate - The startDate of the last recurring instance generated using this recurrence rule + */ + +const recurrenceRuleSchema = new Schema( + { + organizationId: { + type: Schema.Types.ObjectId, + ref: "Organization", + required: true, + }, + baseRecurringEventId: { + type: Schema.Types.ObjectId, + ref: "Event", + required: true, + }, + recurrenceRuleString: { + type: String, + required: true, + }, + startDate: { + type: Date, + required: true, + }, + endDate: { + type: Date, + required: false, + }, + frequency: { + type: String, + required: true, + enum: Object.values(Frequency), + }, + count: { + type: Number, + required: false, + }, + weekDays: { type: [String], required: true, enum: Object.values(WeekDays) }, + latestInstanceDate: { + type: Date, + required: true, + }, + }, + { + timestamps: true, + } +); + +const recurrenceRuleModel = (): Model => + model("RecurrenceRule", recurrenceRuleSchema); + +// This syntax is needed to prevent Mongoose OverwriteModelError while running tests. +export const RecurrenceRule = (models.RecurrenceRule || + recurrenceRuleModel()) as ReturnType; diff --git a/src/resolvers/Mutation/createEvent.ts b/src/resolvers/Mutation/createEvent.ts index d05e10a43e..471cc7397e 100644 --- a/src/resolvers/Mutation/createEvent.ts +++ b/src/resolvers/Mutation/createEvent.ts @@ -1,6 +1,6 @@ import type { MutationResolvers } from "../../types/generatedGraphQLTypes"; import { errors, requestContext } from "../../libraries"; -import type { InterfaceEvent, InterfaceUser } from "../../models"; +import type { InterfaceEvent } from "../../models"; import { User, Organization } from "../../models"; import { USER_NOT_FOUND_ERROR, @@ -10,23 +10,26 @@ import { } from "../../constants"; import { isValidString } from "../../libraries/validators/validateString"; import { compareDates } from "../../libraries/validators/compareDates"; -import { EventAttendee } from "../../models/EventAttendee"; -import { cacheEvents } from "../../services/EventCache/cacheEvents"; -import type mongoose from "mongoose"; import { session } from "../../db"; -import { Weekly, Once } from "../../helpers/eventInstances"; +import { + createSingleEvent, + createRecurringEvent, +} from "../../helpers/event/createEventHelpers"; /** * This function enables to create an event. * @param _parent - parent of current request * @param args - payload provided with the request * @param context - context of entire application - * @remarks The following checks are done: - * 1. If the user exists - * 2. If the organization exists - * 3. If the user is a part of the organization. + * @remarks The following steps are followed: + * 1. Check if the user exists + * 2. Check if the organization exists + * 3. Check if the user is a part of the organization. + * 4. If the event is recurring, create the recurring event instances. + * 5. If the event is non-recurring, create a single event. * @returns Created event */ + export const createEvent: MutationResolvers["createEvent"] = async ( _parent, args, @@ -123,99 +126,51 @@ export const createEvent: MutationResolvers["createEvent"] = async ( ); } + /* c8 ignore start */ if (session) { + // start a transaction session.startTransaction(); } + /* c8 ignore stop */ try { - let createdEvent!: InterfaceEvent[]; - - if (args.data?.recurring) { - switch (args.data?.recurrance) { - case "ONCE": - createdEvent = await Once.generateEvent( - args, - currentUser, - organization, - session - ); - - for (const event of createdEvent) { - await associateEventWithUser(currentUser, event, session); - await cacheEvents([event]); - } - - break; - - case "WEEKLY": - createdEvent = await Weekly.generateEvents( - args, - currentUser, - organization, - session - ); - - for (const event of createdEvent) { - await associateEventWithUser(currentUser, event, session); - await cacheEvents([event]); - } - - break; - } + let createdEvent: InterfaceEvent; + + if (args.data.recurring) { + // create recurring event instances + createdEvent = await createRecurringEvent( + args, + currentUser?._id.toString(), + organization?._id.toString(), + session + ); } else { - createdEvent = await Once.generateEvent( + // create a single non-recurring event + createdEvent = await createSingleEvent( args, - currentUser, - organization, + currentUser?._id.toString(), + organization?._id.toString(), session ); - - for (const event of createdEvent) { - await associateEventWithUser(currentUser, event, session); - await cacheEvents([event]); - } } + /* c8 ignore start */ if (session) { + // commit transaction if everything's successful await session.commitTransaction(); } - // Returns the createdEvent. - return createdEvent[0]; + /* c8 ignore stop */ + return createdEvent; } catch (error) { + /* c8 ignore start */ if (session) { + // abort transaction if something fails await session.abortTransaction(); } + throw error; } -}; - -async function associateEventWithUser( - currentUser: InterfaceUser, - createdEvent: InterfaceEvent, - session: mongoose.ClientSession -): Promise { - await EventAttendee.create( - [ - { - userId: currentUser._id.toString(), - eventId: createdEvent._id, - }, - ], - { session } - ); - await User.updateOne( - { - _id: currentUser._id, - }, - { - $push: { - eventAdmin: createdEvent._id, - createdEvents: createdEvent._id, - registeredEvents: createdEvent._id, - }, - }, - { session } - ); -} + /* c8 ignore stop */ +}; diff --git a/src/resolvers/Query/eventsByOrganizationConnection.ts b/src/resolvers/Query/eventsByOrganizationConnection.ts index 5ad8d4248b..ba0faa6d9c 100644 --- a/src/resolvers/Query/eventsByOrganizationConnection.ts +++ b/src/resolvers/Query/eventsByOrganizationConnection.ts @@ -12,6 +12,7 @@ export const eventsByOrganizationConnection: QueryResolvers["eventsByOrganizatio where = { ...where, status: "ACTIVE", + isBaseRecurringEvent: false, }; const events = await Event.find(where) diff --git a/src/typeDefs/enums.ts b/src/typeDefs/enums.ts index 9e9804c137..55b354eca5 100644 --- a/src/typeDefs/enums.ts +++ b/src/typeDefs/enums.ts @@ -25,6 +25,13 @@ export const enums = gql` location_DESC } + enum Frequency { + YEARLY + MONTHLY + WEEKLY + DAILY + } + enum OrganizationOrderByInput { id_ASC id_DESC @@ -100,6 +107,17 @@ export const enums = gql` SUPERADMIN NON_USER } + + enum WeekDays { + MO + TU + WE + TH + FR + SA + SU + } + enum EducationGrade { NO_GRADE PRE_KG diff --git a/src/typeDefs/inputs.ts b/src/typeDefs/inputs.ts index d0bdd94af4..89fecd3d24 100644 --- a/src/typeDefs/inputs.ts +++ b/src/typeDefs/inputs.ts @@ -244,6 +244,12 @@ export const inputs = gql` recaptchaToken: String! } + input RecurrenceRuleInput { + frequency: Frequency + weekDays: [WeekDays] + count: Int + } + input ToggleUserTagAssignInput { userId: ID! tagId: ID! diff --git a/src/typeDefs/mutations.ts b/src/typeDefs/mutations.ts index a2439dccef..723b70bff7 100644 --- a/src/typeDefs/mutations.ts +++ b/src/typeDefs/mutations.ts @@ -87,7 +87,10 @@ export const mutations = gql` nameOfOrg: String! ): Donation! - createEvent(data: EventInput): Event! @auth + createEvent( + data: EventInput! + recurrenceRuleData: RecurrenceRuleInput + ): Event! @auth createGroupChat(data: createGroupChatInput!): GroupChat! @auth diff --git a/src/typeDefs/types.ts b/src/typeDefs/types.ts index 1923a9a37a..b7fa702b5a 100644 --- a/src/typeDefs/types.ts +++ b/src/typeDefs/types.ts @@ -153,7 +153,7 @@ export const types = gql` title: String! description: String! startDate: Date! - endDate: Date! + endDate: Date startTime: Time endTime: Time allDay: Boolean! diff --git a/src/types/generatedGraphQLTypes.ts b/src/types/generatedGraphQLTypes.ts index 161abec318..b944e6e9ef 100644 --- a/src/types/generatedGraphQLTypes.ts +++ b/src/types/generatedGraphQLTypes.ts @@ -308,7 +308,7 @@ export type Event = { createdAt: Scalars['DateTime']['output']; creator?: Maybe; description: Scalars['String']['output']; - endDate: Scalars['Date']['output']; + endDate?: Maybe; endTime?: Maybe; feedback: Array; isPublic: Scalars['Boolean']['output']; @@ -437,6 +437,12 @@ export type ForgotPasswordData = { userOtp: Scalars['String']['input']; }; +export type Frequency = + | 'DAILY' + | 'MONTHLY' + | 'WEEKLY' + | 'YEARLY'; + export type Gender = | 'FEMALE' | 'MALE' @@ -824,7 +830,8 @@ export type MutationCreateDonationArgs = { export type MutationCreateEventArgs = { - data?: InputMaybe; + data: EventInput; + recurrenceRuleData?: InputMaybe; }; @@ -1667,6 +1674,12 @@ export type Recurrance = | 'WEEKLY' | 'YEARLY'; +export type RecurrenceRuleInput = { + count?: InputMaybe; + frequency?: InputMaybe; + weekDays?: InputMaybe>>; +}; + export type Status = | 'ACTIVE' | 'BLOCKED' @@ -2002,6 +2015,15 @@ export type UsersConnectionResult = { errors: Array; }; +export type WeekDays = + | 'FR' + | 'MO' + | 'SA' + | 'SU' + | 'TH' + | 'TU' + | 'WE'; + export type CreateChatInput = { organizationId: Scalars['ID']['input']; userIds: Array; @@ -2138,6 +2160,7 @@ export type ResolversTypes = { FieldError: ResolverTypeWrapper['FieldError']>; Float: ResolverTypeWrapper; ForgotPasswordData: ForgotPasswordData; + Frequency: Frequency; Gender: Gender; Group: ResolverTypeWrapper; GroupChat: ResolverTypeWrapper; @@ -2187,6 +2210,7 @@ export type ResolversTypes = { Query: ResolverTypeWrapper<{}>; RecaptchaVerification: RecaptchaVerification; Recurrance: Recurrance; + RecurrenceRuleInput: RecurrenceRuleInput; Status: Status; String: ResolverTypeWrapper; Subscription: ResolverTypeWrapper<{}>; @@ -2228,6 +2252,7 @@ export type ResolversTypes = { UsersConnection: ResolverTypeWrapper & { edges: Array }>; UsersConnectionInput: UsersConnectionInput; UsersConnectionResult: ResolverTypeWrapper & { data?: Maybe, errors: Array }>; + WeekDays: WeekDays; createChatInput: CreateChatInput; createGroupChatInput: CreateGroupChatInput; createUserFamilyInput: CreateUserFamilyInput; @@ -2318,6 +2343,7 @@ export type ResolversParentTypes = { PostWhereInput: PostWhereInput; Query: {}; RecaptchaVerification: RecaptchaVerification; + RecurrenceRuleInput: RecurrenceRuleInput; String: Scalars['String']['output']; Subscription: {}; Time: Scalars['Time']['output']; @@ -2562,7 +2588,7 @@ export type EventResolvers; creator?: Resolver, ParentType, ContextType>; description?: Resolver; - endDate?: Resolver; + endDate?: Resolver, ParentType, ContextType>; endTime?: Resolver, ParentType, ContextType>; feedback?: Resolver, ParentType, ContextType>; isPublic?: Resolver; @@ -2751,7 +2777,7 @@ export type MutationResolvers, ParentType, ContextType, RequireFields>; createDirectChat?: Resolver>; createDonation?: Resolver>; - createEvent?: Resolver>; + createEvent?: Resolver>; createGroupChat?: Resolver>; createMember?: Resolver>; createMessageChat?: Resolver>; diff --git a/src/utilities/recurrenceDatesUtil.ts b/src/utilities/recurrenceDatesUtil.ts new file mode 100644 index 0000000000..1075692eb0 --- /dev/null +++ b/src/utilities/recurrenceDatesUtil.ts @@ -0,0 +1,32 @@ +/** + * This function converts the date to UTC. + * @param date - the date to be converted. + * @returns converted date. + */ + +export const convertToUTCDate = (date: Date): Date => { + const year = date.getFullYear(); + const month = date.getMonth(); + const day = date.getDate(); + + // Create a new date object with local year, month, day but at UTC midnight + const utcMidnight = new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); + + return utcMidnight; +}; + +/** + * This function converts the date to a valid rrule string argument. + * @param date - the date string to be converted. + * @returns converted date string. + */ + +export const convertToRRuleDateString = (date: Date): string => { + let dateString = date.toISOString(); + + dateString = dateString.replace(/[-:]/g, ""); + + dateString = dateString.replace(/\.\d{3}/, ""); + + return dateString; +}; diff --git a/tests/resolvers/Mutation/createEvent.spec.ts b/tests/resolvers/Mutation/createEvent.spec.ts index 9a20ba9884..356c96a402 100644 --- a/tests/resolvers/Mutation/createEvent.spec.ts +++ b/tests/resolvers/Mutation/createEvent.spec.ts @@ -3,7 +3,11 @@ import type mongoose from "mongoose"; import { Types } from "mongoose"; import { User, Organization, EventAttendee, Event } from "../../../src/models"; import type { MutationCreateEventArgs } from "../../../src/types/generatedGraphQLTypes"; -import { connect, disconnect } from "../../helpers/db"; +import { + connect, + disconnect, + dropAllCollectionsFromDatabase, +} from "../../helpers/db"; import { LENGTH_VALIDATION_ERROR, @@ -23,12 +27,16 @@ import { UnauthorizedError, } from "../../../src/libraries/errors"; import { fail } from "assert"; +import { addMonths } from "date-fns"; +import { Frequency, RecurrenceRule } from "../../../src/models/RecurrenceRule"; +import { convertToUTCDate } from "../../../src/utilities/recurrenceDatesUtil"; let testUser: TestUserType; let testOrganization: TestOrganizationType; let MONGOOSE_INSTANCE: typeof mongoose; beforeAll(async () => { MONGOOSE_INSTANCE = await connect(); + await dropAllCollectionsFromDatabase(MONGOOSE_INSTANCE); testUser = await createTestUser(); testOrganization = await Organization.create({ @@ -58,13 +66,32 @@ beforeAll(async () => { }); afterAll(async () => { + await dropAllCollectionsFromDatabase(MONGOOSE_INSTANCE); await disconnect(MONGOOSE_INSTANCE); }); describe("resolvers -> Mutation -> createEvent", () => { it(`throws NotFoundError if no user exists with _id === context.userId`, async () => { try { - const args: MutationCreateEventArgs = {}; + const args: MutationCreateEventArgs = { + data: { + organizationId: testOrganization?.id, + allDay: false, + description: "", + endDate: "", + endTime: "", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "", + recurring: false, + startDate: "", + startTime: "", + title: "", + recurrance: "ONCE", + }, + }; const context = { userId: Types.ObjectId().toString(), @@ -195,7 +222,7 @@ describe("resolvers -> Mutation -> createEvent", () => { recurring: false, startDate: new Date("2023-01-01T00:00:00Z"), startTime: new Date().toUTCString(), - title: "newTitle", + title: "singleEventTitle", recurrance: "ONCE", }, }; @@ -219,7 +246,7 @@ describe("resolvers -> Mutation -> createEvent", () => { longitude: 1, location: "newLocation", recurring: false, - title: "newTitle", + title: "singleEventTitle", creatorId: testUser?._id, admins: expect.arrayContaining([testUser?._id]), organization: testOrganization?._id, @@ -227,6 +254,7 @@ describe("resolvers -> Mutation -> createEvent", () => { ); const recurringEvents = await Event.find({ + title: "singleEventTitle", recurring: false, recurrance: "ONCE", }).lean(); @@ -256,7 +284,7 @@ describe("resolvers -> Mutation -> createEvent", () => { ); }); - it(`creates the single recurring event and returns it`, async () => { + it(`creates default Weekly recurring instances if the recurrenceRuleData is not provided`, async () => { await User.updateOne( { _id: testUser?._id, @@ -269,23 +297,28 @@ describe("resolvers -> Mutation -> createEvent", () => { } ); + let startDate = new Date(); + startDate = convertToUTCDate(startDate); + + const endDate = addMonths(startDate, 1); + const args: MutationCreateEventArgs = { data: { organizationId: testOrganization?.id, allDay: false, description: "newDescription", - endDate: new Date("2023-01-29T00:00:00Z"), - endTime: new Date().toUTCString(), + endDate, + endTime: endDate.toUTCString(), isPublic: false, isRegisterable: false, latitude: 1, longitude: 1, location: "newLocation", recurring: true, - startDate: new Date("2023-01-02T00:00:00Z"), - startTime: new Date().toUTCString(), + startDate, + startTime: startDate.toUTCString(), title: "newTitle", - recurrance: "ONCE", + recurrance: "WEEKLY", }, }; @@ -315,14 +348,26 @@ describe("resolvers -> Mutation -> createEvent", () => { }) ); + const recurrenceRule = await RecurrenceRule.findOne({ + startDate, + endDate, + frequency: Frequency.WEEKLY, + }); + + const baseRecurringEvent = await Event.findOne({ + isBaseRecurringEvent: true, + startDate: startDate.toUTCString(), + }); + const recurringEvents = await Event.find({ recurring: true, - recurrance: "ONCE", - startDate: "2023-01-02T00:00:00Z", + isBaseRecurringEvent: false, + recurrenceRuleId: recurrenceRule?._id, + baseRecurringEventId: baseRecurringEvent?._id, }).lean(); expect(recurringEvents).toBeDefined(); - expect(recurringEvents).toHaveLength(1); + expect(recurringEvents.length).toEqual(5); const attendeeExists = await EventAttendee.exists({ userId: testUser?._id, @@ -346,7 +391,7 @@ describe("resolvers -> Mutation -> createEvent", () => { ); }); - it(`creates the Weekly recurring event and returns it`, async () => { + it(`creates the daily recurring event upto an end date based on the recurrenceRuleData`, async () => { await User.updateOne( { _id: testUser?._id, @@ -359,23 +404,32 @@ describe("resolvers -> Mutation -> createEvent", () => { } ); + let startDate = new Date(); + startDate = addMonths(startDate, 1); + startDate = convertToUTCDate(startDate); + + const endDate = addMonths(startDate, 5); + const args: MutationCreateEventArgs = { data: { organizationId: testOrganization?.id, - allDay: false, + allDay: true, description: "newDescription", - endDate: new Date("2023-01-29T00:00:00Z"), - endTime: new Date().toUTCString(), isPublic: false, isRegisterable: false, latitude: 1, longitude: 1, location: "newLocation", recurring: true, - startDate: new Date("2023-01-01T00:00:00Z"), - startTime: new Date().toUTCString(), + startDate, + startTime: startDate.toUTCString(), + endDate, + endTime: endDate.toUTCString(), title: "newTitle", - recurrance: "WEEKLY", + recurrance: "ONCE", + }, + recurrenceRuleData: { + frequency: "DAILY", }, }; @@ -390,7 +444,225 @@ describe("resolvers -> Mutation -> createEvent", () => { expect(createEventPayload).toEqual( expect.objectContaining({ - allDay: false, + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + title: "newTitle", + creatorId: testUser?._id, + admins: expect.arrayContaining([testUser?._id]), + organization: testOrganization?._id, + }) + ); + + const recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.DAILY, + startDate, + endDate, + }); + + const baseRecurringEvent = await Event.findOne({ + isBaseRecurringEvent: true, + startDate: startDate.toUTCString(), + endDate: endDate.toUTCString(), + }); + + const recurringEvents = await Event.find({ + recurring: true, + isBaseRecurringEvent: false, + recurrenceRuleId: recurrenceRule?._id, + baseRecurringEventId: baseRecurringEvent?._id, + }).lean(); + + expect(recurringEvents).toBeDefined(); + + const attendeeExists = await EventAttendee.exists({ + userId: testUser?._id, + eventId: createEventPayload?._id, + }); + + expect(attendeeExists).toBeTruthy(); + + const updatedTestUser = await User.findOne({ + _id: testUser?._id, + }) + .select(["eventAdmin", "createdEvents", "registeredEvents"]) + .lean(); + + expect(updatedTestUser).toEqual( + expect.objectContaining({ + eventAdmin: expect.arrayContaining([createEventPayload?._id]), + createdEvents: expect.arrayContaining([createEventPayload?._id]), + registeredEvents: expect.arrayContaining([createEventPayload?._id]), + }) + ); + }); + + it(`creates the daily recurring event with no end date based on the recurrenceRuleData`, async () => { + await User.updateOne( + { + _id: testUser?._id, + }, + { + $push: { + createdOrganizations: testOrganization?._id, + joinedOrganizations: testOrganization?._id, + }, + } + ); + + let startDate = new Date(); + startDate = addMonths(startDate, 2); + + startDate = convertToUTCDate(startDate); + + const args: MutationCreateEventArgs = { + data: { + organizationId: testOrganization?.id, + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + startDate, + startTime: startDate.toUTCString(), + title: "newTitle", + recurrance: "ONCE", + }, + recurrenceRuleData: { + frequency: "DAILY", + count: 10, + }, + }; + + const context = { + userId: testUser?.id, + }; + const { createEvent: createEventResolver } = await import( + "../../../src/resolvers/Mutation/createEvent" + ); + + const createEventPayload = await createEventResolver?.({}, args, context); + + expect(createEventPayload).toEqual( + expect.objectContaining({ + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + title: "newTitle", + creatorId: testUser?._id, + admins: expect.arrayContaining([testUser?._id]), + organization: testOrganization?._id, + }) + ); + + const recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.DAILY, + startDate, + }); + + const baseRecurringEvent = await Event.findOne({ + isBaseRecurringEvent: true, + startDate: startDate.toUTCString(), + }); + + const recurringEvents = await Event.find({ + recurring: true, + isBaseRecurringEvent: false, + recurrenceRuleId: recurrenceRule?._id, + baseRecurringEventId: baseRecurringEvent?._id, + }).lean(); + + expect(recurringEvents).toBeDefined(); + expect(recurringEvents).toHaveLength(10); + + const attendeeExists = await EventAttendee.exists({ + userId: testUser?._id, + eventId: createEventPayload?._id, + }); + + expect(attendeeExists).toBeTruthy(); + + const updatedTestUser = await User.findOne({ + _id: testUser?._id, + }) + .select(["eventAdmin", "createdEvents", "registeredEvents"]) + .lean(); + + expect(updatedTestUser).toEqual( + expect.objectContaining({ + eventAdmin: expect.arrayContaining([createEventPayload?._id]), + createdEvents: expect.arrayContaining([createEventPayload?._id]), + registeredEvents: expect.arrayContaining([createEventPayload?._id]), + }) + ); + }); + + it(`creates the weekly recurring event with no end date based on the recurrenceRuleData`, async () => { + await User.updateOne( + { + _id: testUser?._id, + }, + { + $push: { + createdOrganizations: testOrganization?._id, + joinedOrganizations: testOrganization?._id, + }, + } + ); + + let startDate = new Date(); + startDate = addMonths(startDate, 3); + + startDate = convertToUTCDate(startDate); + + const args: MutationCreateEventArgs = { + data: { + organizationId: testOrganization?.id, + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + startDate, + startTime: startDate.toUTCString(), + title: "newTitle", + recurrance: "ONCE", + }, + recurrenceRuleData: { + frequency: "WEEKLY", + weekDays: ["TH", "SA"], + count: 10, + }, + }; + + const context = { + userId: testUser?.id, + }; + const { createEvent: createEventResolver } = await import( + "../../../src/resolvers/Mutation/createEvent" + ); + + const createEventPayload = await createEventResolver?.({}, args, context); + + expect(createEventPayload).toEqual( + expect.objectContaining({ + allDay: true, description: "newDescription", isPublic: false, isRegisterable: false, @@ -405,13 +677,241 @@ describe("resolvers -> Mutation -> createEvent", () => { }) ); + const recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.WEEKLY, + startDate, + }); + + const baseRecurringEvent = await Event.findOne({ + isBaseRecurringEvent: true, + startDate: startDate.toUTCString(), + }); + + const recurringEvents = await Event.find({ + recurring: true, + isBaseRecurringEvent: false, + recurrenceRuleId: recurrenceRule?._id, + baseRecurringEventId: baseRecurringEvent?._id, + }).lean(); + + expect(recurringEvents).toBeDefined(); + expect(recurringEvents).toHaveLength(10); + + const attendeeExists = await EventAttendee.exists({ + userId: testUser?._id, + eventId: createEventPayload?._id, + }); + + expect(attendeeExists).toBeTruthy(); + + const updatedTestUser = await User.findOne({ + _id: testUser?._id, + }) + .select(["eventAdmin", "createdEvents", "registeredEvents"]) + .lean(); + + expect(updatedTestUser).toEqual( + expect.objectContaining({ + eventAdmin: expect.arrayContaining([createEventPayload?._id]), + createdEvents: expect.arrayContaining([createEventPayload?._id]), + registeredEvents: expect.arrayContaining([createEventPayload?._id]), + }) + ); + }); + + it(`creates the monthly recurring event with no end date based on the recurrenceRuleData`, async () => { + await User.updateOne( + { + _id: testUser?._id, + }, + { + $push: { + createdOrganizations: testOrganization?._id, + joinedOrganizations: testOrganization?._id, + }, + } + ); + + let startDate = new Date(); + startDate = addMonths(startDate, 4); + + startDate = convertToUTCDate(startDate); + + const args: MutationCreateEventArgs = { + data: { + organizationId: testOrganization?.id, + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + startDate, + startTime: startDate.toUTCString(), + title: "newTitle", + recurrance: "ONCE", + }, + recurrenceRuleData: { + frequency: "MONTHLY", + count: 10, + }, + }; + + const context = { + userId: testUser?.id, + }; + const { createEvent: createEventResolver } = await import( + "../../../src/resolvers/Mutation/createEvent" + ); + + const createEventPayload = await createEventResolver?.({}, args, context); + + expect(createEventPayload).toEqual( + expect.objectContaining({ + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + title: "newTitle", + creatorId: testUser?._id, + admins: expect.arrayContaining([testUser?._id]), + organization: testOrganization?._id, + }) + ); + + const recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.MONTHLY, + startDate, + }); + + const baseRecurringEvent = await Event.findOne({ + isBaseRecurringEvent: true, + startDate: startDate.toUTCString(), + }); + + const recurringEvents = await Event.find({ + recurring: true, + isBaseRecurringEvent: false, + recurrenceRuleId: recurrenceRule?._id, + baseRecurringEventId: baseRecurringEvent?._id, + }).lean(); + + expect(recurringEvents).toBeDefined(); + expect(recurringEvents).toHaveLength(10); + + const attendeeExists = await EventAttendee.exists({ + userId: testUser?._id, + eventId: createEventPayload?._id, + }); + + expect(attendeeExists).toBeTruthy(); + + const updatedTestUser = await User.findOne({ + _id: testUser?._id, + }) + .select(["eventAdmin", "createdEvents", "registeredEvents"]) + .lean(); + + expect(updatedTestUser).toEqual( + expect.objectContaining({ + eventAdmin: expect.arrayContaining([createEventPayload?._id]), + createdEvents: expect.arrayContaining([createEventPayload?._id]), + registeredEvents: expect.arrayContaining([createEventPayload?._id]), + }) + ); + }); + + it(`creates the yearly recurring event with no end date based on the recurrenceRuleData`, async () => { + await User.updateOne( + { + _id: testUser?._id, + }, + { + $push: { + createdOrganizations: testOrganization?._id, + joinedOrganizations: testOrganization?._id, + }, + } + ); + + let startDate = new Date(); + startDate = addMonths(startDate, 5); + + startDate = convertToUTCDate(startDate); + + const args: MutationCreateEventArgs = { + data: { + organizationId: testOrganization?.id, + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + startDate, + startTime: startDate.toUTCString(), + title: "newTitle", + recurrance: "ONCE", + }, + recurrenceRuleData: { + frequency: "YEARLY", + count: 10, + }, + }; + + const context = { + userId: testUser?.id, + }; + const { createEvent: createEventResolver } = await import( + "../../../src/resolvers/Mutation/createEvent" + ); + + const createEventPayload = await createEventResolver?.({}, args, context); + + expect(createEventPayload).toEqual( + expect.objectContaining({ + allDay: true, + description: "newDescription", + isPublic: false, + isRegisterable: false, + latitude: 1, + longitude: 1, + location: "newLocation", + recurring: true, + title: "newTitle", + creatorId: testUser?._id, + admins: expect.arrayContaining([testUser?._id]), + organization: testOrganization?._id, + }) + ); + + const recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.YEARLY, + startDate, + }); + + const baseRecurringEvent = await Event.findOne({ + isBaseRecurringEvent: true, + startDate: startDate.toUTCString(), + }); + const recurringEvents = await Event.find({ recurring: true, - recurrance: "WEEKLY", + isBaseRecurringEvent: false, + recurrenceRuleId: recurrenceRule?._id, + baseRecurringEventId: baseRecurringEvent?._id, }).lean(); expect(recurringEvents).toBeDefined(); - expect(recurringEvents).toHaveLength(5); + expect(recurringEvents).toHaveLength(10); const attendeeExists = await EventAttendee.exists({ userId: testUser?._id,