diff --git a/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts index 19259ba5a38ea3..ea635b63490243 100644 --- a/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts +++ b/apps/api/v2/src/modules/destination-calendars/controllers/destination-calendars.controller.e2e-spec.ts @@ -120,6 +120,7 @@ describe("Platform Destination Calendar Endpoints", () => { domainWideDelegationCredentialId: null, createdAt: new Date(), updatedAt: new Date(), + customCalendarReminder: 10, }, }) ); diff --git a/apps/api/v2/test/mocks/calendars-service-mock.ts b/apps/api/v2/test/mocks/calendars-service-mock.ts index bd91ae777b21f3..ba957cc7b624b3 100644 --- a/apps/api/v2/test/mocks/calendars-service-mock.ts +++ b/apps/api/v2/test/mocks/calendars-service-mock.ts @@ -77,6 +77,7 @@ export class CalendarsServiceMock { domainWideDelegationCredentialId: null, createdAt: new Date(), updatedAt: new Date(), + customCalendarReminder: 10, }, } satisfies Awaited>; } diff --git a/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx b/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx index b046f8cce602c9..ccdde41a5278b9 100644 --- a/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx +++ b/apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx @@ -1,9 +1,16 @@ +import { useLocale } from "@calcom/lib/hooks/useLocale"; import { trpc } from "@calcom/trpc/react"; +import { + reminderSchema, + type ReminderMinutes, +} from "@calcom/trpc/server/routers/viewer/calendars/setDestinationReminder.schema"; +import { showToast } from "@calcom/ui/components/toast"; import { AtomsWrapper } from "../../../../packages/platform/atoms/src/components/atoms-wrapper"; import { DestinationCalendarSettings } from "../../../../packages/platform/atoms/destination-calendar/DestinationCalendar"; export const DestinationCalendarSettingsWebWrapper = () => { + const { t } = useLocale(); const calendars = trpc.viewer.calendars.connectedCalendars.useQuery(); const utils = trpc.useUtils(); const mutation = trpc.viewer.calendars.setDestinationCalendar.useMutation({ @@ -12,10 +19,38 @@ export const DestinationCalendarSettingsWebWrapper = () => { }, }); + const reminderMutation = trpc.viewer.calendars.setDestinationReminder.useMutation({ + onSuccess: () => { + showToast(t("reminder_updated"), "success"); + utils.viewer.calendars.connectedCalendars.invalidate(); + }, + onError: () => { + showToast(t("error_updating_reminder"), "error"); + }, + }); + if (!calendars.data?.connectedCalendars || calendars.data.connectedCalendars.length < 1) { return null; } + const handleReminderChange = (value: ReminderMinutes) => { + const destCal = calendars.data.destinationCalendar; + if (destCal?.credentialId) { + reminderMutation.mutate({ + credentialId: destCal.credentialId, + integration: destCal.integration, + defaultReminder: value, + }); + } + }; + + const validatedReminderValue = reminderSchema.safeParse( + calendars.data.destinationCalendar.customCalendarReminder + ); + const reminderValue: ReminderMinutes = validatedReminderValue.success + ? validatedReminderValue.data + : null; + return ( { value={calendars.data.destinationCalendar.externalId} hidePlaceholder onChange={mutation.mutate} + onReminderChange={handleReminderChange} + reminderValue={reminderValue} + isReminderPending={reminderMutation.isPending} /> ); diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index d95352b64a3fb0..0d4e4feea90e28 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -430,6 +430,13 @@ "add_to_calendar": "Add to calendar", "add_to_calendar_description": "Select where to add events when you're booked.", "add_events_to": "Add events to", + "default_reminder": "Default reminder", + "default_reminder_description": "Set the default reminder time for events added to your Google Calendar.", + "use_default_reminders": "Use default reminders", + "remind_minutes_before": "{{count}} minutes before", + "just_in_time": "Just in time", + "reminder_updated": "Reminder updated", + "error_updating_reminder": "Failed to update reminder", "add_another_calendar": "Add another calendar", "other": "Other", "email_sign_in_subject": "Your sign-in link for {{appName}}", diff --git a/packages/app-store/googlecalendar/lib/CalendarService.ts b/packages/app-store/googlecalendar/lib/CalendarService.ts index 49a41a5a044636..0944313648a053 100644 --- a/packages/app-store/googlecalendar/lib/CalendarService.ts +++ b/packages/app-store/googlecalendar/lib/CalendarService.ts @@ -7,6 +7,7 @@ import { getLocation, getRichDescription } from "@calcom/lib/CalEventParser"; import { ORGANIZER_EMAIL_EXEMPT_DOMAINS } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; import { safeStringify } from "@calcom/lib/safeStringify"; +import { getDestinationCalendarRepository } from "@calcom/features/di/containers/DestinationCalendar"; import { SelectedCalendarRepository } from "@calcom/lib/server/repository/selectedCalendar"; import type { Prisma } from "@calcom/prisma/client"; import type { @@ -48,6 +49,32 @@ export default class GoogleCalendarService implements Calendar { this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] }); } + private async getReminderDuration(credentialId: number): Promise { + try { + const destinationCalendarRepository = getDestinationCalendarRepository(); + return await destinationCalendarRepository.getCustomReminderByCredentialId(credentialId); + } catch (error) { + this.log.warn("Failed to fetch custom calendar reminder", safeStringify(error)); + return null; + } + } + + private getReminders(customReminderMinutes: number | null): { + useDefault: boolean; + overrides?: { method: string; minutes: number }[]; + } { + if (customReminderMinutes !== null) { + return { + useDefault: false, + overrides: [ + { method: "popup", minutes: customReminderMinutes }, + { method: "email", minutes: customReminderMinutes }, + ], + }; + } + return { useDefault: true }; + } + public getCredentialId() { return this.credential.id; } @@ -119,6 +146,9 @@ export default class GoogleCalendarService implements Calendar { ): Promise { this.log.debug("Creating event"); + // Fetch custom reminder duration for this credential's destination calendar + const customReminderMinutes = await this.getReminderDuration(credentialId); + const payload: calendar_v3.Schema$Event = { summary: calEvent.title, description: calEvent.calendarDescription, @@ -131,9 +161,7 @@ export default class GoogleCalendarService implements Calendar { timeZone: calEvent.organizer.timeZone, }, attendees: this.getAttendees({ event: calEvent, hostExternalCalendarId: externalCalendarId }), - reminders: { - useDefault: true, - }, + reminders: this.getReminders(customReminderMinutes), guestsCanSeeOtherGuests: calEvent.seatsPerTimeSlot ? calEvent.seatsShowAttendees : true, iCalUID: calEvent.iCalUID, }; @@ -278,6 +306,9 @@ export default class GoogleCalendarService implements Calendar { } async updateEvent(uid: string, event: CalendarServiceEvent, externalCalendarId: string): Promise { + // Fetch custom reminder duration for this credential's destination calendar + const customReminderMinutes = await this.getReminderDuration(this.credential.id); + const payload: calendar_v3.Schema$Event = { summary: event.title, description: event.calendarDescription, @@ -290,9 +321,7 @@ export default class GoogleCalendarService implements Calendar { timeZone: event.organizer.timeZone, }, attendees: this.getAttendees({ event, hostExternalCalendarId: externalCalendarId }), - reminders: { - useDefault: true, - }, + reminders: this.getReminders(customReminderMinutes), guestsCanSeeOtherGuests: event.seatsPerTimeSlot ? event.seatsShowAttendees : true, }; diff --git a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts index 56a6b2b6faef1c..3f4c4de2bd278b 100644 --- a/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts +++ b/packages/app-store/googlecalendar/lib/__tests__/CalendarService.test.ts @@ -18,13 +18,10 @@ import { expect, test, beforeEach, vi, describe } from "vitest"; import "vitest-fetch-mock"; import logger from "@calcom/lib/logger"; +import { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; import CalendarService from "../CalendarService"; -import { - createMockJWTInstance, - createCredentialForCalendarService, -} from "./utils"; -import { CredentialForCalendarServiceWithEmail } from "@calcom/types/Credential"; +import { createMockJWTInstance } from "./utils"; const log = logger.getSubLogger({ prefix: ["CalendarService.test"] }); @@ -46,7 +43,7 @@ const mockCredential: CredentialForCalendarServiceWithEmail = { appId: "google-calendar", type: "google_calendar", key: { - access_token: "" + access_token: "", }, user: { email: "user@example.com", @@ -59,7 +56,6 @@ const mockCredential: CredentialForCalendarServiceWithEmail = { describe("getAvailability", () => { test("returns availability for selected calendars", async () => { - const calendarService = new CalendarService(mockCredential); setFullMockOAuthManagerRequest(); const mockedBusyTimes1 = [ @@ -460,6 +456,7 @@ describe("createEvent", () => { domainWideDelegationCredentialId: null, createdAt: new Date("2024-06-15T11:00:00Z"), updatedAt: new Date("2024-06-15T11:00:00Z"), + customCalendarReminder: 10, }, ], iCalUID: "test-ical-uid@google.com", @@ -634,6 +631,7 @@ describe("createEvent", () => { domainWideDelegationCredentialId: null, createdAt: new Date("2024-06-15T11:00:00Z"), updatedAt: new Date("2024-06-15T11:00:00Z"), + customCalendarReminder: 10, }, ], calendarDescription: "Weekly team meeting", @@ -672,4 +670,136 @@ describe("createEvent", () => { log.info("createEvent recurring event test passed"); }); + + test("should use default reminders when no custom reminder is configured", async () => { + const calendarService = new CalendarService(mockCredential); + setFullMockOAuthManagerRequest(); + + const mockGoogleEvent = { + id: "mock-event-default-reminder", + summary: "Test Meeting Default Reminder", + start: { dateTime: "2024-06-15T10:00:00Z", timeZone: "UTC" }, + end: { dateTime: "2024-06-15T11:00:00Z", timeZone: "UTC" }, + }; + + const eventsInsertMock = vi.fn().mockResolvedValue({ + data: mockGoogleEvent, + }); + calendarMock.calendar_v3.Calendar().events.insert = eventsInsertMock; + + const testCalEvent = { + type: "test-event-type", + uid: "cal-event-uid-456", + title: "Test Meeting Default Reminder", + startTime: "2024-06-15T10:00:00Z", + endTime: "2024-06-15T11:00:00Z", + organizer: { + id: 1, + name: "Test Organizer", + email: "organizer@example.com", + timeZone: "UTC", + language: { translate: (...args: any[]) => args[0], locale: "en" }, + }, + attendees: [], + calendarDescription: "Test meeting description", + destinationCalendar: [ + { + id: 1, + integration: "google_calendar", + externalId: "primary", + primaryEmail: null, + userId: mockCredential.userId, + eventTypeId: null, + credentialId: mockCredential.id, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + createdAt: new Date("2024-06-15T11:00:00Z"), + updatedAt: new Date("2024-06-15T11:00:00Z"), + customCalendarReminder: null, + }, + ], + }; + + // Mock the getReminderDuration method to return null (no custom reminder configured) + vi.spyOn(calendarService as any, "getReminderDuration").mockResolvedValue(null); + + await calendarService.createEvent(testCalEvent, mockCredential.id); + + const insertCall = eventsInsertMock.mock.calls[0][0]; + + // When no custom reminder is configured, should use Google Calendar's default + expect(insertCall.requestBody.reminders).toEqual({ + useDefault: true, + }); + + log.info("createEvent with default reminders test passed"); + }); + + test("should handle 'just in time' reminder (0 minutes) correctly", async () => { + const calendarService = new CalendarService(mockCredential); + setFullMockOAuthManagerRequest(); + + // Mock the getReminderDuration method to return 0 (just in time) + vi.spyOn(calendarService as any, "getReminderDuration").mockResolvedValue(0); + + const mockGoogleEvent = { + id: "mock-event-just-in-time", + summary: "Test Meeting Just In Time", + start: { dateTime: "2024-06-15T10:00:00Z", timeZone: "UTC" }, + end: { dateTime: "2024-06-15T11:00:00Z", timeZone: "UTC" }, + }; + + const eventsInsertMock = vi.fn().mockResolvedValue({ + data: mockGoogleEvent, + }); + calendarMock.calendar_v3.Calendar().events.insert = eventsInsertMock; + + const testCalEvent = { + type: "test-event-type", + uid: "cal-event-uid-789", + title: "Test Meeting Just In Time", + startTime: "2024-06-15T10:00:00Z", + endTime: "2024-06-15T11:00:00Z", + organizer: { + id: 1, + name: "Test Organizer", + email: "organizer@example.com", + timeZone: "UTC", + language: { translate: (...args: any[]) => args[0], locale: "en" }, + }, + attendees: [], + calendarDescription: "Test meeting description", + destinationCalendar: [ + { + id: 1, + integration: "google_calendar", + externalId: "primary", + primaryEmail: null, + userId: mockCredential.userId, + eventTypeId: null, + credentialId: mockCredential.id, + delegationCredentialId: null, + domainWideDelegationCredentialId: null, + createdAt: new Date("2024-06-15T11:00:00Z"), + updatedAt: new Date("2024-06-15T11:00:00Z"), + customCalendarReminder: 0, + }, + ], + }; + + await calendarService.createEvent(testCalEvent, mockCredential.id); + + const insertCall = eventsInsertMock.mock.calls[0][0]; + + // When "just in time" (0 minutes) is configured, should override with 0-minute reminders + expect(insertCall.requestBody.reminders).toEqual({ + useDefault: false, + overrides: [ + { method: "popup", minutes: 0 }, + { method: "email", minutes: 0 }, + ], + }); + + log.info("createEvent with just in time reminders test passed"); + }); }); diff --git a/packages/features/di/containers/DestinationCalendar.ts b/packages/features/di/containers/DestinationCalendar.ts new file mode 100644 index 00000000000000..b4681825966620 --- /dev/null +++ b/packages/features/di/containers/DestinationCalendar.ts @@ -0,0 +1,11 @@ +import type { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar"; + +import { createContainer } from "../di"; +import { moduleLoader as destinationCalendarRepositoryModuleLoader } from "../modules/DestinationCalendar"; + +const container = createContainer(); + +export function getDestinationCalendarRepository() { + destinationCalendarRepositoryModuleLoader.loadModule(container); + return container.get(destinationCalendarRepositoryModuleLoader.token); +} diff --git a/packages/features/di/modules/DestinationCalendar.ts b/packages/features/di/modules/DestinationCalendar.ts new file mode 100644 index 00000000000000..5a69df86848b3e --- /dev/null +++ b/packages/features/di/modules/DestinationCalendar.ts @@ -0,0 +1,21 @@ +import { DestinationCalendarRepository } from "@calcom/lib/server/repository/destinationCalendar"; +import { DI_TOKENS } from "@calcom/features/di/tokens"; +import { moduleLoader as prismaModuleLoader } from "@calcom/features/di/modules/Prisma"; + +import { createModule, bindModuleToClassOnToken, type ModuleLoader } from "../di"; + +export const destinationCalendarRepositoryModule = createModule(); +const token = DI_TOKENS.DESTINATION_CALENDAR_REPOSITORY; +const moduleToken = DI_TOKENS.DESTINATION_CALENDAR_REPOSITORY_MODULE; +const loadModule = bindModuleToClassOnToken({ + module: destinationCalendarRepositoryModule, + moduleToken, + token, + classs: DestinationCalendarRepository, + dep: prismaModuleLoader, +}); + +export const moduleLoader: ModuleLoader = { + token, + loadModule, +}; diff --git a/packages/features/di/tokens.ts b/packages/features/di/tokens.ts index 0e41688270952b..b9d8bb1fd9121c 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -66,6 +66,8 @@ export const DI_TOKENS = { ASSIGNMENT_REASON_REPOSITORY_MODULE: Symbol("AssignmentReasonRepositoryModule"), CREDENTIAL_REPOSITORY: Symbol("CredentialRepository"), CREDENTIAL_REPOSITORY_MODULE: Symbol("CredentialRepositoryModule"), + DESTINATION_CALENDAR_REPOSITORY: Symbol("DestinationCalendarRepository"), + DESTINATION_CALENDAR_REPOSITORY_MODULE: Symbol("DestinationCalendarRepositoryModule"), MANAGED_EVENT_REASSIGNMENT_SERVICE: Symbol("ManagedEventReassignmentService"), MANAGED_EVENT_REASSIGNMENT_SERVICE_MODULE: Symbol("ManagedEventReassignmentServiceModule"), ORG_MEMBERSHIP_LOOKUP: Symbol("OrgMembershipLookup"), diff --git a/packages/lib/buildCalEventFromBooking.ts b/packages/lib/buildCalEventFromBooking.ts index 7cee621c51dd62..34c2d82eb4b5b8 100644 --- a/packages/lib/buildCalEventFromBooking.ts +++ b/packages/lib/buildCalEventFromBooking.ts @@ -16,6 +16,7 @@ type DestinationCalendar = { domainWideDelegationCredentialId: string | null; createdAt: Date | null; updatedAt: Date | null; + customCalendarReminder: number | null; } | null; type Attendee = { diff --git a/packages/lib/server/repository/destinationCalendar.ts b/packages/lib/server/repository/destinationCalendar.ts new file mode 100644 index 00000000000000..6f64e2a89edadb --- /dev/null +++ b/packages/lib/server/repository/destinationCalendar.ts @@ -0,0 +1,36 @@ +import type { PrismaClient } from "@calcom/prisma"; + +export class DestinationCalendarRepository { + constructor(private prisma: PrismaClient) {} + + async getCustomReminderByCredentialId(credentialId: number): Promise { + const destinationCalendar = await this.prisma.destinationCalendar.findFirst({ + where: { credentialId }, + select: { customCalendarReminder: true }, + }); + return destinationCalendar?.customCalendarReminder ?? null; + } + + async updateCustomReminder({ + userId, + credentialId, + integration, + customCalendarReminder, + }: { + userId: number; + credentialId: number; + integration: string; + customCalendarReminder: number | null; + }) { + return await this.prisma.destinationCalendar.updateMany({ + where: { + userId, + credentialId, + integration, + }, + data: { + customCalendarReminder, + }, + }); + } +} diff --git a/packages/lib/server/service/BookingWebhookFactory.ts b/packages/lib/server/service/BookingWebhookFactory.ts index 44141099a76db5..f35bd7c0fcff58 100644 --- a/packages/lib/server/service/BookingWebhookFactory.ts +++ b/packages/lib/server/service/BookingWebhookFactory.ts @@ -17,6 +17,7 @@ type DestinationCalendar = { updatedAt: Date | null; delegationCredentialId: string | null; domainWideDelegationCredentialId: string | null; + customCalendarReminder: number | null; }; type Response = { diff --git a/packages/platform/atoms/destination-calendar/DestinationCalendar.tsx b/packages/platform/atoms/destination-calendar/DestinationCalendar.tsx index e7a69f493ce7d8..734879a441d61e 100644 --- a/packages/platform/atoms/destination-calendar/DestinationCalendar.tsx +++ b/packages/platform/atoms/destination-calendar/DestinationCalendar.tsx @@ -1,9 +1,11 @@ import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ReminderMinutes } from "@calcom/trpc/server/routers/viewer/calendars/setDestinationReminder.schema"; import { Label } from "@calcom/ui/components/form"; import { cn } from "../src/lib/utils"; import type { DestinationCalendarProps } from "./DestinationCalendarSelector"; import { DestinationCalendarSelector } from "./DestinationCalendarSelector"; +import { DestinationReminderSelector } from "./DestinationReminderSelector"; type DestinationHeaderClassnames = { container?: string; @@ -16,10 +18,20 @@ export type DestinationCalendarClassNames = { header?: DestinationHeaderClassnames; }; -export const DestinationCalendarSettings = ( - props: DestinationCalendarProps & { classNames?: string; classNamesObject?: DestinationCalendarClassNames } -) => { +type DestinationCalendarSettingsProps = DestinationCalendarProps & { + classNames?: string; + classNamesObject?: DestinationCalendarClassNames; + onReminderChange?: ((value: ReminderMinutes) => void) | null; + reminderValue: ReminderMinutes; + isReminderPending?: boolean; +}; + +export const DestinationCalendarSettings = (props: DestinationCalendarSettingsProps) => { const { t } = useLocale(); + const showReminderSelector = + props.onReminderChange !== null && + props.onReminderChange !== undefined && + props.destinationCalendar?.integration === "google_calendar"; return (
-
+
+ {showReminderSelector && ( +
+ +

{t("default_reminder_description")}

+ +
+ )}
diff --git a/packages/platform/atoms/destination-calendar/DestinationReminderSelector.tsx b/packages/platform/atoms/destination-calendar/DestinationReminderSelector.tsx new file mode 100644 index 00000000000000..b2895e3c2dbbb5 --- /dev/null +++ b/packages/platform/atoms/destination-calendar/DestinationReminderSelector.tsx @@ -0,0 +1,50 @@ +"use client"; + +import { useLocale } from "@calcom/lib/hooks/useLocale"; +import type { ReminderMinutes } from "@calcom/trpc/server/routers/viewer/calendars/setDestinationReminder.schema"; +import { Select } from "@calcom/ui/components/form"; + +const REMINDER_OPTIONS: Array<{ value: ReminderMinutes; label: string }> = [ + { value: null, label: "use_default_reminders" }, + { value: 0, label: "just_in_time" }, + { value: 10, label: "remind_minutes_before" }, + { value: 30, label: "remind_minutes_before" }, + { value: 60, label: "remind_minutes_before" }, +]; + +interface DestinationReminderSelectorProps { + value: ReminderMinutes; + onChange: (value: ReminderMinutes) => void; + isPending?: boolean; +} + +export const DestinationReminderSelector = ({ + value, + onChange, + isPending, +}: DestinationReminderSelectorProps) => { + const { t } = useLocale(); + + const options = REMINDER_OPTIONS.map((opt) => ({ + value: opt.value, + label: typeof opt.value === "number" ? t(opt.label, { count: opt.value }) : t(opt.label), + })); + + const selectedOption = options.find((opt) => opt.value === value) ?? options[0]; + + return ( +