feat: add custom calendar reminder for Google Calendar events (#26078)
* 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 <volnei@cal.com> Co-authored-by: Keith Williams <keithwillcode@gmail.com> Co-authored-by: Eunjae Lee <hey@eunjae.dev>
This commit is contained in:
co-authored by
Volnei Munhoz
Keith Williams
Eunjae Lee
parent
0b37520f0d
commit
2881961859
+1
@@ -120,6 +120,7 @@ describe("Platform Destination Calendar Endpoints", () => {
|
||||
domainWideDelegationCredentialId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
customCalendarReminder: 10,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
@@ -77,6 +77,7 @@ export class CalendarsServiceMock {
|
||||
domainWideDelegationCredentialId: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
customCalendarReminder: 10,
|
||||
},
|
||||
} satisfies Awaited<ReturnType<typeof CalendarsService.prototype.getCalendars>>;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<AtomsWrapper>
|
||||
<DestinationCalendarSettings
|
||||
@@ -25,6 +60,9 @@ export const DestinationCalendarSettingsWebWrapper = () => {
|
||||
value={calendars.data.destinationCalendar.externalId}
|
||||
hidePlaceholder
|
||||
onChange={mutation.mutate}
|
||||
onReminderChange={handleReminderChange}
|
||||
reminderValue={reminderValue}
|
||||
isReminderPending={reminderMutation.isPending}
|
||||
/>
|
||||
</AtomsWrapper>
|
||||
);
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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<number | null> {
|
||||
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<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,
|
||||
@@ -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<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,
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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: "<INVALID_TOKEN>"
|
||||
access_token: "<INVALID_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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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"),
|
||||
|
||||
@@ -16,6 +16,7 @@ type DestinationCalendar = {
|
||||
domainWideDelegationCredentialId: string | null;
|
||||
createdAt: Date | null;
|
||||
updatedAt: Date | null;
|
||||
customCalendarReminder: number | null;
|
||||
} | null;
|
||||
|
||||
type Attendee = {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
|
||||
export class DestinationCalendarRepository {
|
||||
constructor(private prisma: PrismaClient) {}
|
||||
|
||||
async getCustomReminderByCredentialId(credentialId: number): Promise<number | null> {
|
||||
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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ type DestinationCalendar = {
|
||||
updatedAt: Date | null;
|
||||
delegationCredentialId: string | null;
|
||||
domainWideDelegationCredentialId: string | null;
|
||||
customCalendarReminder: number | null;
|
||||
};
|
||||
|
||||
type Response = {
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
@@ -29,11 +41,22 @@ export const DestinationCalendarSettings = (
|
||||
)}>
|
||||
<DestinationCalendarSettingsHeading classNames={props.classNamesObject?.header} />
|
||||
<div className="border-t">
|
||||
<div className="border-subtle flex w-full flex-col stack-y-3 border-y-0 p-6">
|
||||
<div className="border-subtle stack-y-3 flex w-full flex-col border-y-0 p-6">
|
||||
<div>
|
||||
<Label className="text-default mb-0 font-medium">{t("add_events_to")}</Label>
|
||||
<DestinationCalendarSelector {...props} />
|
||||
</div>
|
||||
{showReminderSelector && (
|
||||
<div className="mt-4">
|
||||
<Label className="text-default mb-0 font-medium">{t("default_reminder")}</Label>
|
||||
<p className="text-subtle mb-2 text-sm">{t("default_reminder_description")}</p>
|
||||
<DestinationReminderSelector
|
||||
value={props.reminderValue}
|
||||
onChange={props.onReminderChange!}
|
||||
isPending={props.isReminderPending}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<Select
|
||||
name="reminderDuration"
|
||||
options={options}
|
||||
value={selectedOption}
|
||||
onChange={(newValue) => {
|
||||
if (newValue) {
|
||||
onChange(newValue.value);
|
||||
}
|
||||
}}
|
||||
isLoading={isPending}
|
||||
isSearchable={false}
|
||||
className="mt-2 block w-full text-sm"
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "public"."DestinationCalendar" ADD COLUMN "customCalendarReminder" INTEGER;
|
||||
@@ -335,6 +335,7 @@ model DestinationCalendar {
|
||||
delegationCredentialId String?
|
||||
domainWideDelegation DomainWideDelegation? @relation(fields: [domainWideDelegationCredentialId], references: [id], onDelete: Cascade)
|
||||
domainWideDelegationCredentialId String?
|
||||
customCalendarReminder Int?
|
||||
|
||||
@@index([userId])
|
||||
@@index([eventTypeId])
|
||||
|
||||
@@ -2,6 +2,7 @@ import authedProcedure from "../../../procedures/authedProcedure";
|
||||
import { router } from "../../../trpc";
|
||||
import { ZConnectedCalendarsInputSchema } from "./connectedCalendars.schema";
|
||||
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
|
||||
import { ZSetDestinationReminderInputSchema } from "./setDestinationReminder.schema";
|
||||
|
||||
export const calendarsRouter = router({
|
||||
connectedCalendars: authedProcedure.input(ZConnectedCalendarsInputSchema).query(async ({ ctx, input }) => {
|
||||
@@ -17,4 +18,12 @@ export const calendarsRouter = router({
|
||||
|
||||
return setDestinationCalendarHandler({ ctx, input });
|
||||
}),
|
||||
|
||||
setDestinationReminder: authedProcedure
|
||||
.input(ZSetDestinationReminderInputSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { setDestinationReminderHandler } = await import("./setDestinationReminder.handler");
|
||||
|
||||
return setDestinationReminderHandler({ ctx, input });
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getDestinationCalendarRepository } from "@calcom/features/di/containers/DestinationCalendar";
|
||||
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
|
||||
|
||||
import type { TSetDestinationReminderInputSchema } from "./setDestinationReminder.schema";
|
||||
|
||||
type SetDestinationReminderOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
};
|
||||
input: TSetDestinationReminderInputSchema;
|
||||
};
|
||||
|
||||
export const setDestinationReminderHandler = async ({ ctx, input }: SetDestinationReminderOptions) => {
|
||||
const { user } = ctx;
|
||||
const { credentialId, integration, defaultReminder } = input;
|
||||
|
||||
const destinationCalendarRepository = getDestinationCalendarRepository();
|
||||
|
||||
// Update the destination calendar's custom reminder setting
|
||||
await destinationCalendarRepository.updateCustomReminder({
|
||||
userId: user.id,
|
||||
credentialId,
|
||||
integration,
|
||||
customCalendarReminder: defaultReminder,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const VALID_REMINDER_MINUTES = [0, 10, 30, 60] as const;
|
||||
|
||||
export const reminderSchema = z.union([z.literal(0), z.literal(10), z.literal(30), z.literal(60), z.null()]);
|
||||
|
||||
export const ZSetDestinationReminderInputSchema = z.object({
|
||||
credentialId: z.number(),
|
||||
integration: z.string(),
|
||||
defaultReminder: reminderSchema.nullable(),
|
||||
});
|
||||
|
||||
export type TSetDestinationReminderInputSchema = z.infer<typeof ZSetDestinationReminderInputSchema>;
|
||||
export type ReminderMinutes = z.infer<typeof reminderSchema>;
|
||||
Reference in New Issue
Block a user