feat: ability to hide organizer email (#20782)

* feat: hide orgainzer email

* fix type error

* update

* update

* Update schema.prisma
This commit is contained in:
Anik Dhabal Babu authored and GitHub committed 2025-04-23 19:58:29 +00:00
1 parent 2e6d71f493
commit 513f54f4fe
42 files changed
+136 -10

No files matched your search

+1
View File
@@ -39,6 +39,7 @@ export const getEventTypesFromDB = async (id: number) => {
currency: true,
bookingFields: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
disableGuests: true,
@@ -132,6 +132,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(eventTypeRaw.metadata),
recurringEvent: parseRecurringEvent(eventTypeRaw.recurringEvent),
customInputs: customInputSchema.array().parse(eventTypeRaw.customInputs),
hideOrganizerEmail: eventTypeRaw.hideOrganizerEmail,
bookingFields: eventTypeRaw.bookingFields.map((field) => {
return {
...field,
@@ -602,9 +602,11 @@ export default function Success(props: PageProps) {
</span>
<Badge variant="blue">{t("Host")}</Badge>
</div>
<p className="text-default">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
{!bookingInfo.eventType?.hideOrganizerEmail && (
<p className="text-default">
{bookingInfo?.userPrimaryEmail ?? bookingInfo.user.email}
</p>
)}
</div>
)}
{bookingInfo?.attendees.map((attendee) => (
@@ -3140,6 +3140,8 @@
"meeting_satisfaction_question": "How satisfied were you with your recent meeting?",
"meeting_not_joined_question": "didn't join the meeting?",
"reschedule_cta_short": "Reschedule here.",
"hide_organizer_email": "Hide organizer's email",
"hide_organizer_email_description": "Hide organizer's email address from the booking screen, email notifications, and calendar events",
"you_and_conjunction": "You &",
"select_verified_email": "Select a verified email",
"custom_reply_to_email_title": "Custom 'Reply-To' email",
@@ -118,6 +118,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
tAttendees
),
organizer: userOwnerAsPeopleType,
hideOrganizerEmail: bookingToReschedule.eventType?.hideOrganizerEmail,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
@@ -35,6 +35,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
eventType: {
select: {
metadata: true,
hideOrganizerEmail: true,
team: {
select: {
id: true,
@@ -120,6 +121,7 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
tAttendees
),
organizer: userOwnerAsPeopleType,
hideOrganizerEmail: bookingToReschedule.eventType?.hideOrganizerEmail,
team: !!bookingToReschedule.eventType?.team
? {
name: bookingToReschedule.eventType.team.name,
+51 -3
View File
@@ -31,9 +31,14 @@ const testIcsStringContains = ({
// Sometimes the deeply equal stringMatching error appears. Don't want to add flakey tests
// expect(icsString).toEqual(expect.stringContaining(`SUMMARY:${event.title}`));
expect(icsString).toEqual(expect.stringContaining(`DTSTART:${DTSTART}`));
expect(icsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
if (event.hideOrganizerEmail) {
expect(icsString).toEqual(expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}`));
expect(icsString).not.toEqual(expect.stringContaining(`mailto:${event.organizer.email}`));
} else {
expect(icsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
}
expect(icsString).toEqual(expect.stringContaining(`DTEND:${DTEND}`));
expect(icsString).toEqual(expect.stringContaining(`STATUS:${status}`));
// Getting an error expected icsString to deeply equal stringMatching
@@ -158,4 +163,47 @@ describe("generateIcsString", () => {
expect(icsString).toEqual(expect.stringContaining(`LOCATION:${event.location}`));
});
});
describe("hidden organizer email", () => {
test("when hideOrganizerEmail is true, organizer email should be omitted", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
hideOrganizerEmail: true,
});
const status = "CONFIRMED";
const icsString = generateIcsString({
event: event,
status,
});
const assertedIcsString = assertHasIcsString(icsString);
expect(assertedIcsString).toEqual(expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}`));
expect(assertedIcsString).not.toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:`)
);
});
test("when hideOrganizerEmail is false, organizer email should be included", () => {
const event = buildCalendarEvent({
iCalSequence: 0,
attendees: [buildPerson()],
hideOrganizerEmail: false,
});
const status = "CONFIRMED";
const icsString = generateIcsString({
event: event,
status,
});
const assertedIcsString = assertHasIcsString(icsString);
expect(assertedIcsString).toEqual(
expect.stringContaining(`ORGANIZER;CN=${event.organizer.name}:mailto:${event.organizer.email}`)
);
});
});
});
+5 -1
View File
@@ -30,6 +30,7 @@ export type ICSCalendarEvent = Pick<
| "team"
| "type"
| "hideCalendarEventDetails"
| "hideOrganizerEmail"
>;
const toICalDateArray = (date: string): DateArray => {
@@ -73,7 +74,10 @@ const generateIcsString = ({
productId: "calcom/ics",
title: event.title,
description: getRichDescription(event, t),
organizer: { name: event.organizer.name, email: event.organizer.email },
organizer: {
name: event.organizer.name,
...(event.hideOrganizerEmail ? {} : { email: event.organizer.email }),
},
...{ recurrenceRule },
attendees: [
...event.attendees.map((attendee: Person) => ({
+1 -1
View File
@@ -33,7 +33,7 @@ export function WhoInfo(props: { calEvent: CalendarEvent; t: TFunction }) {
<PersonInfo
name={props.calEvent.organizer.name}
role={t("organizer")}
email={props.calEvent.organizer.email}
email={props.calEvent.hideOrganizerEmail ? "" : props.calEvent.organizer.email}
/>
{props.calEvent.team?.members.map((member) => (
<PersonInfo key={member.name} name={member.name} role={t("team_member")} email={member?.email} />
@@ -47,6 +47,7 @@ export class CalendarEventBuilder {
id: number;
hideCalendarNotes?: boolean;
hideCalendarEventDetails?: boolean;
hideOrganizerEmail?: boolean;
schedulingType?: SchedulingType | null;
seatsPerTimeSlot?: number | null;
seatsShowAttendees?: boolean | null;
@@ -60,6 +61,7 @@ export class CalendarEventBuilder {
eventTypeId: eventType.id,
hideCalendarNotes: eventType.hideCalendarNotes,
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
hideOrganizerEmail: eventType.hideOrganizerEmail,
schedulingType: eventType.schedulingType,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
// if seats are not enabled we should default true
@@ -73,6 +73,7 @@ export async function getBookingToDelete(id: number | undefined, uid: string | u
bookingFields: true,
seatsShowAttendees: true,
metadata: true,
hideOrganizerEmail: true,
schedulingType: true,
customReplyToEmail: true,
hosts: {
@@ -50,6 +50,7 @@ const getUserBooking = async (uid: string) => {
slug: true,
timeZone: true,
schedulingType: true,
hideOrganizerEmail: true,
},
},
seatsReferences: {
@@ -265,6 +265,7 @@ async function handler(input: CancelBookingInput) {
platformClientId,
platformRescheduleUrl,
platformCancelUrl,
hideOrganizerEmail: bookingToDelete.eventType?.hideOrganizerEmail,
platformBookingUrl,
customReplyToEmail: bookingToDelete.eventType?.customReplyToEmail,
};
@@ -1019,6 +1019,7 @@ async function handler(
id: eventType.id,
hideCalendarNotes: eventType.hideCalendarNotes,
hideCalendarEventDetails: eventType.hideCalendarEventDetails,
hideOrganizerEmail: eventType.hideOrganizerEmail,
schedulingType: eventType.schedulingType,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
// if seats are not enabled we should default true
@@ -70,6 +70,7 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
destinationCalendar: true,
hideCalendarNotes: true,
hideCalendarEventDetails: true,
hideOrganizerEmail: true,
seatsPerTimeSlot: true,
recurringEvent: true,
seatsShowAttendees: true,
@@ -251,6 +251,7 @@ const handleDeleteCredential = async ({
seatsPerTimeSlot: true,
seatsShowAttendees: true,
eventName: true,
hideOrganizerEmail: true,
team: {
select: {
id: true,
@@ -346,6 +347,7 @@ const handleDeleteCredential = async ({
cancellationReason: "Payment method removed by organizer",
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
@@ -266,6 +266,7 @@ export const roundRobinManualReassignment = async ({
name: eventType.team?.name || "",
id: eventType.team?.id || 0,
},
hideOrganizerEmail: eventType.hideOrganizerEmail,
customInputs: isPrismaObjOrUndefined(booking.customInputs),
...getCalEventResponses({
bookingFields: eventType.bookingFields ?? null,
@@ -318,6 +318,7 @@ export const roundRobinReassignment = async ({
bookingFields: eventType?.bookingFields ?? null,
booking,
}),
hideOrganizerEmail: eventType.hideOrganizerEmail,
customReplyToEmail: eventType?.customReplyToEmail,
location: bookingLocation,
...(platformClientParams ? platformClientParams : {}),
@@ -64,6 +64,7 @@ export type BookingInfo = {
metadata?: Prisma.JsonValue;
cancellationReason?: string | null;
rescheduleReason?: string | null;
hideOrganizerEmail?: boolean;
};
export type ScheduleTextReminderAction = Extract<
@@ -95,6 +95,7 @@ export type EventAdvancedTabCustomClassNames = {
seatsInput: InputClassNames;
};
timezoneLock?: SettingsToggleClassNames;
hideOrganizerEmail?: SettingsToggleClassNames;
eventTypeColors?: SettingsToggleClassNames & {
warningText?: string;
};
@@ -491,6 +492,7 @@ export const EventAdvancedTab = ({
const lockTimeZoneToggleOnBookingPageLocked = shouldLockDisableProps("lockTimeZoneToggleOnBookingPage");
const multiplePrivateLinksLocked = shouldLockDisableProps("multiplePrivateLinks");
const reschedulingPastBookingsLocked = shouldLockDisableProps("allowReschedulingPastBookings");
const hideOrganizerEmailLocked = shouldLockDisableProps("hideOrganizerEmail");
const customReplyToEmailLocked = shouldLockDisableProps("customReplyToEmail");
const disableCancellingLocked = shouldLockDisableProps("disableCancelling");
@@ -971,6 +973,26 @@ export const EventAdvancedTab = ({
</>
)}
/>
<Controller
name="hideOrganizerEmail"
render={({ field: { value, onChange } }) => (
<SettingsToggle
labelClassName={classNames("text-sm", customClassNames?.hideOrganizerEmail?.label)}
toggleSwitchAtTheEnd={true}
switchContainerClassName={classNames(
"border-subtle rounded-lg border py-6 px-4 sm:px-6",
customClassNames?.hideOrganizerEmail?.container
)}
title={t("hide_organizer_email")}
{...hideOrganizerEmailLocked}
description={t("hide_organizer_email_description")}
descriptionClassName={customClassNames?.hideOrganizerEmail?.description}
checked={value}
onCheckedChange={(e) => onChange(e)}
data-testid="hide-organizer-email"
/>
)}
/>
<Controller
name="lockTimeZoneToggleOnBookingPage"
render={({ field: { value, onChange } }) => (
+4 -2
View File
@@ -32,7 +32,7 @@ export const getWhen = (
export const getWho = (
calEvent: Pick<
CalendarEvent,
"attendees" | "seatsPerTimeSlot" | "seatsShowAttendees" | "organizer" | "team"
"attendees" | "seatsPerTimeSlot" | "seatsShowAttendees" | "organizer" | "team" | "hideOrganizerEmail"
>,
t: TFunction
) => {
@@ -49,7 +49,9 @@ export const getWho = (
)
.join("\n");
const organizer = `${calEvent.organizer.name} - ${t("organizer")}\n${calEvent.organizer.email}`;
const organizer = calEvent.hideOrganizerEmail
? `${calEvent.organizer.name} - ${t("organizer")}`
: `${calEvent.organizer.name} - ${t("organizer")}\n${calEvent.organizer.email}`;
const teamMembers = calEvent.team?.members
? calEvent.team.members
+2
View File
@@ -36,6 +36,7 @@ type EventType = {
recurringEvent: Prisma.JsonValue | null;
seatsPerTimeSlot: number | null;
seatsShowAttendees: boolean | null;
hideOrganizerEmail: boolean;
customReplyToEmail: string | null;
};
@@ -94,6 +95,7 @@ export const buildCalEventFromBooking = async ({
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
attendees: attendeesList,
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location,
@@ -31,6 +31,7 @@ class CalendarEventClass implements CalendarEvent {
rejectionReason?: string | null;
hideCalendarNotes?: boolean;
hideCalendarEventDetails?: boolean;
hideOrganizerEmail?: boolean;
additionalNotes?: string | null | undefined;
recurrence?: string;
iCalUID?: string | null;
+1
View File
@@ -92,6 +92,7 @@ const commons = {
disableRescheduling: false,
onlyShowFirstAvailableSlot: false,
allowReschedulingPastBookings: false,
hideOrganizerEmail: false,
id: 0,
hideCalendarNotes: false,
hideCalendarEventDetails: false,
+2
View File
@@ -41,6 +41,7 @@ export async function getBooking(bookingId: number) {
length: true,
price: true,
requiresConfirmation: true,
hideOrganizerEmail: true,
metadata: true,
customReplyToEmail: true,
title: true,
@@ -159,6 +160,7 @@ export async function getBooking(bookingId: number) {
language: { translate: t, locale: user.locale ?? "en" },
id: user.id,
},
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
team: !!booking.eventType?.team
? {
name: booking.eventType.team.name,
+1
View File
@@ -39,6 +39,7 @@ export const eventTypeSelect = Prisma.validator<Prisma.EventTypeSelect>()({
seatsPerTimeSlot: true,
onlyShowFirstAvailableSlot: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
seatsShowAttendees: true,
seatsShowAvailabilityCount: true,
scheduleId: true,
@@ -212,6 +212,7 @@ export class BookingRepository {
requiresConfirmationWillBlockSlot: true,
requiresConfirmation: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
},
},
...(seatedEvent && {
@@ -517,6 +517,7 @@ export class EventTypeRepository {
durationLimits: true,
assignAllTeamMembers: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
assignRRMembersUsingSegment: true,
rrSegmentQueryValue: true,
isRRWeightsEnabled: true,
@@ -803,6 +804,7 @@ export class EventTypeRepository {
periodEndDate: true,
onlyShowFirstAvailableSlot: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
periodCountCalendarDays: true,
rescheduleWithSameRoundRobinHost: true,
periodDays: true,
+1
View File
@@ -150,6 +150,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
autoTranslateDescriptionEnabled: false,
useEventLevelSelectedCalendars: false,
allowReschedulingPastBookings: false,
hideOrganizerEmail: false,
customReplyToEmail: null,
...eventType,
};
@@ -87,6 +87,7 @@ export const useEventTypeForm = ({
slotInterval: eventType.slotInterval,
minimumBookingNotice: eventType.minimumBookingNotice,
allowReschedulingPastBookings: eventType.allowReschedulingPastBookings,
hideOrganizerEmail: eventType.hideOrganizerEmail,
metadata: eventType.metadata,
hosts: eventType.hosts.sort((a, b) => sortHosts(a, b, eventType.isRRWeightsEnabled)),
successRedirectUrl: eventType.successRedirectUrl || "",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "hideOrganizerEmail" BOOLEAN NOT NULL DEFAULT false;
+1
View File
@@ -165,6 +165,7 @@ model EventType {
maxLeadThreshold Int?
selectedCalendars SelectedCalendar[]
allowReschedulingPastBookings Boolean @default(false)
hideOrganizerEmail Boolean @default(false)
/// @zod.custom(imports.emailSchema)
customReplyToEmail String?
+1
View File
@@ -665,6 +665,7 @@ export const allManagedEventTypeProps: { [k in keyof Omit<Prisma.EventTypeSelect
isRRWeightsEnabled: true,
eventTypeColor: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
rescheduleWithSameRoundRobinHost: true,
maxLeadThreshold: true,
customReplyToEmail: true,
@@ -126,6 +126,7 @@ export const Handler = async ({ ctx, input }: Options) => {
bookingFields: true,
disableGuests: true,
metadata: true,
hideOrganizerEmail: true,
customInputs: true,
parentId: true,
customReplyToEmail: true,
@@ -203,6 +204,7 @@ export const Handler = async ({ ctx, input }: Options) => {
timeFormat: getTimeFormatStringFromUserTimeFormat(user.timeFormat),
language: { translate: tOrganizer, locale: user.locale ?? "en" },
},
hideOrganizerEmail: updatedBooking.eventType?.hideOrganizerEmail,
attendees: attendeesList,
location: updatedBooking.location ?? "",
uid: updatedBooking.uid,
@@ -133,6 +133,7 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
timeZone: organizer.timeZone,
language: { translate: tOrganizer, locale: organizer.locale ?? "en" },
},
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
@@ -80,6 +80,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
description: true,
price: true,
bookingFields: true,
hideOrganizerEmail: true,
disableGuests: true,
customReplyToEmail: true,
metadata: true,
@@ -226,6 +227,7 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
? [booking.user?.destinationCalendar]
: [],
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
eventTypeId: booking.eventType?.id,
customReplyToEmail: booking.eventType?.customReplyToEmail,
team: !!booking.eventType?.team
@@ -103,6 +103,7 @@ export async function getBookings({
eventTypeColor: true,
customReplyToEmail: true,
allowReschedulingPastBookings: true,
hideOrganizerEmail: true,
disableCancelling: true,
disableRescheduling: true,
schedulingType: true,
@@ -195,6 +195,7 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
type: event && event.slug ? event.slug : bookingToReschedule.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
hideOrganizerEmail: eventType?.hideOrganizerEmail,
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
@@ -74,6 +74,7 @@ export const chargeCardHandler = async ({ ctx, input }: ChargeCardHandlerOptions
language: { translate: tOrganizer, locale: booking.user?.locale ?? "en" },
},
attendees: attendeesList,
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
paymentInfo: {
amount: booking.payment[0].amount,
currency: booking.payment[0].currency,
@@ -220,6 +220,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
},
},
},
hideOrganizerEmail: true,
},
},
user: true,
@@ -254,6 +255,7 @@ export const activateEventTypeHandler = async ({ ctx, input }: ActivateEventType
endTime: booking.endTime.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || defaultLocale },
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
eventType: {
slug: booking.eventType?.slug || "",
schedulingType: booking.eventType?.schedulingType,
@@ -57,6 +57,7 @@ export const bookingSelect = {
slug: true,
id: true,
schedulingType: true,
hideOrganizerEmail: true,
customReplyToEmail: true,
hosts: {
select: {
@@ -643,6 +644,7 @@ export async function scheduleBookingReminders(
endTime: booking.endTime?.toISOString(),
title: booking.title,
language: { locale: booking?.user?.locale || defaultLocale },
hideOrganizerEmail: booking.eventType?.hideOrganizerEmail,
eventType: {
slug: booking.eventType?.slug || "",
schedulingType: booking.eventType?.schedulingType,
+1
View File
@@ -205,6 +205,7 @@ export interface CalendarEvent {
schedulingType?: SchedulingType | null;
iCalUID?: string | null;
iCalSequence?: number | null;
hideOrganizerEmail?: boolean;
// It has responses to all the fields(system + user)
responses?: CalEventResponses | null;