Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
f587194
feat: add custom calendar reminder for Google Calendar events
Deepak22448 Dec 20, 2025
a8bc82e
fix: address code review feedback
Deepak22448 Dec 20, 2025
1ac7440
fix: add customCalendarReminder to DestinationCalendar types
Deepak22448 Dec 20, 2025
d4222ea
fix: update customCalendarReminder type in test to match Prisma schema
Deepak22448 Dec 20, 2025
681edae
feat: add 'just in time' reminder option for Google Calendar events
Deepak22448 Dec 27, 2025
c38ea4e
fix: add customCalendarReminder to calendars service mock
Deepak22448 Dec 27, 2025
c1a2241
fix: add customCalendarReminder to destination calendars controller test
Deepak22448 Dec 27, 2025
41ac7f9
fix: cast customCalendarReminder to ReminderMinutes type
Deepak22448 Dec 27, 2025
1da0f26
Merge branch 'main' into feat/google-calendar-custom-reminder
volnei Dec 28, 2025
95d92ec
Merge branch 'main' into feat/google-calendar-custom-reminder
keithwillcode Dec 29, 2025
387cf31
refactor: simplify translation logic in DestinationReminderSelector
Deepak22448 Dec 29, 2025
324de3c
Merge branch 'main' into feat/google-calendar-custom-reminder
Deepak22448 Dec 29, 2025
56aa7a7
Merge branch 'main' into feat/google-calendar-custom-reminder
volnei Dec 29, 2025
428a5ba
refactor: make custom calendar reminder opt-in and use repository pat…
Deepak22448 Jan 6, 2026
61a1762
refactor: remove redundant migration file
Deepak22448 Jan 7, 2026
36a3155
Merge branch 'main' into feat/google-calendar-custom-reminder
eunjae-lee Jan 7, 2026
a3d683e
Merge branch 'main' into feat/google-calendar-custom-reminder
eunjae-lee Jan 8, 2026
ab68922
fix: type error for customCalendarReminder in destination calendar se…
Deepak22448 Jan 8, 2026
150faed
refactor: validate customCalendarReminder with Zod schema instead of …
Deepak22448 Jan 8, 2026
39c1dac
Merge branch 'main' into feat/google-calendar-custom-reminder
Deepak22448 Jan 8, 2026
d300dff
Merge branch 'main' into feat/google-calendar-custom-reminder
eunjae-lee Jan 8, 2026
10a1fcc
Merge branch 'main' into feat/google-calendar-custom-reminder
eunjae-lee Jan 8, 2026
9337b22
refactor: use instance methods in DestinationCalendarRepository
Deepak22448 Jan 9, 2026
82cd0cd
Merge branch 'feat/google-calendar-custom-reminder' of github.com:Dee…
Deepak22448 Jan 9, 2026
d9b9a2e
Merge remote-tracking branch 'origin/main' into feat/google-calendar-…
Deepak22448 Jan 10, 2026
c404de3
Merge branch 'main' into feat/google-calendar-custom-reminder
Deepak22448 Jan 12, 2026
b6a0949
refactor: replace singleton with DI for DestinationCalendarRepository
Deepak22448 Jan 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ describe("Platform Destination Calendar Endpoints", () => {
domainWideDelegationCredentialId: null,
createdAt: new Date(),
updatedAt: new Date(),
customCalendarReminder: 10,
},
})
);
Expand Down
1 change: 1 addition & 0 deletions apps/api/v2/test/mocks/calendars-service-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ export class CalendarsServiceMock {
domainWideDelegationCredentialId: null,
createdAt: new Date(),
updatedAt: new Date(),
customCalendarReminder: 10,
},
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
}
Expand Down
38 changes: 38 additions & 0 deletions apps/web/components/apps/DestinationCalendarSettingsWebWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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 (
<AtomsWrapper>
<DestinationCalendarSettings
Expand All @@ -25,6 +60,9 @@ export const DestinationCalendarSettingsWebWrapper = () => {
value={calendars.data.destinationCalendar.externalId}
hidePlaceholder
onChange={mutation.mutate}
onReminderChange={handleReminderChange}
reminderValue={reminderValue}
isReminderPending={reminderMutation.isPending}
/>
</AtomsWrapper>
);
Expand Down
7 changes: 7 additions & 0 deletions apps/web/public/static/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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}}",
Expand Down
41 changes: 35 additions & 6 deletions packages/app-store/googlecalendar/lib/CalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -48,6 +49,32 @@ export default class GoogleCalendarService implements Calendar {
this.log = log.getSubLogger({ prefix: [`[[lib] ${this.integrationName}`] });
}

private async getReminderDuration(credentialId: number): Promise<number | null> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we avoid using direct prisma here and use the repository pattern?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

addressed.

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;
}
Expand Down Expand Up @@ -119,6 +146,9 @@ export default class GoogleCalendarService implements Calendar {
): Promise<NewCalendarEventType> {
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,
Expand All @@ -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,
};
Expand Down Expand Up @@ -278,6 +306,9 @@ export default class GoogleCalendarService implements Calendar {
}

async updateEvent(uid: string, event: CalendarServiceEvent, externalCalendarId: string): Promise<any> {
// 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,
Expand All @@ -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,
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"] });

Expand All @@ -46,7 +43,7 @@ const mockCredential: CredentialForCalendarServiceWithEmail = {
appId: "google-calendar",
type: "google_calendar",
key: {
access_token: "<INVALID_TOKEN>"
access_token: "<INVALID_TOKEN>",
},
user: {
email: "user@example.com",
Expand All @@ -59,7 +56,6 @@ const mockCredential: CredentialForCalendarServiceWithEmail = {

describe("getAvailability", () => {
test("returns availability for selected calendars", async () => {

const calendarService = new CalendarService(mockCredential);
setFullMockOAuthManagerRequest();
const mockedBusyTimes1 = [
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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");
});
});
11 changes: 11 additions & 0 deletions packages/features/di/containers/DestinationCalendar.ts
Original file line number Diff line number Diff line change
@@ -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<DestinationCalendarRepository>(destinationCalendarRepositoryModuleLoader.token);
}
21 changes: 21 additions & 0 deletions packages/features/di/modules/DestinationCalendar.ts
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading