diff --git a/INSTALLATION.md b/INSTALLATION.md index c2f0ed5284..4489bfa84c 100644 --- a/INSTALLATION.md +++ b/INSTALLATION.md @@ -41,7 +41,7 @@ This document provides instructions on how to set up and start a running instanc - [Setting up the RECAPTCHA_SECRET_KEY](#setting-up-the-recaptcha_secret_key) - [Setting up .env MAIL_USERNAME and MAIL_PASSWORD ReCAPTCHA Parameters](#setting-up-env-mail_username-and-mail_password-recaptcha-parameters) - [Setting up SMTP Email Variables in the .env File](#setting-up-smtp-email-variables-in-the-env-file) - - [Setting up Logger configurations _(optional)_](#setting-up-logger-configurations-_optional_) + - [Setting up Logger configurations](#setting-up-logger-configurations) - [Setting up COLORIZE_LOGS in .env file](#setting-up-colorize_logs-in-env-file) - [Setting up LOG_LEVEL in .env file](#setting-up-log_level-in-env-file) - [Importing Sample Database](#importing-sample-database) diff --git a/src/constants.ts b/src/constants.ts index 7a457d7503..f692439785 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -545,6 +545,11 @@ export const RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT = 2; export const RECURRING_EVENT_INSTANCES_MONTHLY_LIMIT = 5; export const RECURRING_EVENT_INSTANCES_YEARLY_LIMIT = 10; +// recurrence rules query date limit in years +// i.e. query limit date to find the pending recurrence patterns +// and then generate new instances ahead of this date +export const RECURRING_EVENT_INSTANCES_QUERY_LIMIT = 1; + // recurring event days export const RECURRENCE_WEEKDAYS = [ "MONDAY", diff --git a/src/helpers/event/createEventHelpers/createRecurringEventInstancesDuringQuery.ts b/src/helpers/event/createEventHelpers/createRecurringEventInstancesDuringQuery.ts new file mode 100644 index 0000000000..57dac149f3 --- /dev/null +++ b/src/helpers/event/createEventHelpers/createRecurringEventInstancesDuringQuery.ts @@ -0,0 +1,154 @@ +import { addDays, addYears } from "date-fns"; +import { RecurrenceRule } from "../../../models/RecurrenceRule"; +import { convertToUTCDate } from "../../../utilities/recurrenceDatesUtil"; +import { Event } from "../../../models"; +import { + generateRecurringEventInstances, + getRecurringInstanceDates, +} from "../recurringEventHelpers"; +import { session } from "../../../db"; +import type { Recurrance } from "../../../types/generatedGraphQLTypes"; +import type { InterfaceRecurringEvent } from "../recurringEventHelpers/generateRecurringEventInstances"; +import { RECURRING_EVENT_INSTANCES_QUERY_LIMIT } from "../../../constants"; + +/** + * This function creates the instances of a recurring event upto a certain date during queries. + * @param organizationId - _id of the organization the events belong to + * @remarks The following steps are followed: + * 1. Get the limit date upto which we would want to query the recurrenceRules and generate new instances. + * 2. Get the recurrence rules to be used for instance generation during this query. + * 3. For every recurrence rule found: + * - find the base recurring event to get the data to be used for new instance generation. + * - get the number of existing instances and how many more to generate based on the recurrenceRule's count (if specified). + * - generate new instances after their latestInstanceDates. + * - update the latestInstanceDate. + */ + +export const createRecurringEventInstancesDuringQuery = async ( + organizationId: string | undefined | null, +): Promise => { + if (!organizationId) { + return; + } + + // get the current calendar date in UTC midnight + const calendarDate = convertToUTCDate(new Date()); + const queryUptoDate = addYears( + calendarDate, + RECURRING_EVENT_INSTANCES_QUERY_LIMIT, + ); + + // get the recurrenceRules + const recurrenceRules = await RecurrenceRule.find({ + organizationId, + latestInstanceDate: { $lt: queryUptoDate }, + }).lean(); + + await Promise.all( + recurrenceRules.map(async (recurrenceRule) => { + // find the baseRecurringEvent for the recurrenceRule + const baseRecurringEvent = await Event.find({ + _id: recurrenceRule.baseRecurringEventId, + }).lean(); + + // get the data from the baseRecurringEvent + const { + _id: baseRecurringEventId, + recurrance, + ...data + } = baseRecurringEvent[0]; + + // get the input data for the generateRecurringEventInstances function + const currentInputData: InterfaceRecurringEvent = { + ...data, + organizationId: recurrenceRule.organizationId.toString(), + recurrance: recurrance as Recurrance, + }; + + // get the properties from recurrenceRule + const { + _id: recurrenceRuleId, + latestInstanceDate, + recurrenceRuleString, + endDate: recurrenceEndDate, + count: totalInstancesCount, + } = recurrenceRule; + + // get the date from which new instances would be generated + const currentRecurrenceStartDate = addDays(latestInstanceDate, 1); + + // get the dates for recurrence + let recurringInstanceDates = getRecurringInstanceDates( + recurrenceRuleString, + currentRecurrenceStartDate, + recurrenceEndDate, + queryUptoDate, + ); + + // find out how many instances following the recurrence rule already exist and how many more to generate + if (totalInstancesCount) { + const totalExistingInstances = await Event.countDocuments({ + recurrenceRuleId, + }); + + const remainingInstances = totalInstancesCount - totalExistingInstances; + + recurringInstanceDates = recurringInstanceDates.slice( + 0, + Math.min(recurringInstanceDates.length, remainingInstances), + ); + } + + /* c8 ignore start */ + if (session) { + // start a transaction + session.startTransaction(); + } + + /* c8 ignore stop */ + try { + if (recurringInstanceDates && recurringInstanceDates.length) { + const updatedLatestRecurringInstanceDate = + recurringInstanceDates[recurringInstanceDates.length - 1]; + + // update the latestInstanceDate of the recurrenceRule + await RecurrenceRule.updateOne( + { + _id: recurrenceRuleId, + }, + { + latestInstanceDate: updatedLatestRecurringInstanceDate, + }, + { session }, + ); + + // generate recurring event instances + await generateRecurringEventInstances({ + data: currentInputData, + baseRecurringEventId: baseRecurringEventId.toString(), + recurrenceRuleId: recurrenceRuleId.toString(), + recurringInstanceDates, + creatorId: baseRecurringEvent[0].creatorId.toString(), + organizationId, + session, + }); + } + + /* c8 ignore start */ + if (session) { + // commit transaction if everything's successful + await session.commitTransaction(); + } + } catch (error) { + if (session) { + // abort transaction if something fails + await session.abortTransaction(); + } + + throw error; + } + + /* c8 ignore stop */ + }), + ); +}; diff --git a/src/helpers/event/createEventHelpers/index.ts b/src/helpers/event/createEventHelpers/index.ts index ebab56acb9..b05861a980 100644 --- a/src/helpers/event/createEventHelpers/index.ts +++ b/src/helpers/event/createEventHelpers/index.ts @@ -1,2 +1,3 @@ export { createSingleEvent } from "./createSingleEvent"; export { createRecurringEvent } from "./createRecurringEvent"; +export { createRecurringEventInstancesDuringQuery } from "./createRecurringEventInstancesDuringQuery"; diff --git a/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts b/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts index aa4830f3b8..4a9e927cc6 100644 --- a/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts +++ b/src/helpers/event/recurringEventHelpers/generateRecurringEventInstances.ts @@ -21,22 +21,24 @@ import { cacheEvents } from "../../../services/EventCache/cacheEvents"; */ interface InterfaceGenerateRecurringInstances { - data: EventInput; + data: InterfaceRecurringEvent; baseRecurringEventId: string; recurrenceRuleId: string; recurringInstanceDates: Date[]; creatorId: string; organizationId: string; + status?: string; session: mongoose.ClientSession; } -interface InterfaceRecurringEvent extends EventInput { - isBaseRecurringEvent: boolean; - recurrenceRuleId: string; - baseRecurringEventId: string; - creatorId: string; - admins: string[]; - organization: string; +export interface InterfaceRecurringEvent extends EventInput { + isBaseRecurringEvent?: boolean; + recurrenceRuleId?: string; + baseRecurringEventId?: string; + creatorId?: string; + admins?: string[]; + organization?: string; + status?: string; } export const generateRecurringEventInstances = async ({ @@ -49,7 +51,7 @@ export const generateRecurringEventInstances = async ({ session, }: InterfaceGenerateRecurringInstances): Promise => { const recurringInstances: InterfaceRecurringEvent[] = []; - recurringInstanceDates.map((date) => { + recurringInstanceDates.map((date): void => { const createdEventInstance = { ...data, startDate: date, @@ -59,8 +61,9 @@ export const generateRecurringEventInstances = async ({ recurrenceRuleId, baseRecurringEventId, creatorId, - admins: [creatorId], + admins: data.admins && data.admins.length ? data.admins : [creatorId], organization: organizationId, + status: data.status, }; recurringInstances.push(createdEventInstance); diff --git a/src/resolvers/Query/eventsByOrganizationConnection.ts b/src/resolvers/Query/eventsByOrganizationConnection.ts index ba0faa6d9c..740521e484 100644 --- a/src/resolvers/Query/eventsByOrganizationConnection.ts +++ b/src/resolvers/Query/eventsByOrganizationConnection.ts @@ -3,9 +3,14 @@ import type { InterfaceEvent } from "../../models"; import { Event } from "../../models"; import { getSort } from "./helperFunctions/getSort"; import { getWhere } from "./helperFunctions/getWhere"; +import { createRecurringEventInstancesDuringQuery } from "../../helpers/event/createEventHelpers"; export const eventsByOrganizationConnection: QueryResolvers["eventsByOrganizationConnection"] = async (_parent, args) => { + // dynamically generate recurring event instances upto a certain date during this query + await createRecurringEventInstancesDuringQuery(args.where?.organization_id); + + // get the where and sort let where = getWhere(args.where); const sort = getSort(args.orderBy); @@ -15,6 +20,7 @@ export const eventsByOrganizationConnection: QueryResolvers["eventsByOrganizatio isBaseRecurringEvent: false, }; + // find all the events according to the requirements const events = await Event.find(where) .sort(sort) .limit(args.first ?? 0) diff --git a/tests/resolvers/Query/eventsByOrganizationConnection.spec.ts b/tests/resolvers/Query/eventsByOrganizationConnection.spec.ts index 627857b5d5..c98875bac3 100644 --- a/tests/resolvers/Query/eventsByOrganizationConnection.spec.ts +++ b/tests/resolvers/Query/eventsByOrganizationConnection.spec.ts @@ -1,21 +1,44 @@ -// @ts-nocheck import "dotenv/config"; import { eventsByOrganizationConnection as eventsByOrganizationConnectionResolver } from "../../../src/resolvers/Query/eventsByOrganizationConnection"; -import { connect, disconnect } from "../../helpers/db"; +import { + connect, + disconnect, + dropAllCollectionsFromDatabase, +} from "../../helpers/db"; import type mongoose from "mongoose"; -import type { QueryEventsByOrganizationConnectionArgs } from "../../../src/types/generatedGraphQLTypes"; +import type { + MutationCreateEventArgs, + QueryEventsByOrganizationConnectionArgs, +} from "../../../src/types/generatedGraphQLTypes"; +import type { InterfaceEvent } from "../../../src/models"; import { Event } from "../../../src/models"; +import type { TestOrganizationType } from "../../helpers/userAndOrg"; import { createTestUserAndOrganization } from "../../helpers/userAndOrg"; import type { TestEventType } from "../../helpers/events"; import { createEventWithRegistrant } from "../../helpers/events"; -import { beforeAll, afterAll, describe, it, expect } from "vitest"; +import { beforeAll, afterAll, describe, it, expect, vi } from "vitest"; +import { addDays, addYears } from "date-fns"; +import { convertToUTCDate } from "../../../src/utilities/recurrenceDatesUtil"; +import type { TestUserType } from "../../helpers/user"; +import type { InterfaceRecurrenceRule } from "../../../src/models/RecurrenceRule"; +import { Frequency, RecurrenceRule } from "../../../src/models/RecurrenceRule"; +import { + RECURRING_EVENT_INSTANCES_DAILY_LIMIT, + RECURRING_EVENT_INSTANCES_QUERY_LIMIT, + RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT, +} from "../../../src/constants"; +import { rrulestr } from "rrule"; +import type { RRule } from "rrule"; let MONGOOSE_INSTANCE: typeof mongoose; let testEvents: TestEventType[]; +let testUser: TestUserType; +let testOrganization: TestOrganizationType; beforeAll(async () => { MONGOOSE_INSTANCE = await connect(); - const [testUser, testOrganization] = await createTestUserAndOrganization(); + await dropAllCollectionsFromDatabase(MONGOOSE_INSTANCE); + [testUser, testOrganization] = await createTestUserAndOrganization(); const testEvent1 = await createEventWithRegistrant( testUser?._id, testOrganization?._id, @@ -38,6 +61,7 @@ beforeAll(async () => { }); afterAll(async () => { + await dropAllCollectionsFromDatabase(MONGOOSE_INSTANCE); await disconnect(MONGOOSE_INSTANCE); }); @@ -92,7 +116,11 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const events = await Event.find(where).sort(sort).lean(); let eventsByOrganizationConnectionPayload = - await eventsByOrganizationConnectionResolver?.({}, args, {}); + (await eventsByOrganizationConnectionResolver?.( + {}, + args, + {}, + )) as InterfaceEvent[]; eventsByOrganizationConnectionPayload = eventsByOrganizationConnectionPayload?.map((event) => { @@ -145,7 +173,11 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const events = await Event.find(where).limit(2).skip(1).sort(sort).lean(); let eventsByOrganizationConnectionPayload = - await eventsByOrganizationConnectionResolver?.({}, args, {}); + (await eventsByOrganizationConnectionResolver?.( + {}, + args, + {}, + )) as InterfaceEvent[]; eventsByOrganizationConnectionPayload = eventsByOrganizationConnectionPayload?.map((event) => { @@ -172,10 +204,10 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { $nin: [testEvents[0]?._id], }, title: { - $nin: [testEvents[0]?.title], + $nin: [testEvents[0]?.title ?? ""], }, description: { - $nin: [testEvents[0]?.description], + $nin: [testEvents[0]?.description ?? ""], }, location: { $nin: [testEvents[0]?.location], @@ -185,9 +217,9 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const args: QueryEventsByOrganizationConnectionArgs = { where: { id_not_in: [testEvents[0]?._id], - title_not_in: [testEvents[0]?.title], - description_not_in: [testEvents[0]?.description], - location_not_in: [testEvents[0]?.location], + title_not_in: [testEvents[0]?.title ?? ""], + description_not_in: [testEvents[0]?.description ?? ""], + location_not_in: [testEvents[0]?.location ?? ""], }, orderBy: "title_DESC", }; @@ -195,7 +227,11 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const events = await Event.find(where).sort(sort).lean(); let eventsByOrganizationConnectionPayload = - await eventsByOrganizationConnectionResolver?.({}, args, {}); + (await eventsByOrganizationConnectionResolver?.( + {}, + args, + {}, + )) as InterfaceEvent[]; eventsByOrganizationConnectionPayload = eventsByOrganizationConnectionPayload?.map((event) => { @@ -224,10 +260,10 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { $in: [testEvents[0]?._id], }, title: { - $in: [testEvents[0]?.title], + $in: [testEvents[0]?.title ?? ""], }, description: { - $in: [testEvents[0]?.description], + $in: [testEvents[0]?.description ?? ""], }, location: { $in: [testEvents[0]?.location], @@ -239,9 +275,9 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { skip: 1, where: { id_in: [testEvents[0]?._id], - title_in: [testEvents[0]?.title], - description_in: [testEvents[0]?.description], - location_in: [testEvents[0]?.location], + title_in: [testEvents[0]?.title ?? ""], + description_in: [testEvents[0]?.description ?? ""], + location_in: [testEvents[0]?.location ?? ""], }, orderBy: "title_ASC", }; @@ -249,7 +285,11 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const events = await Event.find(where).limit(2).skip(1).sort(sort).lean(); let eventsByOrganizationConnectionPayload = - await eventsByOrganizationConnectionResolver?.({}, args, {}); + (await eventsByOrganizationConnectionResolver?.( + {}, + args, + {}, + )) as InterfaceEvent[]; eventsByOrganizationConnectionPayload = eventsByOrganizationConnectionPayload?.map((event) => { @@ -300,7 +340,11 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const events = await Event.find(where).limit(2).skip(1).sort(sort).lean(); let eventsByOrganizationConnectionPayload = - await eventsByOrganizationConnectionResolver?.({}, args, {}); + (await eventsByOrganizationConnectionResolver?.( + {}, + args, + {}, + )) as InterfaceEvent[]; eventsByOrganizationConnectionPayload = eventsByOrganizationConnectionPayload?.map((event) => { @@ -340,7 +384,11 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { const events = await Event.find(where).limit(2).skip(1).sort(sort).lean(); let eventsByOrganizationConnectionPayload = - await eventsByOrganizationConnectionResolver?.({}, args, {}); + (await eventsByOrganizationConnectionResolver?.( + {}, + args, + {}, + )) as InterfaceEvent[]; eventsByOrganizationConnectionPayload = eventsByOrganizationConnectionPayload?.map((event) => { @@ -356,4 +404,243 @@ describe("resolvers -> Query -> organizationsMemberConnection", () => { }); expect(eventsByOrganizationConnectionPayload).toEqual(events); }); + + it("dynamically generates recurring event instances during query for events with no end dates", async () => { + vi.useFakeTimers(); + + const startDate = convertToUTCDate(new Date()); + + const eventArgs: 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", + }, + }; + + const context = { + userId: testUser?.id, + }; + const { createEvent: createEventResolver } = await import( + "../../../src/resolvers/Mutation/createEvent" + ); + + const createEventPayload = await createEventResolver?.( + {}, + eventArgs, + 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, + }), + ); + + let recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.DAILY, + startDate, + }); + + const { recurrenceRuleString } = recurrenceRule as InterfaceRecurrenceRule; + const recurrenceRuleObject: RRule = rrulestr(recurrenceRuleString); + + const generateUptoDate = addYears( + startDate, + RECURRING_EVENT_INSTANCES_DAILY_LIMIT, + ); + + const currentLatestInstanceDate = recurrenceRuleObject.before( + generateUptoDate, + true, + ); + + expect(recurrenceRule?.latestInstanceDate).toEqual( + currentLatestInstanceDate, + ); + + const newMockDate = addDays(currentLatestInstanceDate as Date, 1); + vi.setSystemTime(newMockDate); + + const args: QueryEventsByOrganizationConnectionArgs = { + where: { + organization_id: testOrganization?._id, + }, + }; + + await eventsByOrganizationConnectionResolver?.({}, args, {}); + + recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.DAILY, + startDate, + }); + + const queryUptoDate = addYears( + convertToUTCDate(newMockDate), + RECURRING_EVENT_INSTANCES_QUERY_LIMIT, + ); + const newGenerateUptoDate = addYears( + queryUptoDate, + RECURRING_EVENT_INSTANCES_DAILY_LIMIT, + ); + + const newLatestInstanceDate = recurrenceRuleObject.before( + newGenerateUptoDate, + true, + ); + + expect(recurrenceRule?.latestInstanceDate).toEqual(newLatestInstanceDate); + + vi.useRealTimers(); + }); + + it("dynamically generates recurring event instances during query for a specified number of instances", async () => { + vi.useFakeTimers(); + + const startDate = convertToUTCDate(new Date()); + + const eventArgs: 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", + count: 150, + }, + }; + + const context = { + userId: testUser?.id, + }; + const { createEvent: createEventResolver } = await import( + "../../../src/resolvers/Mutation/createEvent" + ); + + const createEventPayload = await createEventResolver?.( + {}, + eventArgs, + 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, + }), + ); + + let recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.WEEKLY, + startDate, + }); + + const { recurrenceRuleString } = recurrenceRule as InterfaceRecurrenceRule; + const recurrenceRuleObject: RRule = rrulestr(recurrenceRuleString); + + const recurrenceStartDate = startDate; + const generateUptoDate = addYears( + recurrenceStartDate, + RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT, + ); + const currentLatestInstanceDate = recurrenceRuleObject.before( + generateUptoDate, + true, + ); + + expect(recurrenceRule?.latestInstanceDate).toEqual( + currentLatestInstanceDate, + ); + + const generatedWeeklyRecurringInstances = await Event.find({ + recurrenceRuleId: recurrenceRule?._id, + }); + + expect(generatedWeeklyRecurringInstances.length).toBeLessThan(150); + + const newMockDate = addDays(currentLatestInstanceDate as Date, 1); + vi.setSystemTime(newMockDate); + + const args: QueryEventsByOrganizationConnectionArgs = { + where: { + organization_id: testOrganization?._id, + }, + }; + + await eventsByOrganizationConnectionResolver?.({}, args, {}); + + recurrenceRule = await RecurrenceRule.findOne({ + frequency: Frequency.WEEKLY, + startDate, + }); + + const queryUptoDate = addYears( + convertToUTCDate(newMockDate), + RECURRING_EVENT_INSTANCES_QUERY_LIMIT, + ); + const newGenerateUptoDate = addYears( + queryUptoDate, + RECURRING_EVENT_INSTANCES_WEEKLY_LIMIT, + ); + + const newLatestInstanceDate = recurrenceRuleObject.before( + newGenerateUptoDate, + true, + ); + + expect(recurrenceRule?.latestInstanceDate).toEqual(newLatestInstanceDate); + + const allWeeklyRecurringEventInstances = await Event.find({ + recurrenceRuleId: recurrenceRule?._id, + }); + + expect(allWeeklyRecurringEventInstances.length).toEqual(150); + + vi.useRealTimers(); + }); });