feat: v2 expose booking metadata (#17708)
* feat: accept metadata and store it in database * feat: accept metadata and store it in database * feat: add BookingSeat.metadata column * feat: handleNewBooking: store booking seat metadata when first time seated booking * feat: handleSeats, createNewSeat store metadata when 2nd+ seated booking * feat: return booking metadata in output * tests: metadata * test: seated bookings * swagger * fix: reassign bookings test * test: use different metadata in recurring * fix: test * test invalid metadata * refactor: output service * readd new line * chore: bump platform libraries * refactor: metadata doc
This commit is contained in:
@@ -10,7 +10,7 @@
|
||||
"transform": {
|
||||
"^.+\\.(t|j)s$": "ts-jest"
|
||||
},
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts"],
|
||||
"setupFiles": ["<rootDir>/test/setEnvVars.ts", "jest-date-mock"],
|
||||
"reporters": ["default", "jest-summarizing-reporter"],
|
||||
"workerIdleMemoryLimit": "512MB",
|
||||
"maxWorkers": 2
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.61",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.62",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -87,6 +87,7 @@
|
||||
"@types/passport-jwt": "^3.0.13",
|
||||
"@types/supertest": "^2.0.12",
|
||||
"jest": "^29.7.0",
|
||||
"jest-date-mock": "^1.0.10",
|
||||
"prettier": "^2.8.6",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^6.3.3",
|
||||
|
||||
@@ -25,9 +25,26 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import {
|
||||
AttendeeScheduledEmail,
|
||||
OrganizerScheduledEmail,
|
||||
OrganizerReassignedEmail,
|
||||
} from "@calcom/platform-libraries";
|
||||
import { CreateBookingInput_2024_08_13, BookingOutput_2024_08_13 } from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
jest.spyOn(AttendeeScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(OrganizerScheduledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(OrganizerReassignedEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Reassign bookings", () => {
|
||||
let app: INestApplication;
|
||||
|
||||
@@ -22,6 +22,7 @@ import { UserRepositoryFixture } from "test/fixtures/repository/users.repository
|
||||
import { withApiAuth } from "test/utils/withApiAuth";
|
||||
|
||||
import { CAL_API_VERSION_HEADER, SUCCESS_STATUS, VERSION_2024_08_13 } from "@calcom/platform-constants";
|
||||
import { AttendeeCancelledEmail, OrganizerCancelledEmail } from "@calcom/platform-libraries";
|
||||
import {
|
||||
CancelSeatedBookingInput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
@@ -38,6 +39,14 @@ import {
|
||||
} from "@calcom/platform-types";
|
||||
import { PlatformOAuthClient, Team } from "@calcom/prisma/client";
|
||||
|
||||
jest.spyOn(AttendeeCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
jest.spyOn(OrganizerCancelledEmail.prototype as any, "getHtml").mockImplementation(async function () {
|
||||
return "<html><body>Mocked Email Content</body></html>";
|
||||
});
|
||||
|
||||
describe("Bookings Endpoints 2024-08-13", () => {
|
||||
describe("Seated bookings", () => {
|
||||
let app: INestApplication;
|
||||
@@ -163,6 +172,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -205,6 +217,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
@@ -230,6 +243,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "Rust",
|
||||
},
|
||||
metadata: {
|
||||
userId: "200",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -274,6 +290,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: createdSeatedBooking.attendees[0].name,
|
||||
...createdSeatedBooking.attendees[0].bookingFieldsResponses,
|
||||
},
|
||||
metadata: createdSeatedBooking.attendees[0].metadata,
|
||||
});
|
||||
const secondAttendee = data.attendees.find((attendee) => attendee.name === nameAttendeeTwo);
|
||||
expect(secondAttendee).toEqual({
|
||||
@@ -286,6 +303,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
@@ -314,6 +332,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
recurrenceCount,
|
||||
metadata: {
|
||||
userId: "300",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -359,6 +380,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
@@ -392,6 +414,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
@@ -565,6 +588,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: attendee?.name,
|
||||
...attendee?.bookingFieldsResponses,
|
||||
},
|
||||
metadata: attendee?.metadata,
|
||||
});
|
||||
expect(data.location).toBeDefined();
|
||||
expect(data.absentHost).toEqual(false);
|
||||
@@ -769,6 +793,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "1000",
|
||||
},
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
@@ -815,6 +842,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
@@ -848,6 +876,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: body.attendee.name,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
@@ -875,6 +904,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
bookingFieldsResponses: {
|
||||
codingLanguage: "TypeScript",
|
||||
},
|
||||
metadata: {
|
||||
userId: "2000",
|
||||
},
|
||||
recurrenceCount,
|
||||
};
|
||||
|
||||
@@ -921,6 +953,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: nameAttendeeOne,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
||||
});
|
||||
expect(firstBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
@@ -932,6 +965,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: nameAttendeeTwo,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
@@ -965,6 +999,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: nameAttendeeOne,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: createdRecurringSeatedBooking[0].attendees[0].metadata,
|
||||
});
|
||||
expect(secondBooking.attendees[1]).toEqual({
|
||||
name: nameAttendeeTwo,
|
||||
@@ -976,6 +1011,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
name: nameAttendeeTwo,
|
||||
...body.bookingFieldsResponses,
|
||||
},
|
||||
metadata: body.metadata,
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
|
||||
+152
@@ -14,6 +14,7 @@ import { INestApplication } from "@nestjs/common";
|
||||
import { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { Test } from "@nestjs/testing";
|
||||
import { User } from "@prisma/client";
|
||||
import { advanceTo, clear } from "jest-date-mock";
|
||||
import * as request from "supertest";
|
||||
import { BookingsRepositoryFixture } from "test/fixtures/repository/bookings.repository.fixture";
|
||||
import { EventTypesRepositoryFixture } from "test/fixtures/repository/event-types.repository.fixture";
|
||||
@@ -178,6 +179,136 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
|
||||
describe("create bookings", () => {
|
||||
describe("invalid metadata", () => {
|
||||
it("should not be able to create a booking with metadata with more than 50 keys", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
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: {
|
||||
key1: "1",
|
||||
key2: "2",
|
||||
key3: "3",
|
||||
key4: "4",
|
||||
key5: "5",
|
||||
key6: "6",
|
||||
key7: "7",
|
||||
key8: "8",
|
||||
key9: "9",
|
||||
key10: "10",
|
||||
key11: "11",
|
||||
key12: "12",
|
||||
key13: "13",
|
||||
key14: "14",
|
||||
key15: "15",
|
||||
key16: "16",
|
||||
key17: "17",
|
||||
key18: "18",
|
||||
key19: "19",
|
||||
key20: "20",
|
||||
key21: "21",
|
||||
key22: "22",
|
||||
key23: "23",
|
||||
key24: "24",
|
||||
key25: "25",
|
||||
key26: "26",
|
||||
key27: "27",
|
||||
key28: "28",
|
||||
key29: "29",
|
||||
key30: "30",
|
||||
key31: "31",
|
||||
key32: "32",
|
||||
key33: "33",
|
||||
key34: "34",
|
||||
key35: "35",
|
||||
key36: "36",
|
||||
key37: "37",
|
||||
key38: "38",
|
||||
key39: "39",
|
||||
key40: "40",
|
||||
key41: "41",
|
||||
key42: "42",
|
||||
key43: "43",
|
||||
key44: "44",
|
||||
key45: "45",
|
||||
key46: "46",
|
||||
key47: "47",
|
||||
key48: "48",
|
||||
key49: "49",
|
||||
key50: "50",
|
||||
key51: "51",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not be able to create a booking with metadata with a key longer than 40 characters", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
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: {
|
||||
aaaaaaaaaabbbbbbbbbbccccccccccdddddddddde: "1",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it("should not be able to create a booking with metadata with a value longer than 500 characters", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
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: {
|
||||
key: `${"a".repeat(501)}`,
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/bookings")
|
||||
.send(body)
|
||||
.set(CAL_API_VERSION_HEADER, VERSION_2024_08_13)
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
it("should create a booking", async () => {
|
||||
const body: CreateBookingInput_2024_08_13 = {
|
||||
start: new Date(Date.UTC(2030, 0, 8, 13, 0, 0)).toISOString(),
|
||||
@@ -192,6 +323,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
bookingFieldsResponses: {
|
||||
customField: "customValue",
|
||||
},
|
||||
metadata: {
|
||||
userId: "100",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -233,6 +367,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
email: body.attendee.email,
|
||||
...body.bookingFieldsResponses,
|
||||
});
|
||||
expect(data.metadata).toEqual(body.metadata);
|
||||
createdBooking = data;
|
||||
} else {
|
||||
throw new Error(
|
||||
@@ -253,6 +388,9 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
language: "it",
|
||||
},
|
||||
location: "https://meet.google.com/abc-def-ghi",
|
||||
metadata: {
|
||||
userId: "1000",
|
||||
},
|
||||
};
|
||||
|
||||
return request(app.getHttpServer())
|
||||
@@ -288,6 +426,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(firstBooking.location).toEqual(body.location);
|
||||
expect(firstBooking.recurringBookingUid).toBeDefined();
|
||||
expect(firstBooking.absentHost).toEqual(false);
|
||||
expect(firstBooking.metadata).toEqual(body.metadata);
|
||||
|
||||
const secondBooking = data[1];
|
||||
expect(secondBooking.id).toBeDefined();
|
||||
@@ -307,6 +446,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
expect(secondBooking.location).toEqual(body.location);
|
||||
expect(secondBooking.absentHost).toEqual(false);
|
||||
expect(secondBooking.metadata).toEqual(body.metadata);
|
||||
|
||||
const thirdBooking = data[2];
|
||||
expect(thirdBooking.id).toBeDefined();
|
||||
@@ -326,6 +466,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
expect(thirdBooking.location).toEqual(body.location);
|
||||
expect(thirdBooking.absentHost).toEqual(false);
|
||||
expect(thirdBooking.metadata).toEqual(body.metadata);
|
||||
|
||||
createdRecurringBooking = data;
|
||||
} else {
|
||||
@@ -860,6 +1001,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.attendees[0]).toEqual(createdBooking.attendees[0]);
|
||||
expect(data.location).toEqual(createdBooking.location);
|
||||
expect(data.absentHost).toEqual(createdBooking.absentHost);
|
||||
expect(data.metadata).toEqual(createdBooking.metadata);
|
||||
|
||||
rescheduledBooking = data;
|
||||
});
|
||||
@@ -913,6 +1055,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
expect(data.attendees[0]).toEqual(createdRecurringBooking[0].attendees[0]);
|
||||
expect(data.location).toEqual(createdRecurringBooking[0].location);
|
||||
expect(data.absentHost).toEqual(createdRecurringBooking[0].absentHost);
|
||||
expect(data.metadata).toEqual(createdRecurringBooking[0].metadata);
|
||||
|
||||
const oldBooking = await bookingsRepositoryFixture.getByUid(createdRecurringBooking[0].uid);
|
||||
expect(oldBooking).toBeDefined();
|
||||
@@ -952,6 +1095,14 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
|
||||
describe("mark absent", () => {
|
||||
beforeAll(() => {
|
||||
advanceTo(new Date(2035, 0, 9, 15, 0, 0));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
clear();
|
||||
});
|
||||
|
||||
it("should mark host absent", async () => {
|
||||
const body: MarkAbsentBookingInput_2024_08_13 = {
|
||||
host: true,
|
||||
@@ -1018,6 +1169,7 @@ describe("Bookings Endpoints 2024-08-13", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancel bookings", () => {
|
||||
it("should cancel booking", async () => {
|
||||
const body: CancelBookingInput_2024_08_13 = {
|
||||
|
||||
@@ -139,9 +139,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
eventTypeId: inputBooking.eventTypeId,
|
||||
timeZone: inputBooking.attendee.timeZone,
|
||||
language: inputBooking.attendee.language || "en",
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: inputBooking.metadata || {},
|
||||
metadata: {},
|
||||
metadata: inputBooking.metadata || {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: inputBooking.guests,
|
||||
// note(Lauris): responses with name and email are required by the handleNewBooking
|
||||
@@ -243,9 +241,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
recurringEventId,
|
||||
timeZone: inputBooking.attendee.timeZone,
|
||||
language: inputBooking.attendee.language || "en",
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: inputBooking.metadata || {},
|
||||
metadata: {},
|
||||
metadata: inputBooking.metadata || {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: inputBooking.guests,
|
||||
// note(Lauris): responses with name and email are required by the handleNewBooking
|
||||
@@ -285,6 +281,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
const bodyTransformed = this.isRescheduleSeatedBody(body)
|
||||
? await this.transformInputRescheduleSeatedBooking(bookingUid, body)
|
||||
: await this.transformInputRescheduleBooking(bookingUid, body);
|
||||
|
||||
const oAuthClientId = request.get(X_CAL_CLIENT_ID);
|
||||
|
||||
const newRequest = { ...request };
|
||||
@@ -345,9 +342,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
eventTypeId: eventType.id,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: booking.metadata || {},
|
||||
metadata: {},
|
||||
metadata: seat.metadata || {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: [],
|
||||
responses: { ...bookingResponses },
|
||||
@@ -386,9 +381,7 @@ export class InputBookingsService_2024_08_13 {
|
||||
eventTypeId: eventType.id,
|
||||
timeZone: attendee.timeZone,
|
||||
language: attendee.locale,
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// metadata: booking.metadata || {},
|
||||
metadata: {},
|
||||
metadata: booking.metadata || {},
|
||||
hasHashedBookingLink: false,
|
||||
guests: bookingResponses.guests,
|
||||
responses: { ...bookingResponses, rescheduledReason: inputBooking.reschedulingReason },
|
||||
|
||||
@@ -4,6 +4,7 @@ import { plainToClass } from "class-transformer";
|
||||
import { DateTime } from "luxon";
|
||||
import { z } from "zod";
|
||||
|
||||
import { bookingMetadataSchema } from "@calcom/platform-libraries";
|
||||
import {
|
||||
BookingOutput_2024_08_13,
|
||||
CreateRecurringSeatedBookingOutput_2024_08_13,
|
||||
@@ -53,6 +54,15 @@ type DatabaseBooking = Booking & {
|
||||
|
||||
type BookingWithUser = Booking & { user: { id: number; name: string | null; email: string } | null };
|
||||
|
||||
const bookingUserMetadataSchema = bookingMetadataSchema.transform((data) => {
|
||||
if (data === null) return {};
|
||||
// note(Lauris): return only user defined metadata
|
||||
const { videoCallUrl, ...rest } = data;
|
||||
return rest;
|
||||
});
|
||||
|
||||
const seatedBookingMetadataSchema = z.object({}).catchall(z.string());
|
||||
|
||||
@Injectable()
|
||||
export class OutputBookingsService_2024_08_13 {
|
||||
constructor(private readonly bookingsRepository: BookingsRepository_2024_08_13) {}
|
||||
@@ -63,6 +73,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
|
||||
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||
const metadata = bookingUserMetadataSchema.parse(databaseBooking.metadata);
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -95,8 +106,9 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
|
||||
const bookingTransformed = plainToClass(BookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
// note(Lauris): I don't know why plainToClass erases bookings responses so attaching manually
|
||||
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
|
||||
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||
bookingTransformed.metadata = metadata;
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
@@ -121,6 +133,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
const duration = dateEnd.diff(dateStart, "minutes").minutes;
|
||||
|
||||
const bookingResponses = bookingResponsesSchema.parse(databaseBooking.responses);
|
||||
const metadata = bookingUserMetadataSchema.parse(databaseBooking.metadata);
|
||||
|
||||
const booking = {
|
||||
id: databaseBooking.id,
|
||||
@@ -154,7 +167,13 @@ export class OutputBookingsService_2024_08_13 {
|
||||
bookingFieldsResponses: databaseBooking.responses,
|
||||
};
|
||||
|
||||
return plainToClass(RecurringBookingOutput_2024_08_13, booking, { strategy: "excludeAll" });
|
||||
const bookingTransformed = plainToClass(RecurringBookingOutput_2024_08_13, booking, {
|
||||
strategy: "excludeAll",
|
||||
});
|
||||
// note(Lauris): I don't know why plainToClass erases bookings responses and metadata so attaching manually
|
||||
bookingTransformed.bookingFieldsResponses = bookingResponses;
|
||||
bookingTransformed.metadata = metadata;
|
||||
return bookingTransformed;
|
||||
}
|
||||
|
||||
getOutputCreateSeatedBooking(
|
||||
@@ -208,6 +227,7 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
@@ -303,9 +323,9 @@ export class OutputBookingsService_2024_08_13 {
|
||||
};
|
||||
const attendeeParsed = plainToClass(SeatedAttendee, attendeeData, { strategy: "excludeAll" });
|
||||
attendeeParsed.bookingFieldsResponses = responses || {};
|
||||
attendeeParsed.metadata = seatedBookingMetadataSchema.parse(attendee.bookingSeat?.metadata);
|
||||
// note(Lauris): as of now email is not returned for privacy
|
||||
delete attendeeParsed.bookingFieldsResponses.email;
|
||||
|
||||
return attendeeParsed;
|
||||
});
|
||||
|
||||
|
||||
@@ -12850,6 +12850,13 @@
|
||||
"description": "Location for this booking. Displayed in email and calendar event.",
|
||||
"example": "https://example.com/meeting"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
@@ -12913,6 +12920,13 @@
|
||||
"description": "Location for this booking. Displayed in email and calendar event.",
|
||||
"example": "https://example.com/meeting"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
@@ -12982,6 +12996,13 @@
|
||||
"description": "Location for this booking. Displayed in email and calendar event.",
|
||||
"example": "https://example.com/meeting"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
@@ -13101,6 +13122,12 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -13223,6 +13250,12 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -2996,6 +2996,7 @@
|
||||
"post": {
|
||||
"operationId": "BookingsController_2024_08_13_cancelBooking",
|
||||
"summary": "Cancel a booking",
|
||||
"description": ":bookingUid can be :bookingUid of an usual booking, individual recurrence or recurring booking to cancel all recurrences.\n For seated bookings to cancel one individual booking provide :bookingUid and :seatUid in the request body. For recurring seated bookings it is not possible to cancel all of them with 1 call\n like with non-seated recurring bookings by providing recurring bookind uid - you have to cancel each recurrence booking by its bookingUid + seatUid.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "cal-api-version",
|
||||
@@ -11549,6 +11550,13 @@
|
||||
"description": "Location for this booking. Displayed in email and calendar event.",
|
||||
"example": "https://example.com/meeting"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
@@ -11604,6 +11612,13 @@
|
||||
"description": "Location for this booking. Displayed in email and calendar event.",
|
||||
"example": "https://example.com/meeting"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
@@ -11664,6 +11679,13 @@
|
||||
"description": "Location for this booking. Displayed in email and calendar event.",
|
||||
"example": "https://example.com/meeting"
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"description": "You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"bookingFieldsResponses": {
|
||||
"type": "object",
|
||||
"description": "Booking field responses consisting of an object with booking field slug as keys and user response as values.",
|
||||
@@ -11770,6 +11792,12 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
@@ -11884,6 +11912,12 @@
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"example": {
|
||||
"key": "value"
|
||||
}
|
||||
},
|
||||
"attendees": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { EMAIL_FROM_NAME } from "@calcom/lib/constants";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { renderEmail } from "../";
|
||||
import generateIcsFile, { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import OrganizerScheduledEmail from "./organizer-scheduled-email";
|
||||
import type { Reassigned } from "./organizer-scheduled-email";
|
||||
|
||||
export default class OrganizerReassignedEmail extends OrganizerScheduledEmail {
|
||||
protected async getNodeMailerPayload(): Promise<Record<string, unknown>> {
|
||||
@@ -20,12 +22,16 @@ export default class OrganizerReassignedEmail extends OrganizerScheduledEmail {
|
||||
title: this.calEvent.title,
|
||||
date: this.getFormattedDate(),
|
||||
})}`,
|
||||
html: await renderEmail("OrganizerReassignedEmail", {
|
||||
attendee: this.calEvent.organizer,
|
||||
calEvent: this.calEvent,
|
||||
reassigned: this.reassigned,
|
||||
}),
|
||||
html: await this.getHtml(this.calEvent, this.calEvent.organizer, this.reassigned),
|
||||
text: this.getTextBody("event_request_reassigned"),
|
||||
};
|
||||
}
|
||||
|
||||
protected async getHtml(calEvent: CalendarEvent, attendee: Person, reassigned: Reassigned | undefined) {
|
||||
return await renderEmail("OrganizerReassignedEmail", {
|
||||
calEvent,
|
||||
attendee,
|
||||
reassigned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import generateIcsFile from "../lib/generateIcsFile";
|
||||
import { GenerateIcsRole } from "../lib/generateIcsFile";
|
||||
import BaseEmail from "./_base-email";
|
||||
|
||||
type Reassigned = { name: string | null; email: string; reason?: string; byUser?: string };
|
||||
export type Reassigned = { name: string | null; email: string; reason?: string; byUser?: string };
|
||||
export default class OrganizerScheduledEmail extends BaseEmail {
|
||||
calEvent: CalendarEvent;
|
||||
t: TFunction;
|
||||
|
||||
@@ -1183,6 +1183,7 @@ async function handler(
|
||||
description: additionalNotes,
|
||||
responses,
|
||||
},
|
||||
metadata: reqBody.metadata,
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
|
||||
@@ -21,7 +21,8 @@ import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking }
|
||||
|
||||
const createNewSeat = async (
|
||||
rescheduleSeatedBookingObject: NewSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking
|
||||
seatedBooking: SeatedBooking,
|
||||
metadata?: Record<string, string>
|
||||
) => {
|
||||
const {
|
||||
tAttendees,
|
||||
@@ -92,6 +93,7 @@ const createNewSeat = async (
|
||||
description: additionalNotes,
|
||||
responses,
|
||||
},
|
||||
metadata,
|
||||
booking: {
|
||||
connect: {
|
||||
id: seatedBooking.id,
|
||||
|
||||
@@ -95,7 +95,7 @@ const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
loggerWithEventDetails
|
||||
);
|
||||
} else {
|
||||
resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking);
|
||||
resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking, reqBodyMetadata);
|
||||
}
|
||||
|
||||
// If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking
|
||||
|
||||
@@ -9,6 +9,7 @@ import AttendeeCancelledEmail from "@calcom/emails/templates/attendee-cancelled-
|
||||
import AttendeeDeclinedEmail from "@calcom/emails/templates/attendee-declined-email";
|
||||
import AttendeeScheduledEmail from "@calcom/emails/templates/attendee-scheduled-email";
|
||||
import OrganizerCancelledEmail from "@calcom/emails/templates/organizer-cancelled-email";
|
||||
import OrganizerReassignedEmail from "@calcom/emails/templates/organizer-reassigned-email";
|
||||
import OrganizerScheduledEmail from "@calcom/emails/templates/organizer-scheduled-email";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import getBookingInfo from "@calcom/features/bookings/lib/getBookingInfo";
|
||||
@@ -109,7 +110,7 @@ export { handleCancelBooking };
|
||||
|
||||
export { eventTypeBookingFields, eventTypeLocations } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export { EventTypeMetaDataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
export { EventTypeMetaDataSchema, userMetadata, bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export {
|
||||
// note(Lauris): Api to internal
|
||||
@@ -200,3 +201,5 @@ export { AttendeeDeclinedEmail };
|
||||
export { AttendeeCancelledEmail };
|
||||
|
||||
export { OrganizerCancelledEmail };
|
||||
|
||||
export { OrganizerReassignedEmail };
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
|
||||
import type { BookingLanguageType } from "./language";
|
||||
import { BookingLanguage } from "./language";
|
||||
import { ValidateMetadata } from "./validators/validate-metadata";
|
||||
|
||||
class Attendee {
|
||||
@ApiProperty({
|
||||
@@ -122,16 +123,20 @@ export class CreateBookingInput_2024_08_13 {
|
||||
@IsOptional()
|
||||
location?: string;
|
||||
|
||||
// todo(Lauris): expose after refactoring metadata https://app.campsite.co/cal/posts/zysq8w9rwm9c
|
||||
// @ApiProperty({
|
||||
// type: Object,
|
||||
// description: "Optional metadata for the booking.",
|
||||
// example: { key: "value" },
|
||||
// required: false,
|
||||
// })
|
||||
// @IsObject()
|
||||
// @IsOptional()
|
||||
// metadata!: Record<string, unknown>;
|
||||
@ApiProperty({
|
||||
type: Object,
|
||||
description:
|
||||
"You can store any additional data you want here. Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
example: { key: "value" },
|
||||
required: false,
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@ValidateMetadata({
|
||||
message:
|
||||
"Metadata must have at most 50 keys, each key up to 40 characters, and string values up to 500 characters.",
|
||||
})
|
||||
metadata?: Record<string, string>;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
type: Object,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ValidationOptions, ValidationArguments } from "class-validator";
|
||||
import { registerDecorator } from "class-validator";
|
||||
|
||||
export function ValidateMetadata(validationOptions?: ValidationOptions) {
|
||||
return function (object: any, propertyName: string) {
|
||||
registerDecorator({
|
||||
name: "ValidateMetadata",
|
||||
target: object.constructor,
|
||||
propertyName: propertyName,
|
||||
options: validationOptions,
|
||||
validator: {
|
||||
validate(metadata: any) {
|
||||
if (typeof metadata !== "object" || metadata === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keys = Object.keys(metadata);
|
||||
|
||||
if (keys.length > 50) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (key.length > 40) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isInvalidValidKeyValue(metadata[key])) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
defaultMessage(args: ValidationArguments) {
|
||||
return `${args.property} must be an object with up to 50 keys, each key name up to 40 characters, and values with a maximum length of 500 characters.`;
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function isInvalidValidKeyValue(keyValue: any) {
|
||||
if (!isString(keyValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isTooLong(keyValue)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isString(keyValue: any): keyValue is string {
|
||||
return typeof keyValue === "string";
|
||||
}
|
||||
|
||||
function isTooLong(keyValue: string) {
|
||||
return keyValue.length > 500;
|
||||
}
|
||||
@@ -57,6 +57,16 @@ export class SeatedAttendee extends Attendee {
|
||||
@IsObject()
|
||||
@Expose()
|
||||
bookingFieldsResponses!: Record<string, unknown>;
|
||||
|
||||
@ApiProperty({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
required: false,
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
class Host {
|
||||
@@ -189,6 +199,16 @@ class BaseBookingOutput_2024_08_13 {
|
||||
@IsBoolean()
|
||||
@Expose()
|
||||
absentHost!: boolean;
|
||||
|
||||
@ApiProperty({
|
||||
type: Object,
|
||||
example: { key: "value" },
|
||||
required: false,
|
||||
})
|
||||
@IsObject()
|
||||
@IsOptional()
|
||||
@Expose()
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
export class BookingOutput_2024_08_13 extends BaseBookingOutput_2024_08_13 {
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BookingSeat" ADD COLUMN "metadata" JSONB;
|
||||
@@ -1179,6 +1179,7 @@ model BookingSeat {
|
||||
attendee Attendee @relation(fields: [attendeeId], references: [id], onDelete: Cascade)
|
||||
/// @zod.custom(imports.bookingSeatDataSchema)
|
||||
data Json?
|
||||
metadata Json?
|
||||
|
||||
@@index([bookingId])
|
||||
@@index([attendeeId])
|
||||
|
||||
Reference in New Issue
Block a user