feat: Add endpoint to add attendees to existing bookings (#24414)
* feat: add endpoint to add attendees to existing bookings - Created POST /v2/bookings/:bookingUid/attendees endpoint - Added AddAttendeesInput_2024_08_13 for input validation - Added AddAttendeesOutput_2024_08_13 for response format - Created BookingAttendeesService_2024_08_13 for business logic - Created BookingAttendeesController_2024_08_13 for API endpoint - Added validation to check for duplicate attendee emails - Integrated with existing booking and event type repositories - Added validateAndTransformAddAttendeesInput method to InputBookingsService - Fixed pre-existing ESLint no-prototype-builtins warnings - Left placeholder for custom booking field validation logic Co-Authored-By: somay@cal.com <somaychauhan98@gmail.com> * refactor: move booking attendee operations to dedicated repository * refactor: move repository files into dedicated repositories directory * feat: validate guests field availability before adding attendees to booking * feat: migrate addAttendees API to use existing addGuests handler * refactor: remove unused validateAndTransformAddAttendeesInput method from InputBookingsService * refactor: rename attendees to guests in booking API endpoints and types * refactor: rename booking-attendees to booking-guests for consistency * WIP: add e2e tests for add booking guests endpoint * faet: improve guest booking tests * refactor: extract getHtml method in email templates * feat: add email toggle support for guest invites based on OAuth client settings * refactor: addGuests handler * feat: add SMS notifications when adding guests to existing bookings * refactor: rename add-attendees to add-guests for consistent terminology * refactor: added repository pattern in addGuests.handler * test: add attendee scheduled email spy to booking guests tests * fix: use event type team ID instead of user org ID for booking permission check * Update BookingEmailSmsHandler.ts * Remove comments * refactor: rename booking guests to booking attendees * refactor: rename guest-related methods to use attendees terminology for consistency * update api docs * refactor: restructure addGuests handler to top * refactor: update guest email format to use object structure in booking tests * docs: clarify API version header requirement for booking attendees endpoint * docs: add email notification details to booking attendees API documentation * refactor: rename booking attendees to guests for consistency * refactor: rename attendees to guests in booking API endpoints * feat: add email validation for guest invites * feat: improve error handling for guest booking failures --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
This commit is contained in:
34 files changed
+1302
-103
No files matched your search
@@ -1,6 +1,8 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingGuestsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/booking-guests.controller";
|
||||
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { CalVideoService } from "@/ee/bookings/2024-08-13/services/cal-video.service";
|
||||
@@ -15,6 +17,7 @@ import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { EventTypesModule_2024_04_15 } from "@/ee/event-types/event-types_2024_04_15/event-types.module";
|
||||
import { EventTypesModule_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.module";
|
||||
import { EventTypesRepository_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/event-types.repository";
|
||||
import { OutputEventTypesService_2024_06_14 } from "@/ee/event-types/event-types_2024_06_14/services/output-event-types.service";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { InstantBookingModule } from "@/lib/modules/instant-booking.module";
|
||||
import { RecurringBookingModule } from "@/lib/modules/recurring-booking.module";
|
||||
@@ -71,9 +74,11 @@ import { Module } from "@nestjs/common";
|
||||
OAuthClientRepository,
|
||||
OAuthClientUsersService,
|
||||
BookingsService_2024_08_13,
|
||||
BookingGuestsService_2024_08_13,
|
||||
InputBookingsService_2024_08_13,
|
||||
OutputBookingsService_2024_08_13,
|
||||
OutputBookingReferencesService_2024_08_13,
|
||||
OutputEventTypesService_2024_06_14,
|
||||
BookingsRepository_2024_08_13,
|
||||
EventTypesRepository_2024_06_14,
|
||||
BookingSeatRepository,
|
||||
@@ -92,7 +97,7 @@ import { Module } from "@nestjs/common";
|
||||
BookingReferencesRepository_2024_08_13,
|
||||
CalVideoService,
|
||||
],
|
||||
controllers: [BookingsController_2024_08_13],
|
||||
controllers: [BookingsController_2024_08_13, BookingGuestsController_2024_08_13],
|
||||
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
|
||||
})
|
||||
export class BookingsModule_2024_08_13 {}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
|
||||
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||
import { BookingGuestsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-guests.service";
|
||||
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
|
||||
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
|
||||
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
|
||||
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
|
||||
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Controller, Post, Logger, Body, UseGuards, Param, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags as DocsTags, ApiHeader } from "@nestjs/swagger";
|
||||
|
||||
import { BOOKING_WRITE, SUCCESS_STATUS } from "@calcom/platform-constants";
|
||||
import { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Controller({
|
||||
path: "/v2/bookings/:bookingUid/guests",
|
||||
version: VERSION_2024_08_13_VALUE,
|
||||
})
|
||||
@UseGuards(PermissionsGuard)
|
||||
@DocsTags("Bookings / Guests")
|
||||
@ApiHeader({
|
||||
name: "cal-api-version",
|
||||
description: `Must be set to ${VERSION_2024_08_13}. This header is required as this endpoint does not exist in older API versions.`,
|
||||
example: VERSION_2024_08_13,
|
||||
required: true,
|
||||
})
|
||||
export class BookingGuestsController_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingGuestsController_2024_08_13");
|
||||
|
||||
constructor(private readonly bookingGuestsService: BookingGuestsService_2024_08_13) {}
|
||||
|
||||
@Post("/")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Permissions([BOOKING_WRITE])
|
||||
@UseGuards(ApiAuthGuard, BookingUidGuard)
|
||||
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
|
||||
@ApiOperation({
|
||||
summary: "Add guests to an existing booking",
|
||||
description: `Add one or more guests to an existing booking.
|
||||
|
||||
**Email Notifications:**
|
||||
When guests are added, the following notifications are sent (unless disabled by event type settings):
|
||||
|
||||
- **Organizer & Team Members:** Receive an "Add Guests" notification email informing them that new guests have been added to the booking.
|
||||
|
||||
- **New Guests:** Receive a "Scheduled Event" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.
|
||||
|
||||
- **Existing Guests:** Receive an "Add Guests" notification email informing them that additional guests have been added to the booking.
|
||||
|
||||
<Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>
|
||||
`,
|
||||
})
|
||||
async addGuests(
|
||||
@Param("bookingUid") bookingUid: string,
|
||||
@Body() body: AddGuestsInput_2024_08_13,
|
||||
@GetUser() user: ApiAuthGuardUser
|
||||
): Promise<AddGuestsOutput_2024_08_13> {
|
||||
const booking = await this.bookingGuestsService.addGuests(bookingUid, body, user);
|
||||
|
||||
return {
|
||||
status: SUCCESS_STATUS,
|
||||
data: booking,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
import { bootstrap } from "@/app";
|
||||
import { AppModule } from "@/app.module";
|
||||
import { AddGuestsOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/add-guests.output";
|
||||
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
|
||||
import { CreateScheduleInput_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/inputs/create-schedule.input";
|
||||
import { SchedulesModule_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/schedules.module";
|
||||
import { SchedulesService_2024_04_15 } from "@/ee/schedules/schedules_2024_04_15/services/schedules.service";
|
||||
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
|
||||
import { PrismaModule } from "@/modules/prisma/prisma.module";
|
||||
import { UsersModule } from "@/modules/users/users.module";
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
import { OAuthClientRepositoryFixture } from "test/fixtures/repository/oauth-client.repository.fixture";
|
||||
import { TeamRepositoryFixture } from "test/fixtures/repository/team.repository.fixture";
|
||||
import { TokensRepositoryFixture } from "test/fixtures/repository/tokens.repository.fixture";
|
||||
import { UserRepositoryFixture } from "test/fixtures/repository/users.repository.fixture";
|
||||
import { randomString } from "test/utils/randomString";
|
||||
|
||||
import {
|
||||
CAL_API_VERSION_HEADER,
|
||||
ERROR_STATUS,
|
||||
SUCCESS_STATUS,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import {
|
||||
AttendeeAddGuestsEmail,
|
||||
AttendeeScheduledEmail,
|
||||
OrganizerAddGuestsEmail,
|
||||
} from "@calcom/platform-libraries/emails";
|
||||
import type { BookingOutput_2024_08_13, CreateBookingInput_2024_08_13 } from "@calcom/platform-types";
|
||||
import type { User, Team } from "@calcom/prisma/client";
|
||||
|
||||
const attendeeAddGuestsEmailSpy = jest
|
||||
.spyOn(AttendeeAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const organizerAddGuestsEmailSpy = jest
|
||||
.spyOn(OrganizerAddGuestsEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
const attendeeScheduledEmailSpy = jest
|
||||
.spyOn(AttendeeScheduledEmail.prototype, "getHtml")
|
||||
.mockImplementation(() => Promise.resolve("<p>email</p>"));
|
||||
|
||||
type TestUser = {
|
||||
user: User;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
type TestSetup = {
|
||||
organizer: TestUser;
|
||||
unrelatedUser: TestUser;
|
||||
guest: TestUser;
|
||||
eventTypeId: number;
|
||||
bookingUid: string;
|
||||
};
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13 add guests", () => {
|
||||
let app: INestApplication;
|
||||
let organization: Team;
|
||||
|
||||
let userRepositoryFixture: UserRepositoryFixture;
|
||||
let bookingsRepositoryFixture: BookingsRepositoryFixture;
|
||||
let schedulesService: SchedulesService_2024_04_15;
|
||||
let eventTypesRepositoryFixture: EventTypesRepositoryFixture;
|
||||
let oauthClientRepositoryFixture: OAuthClientRepositoryFixture;
|
||||
let teamRepositoryFixture: TeamRepositoryFixture;
|
||||
let tokensRepositoryFixture: TokensRepositoryFixture;
|
||||
|
||||
let testSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [AppModule, PrismaModule, UsersModule, SchedulesModule_2024_04_15],
|
||||
})
|
||||
.overrideGuard(PermissionsGuard)
|
||||
.useValue({
|
||||
canActivate: () => true,
|
||||
})
|
||||
.compile();
|
||||
|
||||
userRepositoryFixture = new UserRepositoryFixture(moduleRef);
|
||||
bookingsRepositoryFixture = new BookingsRepositoryFixture(moduleRef);
|
||||
eventTypesRepositoryFixture = new EventTypesRepositoryFixture(moduleRef);
|
||||
oauthClientRepositoryFixture = new OAuthClientRepositoryFixture(moduleRef);
|
||||
teamRepositoryFixture = new TeamRepositoryFixture(moduleRef);
|
||||
schedulesService = moduleRef.get<SchedulesService_2024_04_15>(SchedulesService_2024_04_15);
|
||||
tokensRepositoryFixture = new TokensRepositoryFixture(moduleRef);
|
||||
|
||||
organization = await teamRepositoryFixture.create({
|
||||
name: `user-emails-2024-08-13-organization-${randomString()}`,
|
||||
});
|
||||
|
||||
await setupTestData();
|
||||
|
||||
app = moduleRef.createNestApplication();
|
||||
bootstrap(app as NestExpressApplication);
|
||||
|
||||
await app.init();
|
||||
});
|
||||
|
||||
async function setupTestData() {
|
||||
const oAuthClient = await createOAuthClient(organization.id, true);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const unrelatedUserData = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-unrelated-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const guestUser = await userRepositoryFixture.create({
|
||||
email: `user-emails-2024-08-13-guest-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClient.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(organizerUser.id, oAuthClient.id);
|
||||
const unrelatedUserTokens = await tokensRepositoryFixture.createTokens(
|
||||
unrelatedUserData.id,
|
||||
oAuthClient.id
|
||||
);
|
||||
const guestTokens = await tokensRepositoryFixture.createTokens(guestUser.id, oAuthClient.id);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-add-guests-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `user-add-guests-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
testSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: {
|
||||
user: unrelatedUserData,
|
||||
accessToken: unrelatedUserTokens.accessToken,
|
||||
refreshToken: unrelatedUserTokens.refreshToken,
|
||||
},
|
||||
guest: {
|
||||
user: guestUser,
|
||||
accessToken: guestTokens.accessToken,
|
||||
refreshToken: guestTokens.refreshToken,
|
||||
},
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: "",
|
||||
};
|
||||
}
|
||||
|
||||
async function createOAuthClient(organizationId: number, emailsEnabled: boolean) {
|
||||
const data = {
|
||||
logo: "logo-url",
|
||||
name: "name",
|
||||
redirectUris: ["http://localhost:5555"],
|
||||
permissions: 32,
|
||||
areEmailsEnabled: emailsEnabled,
|
||||
};
|
||||
const secret = "secret";
|
||||
|
||||
const client = await oauthClientRepositoryFixture.create(organizationId, data, secret);
|
||||
return client;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/guests", () => {
|
||||
beforeAll(async () => {
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
eventTypeId: testSetup.eventTypeId,
|
||||
attendee: {
|
||||
name: "Mr Proper",
|
||||
email: "mr_proper@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
expect(createBookingResponseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
testSetup.bookingUid = createBookingResponseBody.data.uid;
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("should return 401 when adding guests without authentication", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "unauthenticated.guest@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13);
|
||||
|
||||
expect(addGuestsResponse.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization - Organizer", () => {
|
||||
it("should allow booking organizer to add multiple guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "organizer.guest1@example.com" }, { email: "organizer.guest2@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(
|
||||
addGuestsResponseBody,
|
||||
["organizer.guest1@example.com", "organizer.guest2@example.com"],
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("should return 403 when unrelated user tries to add guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [
|
||||
{ email: "non-organizer.guest1@example.com" },
|
||||
{ email: "non-organizer.guest2@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authorization - guest", () => {
|
||||
it("should allow organizer to add a user as guest, then guest can add more guests", async () => {
|
||||
// Step 1: Organizer adds guest user as a guest
|
||||
const addguestBody = {
|
||||
guests: [{ email: testSetup.guest.user.email }],
|
||||
};
|
||||
|
||||
const addguestResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addguestBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addguestResponseBody: AddGuestsOutput_2024_08_13 = addguestResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(addguestResponseBody, [testSetup.guest.user.email], true);
|
||||
|
||||
// Step 2: Now the guest can add their own guests
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "guest.guest1@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.guest.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(addGuestsResponseBody, ["guest.guest1@example.com"], true);
|
||||
});
|
||||
|
||||
it("should return 403 when non-guest user tries to add guests", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "non-guest.guest1@example.com" }, { email: "non-guest.guest2@example.com" }],
|
||||
};
|
||||
|
||||
await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.unrelatedUser.accessToken}`)
|
||||
.expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Validation", () => {
|
||||
it("should return 400 when attempting to add duplicate guest email", async () => {
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "organizer.guest1@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${testSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${testSetup.organizer.accessToken}`);
|
||||
|
||||
expect(addGuestsResponse.status).toEqual(400);
|
||||
expect(addGuestsResponse.body.status).toEqual(ERROR_STATUS);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v2/bookings/:bookingUid/guests - Emails Disabled", () => {
|
||||
let emailsDisabledSetup: TestSetup;
|
||||
|
||||
beforeAll(async () => {
|
||||
const oAuthClientDisabled = await createOAuthClient(organization.id, false);
|
||||
|
||||
const organizerUser = await userRepositoryFixture.create({
|
||||
email: `user-no-emails-2024-08-13-organizer-${randomString()}@api.com`,
|
||||
platformOAuthClients: {
|
||||
connect: { id: oAuthClientDisabled.id },
|
||||
},
|
||||
});
|
||||
|
||||
const organizerTokens = await tokensRepositoryFixture.createTokens(
|
||||
organizerUser.id,
|
||||
oAuthClientDisabled.id
|
||||
);
|
||||
|
||||
const schedule: CreateScheduleInput_2024_04_15 = {
|
||||
name: `user-no-emails-2024-08-13-schedule-${randomString()}`,
|
||||
timeZone: "Europe/Rome",
|
||||
isDefault: true,
|
||||
};
|
||||
await schedulesService.createUserSchedule(organizerUser.id, schedule);
|
||||
|
||||
const eventType = await eventTypesRepositoryFixture.create(
|
||||
{
|
||||
title: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||
slug: `user-no-emails-2024-08-13-event-type-${randomString()}`,
|
||||
length: 60,
|
||||
},
|
||||
organizerUser.id
|
||||
);
|
||||
|
||||
const createBookingBody: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 9, 14, 0, 0)).toISOString(),
|
||||
eventTypeId: eventType.id,
|
||||
attendee: {
|
||||
name: "No Email User",
|
||||
email: "no.email.user@gmail.com",
|
||||
timeZone: "Europe/Rome",
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/no-emails",
|
||||
bookingFieldsResponses: {
|
||||
customField: "noEmailValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "200",
|
||||
},
|
||||
};
|
||||
|
||||
const createBookingResponse = await request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(createBookingBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(201);
|
||||
|
||||
const createBookingResponseBody: CreateBookingOutput_2024_08_13 = createBookingResponse.body;
|
||||
|
||||
if (!responseDataIsBooking(createBookingResponseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
emailsDisabledSetup = {
|
||||
organizer: {
|
||||
user: organizerUser,
|
||||
accessToken: organizerTokens.accessToken,
|
||||
refreshToken: organizerTokens.refreshToken,
|
||||
},
|
||||
unrelatedUser: testSetup.unrelatedUser,
|
||||
guest: testSetup.guest,
|
||||
eventTypeId: eventType.id,
|
||||
bookingUid: createBookingResponseBody.data.uid,
|
||||
};
|
||||
});
|
||||
|
||||
it("should NOT send emails when adding guests (emails disabled)", async () => {
|
||||
attendeeAddGuestsEmailSpy.mockClear();
|
||||
organizerAddGuestsEmailSpy.mockClear();
|
||||
attendeeScheduledEmailSpy.mockClear();
|
||||
|
||||
const addGuestsBody = {
|
||||
guests: [{ email: "no-email.guest1@example.com" }, { email: "no-email.guest2@example.com" }],
|
||||
};
|
||||
|
||||
const addGuestsResponse = await request(app.getHttpServer())
|
||||
.post(`/v2/bookings/${emailsDisabledSetup.bookingUid}/guests`)
|
||||
.send(addGuestsBody)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.set("Authorization", `Bearer ${emailsDisabledSetup.organizer.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const addGuestsResponseBody: AddGuestsOutput_2024_08_13 = addGuestsResponse.body;
|
||||
|
||||
verifyAddGuestsResponse(
|
||||
addGuestsResponseBody,
|
||||
["no-email.guest1@example.com", "no-email.guest2@example.com"],
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await userRepositoryFixture.deleteByEmail(emailsDisabledSetup.organizer.user.email);
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
emailsDisabledSetup.organizer.user.id,
|
||||
emailsDisabledSetup.organizer.user.email
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await teamRepositoryFixture.delete(organization.id);
|
||||
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.organizer.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.unrelatedUser.user.email);
|
||||
await userRepositoryFixture.deleteByEmail(testSetup.guest.user.email);
|
||||
|
||||
await bookingsRepositoryFixture.deleteAllBookings(
|
||||
testSetup.organizer.user.id,
|
||||
testSetup.organizer.user.email
|
||||
);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
function responseDataIsBooking(data: unknown): data is BookingOutput_2024_08_13 {
|
||||
return !Array.isArray(data) && typeof data === "object" && data !== null && "id" in data;
|
||||
}
|
||||
|
||||
function verifyAddGuestsResponse(
|
||||
responseBody: AddGuestsOutput_2024_08_13,
|
||||
expectedGuestEmails: string[],
|
||||
shouldEmailsBeSent: boolean
|
||||
): BookingOutput_2024_08_13 {
|
||||
expect(responseBody.status).toEqual(SUCCESS_STATUS);
|
||||
|
||||
if (!responseDataIsBooking(responseBody.data)) {
|
||||
throw new Error(
|
||||
"Invalid response data - expected booking but received array of possibly recurring bookings"
|
||||
);
|
||||
}
|
||||
|
||||
const bookingData = responseBody.data;
|
||||
|
||||
expect(bookingData.guests).toBeDefined();
|
||||
const guestEmails = bookingData.guests;
|
||||
|
||||
expectedGuestEmails.forEach((email) => {
|
||||
expect(guestEmails).toContain(email);
|
||||
});
|
||||
|
||||
expect(bookingData.guests).toBeDefined();
|
||||
expectedGuestEmails.forEach((email) => {
|
||||
expect(bookingData.guests).toContain(email);
|
||||
});
|
||||
|
||||
if (shouldEmailsBeSent) {
|
||||
expect(attendeeAddGuestsEmailSpy).toHaveBeenCalled();
|
||||
expect(organizerAddGuestsEmailSpy).toHaveBeenCalled();
|
||||
expect(attendeeScheduledEmailSpy).toHaveBeenCalled();
|
||||
} else {
|
||||
expect(attendeeAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||
expect(organizerAddGuestsEmailSpy).not.toHaveBeenCalled();
|
||||
expect(attendeeScheduledEmailSpy).not.toHaveBeenCalled();
|
||||
}
|
||||
|
||||
return bookingData;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { GetBookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
export class AddGuestsOutput_2024_08_13 extends GetBookingOutput_2024_08_13 {}
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,46 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
|
||||
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
|
||||
import { ApiAuthGuardUser } from "@/modules/auth/strategies/api-auth/api-auth.strategy";
|
||||
import { Injectable, Logger, HttpException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { addGuestsHandler } from "@calcom/platform-libraries/bookings";
|
||||
import type { AddGuestsInput_2024_08_13 } from "@calcom/platform-types";
|
||||
|
||||
@Injectable()
|
||||
export class BookingGuestsService_2024_08_13 {
|
||||
private readonly logger = new Logger("BookingGuestsService_2024_08_13");
|
||||
|
||||
constructor(
|
||||
private readonly bookingsRepository: BookingsRepository_2024_08_13,
|
||||
private readonly bookingsService: BookingsService_2024_08_13,
|
||||
private readonly platformBookingsService: PlatformBookingsService
|
||||
) {}
|
||||
|
||||
async addGuests(bookingUid: string, input: AddGuestsInput_2024_08_13, user: ApiAuthGuardUser) {
|
||||
const booking = await this.bookingsRepository.getByUidWithAttendeesAndUserAndEvent(bookingUid);
|
||||
if (!booking) {
|
||||
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
|
||||
}
|
||||
|
||||
const platformClientParams = booking.eventTypeId
|
||||
? await this.platformBookingsService.getOAuthClientParams(booking.eventTypeId)
|
||||
: undefined;
|
||||
|
||||
const emailsEnabled = platformClientParams ? platformClientParams.arePlatformEmailsEnabled : true;
|
||||
|
||||
const res = await addGuestsHandler({
|
||||
ctx: { user },
|
||||
input: { bookingId: booking.id, guests: input.guests },
|
||||
emailsEnabled,
|
||||
});
|
||||
if (res.message === "Guests added") {
|
||||
return await this.bookingsService.getBooking(bookingUid, user);
|
||||
} else {
|
||||
throw new HttpException(
|
||||
"Failed to add guests to the booking",
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { CalendarLink } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
|
||||
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
|
||||
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
|
||||
import { Injectable, Logger, NotFoundException } from "@nestjs/common";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import {
|
||||
eventTypeBookingFieldsSchema,
|
||||
EventTypeWithOwnerAndTeam,
|
||||
@@ -554,7 +554,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
isRescheduleSeatedBody(body: RescheduleBookingInput): body is RescheduleSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||
}
|
||||
|
||||
async transformInputRescheduleSeatedBooking(
|
||||
@@ -770,7 +770,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
}
|
||||
|
||||
isCancelSeatedBody(body: CancelBookingInput): body is CancelSeatedBookingInput_2024_08_13 {
|
||||
return body.hasOwnProperty("seatUid");
|
||||
return Object.prototype.hasOwnProperty.call(body, "seatUid");
|
||||
}
|
||||
|
||||
async transformInputCancelBooking(bookingUid: string, inputBooking: CancelBookingInput_2024_08_13) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import {
|
||||
defaultBookingMetadata,
|
||||
defaultBookingResponses,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsController } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { AppleCalendarService } from "@/ee/calendars/services/apple-calendar.service";
|
||||
|
||||
@@ -286,10 +286,9 @@ export class EventTypesAtomService {
|
||||
.filter(({ ...app }) => app.slug === slug)
|
||||
.map(
|
||||
async ({
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
credentials: _,
|
||||
credential,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
|
||||
key: _2 /* don't leak to frontend */,
|
||||
...app
|
||||
}: EnabledAppType) => {
|
||||
@@ -368,7 +367,7 @@ export class EventTypesAtomService {
|
||||
endTime: endTime.toString(),
|
||||
};
|
||||
if (!eventType) throw new NotFoundException(`Event type with uid ${uid} not found`);
|
||||
if (eventType.users.length === 0 && !!!eventType.team)
|
||||
if (eventType.users.length === 0 && !eventType.team)
|
||||
throw new NotFoundException(`No users found or no team present for event type with uid ${uid}`);
|
||||
const [user] = eventType?.users.length
|
||||
? eventType.users
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BillingProcessor } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { BillingController } from "@/modules/billing/controllers/billing.controller";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppConfig } from "@/config/type";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BILLING_QUEUE, INCREMENT_JOB, IncrementJobDataType } from "@/modules/billing/billing.processor";
|
||||
import { BillingRepository } from "@/modules/billing/billing.repository";
|
||||
import { IBillingService } from "@/modules/billing/interfaces/billing-service.interface";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
|
||||
import { CalendarsCacheService } from "@/ee/calendars/services/calendars-cache.service";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { GoogleCalendarService as GCalService } from "@/ee/calendars/services/gcal.service";
|
||||
import { GoogleCalendarEventResponse } from "@/modules/cal-unified-calendars/pipes/get-calendar-event-details-output-pipe";
|
||||
import { calendar_v3 } from "@googleapis/calendar";
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
|
||||
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/booking-references.repository";
|
||||
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
|
||||
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/repositories/bookings.repository";
|
||||
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
|
||||
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
|
||||
import { MembershipsModule } from "@/modules/memberships/memberships.module";
|
||||
|
||||
@@ -47,7 +47,8 @@ export const AddGuestsDialog = (props: IAddGuestsDialog) => {
|
||||
}
|
||||
const validationResult = ZAddGuestsInputSchema.safeParse(multiEmailValue);
|
||||
if (validationResult.success) {
|
||||
addGuestsMutation.mutate({ bookingId, guests: multiEmailValue });
|
||||
const guests = multiEmailValue.map((email) => ({ email }));
|
||||
addGuestsMutation.mutate({ bookingId, guests });
|
||||
} else {
|
||||
setIsInvalidEmail(true);
|
||||
}
|
||||
|
||||
@@ -8998,6 +8998,64 @@
|
||||
"tags": ["Bookings"]
|
||||
}
|
||||
},
|
||||
"/v2/bookings/{bookingUid}/guests": {
|
||||
"post": {
|
||||
"operationId": "BookingGuestsController_2024_08_13_addGuests",
|
||||
"summary": "Add guests to an existing booking",
|
||||
"description": "Add one or more guests to an existing booking.\n \n **Email Notifications:**\n When guests are added, the following notifications are sent (unless disabled by event type settings):\n \n - **Organizer & Team Members:** Receive an \"Add Guests\" notification email informing them that new guests have been added to the booking.\n \n - **New Guests:** Receive a \"Scheduled Event\" email with full booking details and calendar invite. If they have a phone number, they also receive an SMS notification.\n \n - **Existing Guests:** Receive an \"Add Guests\" notification email informing them that additional guests have been added to the booking.\n \n <Note>The cal-api-version header is required for this endpoint. Without it, the request will fail with a 404 error.</Note>\n ",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
"in": "header",
|
||||
"description": "Must be set to 2024-08-13. This header is required as this endpoint does not exist in older API versions.",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "bookingUid",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddGuestsInput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AddGuestsOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": ["Bookings / Guests"]
|
||||
}
|
||||
},
|
||||
"/v2/calendars/{calendar}/event/{eventUid}": {
|
||||
"get": {
|
||||
"operationId": "CalUnifiedCalendarsController_getCalendarEventDetails",
|
||||
@@ -27285,6 +27343,147 @@
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"Guest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string",
|
||||
"description": "The email of the guest.",
|
||||
"example": "john.doe@example.com"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the guest.",
|
||||
"example": "John Doe"
|
||||
},
|
||||
"timeZone": {
|
||||
"type": "string",
|
||||
"description": "The time zone of the guest.",
|
||||
"example": "America/New_York"
|
||||
},
|
||||
"phoneNumber": {
|
||||
"type": "string",
|
||||
"description": "The phone number of the guest in international format.",
|
||||
"example": "+919876543210"
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"ar",
|
||||
"ca",
|
||||
"de",
|
||||
"es",
|
||||
"eu",
|
||||
"he",
|
||||
"id",
|
||||
"ja",
|
||||
"lv",
|
||||
"pl",
|
||||
"ro",
|
||||
"sr",
|
||||
"th",
|
||||
"vi",
|
||||
"az",
|
||||
"cs",
|
||||
"el",
|
||||
"es-419",
|
||||
"fi",
|
||||
"hr",
|
||||
"it",
|
||||
"km",
|
||||
"nl",
|
||||
"pt",
|
||||
"ru",
|
||||
"sv",
|
||||
"tr",
|
||||
"zh-CN",
|
||||
"bg",
|
||||
"da",
|
||||
"en",
|
||||
"et",
|
||||
"fr",
|
||||
"hu",
|
||||
"iw",
|
||||
"ko",
|
||||
"no",
|
||||
"pt-BR",
|
||||
"sk",
|
||||
"ta",
|
||||
"uk",
|
||||
"zh-TW",
|
||||
"bn"
|
||||
],
|
||||
"description": "The preferred language of the guest. Used for booking confirmation.",
|
||||
"example": "it",
|
||||
"default": "en"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
},
|
||||
"AddGuestsInput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"guests": {
|
||||
"description": "Array of guests to add to the booking",
|
||||
"example": [
|
||||
{
|
||||
"email": "john.doe@example.com",
|
||||
"name": "John Doe",
|
||||
"timeZone": "America/New_York"
|
||||
},
|
||||
{
|
||||
"email": "jane.smith@example.com",
|
||||
"name": "Jane Smith"
|
||||
}
|
||||
],
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Guest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["guests"]
|
||||
},
|
||||
"AddGuestsOutput_2024_08_13": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string",
|
||||
"example": "success",
|
||||
"enum": ["success", "error"]
|
||||
},
|
||||
"data": {
|
||||
"oneOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/BookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/RecurringBookingOutput_2024_08_13"
|
||||
}
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GetSeatedBookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13"
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/GetRecurringSeatedBookingOutput_2024_08_13"
|
||||
}
|
||||
}
|
||||
],
|
||||
"description": "Booking data, which can be either a BookingOutput object, a RecurringBookingOutput object, or an array of RecurringBookingOutput objects"
|
||||
}
|
||||
},
|
||||
"required": ["status", "data"]
|
||||
},
|
||||
"CreateTeamMembershipInput": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -653,7 +653,7 @@ export async function getCredentialForCalendarCache({ credentialId }: { credenti
|
||||
/**
|
||||
* It includes in-memory DelegationCredential credentials as well.
|
||||
*/
|
||||
export async function getUsersCredentialsIncludeServiceAccountKey(user: User) {
|
||||
export async function getUsersCredentialsIncludeServiceAccountKey(user: Pick<User, "id" | "email">) {
|
||||
const credentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
||||
@@ -695,6 +695,48 @@ export const sendAddGuestsEmails = async (calEvent: CalendarEvent, newGuests: st
|
||||
|
||||
await Promise.all(emailsToSend);
|
||||
};
|
||||
|
||||
export const sendAddGuestsEmailsAndSMS = async (args: {
|
||||
calEvent: CalendarEvent;
|
||||
newGuests: string[];
|
||||
eventTypeMetadata?: EventTypeMetadata;
|
||||
}) => {
|
||||
const { calEvent, newGuests, eventTypeMetadata } = args;
|
||||
const calendarEvent = formatCalEvent(calEvent);
|
||||
|
||||
const emailsAndSMSToSend: Promise<unknown>[] = [];
|
||||
|
||||
if (!eventTypeDisableHostEmail(eventTypeMetadata)) {
|
||||
emailsAndSMSToSend.push(sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent })));
|
||||
|
||||
if (calendarEvent.team?.members) {
|
||||
for (const teamMember of calendarEvent.team.members) {
|
||||
emailsAndSMSToSend.push(
|
||||
sendEmail(() => new OrganizerAddGuestsEmail({ calEvent: calendarEvent, teamMember }))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!eventTypeDisableAttendeeEmail(eventTypeMetadata)) {
|
||||
const eventScheduledSMS = new EventSuccessfullyScheduledSMS(calEvent);
|
||||
|
||||
for (const attendee of calendarEvent.attendees) {
|
||||
if (newGuests.includes(attendee.email)) {
|
||||
emailsAndSMSToSend.push(sendEmail(() => new AttendeeScheduledEmail(calendarEvent, attendee)));
|
||||
|
||||
if (attendee.phoneNumber) {
|
||||
emailsAndSMSToSend.push(eventScheduledSMS.sendSMSToAttendee(attendee));
|
||||
}
|
||||
} else {
|
||||
emailsAndSMSToSend.push(sendEmail(() => new AttendeeAddGuestsEmail(calendarEvent, attendee)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(emailsAndSMSToSend);
|
||||
};
|
||||
|
||||
export const sendFeedbackEmail = async (feedback: Feedback) => {
|
||||
await sendEmail(() => new FeedbackEmail(feedback));
|
||||
};
|
||||
|
||||
@@ -5,6 +5,13 @@ import renderEmail from "../src/renderEmail";
|
||||
import AttendeeScheduledEmail from "./attendee-scheduled-email";
|
||||
|
||||
export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
|
||||
async getHtml() {
|
||||
return await renderEmail("AttendeeAddGuestsEmail", {
|
||||
calEvent: this.calEvent,
|
||||
attendee: this.attendee,
|
||||
});
|
||||
}
|
||||
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
icalEvent: generateIcsFile({
|
||||
@@ -20,10 +27,7 @@ export default class AttendeeAddGuestsEmail extends AttendeeScheduledEmail {
|
||||
name: this.calEvent.team?.name || this.calEvent.organizer.name,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("AttendeeAddGuestsEmail", {
|
||||
calEvent: this.calEvent,
|
||||
attendee: this.attendee,
|
||||
}),
|
||||
html: await this.getHtml(),
|
||||
text: this.getTextBody("new_guests_added"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,13 @@ import renderEmail from "../src/renderEmail";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
|
||||
async getHtml() {
|
||||
return await renderEmail("OrganizerAddGuestsEmail", {
|
||||
attendee: this.calEvent.organizer,
|
||||
calEvent: this.calEvent,
|
||||
});
|
||||
}
|
||||
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
const toAddresses = [this.teamMember?.email || this.calEvent.organizer.email];
|
||||
|
||||
@@ -27,10 +34,7 @@ export default class OrganizerAddGuestsEmail extends OrganizerScheduledEmail {
|
||||
name: this.calEvent.attendees[0].name,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("OrganizerAddGuestsEmail", {
|
||||
attendee: this.calEvent.organizer,
|
||||
calEvent: this.calEvent,
|
||||
}),
|
||||
html: await this.getHtml(),
|
||||
text: this.getTextBody("new_guests_added"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
sendScheduledEmailsAndSMS,
|
||||
sendOrganizerRequestEmail,
|
||||
sendAttendeeRequestEmailAndSMS,
|
||||
sendAddGuestsEmailsAndSMS,
|
||||
} from "@calcom/emails";
|
||||
import type { BookingType } from "@calcom/features/bookings/lib/handleNewBooking/originalRescheduledBookingUtils";
|
||||
import type { EventNameObjectType } from "@calcom/features/eventtypes/lib/eventNaming";
|
||||
@@ -68,6 +69,10 @@ type RequestedEmailAndSmsPayload = EmailAndSmsPayload & {
|
||||
additionalNotes?: string | null;
|
||||
};
|
||||
|
||||
type AddGuestsEmailAndSmsPayload = EmailAndSmsPayload & {
|
||||
newGuests: string[];
|
||||
};
|
||||
|
||||
type RescheduledSideEffectsPayload = {
|
||||
action: typeof BookingActionMap.rescheduled;
|
||||
data: RescheduleEmailAndSmsPayload;
|
||||
@@ -322,4 +327,30 @@ export class BookingEmailSmsHandler {
|
||||
this.log.error("Failed to send requested event related emails", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles notifications when guests are added to an existing booking.
|
||||
*/
|
||||
public async handleAddGuests(data: AddGuestsEmailAndSmsPayload) {
|
||||
const {
|
||||
evt,
|
||||
eventType: { metadata },
|
||||
newGuests,
|
||||
} = data;
|
||||
|
||||
this.log.debug(
|
||||
"Action: ADD_GUESTS. Sending add guests emails and SMS.",
|
||||
safeStringify({ calEvent: getPiiFreeCalendarEvent(evt) })
|
||||
);
|
||||
|
||||
try {
|
||||
await sendAddGuestsEmailsAndSMS({
|
||||
calEvent: evt,
|
||||
newGuests,
|
||||
eventTypeMetadata: metadata,
|
||||
});
|
||||
} catch (err) {
|
||||
this.log.error("Failed to send add guests related emails and SMS", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1172,4 +1172,51 @@ export class BookingRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findByIdIncludeDestinationCalendar(bookingId: number) {
|
||||
return await this.prismaClient.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
eventType: true,
|
||||
destinationCalendar: true,
|
||||
references: true,
|
||||
user: {
|
||||
include: {
|
||||
destinationCalendar: true,
|
||||
credentials: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async updateBookingAttendees({
|
||||
bookingId,
|
||||
newAttendees,
|
||||
updatedResponses,
|
||||
}: {
|
||||
bookingId: number;
|
||||
newAttendees: { name: string; email: string; timeZone: string; locale: string | null }[];
|
||||
updatedResponses: Prisma.InputJsonValue;
|
||||
}) {
|
||||
return await this.prismaClient.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: newAttendees,
|
||||
},
|
||||
},
|
||||
responses: updatedResponses,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,3 +8,4 @@ export type {
|
||||
InstantBookingCreateResult,
|
||||
RegularBookingCreateResult,
|
||||
} from "@calcom/features/bookings/lib/dto/types";
|
||||
export { addGuestsHandler } from "@calcom/trpc/server/routers/viewer/bookings/addGuests.handler";
|
||||
@@ -1,3 +1,4 @@
|
||||
import AttendeeAddGuestsEmail from "@calcom/emails/templates/attendee-add-guests-email";
|
||||
import AttendeeCancelledEmail from "@calcom/emails/templates/attendee-cancelled-email";
|
||||
import AttendeeDeclinedEmail from "@calcom/emails/templates/attendee-declined-email";
|
||||
import AttendeeRequestEmail from "@calcom/emails/templates/attendee-request-email";
|
||||
@@ -5,6 +6,7 @@ import AttendeeRescheduledEmail from "@calcom/emails/templates/attendee-reschedu
|
||||
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
|
||||
import AttendeeUpdatedEmail from "@calcom/emails/templates/attendee-updated-email";
|
||||
import AttendeeVerifyEmail from "@calcom/emails/templates/attendee-verify-email";
|
||||
import OrganizerAddGuestsEmail from "@calcom/emails/templates/organizer-add-guests-email";
|
||||
import OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email";
|
||||
import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassigned-email";
|
||||
import OrganizerRequestEmail from "@calcom/emails/templates/organizer-request-email";
|
||||
@@ -16,6 +18,10 @@ import { verifyEmailCodeHandler } from "@calcom/trpc/server/routers/viewer/workf
|
||||
|
||||
export { AttendeeVerifyEmail };
|
||||
|
||||
export { AttendeeAddGuestsEmail };
|
||||
|
||||
export { OrganizerAddGuestsEmail };
|
||||
|
||||
export { AttendeeScheduledEmail };
|
||||
|
||||
export { OrganizerScheduledEmail };
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEmail,
|
||||
ArrayMinSize,
|
||||
IsString,
|
||||
IsOptional,
|
||||
IsTimeZone,
|
||||
IsEnum,
|
||||
Validate,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
|
||||
import type { BookingLanguageType } from "./language";
|
||||
import { BookingLanguage } from "./language";
|
||||
|
||||
class Guest {
|
||||
@ApiProperty({
|
||||
type: String,
|
||||
description: "The email of the guest.",
|
||||
example: "john.doe@example.com",
|
||||
})
|
||||
@IsEmail()
|
||||
email!: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The name of the guest.",
|
||||
example: "John Doe",
|
||||
})
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
name?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The time zone of the guest.",
|
||||
example: "America/New_York",
|
||||
})
|
||||
@IsTimeZone()
|
||||
@IsOptional()
|
||||
timeZone?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: String,
|
||||
description: "The phone number of the guest in international format.",
|
||||
example: "+919876543210",
|
||||
})
|
||||
@IsOptional()
|
||||
@Validate((value: string) => !value || isValidPhoneNumber(value), {
|
||||
message: "Invalid phone number format. Please use international format.",
|
||||
})
|
||||
phoneNumber?: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: BookingLanguage,
|
||||
description: "The preferred language of the guest. Used for booking confirmation.",
|
||||
example: BookingLanguage.it,
|
||||
default: BookingLanguage.en,
|
||||
})
|
||||
@IsEnum(BookingLanguage)
|
||||
@IsOptional()
|
||||
language?: BookingLanguageType;
|
||||
}
|
||||
|
||||
export class AddGuestsInput_2024_08_13 {
|
||||
@ArrayMinSize(1)
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => Guest)
|
||||
@ApiProperty({
|
||||
type: [Guest],
|
||||
description: "Array of guests to add to the booking",
|
||||
example: [
|
||||
{
|
||||
email: "john.doe@example.com",
|
||||
name: "John Doe",
|
||||
timeZone: "America/New_York",
|
||||
},
|
||||
{
|
||||
email: "jane.smith@example.com",
|
||||
name: "Jane Smith",
|
||||
},
|
||||
],
|
||||
})
|
||||
guests!: Guest[];
|
||||
}
|
||||
@@ -9,3 +9,4 @@ export * from "./reschedule-booking-input.pipe";
|
||||
export * from "./cancel-booking-input.pipe";
|
||||
export * from "./decline-booking.input";
|
||||
export * from "./validators/validate-metadata";
|
||||
export * from "./add-guests.input";
|
||||
@@ -3,12 +3,13 @@ import { Type } from "class-transformer";
|
||||
import { IsEnum, ValidateNested } from "class-validator";
|
||||
|
||||
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
|
||||
|
||||
import {
|
||||
BookingOutput_2024_08_13,
|
||||
GetSeatedBookingOutput_2024_08_13,
|
||||
RecurringBookingOutput_2024_08_13,
|
||||
GetRecurringSeatedBookingOutput_2024_08_13,
|
||||
} from "@calcom/platform-types";
|
||||
} from "./booking.output";
|
||||
|
||||
@ApiExtraModels(
|
||||
BookingOutput_2024_08_13,
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
import { getUsersCredentialsIncludeServiceAccountKey } from "@calcom/app-store/delegationCredential";
|
||||
import { eventTypeMetaDataSchemaWithTypedApps } from "@calcom/app-store/zod-utils";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendAddGuestsEmails } from "@calcom/emails";
|
||||
import { BookingEmailSmsHandler } from "@calcom/features/bookings/lib/BookingEmailSmsHandler";
|
||||
import EventManager from "@calcom/features/bookings/lib/EventManager";
|
||||
import { BookingRepository } from "@calcom/features/bookings/repositories/BookingRepository";
|
||||
import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||
import { parseRecurringEvent } from "@calcom/lib/isRecurringEvent";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import type { BookingResponses } from "@calcom/prisma/zod-utils";
|
||||
import { eventTypeBookingFields } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
@@ -17,36 +21,75 @@ import { TRPCError } from "@trpc/server";
|
||||
import type { TrpcSessionUser } from "../../../types";
|
||||
import type { TAddGuestsInputSchema } from "./addGuests.schema";
|
||||
|
||||
type TUser = Pick<NonNullable<TrpcSessionUser>, "id" | "email" | "organizationId"> &
|
||||
Partial<Pick<NonNullable<TrpcSessionUser>, "profile">>;
|
||||
|
||||
type AddGuestsOptions = {
|
||||
ctx: {
|
||||
user: NonNullable<TrpcSessionUser>;
|
||||
user: TUser;
|
||||
};
|
||||
input: TAddGuestsInputSchema;
|
||||
emailsEnabled?: boolean;
|
||||
};
|
||||
export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
|
||||
type Booking = NonNullable<Awaited<ReturnType<BookingRepository["findByIdIncludeDestinationCalendar"]>>>;
|
||||
type OrganizerData = Awaited<ReturnType<typeof getOrganizerData>>;
|
||||
|
||||
export const addGuestsHandler = async ({ ctx, input, emailsEnabled = true }: AddGuestsOptions) => {
|
||||
const { user } = ctx;
|
||||
const { bookingId, guests } = input;
|
||||
|
||||
const booking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
eventType: true,
|
||||
destinationCalendar: true,
|
||||
references: true,
|
||||
user: {
|
||||
include: {
|
||||
destinationCalendar: true,
|
||||
credentials: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const booking = await getBooking(bookingId);
|
||||
|
||||
if (!booking) throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
|
||||
await validateUserPermissions(booking, user);
|
||||
|
||||
validateGuestsFieldEnabled(booking);
|
||||
|
||||
const organizer = await getOrganizerData(booking.userId);
|
||||
|
||||
const uniqueGuests = await sanitizeAndFilterGuests(guests, booking);
|
||||
|
||||
const newGuestsDetails = uniqueGuests.map((guest) => ({
|
||||
name: guest.name || "",
|
||||
email: guest.email,
|
||||
timeZone: guest.timeZone || organizer.timeZone,
|
||||
locale: guest.language || organizer.locale,
|
||||
}));
|
||||
|
||||
const uniqueGuestEmails = uniqueGuests.map((guest) => guest.email);
|
||||
|
||||
const bookingAttendees = await updateBookingAttendees(
|
||||
bookingId,
|
||||
newGuestsDetails,
|
||||
uniqueGuestEmails,
|
||||
booking
|
||||
);
|
||||
|
||||
const attendeesList = await prepareAttendeesList(bookingAttendees.attendees);
|
||||
|
||||
const evt = await buildCalendarEvent(booking, organizer, attendeesList);
|
||||
|
||||
await updateCalendarEvent(booking, evt);
|
||||
|
||||
if (emailsEnabled) {
|
||||
await sendGuestNotifications(evt, booking, uniqueGuestEmails);
|
||||
}
|
||||
|
||||
return { message: "Guests added" };
|
||||
};
|
||||
|
||||
async function getBooking(bookingId: number) {
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
const booking = await bookingRepository.findByIdIncludeDestinationCalendar(bookingId);
|
||||
|
||||
if (!booking || !booking.user) {
|
||||
throw new TRPCError({ code: "NOT_FOUND", message: "booking_not_found" });
|
||||
}
|
||||
|
||||
return booking;
|
||||
}
|
||||
|
||||
async function validateUserPermissions(booking: Booking, user: TUser): Promise<void> {
|
||||
const isOrganizer = booking.userId === user.id;
|
||||
const isAttendee = !!booking.attendees.find((attendee) => attendee.email === user.email);
|
||||
|
||||
@@ -55,7 +98,7 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
const permissionCheckService = new PermissionCheckService();
|
||||
hasBookingUpdatePermission = await permissionCheckService.checkPermission({
|
||||
userId: user.id,
|
||||
teamId: booking.eventType.teamId,
|
||||
teamId: booking.eventType?.teamId,
|
||||
permission: "booking.update",
|
||||
fallbackRoles: [MembershipRole.OWNER, MembershipRole.ADMIN],
|
||||
});
|
||||
@@ -64,10 +107,30 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
if (!hasBookingUpdatePermission && !isOrganizer && !isAttendee) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "you_do_not_have_permission" });
|
||||
}
|
||||
}
|
||||
|
||||
const organizer = await prisma.user.findUniqueOrThrow({
|
||||
function validateGuestsFieldEnabled(booking: Booking): void {
|
||||
const parsedBookingFields = booking?.eventType?.bookingFields
|
||||
? eventTypeBookingFields.parse(booking.eventType.bookingFields)
|
||||
: [];
|
||||
|
||||
const guestsBookingField = parsedBookingFields.find((field) => field.name === "guests");
|
||||
if (guestsBookingField?.hidden) {
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: `Cannot add guests to this booking. The guests field is disabled for event type "${booking?.eventType?.title}" (ID: ${booking?.eventTypeId}). Please contact the event organizer to enable guest additions.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getOrganizerData(userId: number | null) {
|
||||
if (!userId) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "User not found" });
|
||||
}
|
||||
|
||||
return await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: booking.userId || 0,
|
||||
id: userId,
|
||||
},
|
||||
select: {
|
||||
name: true,
|
||||
@@ -76,13 +139,11 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
locale: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const blacklistedGuestEmails = process.env.BLACKLISTED_GUEST_EMAILS
|
||||
? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase())
|
||||
: [];
|
||||
|
||||
function deduplicateGuestEmails(guests: string[]): string[] {
|
||||
const seenBaseEmails = new Set<string>();
|
||||
const deduplicatedGuests = guests.filter((guest) => {
|
||||
return guests.filter((guest) => {
|
||||
const baseEmail = extractBaseEmail(guest).toLowerCase();
|
||||
if (seenBaseEmails.has(baseEmail)) {
|
||||
return false;
|
||||
@@ -90,8 +151,15 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
seenBaseEmails.add(baseEmail);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
const guestEmails = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase());
|
||||
function getBlacklistedEmails(): string[] {
|
||||
return process.env.BLACKLISTED_GUEST_EMAILS
|
||||
? process.env.BLACKLISTED_GUEST_EMAILS.split(",").map((email) => email.toLowerCase())
|
||||
: [];
|
||||
}
|
||||
|
||||
async function getEmailVerificationRequirements(guestEmails: string[]): Promise<Map<string, boolean>> {
|
||||
const userRepo = new UserRepository(prisma);
|
||||
const guestUsers = await userRepo.findManyByEmailsWithEmailVerificationSettings({ emails: guestEmails });
|
||||
|
||||
@@ -101,8 +169,40 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
emailToRequiresVerification.set(matchedBase, user.requiresBookerEmailVerification === true);
|
||||
}
|
||||
|
||||
const uniqueGuests = deduplicatedGuests.filter((guest) => {
|
||||
const baseGuestEmail = extractBaseEmail(guest).toLowerCase();
|
||||
return emailToRequiresVerification;
|
||||
}
|
||||
|
||||
async function sanitizeAndFilterGuests(
|
||||
guests: Array<{
|
||||
email: string;
|
||||
name?: string;
|
||||
timeZone?: string;
|
||||
phoneNumber?: string;
|
||||
language?: string;
|
||||
}>,
|
||||
booking: Booking
|
||||
): Promise<
|
||||
Array<{
|
||||
email: string;
|
||||
name?: string;
|
||||
timeZone?: string;
|
||||
phoneNumber?: string;
|
||||
language?: string;
|
||||
}>
|
||||
> {
|
||||
const guestEmails = guests.map((guest) => guest.email);
|
||||
const deduplicatedGuests = deduplicateGuestEmails(guestEmails);
|
||||
const blacklistedGuestEmails = getBlacklistedEmails();
|
||||
const guestEmailsLowerCase = deduplicatedGuests.map((email) => extractBaseEmail(email).toLowerCase());
|
||||
const emailToRequiresVerification = await getEmailVerificationRequirements(guestEmailsLowerCase);
|
||||
|
||||
// Create a map of email to guest object for easy lookup
|
||||
const emailToGuestMap = new Map(
|
||||
guests.map((guest) => [extractBaseEmail(guest.email).toLowerCase(), guest])
|
||||
);
|
||||
|
||||
const uniqueGuestEmails = deduplicatedGuests.filter((email) => {
|
||||
const baseGuestEmail = extractBaseEmail(email).toLowerCase();
|
||||
return (
|
||||
!booking.attendees.some(
|
||||
(attendee) => extractBaseEmail(attendee.email).toLowerCase() === baseGuestEmail
|
||||
@@ -112,41 +212,37 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
);
|
||||
});
|
||||
|
||||
if (uniqueGuests.length === 0)
|
||||
if (uniqueGuestEmails.length === 0) {
|
||||
throw new TRPCError({ code: "BAD_REQUEST", message: "emails_must_be_unique_valid" });
|
||||
}
|
||||
|
||||
const guestsFullDetails = uniqueGuests.map((guest) => {
|
||||
return {
|
||||
name: "",
|
||||
email: guest,
|
||||
timeZone: organizer.timeZone,
|
||||
locale: organizer.locale,
|
||||
};
|
||||
});
|
||||
// Return the full guest objects for unique emails
|
||||
return uniqueGuestEmails
|
||||
.map((email) => emailToGuestMap.get(extractBaseEmail(email).toLowerCase()))
|
||||
.filter((guest): guest is NonNullable<typeof guest> => guest !== undefined);
|
||||
}
|
||||
|
||||
async function updateBookingAttendees(
|
||||
bookingId: number,
|
||||
newAttendees: { name: string; email: string; timeZone: string; locale: string | null }[],
|
||||
uniqueGuestEmails: string[],
|
||||
booking: Booking
|
||||
) {
|
||||
const bookingResponses = booking.responses as BookingResponses;
|
||||
const bookingRepository = new BookingRepository(prisma);
|
||||
|
||||
const bookingAttendees = await prisma.booking.update({
|
||||
where: {
|
||||
id: bookingId,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
createMany: {
|
||||
data: guestsFullDetails,
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...bookingResponses,
|
||||
guests: [...(bookingResponses?.guests || []), ...uniqueGuests],
|
||||
},
|
||||
return await bookingRepository.updateBookingAttendees({
|
||||
bookingId,
|
||||
newAttendees,
|
||||
updatedResponses: {
|
||||
...bookingResponses,
|
||||
guests: [...(bookingResponses?.guests || []), ...uniqueGuestEmails],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const attendeesListPromises = bookingAttendees.attendees.map(async (attendee) => {
|
||||
async function prepareAttendeesList(attendees: Booking["attendees"]) {
|
||||
const attendeesListPromises = attendees.map(async (attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
@@ -158,7 +254,14 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
};
|
||||
});
|
||||
|
||||
const attendeesList = await Promise.all(attendeesListPromises);
|
||||
return await Promise.all(attendeesListPromises);
|
||||
}
|
||||
|
||||
async function buildCalendarEvent(
|
||||
booking: Booking,
|
||||
organizer: OrganizerData,
|
||||
attendeesList: Awaited<ReturnType<typeof prepareAttendeesList>>
|
||||
): Promise<CalendarEvent> {
|
||||
const tOrganizer = await getTranslation(organizer.locale ?? "en", "common");
|
||||
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
|
||||
|
||||
@@ -199,20 +302,39 @@ export const addGuestsHandler = async ({ ctx, input }: AddGuestsOptions) => {
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(ctx.user);
|
||||
return evt;
|
||||
}
|
||||
|
||||
async function updateCalendarEvent(booking: Booking, evt: CalendarEvent): Promise<void> {
|
||||
if (!booking.user) {
|
||||
throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Booking user not found" });
|
||||
}
|
||||
|
||||
const credentials = await getUsersCredentialsIncludeServiceAccountKey(booking.user);
|
||||
|
||||
const eventManager = new EventManager({
|
||||
...user,
|
||||
...booking.user,
|
||||
credentials: [...credentials],
|
||||
});
|
||||
|
||||
await eventManager.updateCalendarAttendees(evt, booking);
|
||||
}
|
||||
|
||||
try {
|
||||
await sendAddGuestsEmails(evt, uniqueGuests);
|
||||
} catch (err) {
|
||||
console.error("Error sending AddGuestsEmails", err);
|
||||
}
|
||||
async function sendGuestNotifications(
|
||||
evt: CalendarEvent,
|
||||
booking: Booking,
|
||||
uniqueGuests: string[]
|
||||
): Promise<void> {
|
||||
const emailsAndSmsHandler = new BookingEmailSmsHandler({
|
||||
logger: logger,
|
||||
});
|
||||
|
||||
return { message: "Guests added" };
|
||||
};
|
||||
await emailsAndSmsHandler.handleAddGuests({
|
||||
evt,
|
||||
eventType: {
|
||||
metadata: eventTypeMetaDataSchemaWithTypedApps.parse(booking?.eventType?.metadata),
|
||||
schedulingType: booking.eventType?.schedulingType || null,
|
||||
},
|
||||
newGuests: uniqueGuests,
|
||||
});
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const ZGuestSchema = z.object({
|
||||
email: z.string().email(),
|
||||
name: z.string().optional(),
|
||||
timeZone: z.string().optional(),
|
||||
phoneNumber: z.string().optional(),
|
||||
language: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ZAddGuestsInputSchema = z.object({
|
||||
bookingId: z.number(),
|
||||
guests: z.array(z.string().email()),
|
||||
guests: z.array(ZGuestSchema),
|
||||
});
|
||||
|
||||
export type TAddGuestsInputSchema = z.infer<typeof ZAddGuestsInputSchema>;
|
||||
Reference in new issue
Block a user