Hide other attendees on event types with seats (#4766)
* Add seatsHideAttendees to schema * Add migration * Add frontend option to hide attendees * Pass hide attendees to email * Hide attendee names on success email * Add types for existing attendees * Hide other attendees if hidden * Pass seatsHideAttendees to Google Cal * Add translation * Reduce redundancy * Fix type error * Change toggle to show attendee information * Minor text change * Fix type errors * Update snapshots * Merge branch 'main' into seats-hide-attendees * Add back email * Add close.com specific types * Add eslint ignore comments * Merge branch 'seats-hide-attendees' of https://github.com/calcom/cal.com into seats-hide-attendees * Simplify tests Co-authored-by: Peer Richelsen <peeroke@gmail.com> Co-authored-by: Alex van Andel <me@alexvanandel.com>
This commit is contained in:
parent
7f2560e1e3
commit
8fc4d342fd
|
@ -115,14 +115,14 @@ const BookingPage = ({
|
|||
|
||||
const mutation = useMutation(createBooking, {
|
||||
onSuccess: async (responseData) => {
|
||||
const { id, attendees, paymentUid } = responseData;
|
||||
const { id, paymentUid } = responseData;
|
||||
if (paymentUid) {
|
||||
return await router.push(
|
||||
createPaymentLink({
|
||||
paymentUid,
|
||||
date,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
name: bookingForm.getValues("name"),
|
||||
email: bookingForm.getValues("email"),
|
||||
absolute: false,
|
||||
})
|
||||
);
|
||||
|
@ -136,8 +136,8 @@ const BookingPage = ({
|
|||
eventSlug: eventType.slug,
|
||||
username: profile.slug,
|
||||
reschedule: !!rescheduleUid,
|
||||
name: attendees[0].name,
|
||||
email: attendees[0].email,
|
||||
name: bookingForm.getValues("name"),
|
||||
email: bookingForm.getValues("email"),
|
||||
location: responseData.location,
|
||||
eventName: profile.eventName || "",
|
||||
bookingId: id,
|
||||
|
@ -523,7 +523,15 @@ const BookingPage = ({
|
|||
)}
|
||||
{!!eventType.seatsPerTimeSlot && (
|
||||
<div className="text-bookinghighlight flex items-start text-sm">
|
||||
<Icon.FiUser className="mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4" />
|
||||
<Icon.FiUser
|
||||
className={`mr-[10px] ml-[2px] mt-[2px] inline-block h-4 w-4 ${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
? "text-rose-600"
|
||||
: booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.33
|
||||
? "text-yellow-500"
|
||||
: "text-bookinghighlight"
|
||||
}`}
|
||||
/>
|
||||
<p
|
||||
className={`${
|
||||
booking && booking.attendees.length / eventType.seatsPerTimeSlot >= 0.5
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
TextField,
|
||||
Tooltip,
|
||||
} from "@calcom/ui/v2";
|
||||
import CheckboxField from "@calcom/ui/v2/core/form/Checkbox";
|
||||
|
||||
import CustomInputTypeForm from "@components/v2/eventtype/CustomInputTypeForm";
|
||||
|
||||
|
@ -429,6 +430,13 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupInfered
|
|||
onChange(Number(e.target.value));
|
||||
}}
|
||||
/>
|
||||
<div className="mt-6">
|
||||
<CheckboxField
|
||||
description={t("show_attendees")}
|
||||
onChange={(e) => formMethods.setValue("seatsShowAttendees", e.target.checked)}
|
||||
defaultChecked={!!eventType.seatsShowAttendees}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -32,6 +32,18 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
{
|
||||
email: "pro@example.com",
|
||||
name: "pro@example.com",
|
||||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
{
|
||||
email: "pro@example.com",
|
||||
name: "pro@example.com",
|
||||
timeZone: "America/Chihuahua",
|
||||
language,
|
||||
},
|
||||
],
|
||||
location: "Zoom video",
|
||||
destinationCalendar: null,
|
||||
|
@ -48,7 +60,7 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => {
|
|||
res.setHeader("Content-Type", "text/html");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, private, must-revalidate");
|
||||
res.write(
|
||||
renderEmail("OrganizerRequestReminderEmail", {
|
||||
renderEmail("AttendeeScheduledEmail", {
|
||||
attendee: evt.attendees[0],
|
||||
calEvent: evt,
|
||||
})
|
||||
|
|
|
@ -69,6 +69,7 @@ export type FormValues = {
|
|||
periodCountCalendarDays: "1" | "0";
|
||||
periodDates: { startDate: Date; endDate: Date };
|
||||
seatsPerTimeSlot: number | null;
|
||||
seatsShowAttendees: boolean | null;
|
||||
seatsPerTimeSlotEnabled: boolean;
|
||||
minimumBookingNotice: number;
|
||||
beforeBufferTime: number;
|
||||
|
@ -232,6 +233,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
beforeBufferTime,
|
||||
afterBufferTime,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
bookingLimits,
|
||||
recurringEvent,
|
||||
locations,
|
||||
|
@ -259,6 +261,7 @@ const EventTypePage = (props: inferSSRProps<typeof getServerSideProps>) => {
|
|||
afterEventBuffer: afterBufferTime,
|
||||
bookingLimits,
|
||||
seatsPerTimeSlot,
|
||||
seatsShowAttendees,
|
||||
metadata,
|
||||
});
|
||||
}}>
|
||||
|
@ -391,6 +394,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||
price: true,
|
||||
destinationCalendar: true,
|
||||
seatsPerTimeSlot: true,
|
||||
seatsShowAttendees: true,
|
||||
workflows: {
|
||||
include: {
|
||||
workflow: {
|
||||
|
|
|
@ -141,7 +141,15 @@ type SuccessProps = inferSSRProps<typeof getServerSideProps>;
|
|||
export default function Success(props: SuccessProps) {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const { location: _location, name, reschedule, listingStatus, status, isSuccessBookingPage } = router.query;
|
||||
const {
|
||||
location: _location,
|
||||
name,
|
||||
email,
|
||||
reschedule,
|
||||
listingStatus,
|
||||
status,
|
||||
isSuccessBookingPage,
|
||||
} = router.query;
|
||||
const location: ReturnType<typeof getEventLocationValue> = Array.isArray(_location)
|
||||
? _location[0] || ""
|
||||
: _location || "";
|
||||
|
@ -357,14 +365,23 @@ export default function Success(props: SuccessProps) {
|
|||
<p className="text-bookinglight">{bookingInfo.user.email}</p>
|
||||
</div>
|
||||
)}
|
||||
{bookingInfo?.attendees.map((attendee, index) => (
|
||||
<div
|
||||
key={attendee.name}
|
||||
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
|
||||
<p>{attendee.name}</p>
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
{!!eventType.seatsShowAttendees
|
||||
? bookingInfo?.attendees
|
||||
.filter((attendee) => attendee.email === email)
|
||||
.map((attendee) => (
|
||||
<div key={attendee.name} className="mb-3">
|
||||
<p>{attendee.name}</p>
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))
|
||||
: bookingInfo?.attendees.map((attendee, index) => (
|
||||
<div
|
||||
key={attendee.name}
|
||||
className={index === bookingInfo.attendees.length - 1 ? "" : "mb-3"}>
|
||||
<p>{attendee.name}</p>
|
||||
<p className="text-bookinglight">{attendee.email}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
@ -725,6 +742,7 @@ const getEventTypesFromDB = async (id: number) => {
|
|||
},
|
||||
},
|
||||
metadata: true,
|
||||
seatsShowAttendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","bookingId":"[redacted/dynamic]","metadata":{},"additionalInformation":"[redacted/dynamic]"}}
|
||||
|
|
|
@ -1 +1 @@
|
|||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","uid":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}}
|
||||
{"triggerEvent":"BOOKING_CREATED","createdAt":"[redacted/dynamic]","payload":{"type":"30 min","title":"30 min between PRO and Test Testson","description":"","additionalNotes":"","customInputs":{},"startTime":"[redacted/dynamic]","endTime":"[redacted/dynamic]","organizer":{"name":"PRO","email":"[redacted/dynamic]","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"},"attendees":[{"email":"test@example.com","name":"Test Testson","timeZone":"[redacted/dynamic]","language":"[redacted/dynamic]"}],"location":"[redacted/dynamic]","destinationCalendar":null,"hideCalendarNotes":false,"requiresConfirmation":"[redacted/dynamic]","eventTypeId":"[redacted/dynamic]","seatsShowAttendees":false,"uid":"[redacted/dynamic]","eventTitle":"30 min","eventDescription":null,"price":0,"currency":"usd","length":30,"bookingId":"[redacted/dynamic]","metadata":{},"status":"ACCEPTED","additionalInformation":"[redacted/dynamic]"}}
|
|
@ -1278,6 +1278,7 @@
|
|||
"limit_booking_frequency_description":"Limit how many times this event can be booked",
|
||||
"add_limit":"Add Limit",
|
||||
"team_name_required": "Team name required",
|
||||
"show_attendees": "Share attendee information between guests",
|
||||
"how_additional_inputs_as_variables": "How to use Additional Inputs as Variables",
|
||||
"format": "Format",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
|
|
|
@ -55,7 +55,7 @@ test("retrieve contact IDs: all exist", async () => {
|
|||
|
||||
const event = {
|
||||
attendees,
|
||||
} as CalendarEvent;
|
||||
} as { attendees: { email: string; name: string | null; id: string }[] };
|
||||
|
||||
CloseCom.prototype.contact = {
|
||||
search: () => ({ data: attendees }),
|
||||
|
@ -225,7 +225,7 @@ test("prepare data to create custom activity type instance: one attendees, with
|
|||
attendees,
|
||||
startTime: now.toISOString(),
|
||||
additionalNotes: "Some comment!",
|
||||
} as CalendarEvent;
|
||||
} as any;
|
||||
|
||||
CloseCom.prototype.activity = {
|
||||
type: {
|
||||
|
|
|
@ -82,12 +82,22 @@ export default class GoogleCalendarService implements Calendar {
|
|||
timeZone: calEventRaw.organizer.timeZone,
|
||||
},
|
||||
attendees: [
|
||||
{ ...calEventRaw.organizer, organizer: true, responseStatus: "accepted" },
|
||||
...calEventRaw.attendees.map((attendee) => ({ ...attendee, responseStatus: "accepted" })),
|
||||
{
|
||||
...calEventRaw.organizer,
|
||||
id: String(calEventRaw.organizer.id),
|
||||
organizer: true,
|
||||
responseStatus: "accepted",
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
...calEventRaw.attendees.map(({ id, ...rest }) => ({
|
||||
rest,
|
||||
responseStatus: "accepted",
|
||||
})),
|
||||
],
|
||||
reminders: {
|
||||
useDefault: true,
|
||||
},
|
||||
guestsCanSeeOtherGuests: calEventRaw.seatsShowAttendees,
|
||||
};
|
||||
|
||||
if (calEventRaw.location) {
|
||||
|
@ -160,10 +170,23 @@ export default class GoogleCalendarService implements Calendar {
|
|||
dateTime: event.endTime,
|
||||
timeZone: event.organizer.timeZone,
|
||||
},
|
||||
attendees: [{ ...event.organizer, organizer: true, responseStatus: "accepted" }, ...event.attendees],
|
||||
attendees: [
|
||||
{
|
||||
...event.organizer,
|
||||
id: String(event.organizer.id),
|
||||
organizer: true,
|
||||
responseStatus: "accepted",
|
||||
},
|
||||
// eslint-disable-next-line
|
||||
...event.attendees.map(({ id, ...rest }) => ({
|
||||
rest,
|
||||
responseStatus: "accepted",
|
||||
})),
|
||||
],
|
||||
reminders: {
|
||||
useDefault: true,
|
||||
},
|
||||
guestsCanSeeOtherGuests: event.seatsShowAttendees,
|
||||
};
|
||||
|
||||
if (event.location) {
|
||||
|
|
|
@ -84,17 +84,18 @@ export const sendRescheduledEmails = async (calEvent: CalendarEvent) => {
|
|||
export const sendScheduledSeatsEmails = async (
|
||||
calEvent: CalendarEvent,
|
||||
invitee: Person,
|
||||
newSeat: boolean
|
||||
newSeat: boolean,
|
||||
showAttendees: boolean
|
||||
) => {
|
||||
const emailsToSend: Promise<unknown>[] = [];
|
||||
|
||||
emailsToSend.push(
|
||||
new Promise((resolve, reject) => {
|
||||
try {
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, invitee);
|
||||
const scheduledEmail = new AttendeeScheduledEmail(calEvent, invitee, showAttendees);
|
||||
resolve(scheduledEmail.sendEmail());
|
||||
} catch (e) {
|
||||
reject(console.error("AttendeeRescheduledEmail.sendEmail failed", e));
|
||||
reject(console.error("AttendeeScheduledEmail.sendEmail failed", e));
|
||||
}
|
||||
})
|
||||
);
|
||||
|
|
|
@ -12,14 +12,24 @@ import BaseEmail from "./_base-email";
|
|||
export default class AttendeeScheduledEmail extends BaseEmail {
|
||||
calEvent: CalendarEvent;
|
||||
attendee: Person;
|
||||
showAttendees: boolean | undefined;
|
||||
t: TFunction;
|
||||
|
||||
constructor(calEvent: CalendarEvent, attendee: Person) {
|
||||
constructor(calEvent: CalendarEvent, attendee: Person, showAttendees?: boolean | undefined) {
|
||||
super();
|
||||
this.name = "SEND_BOOKING_CONFIRMATION";
|
||||
this.calEvent = calEvent;
|
||||
this.attendee = attendee;
|
||||
this.showAttendees = showAttendees;
|
||||
this.t = attendee.language.translate;
|
||||
|
||||
if (!this.showAttendees) {
|
||||
this.calEvent.attendees = [
|
||||
{
|
||||
...this.attendee,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
protected getiCalEventAsString(): string | undefined {
|
||||
|
|
|
@ -153,6 +153,7 @@ const getEventTypesFromDB = async (eventTypeId: number) => {
|
|||
hideCalendarNotes: true,
|
||||
seatsPerTimeSlot: true,
|
||||
recurringEvent: true,
|
||||
seatsShowAttendees: true,
|
||||
bookingLimits: true,
|
||||
workflows: {
|
||||
include: {
|
||||
|
@ -442,6 +443,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
|||
hideCalendarNotes: eventType.hideCalendarNotes,
|
||||
requiresConfirmation: eventType.requiresConfirmation ?? false,
|
||||
eventTypeId: eventType.id,
|
||||
seatsShowAttendees: !!eventType.seatsShowAttendees,
|
||||
};
|
||||
|
||||
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
|
||||
|
@ -502,7 +504,7 @@ async function handler(req: NextApiRequest & { userId?: number }) {
|
|||
|
||||
const newSeat = booking.attendees.length !== 0;
|
||||
|
||||
await sendScheduledSeatsEmails(evt, invitee[0], newSeat);
|
||||
await sendScheduledSeatsEmails(evt, invitee[0], newSeat, !!eventType.seatsShowAttendees);
|
||||
|
||||
const credentials = await refreshCredentials(organizerUser.credentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
|
|
|
@ -76,6 +76,7 @@ const commons = {
|
|||
currency: "usd",
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
seatsPerTimeSlot: null,
|
||||
seatsShowAttendees: null,
|
||||
id: 0,
|
||||
hideCalendarNotes: false,
|
||||
recurringEvent: null,
|
||||
|
|
|
@ -19,7 +19,7 @@ export const buildPerson = (person?: Partial<Person>): Person => {
|
|||
email: faker.internet.email(),
|
||||
timeZone: faker.address.timeZone(),
|
||||
username: faker.internet.userName(),
|
||||
id: faker.datatype.uuid(),
|
||||
id: faker.datatype.number(),
|
||||
language: {
|
||||
locale: faker.random.locale(),
|
||||
translate: (key: string) => key,
|
||||
|
@ -85,6 +85,7 @@ export const buildEventType = (eventType?: Partial<EventType>): EventType => {
|
|||
beforeEventBuffer: 0,
|
||||
afterEventBuffer: 0,
|
||||
seatsPerTimeSlot: null,
|
||||
seatsShowAttendees: null,
|
||||
schedulingType: null,
|
||||
scheduleId: null,
|
||||
bookingLimits: null,
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "EventType" ADD COLUMN "seatsShowAttendees" BOOLEAN;
|
|
@ -68,6 +68,7 @@ model EventType {
|
|||
beforeEventBuffer Int @default(0)
|
||||
afterEventBuffer Int @default(0)
|
||||
seatsPerTimeSlot Int?
|
||||
seatsShowAttendees Boolean?
|
||||
schedulingType SchedulingType?
|
||||
schedule Schedule? @relation(fields: [scheduleId], references: [id])
|
||||
scheduleId Int?
|
||||
|
|
|
@ -22,7 +22,9 @@ export type Person = {
|
|||
timeZone: string;
|
||||
language: { translate: TFunction; locale: string };
|
||||
username?: string;
|
||||
id?: string;
|
||||
id?: number;
|
||||
bookingId?: number;
|
||||
locale?: string;
|
||||
};
|
||||
|
||||
export type EventBusyDate = {
|
||||
|
@ -141,6 +143,7 @@ export interface CalendarEvent {
|
|||
recurrence?: string;
|
||||
recurringEvent?: RecurringEvent | null;
|
||||
eventTypeId?: number | null;
|
||||
seatsShowAttendees?: boolean | null;
|
||||
}
|
||||
|
||||
export interface EntryPoint {
|
||||
|
|
Loading…
Reference in New Issue
Block a user