From 2881961859c8cef47fb3a92da4a47b9568627a3d Mon Sep 17 00:00:00 2001 From: Deepak Sharma <87939462+Deepak22448@users.noreply.github.com> Date: Wed, 14 Jan 2026 17:02:00 +0530 Subject: [PATCH] feat: add custom calendar reminder for Google Calendar events (#26078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add custom calendar reminder for Google Calendar events Allows users to set default reminder notifications (10, 30, or 60 minutes) for events created via Cal.com bookings in Google Calendar. This addresses the issue where Google Calendar's default reminders don't apply to API-created events. - Add customCalendarReminder field to DestinationCalendar model - Create reminder selector UI in destination calendar settings - Update GoogleCalendarService to apply custom reminders (popup + email) - Add TRPC endpoint for updating reminder settings * fix: address code review feedback - Use z.union with literal values (10, 30, 60) for stricter schema validation - Add localization for toast messages using t() - Fix test data to use null for customCalendarReminder to match test intent * fix: add customCalendarReminder to DestinationCalendar types Update manually-defined DestinationCalendar types to include the new customCalendarReminder field added in the previous commit. This fixes type mismatches when passing Prisma-persisted destination calendars to functions using these local type definitions. * fix: update customCalendarReminder type in test to match Prisma schema The customCalendarReminder field is defined as a non-nullable Int with default value of 10 in the Prisma schema. Changed test value from null to 10 to fix TS2345 type error. * feat: add 'just in time' reminder option for Google Calendar events - Add 0 (just in time) as a valid reminder option alongside 10, 30, 60 minutes - Update TRPC schema to accept 0 as valid reminder value - Add 'Just in time' option to reminder dropdown selector UI - Add translation for 'just_in_time' key in English locale - Include comprehensive test case for 0-minute (just in time) reminders - Remove unused import from test file This addresses reviewer feedback to provide flexible 'just in time' option that allows reminders to fire at exact event start time. 🤖 Generated with Claude Code * fix: add customCalendarReminder to calendars service mock - Add missing customCalendarReminder field to destination calendar mock - Matches the Prisma schema requirement - Fixes type error in calendars controller e2e test * fix: add customCalendarReminder to destination calendars controller test - Add missing customCalendarReminder field to mock destination calendar - Matches Prisma schema requirement - Fixes type error in destination-calendars controller e2e test * fix: cast customCalendarReminder to ReminderMinutes type - Add type cast for reminderValue to ensure type safety - Use nullish coalescing (??) to preserve 0 as valid reminder value - Ensures database number type is properly cast to ReminderMinutes union * refactor: simplify translation logic in DestinationReminderSelector - Remove conditional check for 'just_in_time' label - Pass count parameter to all translations uniformly - Translation function handles unused parameters gracefully Addresses review feedback from @Khaan25 * refactor: make custom calendar reminder opt-in and use repository pattern - Change default from 10 to null (opt-in feature) - Add DestinationCalendarRepository to avoid direct Prisma usage - Add 'Use default reminders' option to UI * refactor: remove redundant migration file The original migration already creates customCalendarReminder as nullable, so this second migration to make it nullable is not needed. * fix: type error for customCalendarReminder in destination calendar settings * refactor: validate customCalendarReminder with Zod schema instead of type casting * refactor: use instance methods in DestinationCalendarRepository Changed from static to instance methods with getInstance() pattern as requested in review. * refactor: replace singleton with DI for DestinationCalendarRepository - Remove getInstance() singleton pattern - Add DI tokens, module, and container - Update GoogleCalendarService and tRPC handler to use DI --------- Co-authored-by: Volnei Munhoz Co-authored-by: Keith Williams Co-authored-by: Eunjae Lee --- ...stination-calendars.controller.e2e-spec.ts | 1 + .../v2/test/mocks/calendars-service-mock.ts | 1 + .../DestinationCalendarSettingsWebWrapper.tsx | 38 +++++ apps/web/public/static/locales/en/common.json | 7 + .../googlecalendar/lib/CalendarService.ts | 41 ++++- .../lib/__tests__/CalendarService.test.ts | 144 +++++++++++++++++- .../di/containers/DestinationCalendar.ts | 11 ++ .../di/modules/DestinationCalendar.ts | 21 +++ packages/features/di/tokens.ts | 2 + packages/lib/buildCalEventFromBooking.ts | 1 + .../server/repository/destinationCalendar.ts | 36 +++++ .../server/service/BookingWebhookFactory.ts | 1 + .../DestinationCalendar.tsx | 31 +++- .../DestinationReminderSelector.tsx | 50 ++++++ .../migration.sql | 2 + packages/prisma/schema.prisma | 1 + .../routers/viewer/calendars/_router.tsx | 9 ++ .../setDestinationReminder.handler.ts | 28 ++++ .../setDestinationReminder.schema.ts | 14 ++ 19 files changed, 422 insertions(+), 17 deletions(-) create mode 100644 packages/features/di/containers/DestinationCalendar.ts create mode 100644 packages/features/di/modules/DestinationCalendar.ts create mode 100644 packages/lib/server/repository/destinationCalendar.ts create mode 100644 packages/platform/atoms/destination-calendar/DestinationReminderSelector.tsx create mode 100644 packages/prisma/migrations/20251220034814_add_custom_calendar_reminder/migration.sql create mode 100644 packages/trpc/server/routers/viewer/calendars/setDestinationReminder.handler.ts create mode 100644 packages/trpc/server/routers/viewer/calendars/setDestinationReminder.schema.ts 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 19259ba5a3..ea635b6349 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 bd91ae777b..ba957cc7b6 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 b046f8cce6..ccdde41a52 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 0e29c4a136..d8ebddd2fb 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -433,6 +433,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 49a41a5a04..0944313648 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 56a6b2b6fa..3f4c4de2bd 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 0000000000..b468182596 --- /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 0000000000..5a69df8684 --- /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 e79f36e8dc..4812b0f382 100644 --- a/packages/features/di/tokens.ts +++ b/packages/features/di/tokens.ts @@ -70,6 +70,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 7cee621c51..34c2d82eb4 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 0000000000..6f64e2a89e --- /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 4f3f6c4199..08f9add26e 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 e7a69f493c..734879a441 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 0000000000..b2895e3c2d --- /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 ( +