diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx index e154eac956..3d2c06c300 100644 --- a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/SettingsLayoutAppDirClient.tsx @@ -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 ? [ { diff --git a/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/guest-notifications/page.tsx b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/guest-notifications/page.tsx new file mode 100644 index 0000000000..f6cbc55a7c --- /dev/null +++ b/apps/web/app/(use-page-wrapper)/settings/(settings-layout)/organizations/(org-admin-only)/guest-notifications/page.tsx @@ -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 ( + + + + ); +}; + +export default Page; diff --git a/apps/web/public/static/locales/en/common.json b/apps/web/public/static/locales/en/common.json index e3c9620a3d..8e6eb108fa 100644 --- a/apps/web/public/static/locales/en/common.json +++ b/apps/web/public/static/locales/en/common.json @@ -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", diff --git a/packages/emails/email-manager.test.ts b/packages/emails/email-manager.test.ts new file mode 100644 index 0000000000..6a592c3b99 --- /dev/null +++ b/packages/emails/email-manager.test.ts @@ -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); + }); + }); +}); diff --git a/packages/emails/email-manager.ts b/packages/emails/email-manager.ts index 4affbac3d2..493c27880b 100644 --- a/packages/emails/email-manager.ts +++ b/packages/emails/email-manager.ts @@ -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>, + 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[] = []; + 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[] = []; 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[] = []; + 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[] = []; + 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[] = []; + 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[] = []; + 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[] = []; @@ -444,6 +506,7 @@ export const sendCancelledEmailsAndSMS = async ( const emailsToSend: Promise[] = []; 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[] = []; - 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[] = []; 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[] = []; + 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))); } } diff --git a/packages/emails/email-types.ts b/packages/emails/email-types.ts new file mode 100644 index 0000000000..da687c4cbd --- /dev/null +++ b/packages/emails/email-types.ts @@ -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", +} diff --git a/packages/features/CalendarEventBuilder.ts b/packages/features/CalendarEventBuilder.ts index 018c17e75a..9ec0e6346a 100644 --- a/packages/features/CalendarEventBuilder.ts +++ b/packages/features/CalendarEventBuilder.ts @@ -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, diff --git a/packages/features/bookings/lib/getBookingToDelete.ts b/packages/features/bookings/lib/getBookingToDelete.ts index aec914bda5..ecc33ee3d0 100644 --- a/packages/features/bookings/lib/getBookingToDelete.ts +++ b/packages/features/bookings/lib/getBookingToDelete.ts @@ -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, diff --git a/packages/features/bookings/lib/handleCancelBooking.ts b/packages/features/bookings/lib/handleCancelBooking.ts index b9d96d1814..17275efe63 100644 --- a/packages/features/bookings/lib/handleCancelBooking.ts +++ b/packages/features/bookings/lib/handleCancelBooking.ts @@ -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 }; diff --git a/packages/features/bookings/lib/payment/handleNoShowFee.ts b/packages/features/bookings/lib/payment/handleNoShowFee.ts index 4d84a3c051..58bae570b3 100644 --- a/packages/features/bookings/lib/payment/handleNoShowFee.ts +++ b/packages/features/bookings/lib/payment/handleNoShowFee.ts @@ -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) { diff --git a/packages/features/bookings/lib/service/RegularBookingService.ts b/packages/features/bookings/lib/service/RegularBookingService.ts index 636e16f71c..9201950b05 100644 --- a/packages/features/bookings/lib/service/RegularBookingService.ts +++ b/packages/features/bookings/lib/service/RegularBookingService.ts @@ -1394,6 +1394,7 @@ async function handler( platformCancelUrl, platformBookingUrl, }) + .withOrganization(organizerOrganizationId) .withHashedLink(hasHashedBookingLink ? reqBody.hashedLink ?? null : null) .build(); diff --git a/packages/features/bookings/repositories/BookingRepository.ts b/packages/features/bookings/repositories/BookingRepository.ts index 0cd30bafdf..07010c43a0 100644 --- a/packages/features/bookings/repositories/BookingRepository.ts +++ b/packages/features/bookings/repositories/BookingRepository.ts @@ -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, + }, + }, }, }, }, diff --git a/packages/features/credentials/handleDeleteCredential.ts b/packages/features/credentials/handleDeleteCredential.ts index b3f7c3e131..ad43caab0a 100644 --- a/packages/features/credentials/handleDeleteCredential.ts +++ b/packages/features/credentials/handleDeleteCredential.ts @@ -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, diff --git a/packages/features/ee/organizations/pages/components/DisableGuestBookingEmailsSetting.tsx b/packages/features/ee/organizations/pages/components/DisableGuestBookingEmailsSetting.tsx new file mode 100644 index 0000000000..b44670daca --- /dev/null +++ b/packages/features/ee/organizations/pages/components/DisableGuestBookingEmailsSetting.tsx @@ -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; + +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 ( + + {t(labelKey)} + {t(descriptionKey)} + + onToggle(emailType, !checked)} + data-testid={testId} + disabled={disabled} + aria-label={t(labelKey)} + /> + + + ); +}; + +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.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(null); + + const [emailSettings, setEmailSettings] = useState({ + [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 ( +
+ + { + if (dialogMode === "all") { + handleDisableAll(dialogAction === "disable"); + } else { + confirmIndividualToggle(); + } + setShowConfirmDialog(false); + }}> +

+ {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]) : "", + })} +

