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:
Joe Au-Yeung 2022-10-18 15:41:50 -04:00 committed by GitHub
parent 7f2560e1e3
commit 8fc4d342fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 125 additions and 30 deletions

View File

@ -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

View File

@ -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>
)}
/>

View File

@ -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,
})

View File

@ -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: {

View File

@ -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,
},
});

View File

@ -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]"}}

View File

@ -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]"}}

View File

@ -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",

View File

@ -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: {

View File

@ -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) {

View File

@ -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));
}
})
);

View File

@ -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 {

View File

@ -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 });

View File

@ -76,6 +76,7 @@ const commons = {
currency: "usd",
schedulingType: SchedulingType.COLLECTIVE,
seatsPerTimeSlot: null,
seatsShowAttendees: null,
id: 0,
hideCalendarNotes: false,
recurringEvent: null,

View File

@ -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,

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "EventType" ADD COLUMN "seatsShowAttendees" BOOLEAN;

View File

@ -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?

View File

@ -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 {