From d8a87e9f04609b77e91afdea1a189beca8ff93ba Mon Sep 17 00:00:00 2001 From: Lauris Skraucis Date: Mon, 8 Sep 2025 11:50:36 +0200 Subject: [PATCH] fix: v2 seated bookings (#23514) * fix: email manage link missing seat uid * fix: toggle seated booking attendees * fix: email manage link seat uid * chore: update platform libraries --- apps/api/v2/package.json | 2 +- .../e2e/booking-fields.e2e-spec.ts | 1 + .../e2e/seated-bookings.e2e-spec.ts | 81 +++++++++++ .../2024-08-13/services/bookings.service.ts | 26 +++- .../2024-08-13/services/output.service.ts | 136 ++++++++++-------- packages/lib/CalEventParser.ts | 11 +- yarn.lock | 10 +- 7 files changed, 190 insertions(+), 77 deletions(-) diff --git a/apps/api/v2/package.json b/apps/api/v2/package.json index 3cae74673a..987840ced9 100644 --- a/apps/api/v2/package.json +++ b/apps/api/v2/package.json @@ -38,7 +38,7 @@ "@axiomhq/winston": "^1.2.0", "@calcom/platform-constants": "*", "@calcom/platform-enums": "*", - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.344", + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.346", "@calcom/platform-types": "*", "@calcom/platform-utils": "*", "@calcom/prisma": "*", diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts index 1801d9285e..2876953b6c 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/booking-fields.e2e-spec.ts @@ -117,6 +117,7 @@ describe("Bookings Endpoints 2024-08-13", () => { slug: seatedEventTypeSlug, length: 60, seatsPerTimeSlot: 3, + seatsShowAttendees: true, }, user.id ); diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts index e2bdd5b1c0..2702895c9a 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/controllers/e2e/seated-bookings.e2e-spec.ts @@ -53,8 +53,10 @@ describe("Bookings Endpoints 2024-08-13", () => { let apiKeyString: string; let seatedEventTypeId: number; + let seatedEventTypeIdAttendeesDisabledId: number; const seatedEventSlug = `seated-bookings-event-type-${randomString()}`; + const seatedEventSlugAttendeesDisabled = `seated-bookings-event-type-attendees-disabled-${randomString()}`; let createdSeatedBooking: CreateSeatedBookingOutput_2024_08_13; let createdSeatedBooking2: CreateSeatedBookingOutput_2024_08_13; @@ -121,6 +123,28 @@ describe("Bookings Endpoints 2024-08-13", () => { ); seatedEventTypeId = seatedEvent.id; + const seatedEventAttendeesDisabled = await eventTypesRepositoryFixture.create( + { + title: `seated-bookings-2024-08-13-event-type-attendees-disabled-${randomString()}`, + slug: seatedEventSlugAttendeesDisabled, + length: 60, + seatsPerTimeSlot: 5, + seatsShowAttendees: false, + seatsShowAvailabilityCount: false, + locations: [{ type: "inPerson", address: "via 10, rome, italy" }], + metadata: { + disableStandardEmails: { + all: { + attendee: true, + host: true, + }, + }, + }, + }, + user.id + ); + seatedEventTypeIdAttendeesDisabledId = seatedEventAttendeesDisabled.id; + app = moduleRef.createNestApplication(); bootstrap(app as NestExpressApplication); @@ -414,6 +438,63 @@ describe("Bookings Endpoints 2024-08-13", () => { }); }); + it("should book an event type with attendees disabled", async () => { + const body: CreateBookingInput_2024_08_13 = { + start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(), + eventTypeId: seatedEventTypeIdAttendeesDisabledId, + attendee: { + name: nameAttendeeOne, + email: emailAttendeeOne, + timeZone: "Europe/Rome", + language: "it", + }, + bookingFieldsResponses: { + codingLanguage: "TypeScript", + }, + metadata: { + userId: "100", + }, + }; + + return request(app.getHttpServer()) + .post("/v2/bookings") + .send(body) + .set(CAL_API_VERSION_HEADER, VERSION_2024_08_13) + .expect(201) + .then(async (response) => { + const responseBody: CreateBookingOutput_2024_08_13 = response.body; + expect(responseBody.status).toEqual(SUCCESS_STATUS); + expect(responseBody.data).toBeDefined(); + expect(responseDataIsCreateSeatedBooking(responseBody.data)).toBe(true); + + if (responseDataIsCreateSeatedBooking(responseBody.data)) { + const data: CreateSeatedBookingOutput_2024_08_13 = responseBody.data; + expect(data.seatUid).toBeDefined(); + expect(data.id).toBeDefined(); + expect(data.uid).toBeDefined(); + expect(data.hosts[0].id).toEqual(user.id); + expect(data.status).toEqual("accepted"); + expect(data.start).toEqual(body.start); + expect(data.end).toEqual( + DateTime.fromISO(body.start, { zone: "utc" }).plus({ hours: 1 }).toISO() + ); + expect(data.duration).toEqual(60); + expect(data.eventTypeId).toEqual(seatedEventTypeIdAttendeesDisabledId); + expect(data.eventType).toEqual({ + id: seatedEventTypeIdAttendeesDisabledId, + slug: seatedEventSlugAttendeesDisabled, + }); + expect(data.attendees.length).toEqual(0); + expect(data.location).toBeDefined(); + expect(data.absentHost).toEqual(false); + } else { + throw new Error( + "Invalid response data - expected seated booking but received non array response" + ); + } + }); + }); + describe("cancel seated booking", () => { describe("cancel seated booking as attendee", () => { it("should cancel seated booking", async () => { diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts index c66307bf06..c8d7c005cc 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/bookings.service.ts @@ -576,10 +576,13 @@ export class BookingsService_2024_08_13 { return this.outputService.getOutputRecurringBooking(booking); } if (isRecurring && isSeated) { - return this.outputService.getOutputRecurringSeatedBooking(booking); + return this.outputService.getOutputRecurringSeatedBooking( + booking, + !!booking.eventType?.seatsShowAttendees + ); } if (isSeated) { - return this.outputService.getOutputSeatedBooking(booking); + return this.outputService.getOutputSeatedBooking(booking, !!booking.eventType?.seatsShowAttendees); } return this.outputService.getOutputBooking(booking); } @@ -591,7 +594,10 @@ export class BookingsService_2024_08_13 { const ids = recurringBooking.map((booking) => booking.id); const isRecurringSeated = !!recurringBooking[0].eventType?.seatsPerTimeSlot; if (isRecurringSeated) { - return this.outputService.getOutputRecurringSeatedBookings(ids); + return this.outputService.getOutputRecurringSeatedBookings( + ids, + !!recurringBooking[0].eventType?.seatsShowAttendees + ); } return this.outputService.getOutputRecurringBookings(ids); @@ -657,9 +663,19 @@ export class BookingsService_2024_08_13 { if (isRecurring && !isSeated) { formattedBookings.push(this.outputService.getOutputRecurringBooking(formatted)); } else if (isRecurring && isSeated) { - formattedBookings.push(this.outputService.getOutputRecurringSeatedBooking(formatted)); + formattedBookings.push( + this.outputService.getOutputRecurringSeatedBooking( + formatted, + !!formatted.eventType?.seatsShowAttendees + ) + ); } else if (isSeated) { - formattedBookings.push(await this.outputService.getOutputSeatedBooking(formatted)); + formattedBookings.push( + await this.outputService.getOutputSeatedBooking( + formatted, + !!formatted.eventType?.seatsShowAttendees + ) + ); } else { formattedBookings.push(await this.outputService.getOutputBooking(formatted)); } diff --git a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts index 604f728166..84512c55ca 100644 --- a/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts +++ b/apps/api/v2/src/ee/bookings/2024-08-13/services/output.service.ts @@ -68,6 +68,7 @@ type DatabaseBooking = Booking & { eventType: { id: number; slug: string; + seatsShowAttendees?: boolean | null; } | null; attendees: { name: string; @@ -167,6 +168,7 @@ export class OutputBookingsService_2024_08_13 { getUserDefinedMetadata(databaseMetadata: DatabaseMetadata) { if (databaseMetadata === null) return {}; + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { videoCallUrl, ...userDefinedMetadata } = databaseMetadata; return userDefinedMetadata; @@ -266,11 +268,14 @@ export class OutputBookingsService_2024_08_13 { databaseBooking: DatabaseBooking, seatUid: string ): Promise { - const getSeatedBookingOutput = await this.getOutputSeatedBooking(databaseBooking); + const getSeatedBookingOutput = await this.getOutputSeatedBooking( + databaseBooking, + !!databaseBooking.eventType?.seatsShowAttendees + ); return { ...getSeatedBookingOutput, seatUid }; } - async getOutputSeatedBooking(databaseBooking: DatabaseBooking) { + async getOutputSeatedBooking(databaseBooking: DatabaseBooking, showAttendees: boolean) { const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); const duration = dateEnd.diff(dateStart, "minutes").minutes; @@ -317,41 +322,43 @@ export class OutputBookingsService_2024_08_13 { const parsed = plainToClass(GetSeatedBookingOutput_2024_08_13, booking, { strategy: "excludeAll" }); // note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually - parsed.attendees = databaseBooking.attendees.map((attendee) => { - const { responses } = safeParse( - seatedBookingDataSchema, - attendee.bookingSeat?.data, - defaultSeatedBookingData, - false - ); + parsed.attendees = showAttendees + ? databaseBooking.attendees.map((attendee) => { + const { responses } = safeParse( + seatedBookingDataSchema, + attendee.bookingSeat?.data, + defaultSeatedBookingData, + false + ); - const attendeeData = { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: attendee.locale, - absent: !!attendee.noShow, - seatUid: attendee.bookingSeat?.referenceUid, - bookingFieldsResponses: {}, - }; - const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); - attendeeParsed.bookingFieldsResponses = responses || {}; - attendeeParsed.metadata = safeParse( - seatedBookingMetadataSchema, - attendee.bookingSeat?.metadata, - defaultSeatedBookingMetadata, - false - ); - // note(Lauris): as of now email is not returned for privacy - delete attendeeParsed.bookingFieldsResponses.email; + const attendeeData = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + seatUid: attendee.bookingSeat?.referenceUid, + bookingFieldsResponses: {}, + }; + const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); + attendeeParsed.bookingFieldsResponses = responses || {}; + attendeeParsed.metadata = safeParse( + seatedBookingMetadataSchema, + attendee.bookingSeat?.metadata, + defaultSeatedBookingMetadata, + false + ); + // note(Lauris): as of now email is not returned for privacy + delete attendeeParsed.bookingFieldsResponses.email; - return attendeeParsed; - }); + return attendeeParsed; + }) + : []; return parsed; } - async getOutputRecurringSeatedBookings(bookingsIds: number[]) { + async getOutputRecurringSeatedBookings(bookingsIds: number[], showAttendees: boolean) { const transformed = []; for (const bookingId of bookingsIds) { @@ -361,7 +368,7 @@ export class OutputBookingsService_2024_08_13 { throw new Error(`Booking with id=${bookingId} was not found in the database`); } - transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking)); + transformed.push(this.getOutputRecurringSeatedBooking(databaseBooking, showAttendees)); } return transformed.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()); @@ -386,11 +393,14 @@ export class OutputBookingsService_2024_08_13 { databaseBooking: DatabaseBooking, seatUid: string ): CreateRecurringSeatedBookingOutput_2024_08_13 { - const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking(databaseBooking); + const getRecurringSeatedBookingOutput = this.getOutputRecurringSeatedBooking( + databaseBooking, + !!databaseBooking.eventType?.seatsShowAttendees + ); return { ...getRecurringSeatedBookingOutput, seatUid }; } - getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking) { + getOutputRecurringSeatedBooking(databaseBooking: DatabaseBooking, showAttendees: boolean) { const dateStart = DateTime.fromISO(databaseBooking.startTime.toISOString()); const dateEnd = DateTime.fromISO(databaseBooking.endTime.toISOString()); const duration = dateEnd.diff(dateStart, "minutes").minutes; @@ -430,35 +440,37 @@ export class OutputBookingsService_2024_08_13 { }); // note(Lauris): I don't know why plainToClass erases booking.attendees[n].responses so attaching manually - parsed.attendees = databaseBooking.attendees.map((attendee) => { - const { responses } = safeParse( - seatedBookingDataSchema, - attendee.bookingSeat?.data, - defaultSeatedBookingData, - false - ); + parsed.attendees = showAttendees + ? databaseBooking.attendees.map((attendee) => { + const { responses } = safeParse( + seatedBookingDataSchema, + attendee.bookingSeat?.data, + defaultSeatedBookingData, + false + ); - const attendeeData = { - name: attendee.name, - email: attendee.email, - timeZone: attendee.timeZone, - language: attendee.locale, - absent: !!attendee.noShow, - seatUid: attendee.bookingSeat?.referenceUid, - bookingFieldsResponses: {}, - }; - const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); - attendeeParsed.bookingFieldsResponses = responses || {}; - attendeeParsed.metadata = safeParse( - seatedBookingMetadataSchema, - attendee.bookingSeat?.metadata, - defaultSeatedBookingMetadata, - false - ); - // note(Lauris): as of now email is not returned for privacy - delete attendeeParsed.bookingFieldsResponses.email; - return attendeeParsed; - }); + const attendeeData = { + name: attendee.name, + email: attendee.email, + timeZone: attendee.timeZone, + language: attendee.locale, + absent: !!attendee.noShow, + seatUid: attendee.bookingSeat?.referenceUid, + bookingFieldsResponses: {}, + }; + const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" }); + attendeeParsed.bookingFieldsResponses = responses || {}; + attendeeParsed.metadata = safeParse( + seatedBookingMetadataSchema, + attendee.bookingSeat?.metadata, + defaultSeatedBookingMetadata, + false + ); + // note(Lauris): as of now email is not returned for privacy + delete attendeeParsed.bookingFieldsResponses.email; + return attendeeParsed; + }) + : []; return parsed; } diff --git a/packages/lib/CalEventParser.ts b/packages/lib/CalEventParser.ts index 08d0a6f2e4..454cebcc4f 100644 --- a/packages/lib/CalEventParser.ts +++ b/packages/lib/CalEventParser.ts @@ -157,11 +157,12 @@ const getSeatReferenceId = (calEvent: Pick): st }; export const getBookingUrl = (calEvent: CalendarEvent) => { + const seatReferenceUid = getSeatReferenceId(calEvent); if (calEvent.platformClientId) { if (!calEvent.platformBookingUrl) return ""; return `${calEvent.platformBookingUrl}/${getUid(calEvent)}?slug=${calEvent.type}&username=${ calEvent.organizer.username - }&changes=true`; + }${seatReferenceUid ? `&seatReferenceUid=${seatReferenceUid}` : ""}&changes=true`; } return `${calEvent.bookerUrl ?? WEBAPP_URL}/booking/${getUid(calEvent)}?changes=true`; @@ -174,6 +175,8 @@ export const getPlatformManageLink = ( t: TFunction ) => { const shouldDisplayReschedule = !calEvent.recurringEvent && calEvent.platformRescheduleUrl; + const seatUid = getSeatReferenceId(calEvent); + let res = calEvent.platformBookingUrl || shouldDisplayReschedule || calEvent.platformCancelUrl ? `${t("need_to_reschedule_or_cancel")} ` @@ -181,9 +184,9 @@ export const getPlatformManageLink = ( if (calEvent.platformBookingUrl) { res += `Check Here: ${calEvent.platformBookingUrl}/${getUid(calEvent)}?slug=${calEvent.type}&username=${ calEvent.organizer.username - }${calEvent?.team ? `&teamId=${calEvent.team.id}` : ""}&changes=true${ - calEvent.platformCancelUrl || shouldDisplayReschedule ? ` ${t("or_lowercase")} ` : "" - }`; + }${calEvent?.team ? `&teamId=${calEvent.team.id}` : ""}${ + seatUid ? `&seatReferenceUid=${seatUid}` : "" + }&changes=true${calEvent.platformCancelUrl || shouldDisplayReschedule ? ` ${t("or_lowercase")} ` : ""}`; } if (calEvent.platformCancelUrl) { res += `${t("cancel")}: ${getCancelLink(calEvent)}`; diff --git a/yarn.lock b/yarn.lock index 086843945b..88f44974e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2708,7 +2708,7 @@ __metadata: "@axiomhq/winston": ^1.2.0 "@calcom/platform-constants": "*" "@calcom/platform-enums": "*" - "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.343" + "@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.346" "@calcom/platform-types": "*" "@calcom/platform-utils": "*" "@calcom/prisma": "*" @@ -3767,13 +3767,13 @@ __metadata: languageName: unknown linkType: soft -"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.343": - version: 0.0.343 - resolution: "@calcom/platform-libraries@npm:0.0.343" +"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.346": + version: 0.0.346 + resolution: "@calcom/platform-libraries@npm:0.0.346" dependencies: "@calcom/features": "*" "@calcom/lib": "*" - checksum: 332910a95336b720999e24c6eaf1e7780a507347e4610dbbeff023d69edc2a47f83f4c3014763fbcd23bb92a34d9a61c83d400c71d4501466b5b5f36add4173d + checksum: 60e49dd260b7af234492f9e5c177b968d59074540f663e43c2c850e68b15ccdf0a3a7216b36563f2eb842f28ef667aac16378a833cb4a30ae7c0e9bbd11c7394 languageName: node linkType: hard