+
+
+ + + + { + setDialogAction(checked ? "disable" : "enable"); + setDialogMode("all"); + setShowConfirmDialog(true); + }} + /> + +
+ + + + + + + + + + + + + + + + + + + +
{t("email_type")}{t("description")}{t("enabled")}
+
+
+ ); +}; + +export default DisableGuestBookingEmailsSetting; diff --git a/packages/features/ee/organizations/pages/settings/guest-notifications.tsx b/packages/features/ee/organizations/pages/settings/guest-notifications.tsx new file mode 100644 index 0000000000..64ae6508f8 --- /dev/null +++ b/packages/features/ee/organizations/pages/settings/guest-notifications.tsx @@ -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 ( + +
+ + + + + +
+
+ ); +}; + +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 ; + + if (!currentOrg) return null; + + if (!currentOrg.organizationSettings) return null; + + return ( + +
+ +
+
+ ); +}; + +export default GuestNotificationsView; diff --git a/packages/features/ee/organizations/repositories/OrganizationRepository.ts b/packages/features/ee/organizations/repositories/OrganizationRepository.ts index 119280ec2d..9439699b4d 100644 --- a/packages/features/ee/organizations/repositories/OrganizationRepository.ts +++ b/packages/features/ee/organizations/repositories/OrganizationRepository.ts @@ -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, diff --git a/packages/features/ee/round-robin/roundRobinManualReassignment.ts b/packages/features/ee/round-robin/roundRobinManualReassignment.ts index 7d017dd8d2..9d13c57c04 100644 --- a/packages/features/ee/round-robin/roundRobinManualReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinManualReassignment.ts @@ -313,6 +313,7 @@ export const roundRobinManualReassignment = async ({ location: bookingLocation, ...(platformClientParams ? platformClientParams : {}), conferenceCredentialId: conferenceCredentialId ?? undefined, + organizationId: orgId, }; if (hasOrganizerChanged) { diff --git a/packages/features/ee/round-robin/roundRobinReassignment.ts b/packages/features/ee/round-robin/roundRobinReassignment.ts index c2030dfce0..d25acf9924 100644 --- a/packages/features/ee/round-robin/roundRobinReassignment.ts +++ b/packages/features/ee/round-robin/roundRobinReassignment.ts @@ -340,6 +340,7 @@ export const roundRobinReassignment = async ({ customReplyToEmail: eventType?.customReplyToEmail, location: bookingLocation, ...(platformClientParams ? platformClientParams : {}), + organizationId: orgId, }; if (hasOrganizerChanged) { diff --git a/packages/features/organizations/repositories/OrganizationSettingsRepository.ts b/packages/features/organizations/repositories/OrganizationSettingsRepository.ts new file mode 100644 index 0000000000..9e31aa20b7 --- /dev/null +++ b/packages/features/organizations/repositories/OrganizationSettingsRepository.ts @@ -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, + }, + }); + } +} diff --git a/packages/lib/__tests__/buildCalEventFromBooking.test.ts b/packages/lib/__tests__/buildCalEventFromBooking.test.ts index 59d181df8f..f04c5adeb3 100644 --- a/packages/lib/__tests__/buildCalEventFromBooking.test.ts +++ b/packages/lib/__tests__/buildCalEventFromBooking.test.ts @@ -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 diff --git a/packages/lib/buildCalEventFromBooking.ts b/packages/lib/buildCalEventFromBooking.ts index 0004c36b0d..7cee621c51 100644 --- a/packages/lib/buildCalEventFromBooking.ts +++ b/packages/lib/buildCalEventFromBooking.ts @@ -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, }; }; diff --git a/packages/lib/builders/CalendarEvent/class.ts b/packages/lib/builders/CalendarEvent/class.ts index 7971d695d2..2e5d7d07c8 100644 --- a/packages/lib/builders/CalendarEvent/class.ts +++ b/packages/lib/builders/CalendarEvent/class.ts @@ -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 diff --git a/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql b/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql new file mode 100644 index 0000000000..b0d7c98fe9 --- /dev/null +++ b/packages/prisma/migrations/20251118120422_add_attendee_email_setting/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 0241e58209..e92fde3d2f 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -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 { diff --git a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts index 7f79f4a6de..6b0364bcfd 100644 --- a/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts +++ b/packages/trpc/server/routers/loggedInViewer/connectAndJoin.handler.ts @@ -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, diff --git a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts index e6e758d29b..447a253d74 100644 --- a/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/addGuests.handler.ts @@ -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) { diff --git a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts index 4882d2d7f9..dd1575352b 100644 --- a/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @@ -253,6 +253,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => { } : undefined, ...(platformClientParams ? platformClientParams : {}), + organizationId: organizerOrganizationId ?? booking.eventType?.team?.parentId ?? null, additionalNotes: booking.description, }; diff --git a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts index 203947232a..c715c0daab 100644 --- a/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts +++ b/packages/trpc/server/routers/viewer/bookings/editLocation.handler.ts @@ -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({ diff --git a/packages/trpc/server/routers/viewer/bookings/util.ts b/packages/trpc/server/routers/viewer/bookings/util.ts index ec990144db..83b3fa444a 100644 --- a/packages/trpc/server/routers/viewer/bookings/util.ts +++ b/packages/trpc/server/routers/viewer/bookings/util.ts @@ -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[]; diff --git a/packages/trpc/server/routers/viewer/organizations/update.handler.ts b/packages/trpc/server/routers/viewer/organizations/update.handler.ts index c142933454..86984afba1 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.handler.ts @@ -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; diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 6ecf7ce5b6..ec617dc998 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -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; diff --git a/packages/types/Calendar.d.ts b/packages/types/Calendar.d.ts index 009ad2fe06..589dc63073 100644 --- a/packages/types/Calendar.d.ts +++ b/packages/types/Calendar.d.ts @@ -223,6 +223,7 @@ export interface CalendarEvent { domainWideDelegationCredentialId?: string | null; customReplyToEmail?: string | null; rescheduledBy?: string; + organizationId?: number | null; hasOrganizerChanged?: boolean; }