* perf: implement quick app store loading optimizations - Add conditional app store imports in videoClient and handlePayment - Implement lazy calendar manager pattern in CalendarManager - Enhance createCachedImport with better concurrency handling - Create calendar-only registry for common calendar operations - Add performance instrumentation for debugging These optimizations reduce initial app store loading time by avoiding module-level imports and creating smaller, focused registries for calendar operations. Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: revert getCalendar to use appStore for proper test mocking - Reverted getCalendar.ts to use main appStore instead of calendarStore - This ensures test mocking system works properly with existing appStoreMock - Fixes unit test failures where Google Calendar references were getting null values - All collective scheduling tests now pass Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * perf: implement CalendarServiceMap for optimized calendar loading - Add CalendarServiceMap generation following CrmServiceMap pattern - Update getCalendar.ts to use generated calendar service map - Remove manual calendar-registry.ts in favor of auto-generated approach - Reduces calendar initialization from loading 48+ apps to ~10 calendar apps Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: update test mocks for CalendarServiceMap compatibility - Add missing SelectedCalendar fields (createdAt, updatedAt, lastErrorAt, watchAttempts, etc.) - Fix CredentialPayload type errors by adding user.email and delegationCredentialId - Mock CalendarServiceMap to use vi.importActual for real calendar services - Ensure calendar service tests work with new lazy loading approach Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: resolve CalendarServiceMap test compatibility issues - Update getCalendarsEvents.test.ts mocks to work with CalendarServiceMap dynamic imports - Add missing SelectedCalendar fields (createdAt, updatedAt, lastErrorAt, watchAttempts, etc.) - Fix CredentialPayload type errors by adding user.email and delegationCredentialId - Use type assertion in getCalendar.ts to resolve credential type conflicts - Ensure calendar service tests work with new lazy loading approach Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: update test mocking for CalendarServiceMap compatibility - Add comprehensive vi.mock for calendar.services.generated in delegation-credential tests - Mock GoogleCalendarService and Office365CalendarService with proper return values - Update all test files to use await with mockCalendarToHaveNoBusySlots - Ensure calendar events return expected meetingId, meetingPassword, meetingUrl values - Fix async/await compatibility issues in booking scenario test utilities Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * fix: resolve TypeScript errors in CalendarServiceMap mocking - Extract CalendarServiceMap promise to variable to fix 'always true' condition - Ensure vi.mocked is called on Promise type for proper mockResolvedValue access - Add await keywords to calendar mock calls in test files - Maintain existing functionality while making code type-safe Co-Authored-By: keith@cal.com <keithwillcode@gmail.com> * Remove Exchange 2013 and 2016 * Mock Exchange in all tests * Fix tests * Remove exchange 2013 and 2016 from app store index * Fix merge error * Await when getting calendar service * Fix selectedSlot test * Add missing variable * Update openapi.json * Updated CalendarService imports * try again * WIP migrate calendar apps to ES6 * Revert "WIP migrate calendar apps to ES6" This reverts commit 15bf2c83305e82050779d0bca3380fa9573db1e0. * Revert changes back to e23991024a5a455c14aa05f100a5b56288db343a This reverts all calendar service changes that were causing circular dependency issues during builds and E2E tests. * Remove circular dependency for location constants * Update yarn.lock with removed package * Add empty map when running E2E * Type fies * Fix merge conflict * Remove logging statements * Throw error and reset state if failing to load app * Revert "Remove Exchange 2013 and 2016" This reverts commit fedaf6346bd9e4c63337276d5a6f9e8c3943056c. * Re-introduce exchange{2013,2016} Revert the removal in app-store/index.ts also. * Trying to fix tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Joe Au-Yeung <j.auyeung419@gmail.com> Co-authored-by: Joe Au-Yeung <65426560+joeauyeung@users.noreply.github.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
566 lines
19 KiB
TypeScript
566 lines
19 KiB
TypeScript
import {
|
|
createBookingScenario,
|
|
getBooker,
|
|
getDate,
|
|
getGoogleCalendarCredential,
|
|
getOrganizer,
|
|
getScenarioData,
|
|
mockCalendarToHaveNoBusySlots,
|
|
mockSuccessfulVideoMeetingCreation,
|
|
TestData,
|
|
Timezones,
|
|
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
|
import {
|
|
expectBookingCreatedWebhookToHaveBeenFired,
|
|
expectBookingToBeInDatabase,
|
|
expectSuccessfulBookingCreationEmails,
|
|
expectSuccessfulCalendarEventCreationInCalendar,
|
|
} from "@calcom/web/test/utils/bookingScenario/expects";
|
|
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
|
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
|
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import { describe, expect } from "vitest";
|
|
|
|
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
|
import logger from "@calcom/lib/logger";
|
|
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
|
|
|
import { handleNewRecurringBooking } from "../../handleNewRecurringBooking";
|
|
|
|
const DAY_IN_MS = 1000 * 60 * 60 * 24;
|
|
|
|
function getPlusDayDate(date: string, days: number) {
|
|
return new Date(new Date(date).getTime() + days * DAY_IN_MS);
|
|
}
|
|
|
|
// Local test runs sometime gets too slow
|
|
const timeout = process.env.CI ? 5000 : 20000;
|
|
describe("handleNewRecurringBooking", () => {
|
|
setupAndTeardown();
|
|
|
|
describe("Recurring EventType:", () => {
|
|
describe("User event type:", () => {
|
|
test(
|
|
`should create successful bookings for the number of slots requested
|
|
1. Should create the same number of bookings as requested slots in the database
|
|
2. Should send emails for the first booking only to the booker as well as organizer
|
|
3. Should create a calendar event for every booking in the destination calendar
|
|
3. Should trigger BOOKING_CREATED webhook for every booking
|
|
`,
|
|
async ({ emails }) => {
|
|
const booker = getBooker({
|
|
email: "booker@example.com",
|
|
name: "Booker",
|
|
});
|
|
|
|
const organizer = getOrganizer({
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
id: 101,
|
|
schedules: [TestData.schedules.IstWorkHours],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "organizer@google-calendar.com",
|
|
},
|
|
});
|
|
|
|
const recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
await createBookingScenario(
|
|
getScenarioData({
|
|
webhooks: [
|
|
{
|
|
userId: organizer.id,
|
|
eventTriggers: ["BOOKING_CREATED"],
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
active: true,
|
|
eventTypeId: 1,
|
|
appId: null,
|
|
},
|
|
],
|
|
eventTypes: [
|
|
{
|
|
id: 1,
|
|
slotInterval: 30,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
|
|
organizer,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
const calendarMock = await mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 2;
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
|
|
// Create an array of booking data for multiple slots
|
|
const bookingDataArray = Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
});
|
|
|
|
// Call handleNewRecurringBooking directly instead of through API
|
|
const createdBookings = await handleNewRecurringBooking({
|
|
bookingData: bookingDataArray,
|
|
userId: -1, // Simulating anonymous user like in the API test
|
|
});
|
|
|
|
expect(createdBookings.length).toBe(numOfSlotsToBeBooked);
|
|
for (const [index, createdBooking] of Object.entries(createdBookings)) {
|
|
logger.debug("Assertion for Booking with index:", index, { createdBooking });
|
|
expect(createdBooking.responses).toEqual(
|
|
expect.objectContaining({
|
|
email: booker.email,
|
|
name: booker.name,
|
|
})
|
|
);
|
|
|
|
expect(createdBooking).toEqual(
|
|
expect.objectContaining({
|
|
location: "integrations:daily",
|
|
})
|
|
);
|
|
|
|
await expectBookingToBeInDatabase({
|
|
description: "",
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBooking.uid!,
|
|
eventTypeId: mockBookingData.eventTypeId,
|
|
status: BookingStatus.ACCEPTED,
|
|
recurringEventId: mockBookingData.recurringEventId,
|
|
references: [
|
|
{
|
|
type: "daily_video",
|
|
uid: "MOCK_ID",
|
|
meetingId: "MOCK_ID",
|
|
meetingPassword: "MOCK_PASS",
|
|
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
type: "google_calendar",
|
|
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
meetingPassword: "MOCK_PASSWORD",
|
|
},
|
|
],
|
|
});
|
|
|
|
expectBookingCreatedWebhookToHaveBeenFired({
|
|
booker,
|
|
organizer,
|
|
location: "integrations:daily",
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
videoCallUrl: `${WEBAPP_URL}/video/${createdBookings[0].uid}`,
|
|
});
|
|
}
|
|
|
|
expectSuccessfulBookingCreationEmails({
|
|
booker,
|
|
booking: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
uid: createdBookings[0].uid!,
|
|
urlOrigin: WEBSITE_URL,
|
|
},
|
|
organizer,
|
|
emails,
|
|
bookingTimeRange: {
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
start: createdBookings[0].startTime!,
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
end: createdBookings[0].endTime!,
|
|
},
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
recurrence: {
|
|
...recurrence,
|
|
count: recurringCountInRequest,
|
|
},
|
|
});
|
|
|
|
expect(emails.get().length).toBe(2);
|
|
|
|
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, [
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
{
|
|
calendarId: "event-type-1@google-calendar.com",
|
|
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
|
},
|
|
]);
|
|
},
|
|
timeout
|
|
);
|
|
|
|
test.skip(
|
|
`should fail recurring booking if second slot is already booked`,
|
|
async ({}) => {
|
|
const booker = getBooker({
|
|
email: "booker@example.com",
|
|
name: "Booker",
|
|
});
|
|
|
|
const organizer = getOrganizer({
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
id: 101,
|
|
schedules: [TestData.schedules.IstWorkHours],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "organizer@google-calendar.com",
|
|
},
|
|
});
|
|
|
|
const recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus2DateString = getDate({ dateIncrement: 2 }).dateString;
|
|
await createBookingScenario(
|
|
getScenarioData({
|
|
webhooks: [
|
|
{
|
|
userId: organizer.id,
|
|
eventTriggers: ["BOOKING_CREATED"],
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
active: true,
|
|
eventTypeId: 1,
|
|
appId: null,
|
|
},
|
|
],
|
|
eventTypes: [
|
|
{
|
|
id: 1,
|
|
slotInterval: 30,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
users: [
|
|
{
|
|
id: 101,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
{
|
|
uid: "booking-1-uid",
|
|
eventTypeId: 1,
|
|
userId: organizer.id,
|
|
status: BookingStatus.ACCEPTED,
|
|
startTime: `${plus2DateString}T04:00:00.000Z`,
|
|
endTime: `${plus2DateString}T04:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 2;
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
|
|
// Create an array of booking data for multiple slots
|
|
const bookingDataArray = Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
});
|
|
|
|
await expect(
|
|
async () =>
|
|
await handleNewRecurringBooking({
|
|
bookingData: bookingDataArray,
|
|
userId: -1,
|
|
})
|
|
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
|
},
|
|
timeout
|
|
);
|
|
});
|
|
|
|
describe("Round robin event type:", () => {
|
|
test.skip("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
|
const booker = getBooker({
|
|
email: "booker@example.com",
|
|
name: "Booker",
|
|
});
|
|
|
|
const organizer = getOrganizer({
|
|
name: "Organizer",
|
|
email: "organizer@example.com",
|
|
id: 101,
|
|
defaultScheduleId: null,
|
|
teams: [
|
|
{
|
|
membership: {
|
|
accepted: true,
|
|
},
|
|
team: {
|
|
id: 1,
|
|
name: "Team 1",
|
|
slug: "team-1",
|
|
},
|
|
},
|
|
],
|
|
schedules: [TestData.schedules.IstMorningShift],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: TestData.apps["google-calendar"].type,
|
|
externalId: "organizer@google-calendar.com",
|
|
},
|
|
});
|
|
|
|
const otherTeamMembers = [
|
|
{
|
|
name: "Other Team Member 1",
|
|
username: "other-team-member-1",
|
|
timeZone: Timezones["+5:30"],
|
|
defaultScheduleId: null,
|
|
email: "other-team-member-1@example.com",
|
|
id: 102,
|
|
schedules: [TestData.schedules.IstMorningShift],
|
|
credentials: [getGoogleCalendarCredential()],
|
|
selectedCalendars: [TestData.selectedCalendars.google],
|
|
destinationCalendar: {
|
|
integration: TestData.apps["google-calendar"].type,
|
|
externalId: "other-team-member-1@google-calendar.com",
|
|
},
|
|
},
|
|
];
|
|
|
|
const recurrence = getRecurrence({
|
|
type: "weekly",
|
|
numberOfOccurrences: 3,
|
|
});
|
|
|
|
const plus1DateString = getDate({ dateIncrement: 1 }).dateString;
|
|
const plus2DateString = getDate({ dateIncrement: 2 }).dateString;
|
|
|
|
await createBookingScenario(
|
|
getScenarioData({
|
|
webhooks: [
|
|
{
|
|
userId: organizer.id,
|
|
eventTriggers: ["BOOKING_CREATED"],
|
|
subscriberUrl: "http://my-webhook.example.com",
|
|
active: true,
|
|
eventTypeId: 1,
|
|
appId: null,
|
|
},
|
|
],
|
|
eventTypes: [
|
|
{
|
|
id: 1,
|
|
slotInterval: 30,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
length: 30,
|
|
recurringEvent: recurrence,
|
|
hosts: [
|
|
{
|
|
userId: 101,
|
|
isFixed: false,
|
|
},
|
|
{
|
|
userId: 102,
|
|
isFixed: true,
|
|
},
|
|
],
|
|
destinationCalendar: {
|
|
integration: "google_calendar",
|
|
externalId: "event-type-1@google-calendar.com",
|
|
},
|
|
},
|
|
],
|
|
bookings: [
|
|
{
|
|
userId: 102,
|
|
attendees: [
|
|
{
|
|
email: "IntegrationTestUser102@example.com",
|
|
},
|
|
],
|
|
eventTypeId: 1,
|
|
status: "ACCEPTED",
|
|
startTime: `${plus2DateString}T04:00:00.000Z`,
|
|
endTime: `${plus2DateString}T04:30:00.000Z`,
|
|
},
|
|
],
|
|
organizer,
|
|
usersApartFromOrganizer: otherTeamMembers,
|
|
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
|
|
})
|
|
);
|
|
|
|
mockSuccessfulVideoMeetingCreation({
|
|
metadataLookupKey: "dailyvideo",
|
|
videoMeetingData: {
|
|
id: "MOCK_ID",
|
|
password: "MOCK_PASS",
|
|
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
|
},
|
|
});
|
|
|
|
mockCalendarToHaveNoBusySlots("googlecalendar", {
|
|
create: {
|
|
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
|
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
|
},
|
|
});
|
|
|
|
const recurringCountInRequest = 4;
|
|
const mockBookingData = getMockRequestDataForBooking({
|
|
data: {
|
|
eventTypeId: 1,
|
|
start: `${plus1DateString}T04:00:00.000Z`,
|
|
end: `${plus1DateString}T04:30:00.000Z`,
|
|
recurringEventId: uuidv4(),
|
|
recurringCount: recurringCountInRequest,
|
|
responses: {
|
|
email: booker.email,
|
|
name: booker.name,
|
|
location: { optionValue: "", value: "integrations:daily" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const numOfSlotsToBeBooked = 4;
|
|
|
|
// Create an array of booking data for multiple slots
|
|
const bookingDataArray = Array(numOfSlotsToBeBooked)
|
|
.fill(mockBookingData)
|
|
.map((mockBookingData, index) => {
|
|
return {
|
|
...mockBookingData,
|
|
schedulingType: SchedulingType.ROUND_ROBIN,
|
|
start: getPlusDayDate(mockBookingData.start, index).toISOString(),
|
|
end: getPlusDayDate(mockBookingData.end, index).toISOString(),
|
|
};
|
|
});
|
|
|
|
await expect(() =>
|
|
handleNewRecurringBooking({
|
|
bookingData: bookingDataArray,
|
|
userId: -1,
|
|
})
|
|
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
|
});
|
|
});
|
|
});
|
|
|
|
function getRecurrence({
|
|
type,
|
|
numberOfOccurrences,
|
|
}: {
|
|
type: "weekly" | "monthly" | "yearly";
|
|
numberOfOccurrences: number;
|
|
}) {
|
|
const freq = type === "yearly" ? 0 : type === "monthly" ? 1 : 2;
|
|
return { freq, count: numberOfOccurrences, interval: 1 };
|
|
}
|
|
});
|