feat: Disable booking emails to guests (#25217)

* feat: disable SMS org setting

* chore: undo

* fix: cal evnet

* feat: add more settings

* tests: add email manager unit tests

* fix: update

* fix: type error

* fix: test

* refactor: UI

* refactor: UI

* perf: fetch only once

* perf: fetch only once

* chore: use org

* fix: test

* test: add unit tests

* refactor: email manager

* fix: add confirmation dialog for individual checkbox

* fix: sms

* chore: common.json
This commit is contained in:
Udit Takkar
2025-12-03 17:21:31 +00:00
committed by GitHub
parent 1c3ced5b70
commit 35d6c41fff
32 changed files with 936 additions and 27 deletions
@@ -91,6 +91,10 @@ const getTabs = (orgBranding: OrganizationBranding | null) => {
href: "/settings/organizations/general",
trackingMetadata: { section: "organization", page: "general" },
},
{
name: "guest_notifications",
href: "/settings/organizations/guest-notifications",
},
...(orgBranding
? [
{
@@ -0,0 +1,55 @@
import { _generateMetadata, getTranslate } from "app/_utils";
import { redirect } from "next/navigation";
import GuestNotificationsView from "@calcom/features/ee/organizations/pages/settings/guest-notifications";
import { Resource } from "@calcom/features/pbac/domain/types/permission-registry";
import { getResourcePermissions } from "@calcom/features/pbac/lib/resource-permissions";
import SettingsHeader from "@calcom/features/settings/appDir/SettingsHeader";
import { MembershipRole } from "@calcom/prisma/enums";
import { validateUserHasOrg } from "../../actions/validateUserHasOrg";
export const generateMetadata = async () =>
await _generateMetadata(
(t) => t("guest_notifications"),
(t) => t("guest_notifications_description"),
undefined,
undefined,
"/settings/organizations/guest-notifications"
);
const Page = async () => {
const session = await validateUserHasOrg();
const t = await getTranslate();
if (!session?.user.id || !session?.user.profile?.organizationId || !session?.user.org) {
return redirect("/settings/profile");
}
const { canRead, canEdit } = await getResourcePermissions({
userId: session.user.id,
teamId: session.user.profile.organizationId,
resource: Resource.Organization,
userRole: session.user.org.role,
fallbackRoles: {
read: {
roles: [MembershipRole.MEMBER, MembershipRole.ADMIN, MembershipRole.OWNER],
},
update: {
roles: [MembershipRole.ADMIN, MembershipRole.OWNER],
},
},
});
if (!canRead) {
return redirect("/settings/profile");
}
return (
<SettingsHeader title={t("guest_notifications")} description={t("guest_notifications_description")}>
<GuestNotificationsView permissions={{ canRead, canEdit }} />
</SettingsHeader>
);
};
export default Page;
@@ -308,6 +308,8 @@
"guest": "Guest",
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
"confirmation": "Confirmation",
"cancellation": "Cancellation",
"request": "Request",
"what_booker_should_provide": "What your booker should provide to receive confirmations",
"404_the_user": "The username",
"username": "Username",
@@ -3932,6 +3934,37 @@
"no_description_provided": "No description provided",
"organization_blocklist": "Organization Blocklist",
"manage_blocked_emails_and_domains": "Manage blocked emails and domains for your organization",
"guest_notifications": "Guest Notifications",
"guest_notifications_description": "Manage guest booking emails across all event types in your organization",
"guest_booking_email_notifications": "Guest Booking Email Notifications",
"guest_booking_email_notifications_description": "Manage which booking emails are sent to guests across all event types in your organization",
"disable_guest_emails_warning": "These settings control important booking emails to keep your guests updated. They apply to all event types in your organization. If you disable emails, we recommend setting up org-wide workflows instead.",
"disable_all_booking_emails_to_guests": "Disable all booking emails to guests",
"disable_all": "Disable All",
"enable_all": "Enable All",
"disable_all_booking_emails_to_guests_description": "When enabled, guests will not receive any booking-related emails. This does not affect Workflow emails.",
"disable_all_guest_booking_emails_confirm_title": "Are you sure you want to disable all booking emails?",
"disable_all_guest_booking_emails_confirm_description": "This will prevent guests from receiving important booking updates across all event types in your organization. We recommend setting up custom org-wide workflow.",
"enable_all_guest_booking_emails_confirm_title": "Are you sure you want to enable all booking emails?",
"enable_all_guest_booking_emails_confirm_description": "This will allow guests to receive all booking-related email notifications across all event types in your organization.",
"disable_individual_guest_email_confirm_title": "Are you sure you want to disable {{emailType}} emails?",
"disable_individual_guest_email_confirm_description": "This will prevent guests from receiving {{emailType}} emails across all event types in your organization. We recommend setting up a custom org-wide Workflow for this.",
"email_type": "Email Type",
"guest_confirmation_email_description": "Email sent when booking is created",
"guest_cancellation_email_description": "Email sent when booking is cancelled",
"guest_rescheduled_email_description": "Email sent when booking is rescheduled",
"guest_request_email_description": "Email sent when booking is requested",
"attendee_request_email_description": "Email sent when booking is requested or declined",
"attendee_reassigned_email_description": "Email sent when host is reassigned for the booking",
"attendee_awaiting_payment_email_description": "Email sent when payment is pending",
"attendee_reschedule_request_email_description": "Email sent when organizer requests attendee to reschedule",
"attendee_location_change_email_description": "Email sent when meeting location is updated",
"attendee_new_event_email_description": "Email sent to existing attendees when new guests are added",
"host_reassignment": "Host Reassignment",
"awaiting_payment": "Awaiting Payment",
"reschedule_request": "Reschedule Request",
"location_change": "Location Change",
"guest_added": "Guest Added",
"invalid_domain_format": "Invalid domain format. Example: example.com",
"invalid_email_address": "Invalid email address. Example: user@example.com",
"reason_for_adding_to_blocklist": "Reason for adding to blocklist",
+152
View File
@@ -0,0 +1,152 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
import type { EventTypeMetadata } from "@calcom/prisma/zod-utils";
import { shouldSkipAttendeeEmailWithSettings, fetchOrganizationEmailSettings } from "./email-manager";
const mockGetEmailSettings = vi.fn();
vi.mock("@calcom/features/organizations/repositories/OrganizationSettingsRepository", () => ({
OrganizationSettingsRepository: vi.fn().mockImplementation(() => ({
getEmailSettings: mockGetEmailSettings,
})),
}));
vi.mock("@calcom/prisma", () => ({
prisma: {},
}));
describe("shouldSkipAttendeeEmailWithSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe.each([
["confirmation", "disableAttendeeConfirmationEmail"],
["cancellation", "disableAttendeeCancellationEmail"],
["rescheduled", "disableAttendeeRescheduledEmail"],
["request", "disableAttendeeRequestEmail"],
["reassigned", "disableAttendeeReassignedEmail"],
["awaiting_payment", "disableAttendeeAwaitingPaymentEmail"],
["reschedule_request", "disableAttendeeRescheduleRequestEmail"],
["location_change", "disableAttendeeLocationChangeEmail"],
["new_event", "disableAttendeeNewEventEmail"],
] as const)("Email type: %s", (emailType, settingKey) => {
it(`should skip email when organization has ${settingKey} enabled`, async () => {
const orgSettings = {
disableAttendeeConfirmationEmail: settingKey === "disableAttendeeConfirmationEmail",
disableAttendeeCancellationEmail: settingKey === "disableAttendeeCancellationEmail",
disableAttendeeRescheduledEmail: settingKey === "disableAttendeeRescheduledEmail",
disableAttendeeRequestEmail: settingKey === "disableAttendeeRequestEmail",
disableAttendeeReassignedEmail: settingKey === "disableAttendeeReassignedEmail",
disableAttendeeAwaitingPaymentEmail: settingKey === "disableAttendeeAwaitingPaymentEmail",
disableAttendeeRescheduleRequestEmail: settingKey === "disableAttendeeRescheduleRequestEmail",
disableAttendeeLocationChangeEmail: settingKey === "disableAttendeeLocationChangeEmail",
disableAttendeeNewEventEmail: settingKey === "disableAttendeeNewEventEmail",
};
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, emailType);
expect(result).toBe(true);
});
it(`should send email when organization has ${settingKey} disabled`, async () => {
const orgSettings = {
disableAttendeeConfirmationEmail: false,
disableAttendeeCancellationEmail: false,
disableAttendeeRescheduledEmail: false,
disableAttendeeRequestEmail: false,
disableAttendeeReassignedEmail: false,
disableAttendeeAwaitingPaymentEmail: false,
disableAttendeeRescheduleRequestEmail: false,
disableAttendeeLocationChangeEmail: false,
disableAttendeeNewEventEmail: false,
};
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, emailType);
expect(result).toBe(false);
});
});
describe("Metadata fallback", () => {
it("should skip email when metadata has disableStandardEmails.all.attendee enabled", () => {
const metadata: EventTypeMetadata = {
disableStandardEmails: {
all: {
attendee: true,
},
},
};
const result = shouldSkipAttendeeEmailWithSettings(metadata, null, "confirmation");
expect(result).toBe(true);
});
});
describe("Priority: organization settings override metadata", () => {
it("should skip email when org setting is enabled even if metadata allows", () => {
const orgSettings = {
disableAttendeeConfirmationEmail: true,
disableAttendeeCancellationEmail: false,
disableAttendeeRescheduledEmail: false,
disableAttendeeRequestEmail: false,
disableAttendeeReassignedEmail: false,
disableAttendeeAwaitingPaymentEmail: false,
disableAttendeeRescheduleRequestEmail: false,
disableAttendeeLocationChangeEmail: false,
disableAttendeeNewEventEmail: false,
};
const metadata: EventTypeMetadata = {
disableStandardEmails: {
confirmation: {
attendee: false,
},
},
};
const result = shouldSkipAttendeeEmailWithSettings(metadata, orgSettings, "confirmation");
expect(result).toBe(true);
});
});
describe("Edge cases", () => {
it("should send email when organizationSettings is null", () => {
const result = shouldSkipAttendeeEmailWithSettings(undefined, null, "confirmation");
expect(result).toBe(false);
});
it("should send email when emailType is undefined", () => {
const orgSettings = {
disableAttendeeConfirmationEmail: true,
disableAttendeeCancellationEmail: false,
disableAttendeeRescheduledEmail: false,
disableAttendeeRequestEmail: false,
disableAttendeeReassignedEmail: false,
disableAttendeeAwaitingPaymentEmail: false,
disableAttendeeRescheduleRequestEmail: false,
disableAttendeeLocationChangeEmail: false,
disableAttendeeNewEventEmail: false,
};
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, undefined);
expect(result).toBe(false);
});
it("should send email when metadata is undefined and org settings are disabled", () => {
const orgSettings = {
disableAttendeeConfirmationEmail: false,
disableAttendeeCancellationEmail: false,
disableAttendeeRescheduledEmail: false,
disableAttendeeRequestEmail: false,
disableAttendeeReassignedEmail: false,
disableAttendeeAwaitingPaymentEmail: false,
disableAttendeeRescheduleRequestEmail: false,
disableAttendeeLocationChangeEmail: false,
disableAttendeeNewEventEmail: false,
};
const result = shouldSkipAttendeeEmailWithSettings(undefined, orgSettings, "confirmation");
expect(result).toBe(false);
});
});
});
+110 -25
View File
@@ -5,10 +5,12 @@ import dayjs from "@calcom/dayjs";
import type BaseEmail from "@calcom/emails/templates/_base-email";
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
import { getEventName } from "@calcom/features/eventtypes/lib/eventNaming";
import { OrganizationSettingsRepository } from "@calcom/features/organizations/repositories/OrganizationSettingsRepository";
import { formatCalEvent } from "@calcom/lib/formatCalendarEvent";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { prisma } from "@calcom/prisma";
import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
@@ -21,6 +23,7 @@ import EventRequestSMS from "../sms/attendee/event-request-sms";
import EventRequestToRescheduleSMS from "../sms/attendee/event-request-to-reschedule-sms";
import EventSuccessfullyReScheduledSMS from "../sms/attendee/event-rescheduled-sms";
import EventSuccessfullyScheduledSMS from "../sms/attendee/event-scheduled-sms";
import { EmailType } from "./email-types";
import AttendeeAddGuestsEmail from "./templates/attendee-add-guests-email";
import AttendeeAwaitingPaymentEmail from "./templates/attendee-awaiting-payment-email";
import AttendeeCancelledEmail from "./templates/attendee-cancelled-email";
@@ -56,7 +59,48 @@ const sendEmail = (prepare: () => BaseEmail) => {
});
};
const eventTypeDisableAttendeeEmail = (metadata?: EventTypeMetadata) => {
export const fetchOrganizationEmailSettings = async (organizationId?: number | null | undefined) => {
if (!organizationId) return null;
const repo = new OrganizationSettingsRepository(prisma);
return await repo.getEmailSettings(organizationId);
};
export const shouldSkipAttendeeEmailWithSettings = (
metadata: EventTypeMetadata | undefined,
organizationSettings: Awaited<ReturnType<typeof fetchOrganizationEmailSettings>>,
emailType?: EmailType
): boolean => {
if (organizationSettings && emailType) {
switch (emailType) {
case EmailType.CONFIRMATION:
if (organizationSettings.disableAttendeeConfirmationEmail) return true;
break;
case EmailType.CANCELLATION:
if (organizationSettings.disableAttendeeCancellationEmail) return true;
break;
case EmailType.RESCHEDULED:
if (organizationSettings.disableAttendeeRescheduledEmail) return true;
break;
case EmailType.REQUEST:
if (organizationSettings.disableAttendeeRequestEmail) return true;
break;
case EmailType.REASSIGNED:
if (organizationSettings.disableAttendeeReassignedEmail) return true;
break;
case EmailType.AWAITING_PAYMENT:
if (organizationSettings.disableAttendeeAwaitingPaymentEmail) return true;
break;
case EmailType.RESCHEDULE_REQUEST:
if (organizationSettings.disableAttendeeRescheduleRequestEmail) return true;
break;
case EmailType.LOCATION_CHANGE:
if (organizationSettings.disableAttendeeLocationChangeEmail) return true;
break;
case EmailType.NEW_EVENT:
if (organizationSettings.disableAttendeeNewEventEmail) return true;
break;
}
}
return !!metadata?.disableStandardEmails?.all?.attendee;
};
@@ -73,6 +117,7 @@ const _sendScheduledEmailsAndSMS = async (
) => {
const formattedCalEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!hostEmailDisabled && !eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: formattedCalEvent })));
@@ -86,7 +131,10 @@ const _sendScheduledEmailsAndSMS = async (
}
}
if (!attendeeEmailDisabled && !eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (
!attendeeEmailDisabled &&
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.CONFIRMATION)
) {
emailsToSend.push(
...formattedCalEvent.attendees.map((attendee) => {
return sendEmail(
@@ -154,13 +202,16 @@ export const sendRoundRobinRescheduledEmailsAndSMS = async (
const calendarEvent = formatCalEvent(calEvent);
const emailsAndSMSToSend: Promise<unknown>[] = [];
const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent);
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
for (const person of teamMembersAndAttendees) {
const isAttendee = calendarEvent.attendees.some((attendee) => attendee.email === person.email);
const isTeamMember = !!calendarEvent.team?.members.some((member) => member.email === person.email);
if (isAttendee && !isTeamMember) {
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)
) {
emailsAndSMSToSend.push(sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, person)));
if (person.phoneNumber) {
emailsAndSMSToSend.push(successfullyReScheduledSMS.sendSMSToAttendee(person));
@@ -186,7 +237,9 @@ export const sendReassignedUpdatedEmailsAndSMS = async ({
calEvent: CalendarEvent;
eventTypeMetadata?: EventTypeMetadata;
}) => {
if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return;
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.REASSIGNED))
return;
const emailsToSend = calEvent.attendees.map((attendee) =>
sendEmail(() => new AttendeeUpdatedEmail(calEvent, attendee))
@@ -252,6 +305,7 @@ const _sendRescheduledEmailsAndSMS = async (
) => {
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));
@@ -265,7 +319,7 @@ const _sendRescheduledEmailsAndSMS = async (
}
}
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED)) {
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeRescheduledEmail(calendarEvent, attendee));
@@ -291,10 +345,11 @@ export const sendRescheduledSeatEmailAndSMS = async (
const clonedCalEvent = cloneDeep(calendarEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!eventTypeDisableHostEmail(eventTypeMetadata))
emailsToSend.push(sendEmail(() => new OrganizerRescheduledEmail({ calEvent: calendarEvent })));
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata))
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.RESCHEDULED))
emailsToSend.push(sendEmail(() => new AttendeeRescheduledEmail(clonedCalEvent, attendee)));
const successfullyReScheduledSMS = new EventSuccessfullyReScheduledSMS(calEvent);
@@ -315,6 +370,7 @@ export const sendScheduledSeatsEmailsAndSMS = async (
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!hostEmailDisabled && !eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerScheduledEmail({ calEvent: calendarEvent, newSeat })));
@@ -328,7 +384,10 @@ export const sendScheduledSeatsEmailsAndSMS = async (
}
}
if (!attendeeEmailDisabled && !eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (
!attendeeEmailDisabled &&
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.CONFIRMATION)
) {
emailsToSend.push(
sendEmail(
() =>
@@ -356,8 +415,9 @@ export const sendCancelledSeatEmailsAndSMS = async (
const formattedCalEvent = formatCalEvent(calEvent);
const clonedCalEvent = cloneDeep(formattedCalEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata))
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.CANCELLATION))
emailsToSend.push(sendEmail(() => new AttendeeCancelledSeatEmail(clonedCalEvent, cancelledAttendee)));
if (!eventTypeDisableHostEmail(eventTypeMetadata))
emailsToSend.push(
@@ -402,7 +462,8 @@ const _sendAttendeeRequestEmailAndSMS = async (
attendee: Person,
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return;
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.REQUEST)) return;
const calendarEvent = formatCalEvent(calEvent);
await sendEmail(() => new AttendeeRequestEmail(calendarEvent, attendee));
@@ -419,7 +480,8 @@ export const sendDeclinedEmailsAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return;
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.REQUEST)) return;
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
@@ -444,6 +506,7 @@ export const sendCancelledEmailsAndSMS = async (
const emailsToSend: Promise<unknown>[] = [];
const calEventLength = calendarEvent.length;
const eventDuration = dayjs(calEvent.endTime).diff(calEvent.startTime, "minutes");
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (typeof calEventLength !== "number") {
logger.error(
@@ -464,7 +527,7 @@ export const sendCancelledEmailsAndSMS = async (
}
}
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.CANCELLATION)) {
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(
@@ -519,20 +582,24 @@ export const sendAwaitingPaymentEmailAndSMS = async (
calEvent: CalendarEvent,
eventTypeMetadata?: EventTypeMetadata
) => {
if (eventTypeDisableAttendeeEmail(eventTypeMetadata)) return;
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
const emailsToSend: Promise<unknown>[] = [];
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeAwaitingPaymentEmail(calEvent, attendee));
})
);
if (
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.AWAITING_PAYMENT)
) {
emailsToSend.push(
...calEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeAwaitingPaymentEmail(calEvent, attendee));
})
);
}
await Promise.all(emailsToSend);
const awaitingPaymentSMS = new AwaitingPaymentSMS(calEvent);
await awaitingPaymentSMS.sendSMSToAttendees();
};
export const sendRequestRescheduleEmailAndSMS = async (
calEvent: CalendarEvent,
metadata: { rescheduleLink: string },
@@ -540,11 +607,18 @@ export const sendRequestRescheduleEmailAndSMS = async (
) => {
const emailsToSend: Promise<unknown>[] = [];
const calendarEvent = formatCalEvent(calEvent);
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerRequestedToRescheduleEmail(calendarEvent, metadata)));
}
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (
!shouldSkipAttendeeEmailWithSettings(
eventTypeMetadata,
organizationSettings,
EmailType.RESCHEDULE_REQUEST
)
) {
emailsToSend.push(sendEmail(() => new AttendeeWasRequestedToRescheduleEmail(calendarEvent, metadata)));
}
@@ -560,6 +634,7 @@ export const sendLocationChangeEmailsAndSMS = async (
const calendarEvent = formatCalEvent(calEvent);
const emailsToSend: Promise<unknown>[] = [];
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
emailsToSend.push(sendEmail(() => new OrganizerLocationChangeEmail({ calEvent: calendarEvent })));
@@ -573,7 +648,9 @@ export const sendLocationChangeEmailsAndSMS = async (
}
}
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
if (
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.LOCATION_CHANGE)
) {
emailsToSend.push(
...calendarEvent.attendees.map((attendee) => {
return sendEmail(() => new AttendeeLocationChangeEmail(calendarEvent, attendee));
@@ -634,17 +711,25 @@ export const sendAddGuestsEmailsAndSMS = async (args: {
}
}
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calEvent);
const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calEvent);
const organizationSettings = await fetchOrganizationEmailSettings(calEvent.organizationId);
for (const attendee of calendarEvent.attendees) {
if (newGuests.includes(attendee.email)) {
for (const attendee of calendarEvent.attendees) {
if (newGuests.includes(attendee.email)) {
// New guests get confirmation emails
if (
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.CONFIRMATION)
) {
emailsAndSMSToSend.push(sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee)));
if (attendee.phoneNumber) {
emailsAndSMSToSend.push(eventScheduledSMS.sendSMSToAttendee(attendee));
}
} else {
}
} else {
if (
!shouldSkipAttendeeEmailWithSettings(eventTypeMetadata, organizationSettings, EmailType.NEW_EVENT)
) {
emailsAndSMSToSend.push(sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee)));
}
}
+11
View File
@@ -0,0 +1,11 @@
export enum EmailType {
CONFIRMATION = "confirmation",
CANCELLATION = "cancellation",
RESCHEDULED = "rescheduled",
REQUEST = "request",
REASSIGNED = "reassigned",
AWAITING_PAYMENT = "awaiting_payment",
RESCHEDULE_REQUEST = "reschedule_request",
LOCATION_CHANGE = "location_change",
NEW_EVENT = "new_event",
}
+10 -1
View File
@@ -184,7 +184,8 @@ export class CalendarEventBuilder {
})
.withRecurring(recurring)
.withUid(uid)
.withOneTimePassword(oneTimePassword);
.withOneTimePassword(oneTimePassword)
.withOrganization(organizationId);
// Seats
if (seatsReferences?.length && bookingResponses) {
@@ -499,6 +500,14 @@ export class CalendarEventBuilder {
return this;
}
withOrganization(organizationId?: number | null) {
this.event = {
...this.event,
organizationId,
};
return this;
}
withHashedLink(hashedLink?: string | null) {
this.event = {
...this.event,
@@ -23,6 +23,11 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
name: true,
destinationCalendar: true,
locale: true,
profiles: {
select: {
organizationId: true,
},
},
},
},
location: true,
@@ -313,6 +313,7 @@ async function handler(input: CancelBookingInput) {
hideOrganizerEmail: bookingToDelete.eventType?.hideOrganizerEmail,
platformBookingUrl,
customReplyToEmail: bookingToDelete.eventType?.customReplyToEmail,
organizationId: ownerProfile?.organizationId ?? null,
};
const dataForWebhooks = { evt, webhooks, eventTypeInfo };
@@ -31,6 +31,9 @@ export const handleNoShowFee = async ({
name?: string | null;
locale: string | null;
timeZone: string;
profiles: {
organizationId: number | null;
}[];
} | null;
eventType: {
title: string;
@@ -97,6 +100,7 @@ export const handleNoShowFee = async ({
currency: payment.currency,
paymentOption: payment.paymentOption,
},
organizationId: booking.user?.profiles?.[0]?.organizationId ?? null,
};
if (teamId) {
@@ -1394,6 +1394,7 @@ async function handler(
platformCancelUrl,
platformBookingUrl,
})
.withOrganization(organizerOrganizationId)
.withHashedLink(hasHashedBookingLink ? reqBody.hashedLink ?? null : null)
.build();
@@ -1383,6 +1383,19 @@ export class BookingRepository {
metadata: true,
},
},
user: {
select: {
email: true,
name: true,
timeZone: true,
locale: true,
profiles: {
select: {
organizationId: true,
},
},
},
},
payment: {
select: {
id: true,
@@ -1425,6 +1438,11 @@ export class BookingRepository {
include: {
destinationCalendar: true,
credentials: true,
profiles: {
select: {
organizationId: true,
},
},
},
},
},
@@ -236,6 +236,11 @@ const handleDeleteCredential = async ({
name: true,
destinationCalendar: true,
locale: true,
profiles: {
select: {
organizationId: true,
},
},
},
},
location: true,
@@ -357,6 +362,7 @@ const handleDeleteCredential = async ({
members: [],
}
: undefined,
organizationId: booking.user?.profiles?.[0]?.organizationId ?? null,
},
{
eventName: booking?.eventType?.eventName,
@@ -0,0 +1,324 @@
"use client";
import { useState } from "react";
import { EmailType } from "@calcom/emails/email-types";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc";
import { Alert } from "@calcom/ui/components/alert";
import { ConfirmationDialogContent } from "@calcom/ui/components/dialog";
import { Checkbox, SettingsToggle } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
const EMAIL_TYPE_TO_SETTING_KEY = {
[EmailType.CONFIRMATION]: "disableAttendeeConfirmationEmail",
[EmailType.CANCELLATION]: "disableAttendeeCancellationEmail",
[EmailType.RESCHEDULED]: "disableAttendeeRescheduledEmail",
[EmailType.REQUEST]: "disableAttendeeRequestEmail",
[EmailType.REASSIGNED]: "disableAttendeeReassignedEmail",
[EmailType.AWAITING_PAYMENT]: "disableAttendeeAwaitingPaymentEmail",
[EmailType.RESCHEDULE_REQUEST]: "disableAttendeeRescheduleRequestEmail",
[EmailType.LOCATION_CHANGE]: "disableAttendeeLocationChangeEmail",
[EmailType.NEW_EVENT]: "disableAttendeeNewEventEmail",
} as const;
type EmailSettings = Record<EmailType, boolean>;
interface EmailRowProps {
emailType: EmailType;
labelKey: string;
descriptionKey: string;
isDisabled: boolean;
onToggle: (type: EmailType, disabled: boolean) => void;
testId: string;
disabled?: boolean;
}
const EmailRow = ({
emailType,
labelKey,
descriptionKey,
isDisabled,
onToggle,
testId,
disabled,
}: EmailRowProps) => {
const { t } = useLocale();
return (
<tr>
<td className="text-default px-6 py-4 text-sm font-medium">{t(labelKey)}</td>
<td className="text-default px-6 py-4 text-sm">{t(descriptionKey)}</td>
<td className="px-6 py-4 text-center">
<Checkbox
checked={!isDisabled}
onCheckedChange={(checked) => onToggle(emailType, !checked)}
data-testid={testId}
disabled={disabled}
aria-label={t(labelKey)}
/>
</td>
</tr>
);
};
interface IDisableGuestBookingEmailsSettingProps {
orgId: number;
settings: {
disableAttendeeConfirmationEmail: boolean;
disableAttendeeCancellationEmail: boolean;
disableAttendeeRescheduledEmail: boolean;
disableAttendeeRequestEmail: boolean;
disableAttendeeReassignedEmail: boolean;
disableAttendeeAwaitingPaymentEmail: boolean;
disableAttendeeRescheduleRequestEmail: boolean;
disableAttendeeLocationChangeEmail: boolean;
disableAttendeeNewEventEmail: boolean;
};
readOnly?: boolean;
}
const EMAIL_TYPE_LABELS: Record<EmailType, string> = {
[EmailType.CONFIRMATION]: "confirmation",
[EmailType.CANCELLATION]: "cancellation",
[EmailType.RESCHEDULED]: "rescheduled",
[EmailType.REQUEST]: "request",
[EmailType.REASSIGNED]: "host_reassignment",
[EmailType.AWAITING_PAYMENT]: "awaiting_payment",
[EmailType.RESCHEDULE_REQUEST]: "reschedule_request",
[EmailType.LOCATION_CHANGE]: "location_change",
[EmailType.NEW_EVENT]: "guest_added",
};
const DisableGuestBookingEmailsSetting = (props: IDisableGuestBookingEmailsSettingProps) => {
const { readOnly = false } = props;
const utils = trpc.useUtils();
const { t } = useLocale();
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
const [dialogAction, setDialogAction] = useState<"enable" | "disable">("disable");
const [dialogMode, setDialogMode] = useState<"all" | "individual">("all");
const [pendingEmailType, setPendingEmailType] = useState<EmailType | null>(null);
const [emailSettings, setEmailSettings] = useState<EmailSettings>({
[EmailType.CONFIRMATION]: props.settings.disableAttendeeConfirmationEmail,
[EmailType.CANCELLATION]: props.settings.disableAttendeeCancellationEmail,
[EmailType.RESCHEDULED]: props.settings.disableAttendeeRescheduledEmail,
[EmailType.REQUEST]: props.settings.disableAttendeeRequestEmail,
[EmailType.REASSIGNED]: props.settings.disableAttendeeReassignedEmail,
[EmailType.AWAITING_PAYMENT]: props.settings.disableAttendeeAwaitingPaymentEmail,
[EmailType.RESCHEDULE_REQUEST]: props.settings.disableAttendeeRescheduleRequestEmail,
[EmailType.LOCATION_CHANGE]: props.settings.disableAttendeeLocationChangeEmail,
[EmailType.NEW_EVENT]: props.settings.disableAttendeeNewEventEmail,
});
const allDisabled = Object.values(emailSettings).every(Boolean);
const mutation = trpc.viewer.organizations.update.useMutation({
onSuccess: async () => {
showToast(t("your_org_updated_successfully"), "success");
},
onError: () => {
showToast(t("error_updating_settings"), "error");
},
onSettled: () => {
utils.viewer.organizations.listCurrent.invalidate();
},
});
const handleDisableAll = (disable: boolean) => {
const apiPayload = Object.fromEntries(
Object.values(EMAIL_TYPE_TO_SETTING_KEY).map((key) => [key, disable])
);
mutation.mutate(apiPayload);
setEmailSettings(
Object.fromEntries(Object.values(EmailType).map((type) => [type, disable])) as EmailSettings
);
};
const handleIndividualToggle = (type: EmailType, disabled: boolean) => {
if (disabled) {
setPendingEmailType(type);
setDialogAction("disable");
setDialogMode("individual");
setShowConfirmDialog(true);
} else {
const settingKey = EMAIL_TYPE_TO_SETTING_KEY[type];
mutation.mutate({ [settingKey]: false });
setEmailSettings((prev) => ({ ...prev, [type]: false }));
}
};
const confirmIndividualToggle = () => {
if (pendingEmailType === null) return;
const settingKey = EMAIL_TYPE_TO_SETTING_KEY[pendingEmailType];
const shouldDisable = dialogAction === "disable";
mutation.mutate({ [settingKey]: shouldDisable });
setEmailSettings((prev) => ({ ...prev, [pendingEmailType]: shouldDisable }));
setPendingEmailType(null);
};
return (
<div className="space-y-6">
<Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<ConfirmationDialogContent
variety={dialogAction === "disable" ? "danger" : "warning"}
title={
dialogMode === "all"
? t(
dialogAction === "disable"
? "disable_all_guest_booking_emails_confirm_title"
: "enable_all_guest_booking_emails_confirm_title"
)
: t("disable_individual_guest_email_confirm_title", {
emailType: pendingEmailType ? t(EMAIL_TYPE_LABELS[pendingEmailType]) : "",
})
}
confirmBtnText={t(
dialogMode === "all"
? dialogAction === "disable"
? "disable_all"
: "enable_all"
: "disable_email"
)}
onConfirm={() => {
if (dialogMode === "all") {
handleDisableAll(dialogAction === "disable");
} else {
confirmIndividualToggle();
}
setShowConfirmDialog(false);
}}>
<p className="mt-2">
{dialogMode === "all"
? t(
dialogAction === "disable"
? "disable_all_guest_booking_emails_confirm_description"
: "enable_all_guest_booking_emails_confirm_description"
)
: t("disable_individual_guest_email_confirm_description", {
emailType: pendingEmailType ? t(EMAIL_TYPE_LABELS[pendingEmailType]) : "",
})}
</p>
</ConfirmationDialogContent>
</Dialog>
<Alert severity="warning" title={t("disable_guest_emails_warning")} />
<SettingsToggle
toggleSwitchAtTheEnd={true}
checked={allDisabled}
title={t("disable_all_booking_emails_to_guests")}
labelClassName="text-sm font-semibold"
description={t("disable_all_booking_emails_to_guests_description")}
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
data-testid="disable-all-guest-emails"
disabled={readOnly}
onCheckedChange={(checked) => {
setDialogAction(checked ? "disable" : "enable");
setDialogMode("all");
setShowConfirmDialog(true);
}}
/>
<div className="border-subtle overflow-hidden rounded-lg border">
<table className="w-full">
<thead>
<tr className="bg-muted border-subtle border-b">
<th className="text-emphasis px-6 py-3 text-left text-sm font-medium">{t("email_type")}</th>
<th className="text-emphasis px-6 py-3 text-left text-sm font-medium">{t("description")}</th>
<th className="text-emphasis px-6 py-3 text-right text-sm font-medium">{t("enabled")}</th>
</tr>
</thead>
<tbody className="divide-subtle divide-y">
<EmailRow
emailType={EmailType.CONFIRMATION}
labelKey="confirmation"
descriptionKey="guest_confirmation_email_description"
isDisabled={emailSettings[EmailType.CONFIRMATION]}
onToggle={handleIndividualToggle}
testId="disable-guest-confirmation-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.CANCELLATION}
labelKey="cancellation"
descriptionKey="guest_cancellation_email_description"
isDisabled={emailSettings[EmailType.CANCELLATION]}
onToggle={handleIndividualToggle}
testId="disable-guest-cancellation-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.RESCHEDULED}
labelKey="rescheduled"
descriptionKey="guest_rescheduled_email_description"
isDisabled={emailSettings[EmailType.RESCHEDULED]}
onToggle={handleIndividualToggle}
testId="disable-guest-rescheduled-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.REQUEST}
labelKey="request"
descriptionKey="attendee_request_email_description"
isDisabled={emailSettings[EmailType.REQUEST]}
onToggle={handleIndividualToggle}
testId="disable-attendee-request-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.REASSIGNED}
labelKey="host_reassignment"
descriptionKey="attendee_reassigned_email_description"
isDisabled={emailSettings[EmailType.REASSIGNED]}
onToggle={handleIndividualToggle}
testId="disable-attendee-reassigned-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.AWAITING_PAYMENT}
labelKey="awaiting_payment"
descriptionKey="attendee_awaiting_payment_email_description"
isDisabled={emailSettings[EmailType.AWAITING_PAYMENT]}
onToggle={handleIndividualToggle}
testId="disable-attendee-awaiting-payment-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.RESCHEDULE_REQUEST}
labelKey="reschedule_request"
descriptionKey="attendee_reschedule_request_email_description"
isDisabled={emailSettings[EmailType.RESCHEDULE_REQUEST]}
onToggle={handleIndividualToggle}
testId="disable-attendee-reschedule-request-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.LOCATION_CHANGE}
labelKey="location_change"
descriptionKey="attendee_location_change_email_description"
isDisabled={emailSettings[EmailType.LOCATION_CHANGE]}
onToggle={handleIndividualToggle}
testId="disable-attendee-location-change-email"
disabled={readOnly || allDisabled}
/>
<EmailRow
emailType={EmailType.NEW_EVENT}
labelKey="guest_added"
descriptionKey="attendee_new_event_email_description"
isDisabled={emailSettings[EmailType.NEW_EVENT]}
onToggle={handleIndividualToggle}
testId="disable-attendee-new-event-email"
disabled={readOnly || allDisabled}
/>
</tbody>
</table>
</div>
</div>
);
};
export default DisableGuestBookingEmailsSetting;
@@ -0,0 +1,65 @@
"use client";
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
import { trpc } from "@calcom/trpc/react";
import { SkeletonContainer, SkeletonText, SkeletonButton } from "@calcom/ui/components/skeleton";
import DisableGuestBookingEmailsSetting from "../components/DisableGuestBookingEmailsSetting";
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="mb-8 mt-6 space-y-6">
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonText className="h-8 w-full" />
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
</div>
</SkeletonContainer>
);
};
const GuestNotificationsView = ({ permissions }: { permissions: { canRead: boolean; canEdit: boolean } }) => {
const { data: currentOrg, isPending } = trpc.viewer.organizations.listCurrent.useQuery();
const isInviteOpen = !currentOrg?.user.accepted;
const isDisabled = !permissions.canEdit || isInviteOpen;
if (isPending) return <SkeletonLoader />;
if (!currentOrg) return null;
if (!currentOrg.organizationSettings) return null;
return (
<LicenseRequired>
<div className="space-y-8">
<DisableGuestBookingEmailsSetting
orgId={currentOrg.id}
settings={{
disableAttendeeConfirmationEmail:
currentOrg.organizationSettings.disableAttendeeConfirmationEmail ?? false,
disableAttendeeCancellationEmail:
currentOrg.organizationSettings.disableAttendeeCancellationEmail ?? false,
disableAttendeeRescheduledEmail:
currentOrg.organizationSettings.disableAttendeeRescheduledEmail ?? false,
disableAttendeeRequestEmail: currentOrg.organizationSettings.disableAttendeeRequestEmail ?? false,
disableAttendeeReassignedEmail:
currentOrg.organizationSettings.disableAttendeeReassignedEmail ?? false,
disableAttendeeAwaitingPaymentEmail:
currentOrg.organizationSettings.disableAttendeeAwaitingPaymentEmail ?? false,
disableAttendeeRescheduleRequestEmail:
currentOrg.organizationSettings.disableAttendeeRescheduleRequestEmail ?? false,
disableAttendeeLocationChangeEmail:
currentOrg.organizationSettings.disableAttendeeLocationChangeEmail ?? false,
disableAttendeeNewEventEmail:
currentOrg.organizationSettings.disableAttendeeNewEventEmail ?? false,
}}
readOnly={isDisabled}
/>
</div>
</LicenseRequired>
);
};
export default GuestNotificationsView;
@@ -289,6 +289,15 @@ export class OrganizationRepository {
disablePhoneOnlySMSNotifications: true,
disableAutofillOnBookingPage: true,
orgAutoJoinOnSignup: true,
disableAttendeeConfirmationEmail: true,
disableAttendeeCancellationEmail: true,
disableAttendeeRescheduledEmail: true,
disableAttendeeRequestEmail: true,
disableAttendeeReassignedEmail: true,
disableAttendeeAwaitingPaymentEmail: true,
disableAttendeeRescheduleRequestEmail: true,
disableAttendeeLocationChangeEmail: true,
disableAttendeeNewEventEmail: true,
},
});
@@ -309,6 +318,15 @@ export class OrganizationRepository {
disablePhoneOnlySMSNotifications: organizationSettings?.disablePhoneOnlySMSNotifications,
disableAutofillOnBookingPage: organizationSettings?.disableAutofillOnBookingPage,
orgAutoJoinOnSignup: organizationSettings?.orgAutoJoinOnSignup,
disableAttendeeConfirmationEmail: organizationSettings?.disableAttendeeConfirmationEmail,
disableAttendeeCancellationEmail: organizationSettings?.disableAttendeeCancellationEmail,
disableAttendeeRescheduledEmail: organizationSettings?.disableAttendeeRescheduledEmail,
disableAttendeeRequestEmail: organizationSettings?.disableAttendeeRequestEmail,
disableAttendeeReassignedEmail: organizationSettings?.disableAttendeeReassignedEmail,
disableAttendeeAwaitingPaymentEmail: organizationSettings?.disableAttendeeAwaitingPaymentEmail,
disableAttendeeRescheduleRequestEmail: organizationSettings?.disableAttendeeRescheduleRequestEmail,
disableAttendeeLocationChangeEmail: organizationSettings?.disableAttendeeLocationChangeEmail,
disableAttendeeNewEventEmail: organizationSettings?.disableAttendeeNewEventEmail,
},
user: {
role: membership?.role,
@@ -313,6 +313,7 @@ export const roundRobinManualReassignment = async ({
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
conferenceCredentialId: conferenceCredentialId ?? undefined,
organizationId: orgId,
};
if (hasOrganizerChanged) {
@@ -340,6 +340,7 @@ export const roundRobinReassignment = async ({
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
organizationId: orgId,
};
if (hasOrganizerChanged) {
@@ -0,0 +1,22 @@
import type { PrismaClient } from "@calcom/prisma";
export class OrganizationSettingsRepository {
constructor(private prismaClient: PrismaClient) {}
async getEmailSettings(organizationId: number) {
return await this.prismaClient.organizationSettings.findUnique({
where: { organizationId },
select: {
disableAttendeeConfirmationEmail: true,
disableAttendeeCancellationEmail: true,
disableAttendeeRescheduledEmail: true,
disableAttendeeRequestEmail: true,
disableAttendeeReassignedEmail: true,
disableAttendeeAwaitingPaymentEmail: true,
disableAttendeeRescheduleRequestEmail: true,
disableAttendeeLocationChangeEmail: true,
disableAttendeeNewEventEmail: true,
},
});
}
}
@@ -91,6 +91,7 @@ describe("buildCalEventFromBooking", () => {
organizer,
location,
conferenceCredentialId,
organizationId: null,
});
expect(result).toEqual({
@@ -127,6 +128,7 @@ describe("buildCalEventFromBooking", () => {
hideOrganizerEmail: undefined,
iCalSequence: 0,
iCalUID: booking.iCalUID,
organizationId: null,
});
expect(parseRecurringEvent).toHaveBeenCalledWith(booking.eventType?.recurringEvent);
@@ -154,6 +156,7 @@ describe("buildCalEventFromBooking", () => {
organizer,
location,
conferenceCredentialId,
organizationId: null,
});
expect(result).toEqual({
@@ -182,6 +185,7 @@ describe("buildCalEventFromBooking", () => {
hideOrganizerEmail: undefined,
iCalSequence: 0,
iCalUID: "icaluid",
organizationId: null,
});
// @ts-expect-error - locale is set in mock
+3
View File
@@ -63,11 +63,13 @@ export const buildCalEventFromBooking = async ({
organizer,
location,
conferenceCredentialId,
organizationId,
}: {
booking: Booking;
organizer: Organizer;
location: string;
conferenceCredentialId: number | null;
organizationId: number | null;
}) => {
const attendeesList = await Promise.all(
booking.attendees.map(async (attendee) => {
@@ -113,5 +115,6 @@ export const buildCalEventFromBooking = async ({
customReplyToEmail: booking.eventType?.customReplyToEmail,
iCalUID: booking.iCalUID ?? booking.uid,
iCalSequence: booking.iCalSequence ?? 0,
organizationId,
};
};
@@ -35,6 +35,7 @@ class CalendarEventClass implements CalendarEvent {
recurrence?: string;
iCalUID?: string | null;
customReplyToEmail?: string | null;
organizationId?: number | null;
constructor(initProps?: CalendarEvent) {
// If more parameters are given we update this
@@ -0,0 +1,10 @@
-- AlterTable
ALTER TABLE "public"."OrganizationSettings" ADD COLUMN "disableAttendeeAwaitingPaymentEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeCancellationEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeConfirmationEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeLocationChangeEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeNewEventEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeReassignedEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeRequestEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeRescheduleRequestEmail" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "disableAttendeeRescheduledEmail" BOOLEAN NOT NULL DEFAULT false;
+11 -1
View File
@@ -1,4 +1,4 @@
// This is your Prisma Schema file
// This is your Prisma Schema file
// learn more about it in the docs: https://pris.ly/d/prisma-schema
datasource db {
@@ -688,6 +688,16 @@ model OrganizationSettings {
disablePhoneOnlySMSNotifications Boolean @default(false)
disableAutofillOnBookingPage Boolean @default(false)
orgAutoJoinOnSignup Boolean @default(true)
disableAttendeeConfirmationEmail Boolean @default(false)
disableAttendeeCancellationEmail Boolean @default(false)
disableAttendeeRescheduledEmail Boolean @default(false)
disableAttendeeRequestEmail Boolean @default(false)
disableAttendeeReassignedEmail Boolean @default(false)
disableAttendeeAwaitingPaymentEmail Boolean @default(false)
disableAttendeeRescheduleRequestEmail Boolean @default(false)
disableAttendeeLocationChangeEmail Boolean @default(false)
disableAttendeeNewEventEmail Boolean @default(false)
}
enum MembershipRole {
@@ -213,6 +213,7 @@ export const Handler = async ({ ctx, input }: Options) => {
eventTypeId: eventType?.id,
videoCallData,
customReplyToEmail: eventType?.customReplyToEmail,
organizationId: user?.organizationId ?? null,
team: updatedBooking.eventType?.team
? {
name: updatedBooking.eventType.team.name,
@@ -291,6 +291,7 @@ async function buildCalendarEvent(
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
customReplyToEmail: booking.eventType?.customReplyToEmail,
organizationId: booking.user?.profiles?.[0]?.organizationId ?? null,
};
if (videoCallReference) {
@@ -253,6 +253,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
}
: undefined,
...(platformClientParams ? platformClientParams : {}),
organizationId: organizerOrganizationId ?? booking.eventType?.team?.parentId ?? null,
additionalNotes: booking.description,
};
@@ -262,6 +262,7 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) {
const { booking, user: loggedInUser } = ctx;
const organizer = await new UserRepository(prisma).findByIdOrThrow({ id: booking.userId || 0 });
const organizationId = booking.user?.profiles?.[0]?.organizationId ?? null;
const newLocationInEvtFormat = await getLocationInEvtFormatOrThrow({
location: newLocation,
@@ -274,6 +275,7 @@ export async function editLocationHandler({ ctx, input }: EditLocationOptions) {
organizer,
location: newLocationInEvtFormat,
conferenceCredentialId,
organizationId,
});
const eventManager = new EventManager({
@@ -40,6 +40,11 @@ export const bookingsProcedure = authedProcedure
include: {
destinationCalendar: true,
credentials: true,
profiles: {
select: {
organizationId: true,
},
},
},
},
};
@@ -110,6 +115,7 @@ export type BookingsProcedureContext = {
| (User & {
destinationCalendar: DestinationCalendar | null;
credentials: Credential[];
profiles: { organizationId: number }[];
})
| null;
references: BookingReference[];
@@ -99,6 +99,51 @@ const updateOrganizationSettings = async ({
data.orgAutoJoinOnSignup = input.orgAutoJoinOnSignup;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeConfirmationEmail")) {
data.disableAttendeeConfirmationEmail = input.disableAttendeeConfirmationEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeCancellationEmail")) {
data.disableAttendeeCancellationEmail = input.disableAttendeeCancellationEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeRescheduledEmail")) {
data.disableAttendeeRescheduledEmail = input.disableAttendeeRescheduledEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeRequestEmail")) {
data.disableAttendeeRequestEmail = input.disableAttendeeRequestEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeReassignedEmail")) {
data.disableAttendeeReassignedEmail = input.disableAttendeeReassignedEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeAwaitingPaymentEmail")) {
data.disableAttendeeAwaitingPaymentEmail = input.disableAttendeeAwaitingPaymentEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeRescheduleRequestEmail")) {
data.disableAttendeeRescheduleRequestEmail = input.disableAttendeeRescheduleRequestEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeLocationChangeEmail")) {
data.disableAttendeeLocationChangeEmail = input.disableAttendeeLocationChangeEmail;
}
// eslint-disable-next-line no-prototype-builtins
if (input.hasOwnProperty("disableAttendeeNewEventEmail")) {
data.disableAttendeeNewEventEmail = input.disableAttendeeNewEventEmail;
}
// If no settings values have changed lets skip this update
if (Object.keys(data).length === 0) return;
@@ -37,6 +37,15 @@ export const ZUpdateInputSchema = z.object({
disablePhoneOnlySMSNotifications: z.boolean().optional(),
disableAutofillOnBookingPage: z.boolean().optional(),
orgAutoJoinOnSignup: z.boolean().optional(),
disableAttendeeConfirmationEmail: z.boolean().optional(),
disableAttendeeCancellationEmail: z.boolean().optional(),
disableAttendeeRescheduledEmail: z.boolean().optional(),
disableAttendeeRequestEmail: z.boolean().optional(),
disableAttendeeReassignedEmail: z.boolean().optional(),
disableAttendeeAwaitingPaymentEmail: z.boolean().optional(),
disableAttendeeRescheduleRequestEmail: z.boolean().optional(),
disableAttendeeLocationChangeEmail: z.boolean().optional(),
disableAttendeeNewEventEmail: z.boolean().optional(),
});
export type TUpdateInputSchema = z.infer<typeof ZUpdateInputSchema>;
+1
View File
@@ -223,6 +223,7 @@ export interface CalendarEvent {
domainWideDelegationCredentialId?: string | null;
customReplyToEmail?: string | null;
rescheduledBy?: string;
organizationId?: number | null;
hasOrganizerChanged?: boolean;
}