Refactor/booking logic (#183)

refs #175 

Reuses the same logic for creating bookings from the web app.

Co-authored-by: Leo Giovanetti <hello@leog.me>
This commit is contained in:
Omar López
2022-10-13 12:29:30 -06:00
committed by GitHub
co-authored by Leo Giovanetti
parent f13694fd13
commit 8c24c5c714
9 changed files with 88 additions and 266 deletions
-79
View File
@@ -1,79 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Webhook } from "@prisma/client";
import { compile } from "handlebars";
// import type { CalendarEvent } from "@calcom/types/Calendar"; Add this to make it strict, change data: any to CalendarEvent type
type ContentType = "application/json" | "application/x-www-form-urlencoded";
function applyTemplate(template: string, data: any, contentType: ContentType) {
const compiled = compile(template)(data);
if (contentType === "application/json") {
return JSON.stringify(jsonParse(compiled));
}
return compiled;
}
function jsonParse(jsonString: string) {
try {
return JSON.parse(jsonString);
} catch (e) {
// don't do anything.
console.error(`error jsonParsing in sendPayload`);
}
return false;
}
const sendPayload = async (
triggerEvent: string,
createdAt: string,
webhook: Pick<Webhook, "subscriberUrl" | "appId" | "payloadTemplate">,
data: any & {
metadata?: { [key: string]: string };
rescheduleUid?: string;
bookingId?: number;
}
) => {
const { subscriberUrl, appId, payloadTemplate: template } = webhook;
if (!subscriberUrl || !data) {
throw new Error("Missing required elements to send webhook payload.");
}
const contentType =
!template || jsonParse(template) ? "application/json" : "application/x-www-form-urlencoded";
data.description = data.description || data.additionalNotes;
let body;
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
if (appId === "zapier") {
body = JSON.stringify(data);
} else if (template) {
body = applyTemplate(template, data, contentType);
} else {
body = JSON.stringify({
triggerEvent: triggerEvent,
createdAt: createdAt,
payload: data,
});
}
const response = await fetch(subscriberUrl, {
method: "POST",
headers: {
"Content-Type": contentType,
},
body,
});
const text = await response.text();
return {
ok: response.ok,
status: response.status,
message: text,
};
};
export default sendPayload;
-40
View File
@@ -1,40 +0,0 @@
import { WebhookTriggerEvents, PrismaClient } from "@prisma/client";
export type GetSubscriberOptions = {
userId: number;
eventTypeId: number;
triggerEvent: WebhookTriggerEvents;
};
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient) => {
const { userId, eventTypeId } = options;
const allWebhooks = await prisma.webhook.findMany({
where: {
OR: [
{
userId,
},
{
eventTypeId,
},
],
AND: {
eventTriggers: {
has: options.triggerEvent,
},
active: {
equals: true,
},
},
},
select: {
subscriberUrl: true,
payloadTemplate: true,
appId: true,
},
});
return allWebhooks;
};
export default getWebhooks;
+5 -11
View File
@@ -1,8 +1,12 @@
import { z } from "zod";
import { _BookingModel as Booking } from "@calcom/prisma/zod";
import { extendedBookingCreateBody } from "@calcom/prisma/zod-utils";
import { schemaQueryUserId } from "./shared/queryUserId";
const schemaBookingBaseBodyParams = Booking.pick({
uid: true,
userId: true,
eventTypeId: true,
title: true,
@@ -10,17 +14,7 @@ const schemaBookingBaseBodyParams = Booking.pick({
endTime: true,
}).partial();
const schemaBookingCreateParams = z
.object({
eventTypeId: z.number(),
title: z.string(),
startTime: z.date().or(z.string()),
endTime: z.date().or(z.string()),
recurringCount: z.number().optional(),
})
.strict();
export const schemaBookingCreateBodyParams = schemaBookingBaseBodyParams.merge(schemaBookingCreateParams);
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
const schemaBookingEditParams = z
.object({
+10
View File
@@ -0,0 +1,10 @@
const path = require("path");
const i18nConfig = require("@calcom/config/next-i18next.config");
/** @type {import("next-i18next").UserConfig} */
const config = {
...i18nConfig,
localePath: path.resolve("../web/public/static/locales"),
};
module.exports = config;
+2
View File
@@ -14,9 +14,11 @@ const withTM = require("next-transpile-modules")([
"@calcom/ui",
]);
const { withAxiom } = require("next-axiom");
const { i18n } = require("./next-i18next.config");
module.exports = withAxiom(
withTM({
i18n,
async rewrites() {
return {
afterFiles: [
+1 -1
View File
@@ -39,7 +39,7 @@ import { schemaQueryIdParseInt } from "@lib/validations/shared/queryIdTransformP
* - bookings
* responses:
* 201:
* description: OK, booking edited successfuly
* description: OK, booking edited successfully
* 400:
* description: Bad request. Booking body is invalid.
* 401:
+7 -121
View File
@@ -1,14 +1,6 @@
import { HttpError } from "@/../../packages/lib/http-error";
import { WebhookTriggerEvents } from "@prisma/client";
import type { NextApiRequest, NextApiResponse } from "next";
import { v4 as uuidv4 } from "uuid";
import z from "zod";
import type { NextApiRequest } from "next";
import { BookingResponse, BookingsResponse } from "@calcom/api/lib/types";
import sendPayload from "@calcom/api/lib/utils/sendPayload";
import getWebhooks from "@calcom/api/lib/utils/webhookSubscriptions";
import { schemaBookingCreateBodyParams, schemaBookingReadPublic } from "@calcom/api/lib/validations/booking";
import { schemaEventTypeReadPublic } from "@calcom/api/lib/validations/event-type";
import handleNewBooking from "@calcom/features/bookings/lib/handleNewBooking";
import { defaultResponder } from "@calcom/lib/server";
/**
@@ -53,117 +45,11 @@ import { defaultResponder } from "@calcom/lib/server";
* 401:
* description: Authorization information is missing or invalid.
*/
async function handler(
{ body, userId, isAdmin, prisma }: NextApiRequest,
res: NextApiResponse<BookingsResponse | BookingResponse>
) {
const booking = schemaBookingCreateBodyParams.parse(body);
if (!isAdmin) {
booking.userId = userId;
}
const eventTypeDb = await prisma.eventType.findUnique({
where: { id: booking.eventTypeId },
});
if (!eventTypeDb) throw new HttpError({ statusCode: 400, message: "Invalid eventTypeId." });
const eventType = schemaEventTypeReadPublic.parse(eventTypeDb);
let bookings: z.infer<typeof schemaBookingReadPublic>[];
if (!eventType) throw new HttpError({ statusCode: 400, message: "Could not create new booking" });
if (eventType.recurringEvent) {
console.log("Event type has recurring configuration");
if (!booking.recurringCount) throw new HttpError({ statusCode: 400, message: "Missing recurringCount." });
if (eventType.recurringEvent.count && booking.recurringCount > eventType?.recurringEvent.count) {
throw new HttpError({ statusCode: 400, message: "Invalid recurringCount." });
}
// Event type is recurring, ceating each booking
const recurringEventId = uuidv4();
const allBookings = await Promise.all(
Array.from(Array(booking.recurringCount).keys()).map(async () => {
return await prisma.booking.create({
data: {
uid: uuidv4(),
recurringEventId,
eventTypeId: booking.eventTypeId,
title: booking.title,
startTime: booking.startTime,
endTime: booking.endTime,
userId: booking.userId,
},
});
})
);
bookings = allBookings.map((book) => schemaBookingReadPublic.parse(book));
} else {
// Event type not recurring, creating as single one
const data = await prisma.booking.create({
data: {
uid: uuidv4(),
eventTypeId: booking.eventTypeId,
title: booking.title,
startTime: booking.startTime,
endTime: booking.endTime,
userId: booking.userId,
},
});
bookings = [schemaBookingReadPublic.parse(data)];
}
await Promise.all(
bookings.map(async (booking) => {
const evt = {
type: eventType?.title || booking.title,
title: booking.title,
description: "",
additionalNotes: "",
customInputs: {},
startTime: booking.startTime.toISOString(),
endTime: booking.endTime.toISOString(),
organizer: {
name: "",
email: "",
timeZone: "",
language: {
locale: "en",
},
},
attendees: [],
location: "",
destinationCalendar: null,
hideCalendar: false,
uid: booking.uid,
metadata: {},
};
console.log(`evt: ${evt}`);
// Send Webhook call if hooked to BOOKING_CREATED
const triggerEvent = WebhookTriggerEvents.BOOKING_CREATED;
console.log(`Trigger Event: ${triggerEvent}`);
const subscriberOptions = {
userId,
eventTypeId: booking.eventTypeId as number,
triggerEvent,
};
console.log(`subscriberOptions: ${subscriberOptions}`);
const subscribers = await getWebhooks(subscriberOptions, prisma);
console.log(`subscribers: ${subscribers}`);
const bookingId = booking?.id;
await Promise.all(
subscribers.map((sub) =>
sendPayload(triggerEvent, new Date().toISOString(), sub, {
...evt,
bookingId,
})
)
);
console.log("All promises resolved! About to send the response");
})
);
if (bookings.length > 1) {
res.status(201).json({ bookings, message: "Bookings created successfully." });
} else {
res.status(201).json({ booking: bookings[0], message: "Booking created successfully." });
}
async function handler(req: NextApiRequest) {
const { userId, isAdmin } = req;
if (isAdmin) req.userId = req.body.userId || userId;
const booking = await handleNewBooking(req);
return booking;
}
export default defaultResponder(handler);
+62 -14
View File
@@ -1,19 +1,23 @@
import { Booking, WebhookTriggerEvents } from "@prisma/client";
import { Request, Response } from "express";
import { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import sendPayload from "@calcom/api/lib/utils/sendPayload";
import handler from "@calcom/api/pages/api/bookings/_post";
import dayjs from "@calcom/dayjs";
import { buildEventType, buildWebhook, buildBooking } from "@calcom/lib/test/builder";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
import { buildBooking, buildEventType, buildWebhook, buildUser } from "@calcom/lib/test/builder";
import prisma from "@calcom/prisma";
import { prismaMock } from "../../../../../tests/config/singleton";
import handler from "../../../pages/api/bookings/_post";
type CustomNextApiRequest = NextApiRequest & Request;
type CustomNextApiResponse = NextApiResponse & Response;
jest.mock("@calcom/api/lib/utils/sendPayload");
jest.mock("@calcom/features/webhooks/lib/sendPayload");
jest.mock("@calcom/lib/server/i18n", () => {
return {
getTranslation: (key: string) => key,
};
});
describe("POST /api/bookings", () => {
describe("Errors", () => {
@@ -29,7 +33,7 @@ describe("POST /api/bookings", () => {
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message:
"'invalid_type' in 'eventTypeId': Required; 'invalid_type' in 'title': Required; 'invalid_type' in 'startTime': Required; 'invalid_type' in 'startTime': Required; 'invalid_type' in 'endTime': Required; 'invalid_type' in 'endTime': Required",
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'eventTypeId': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
})
);
});
@@ -53,7 +57,8 @@ describe("POST /api/bookings", () => {
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "Invalid eventTypeId.",
message:
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
})
);
});
@@ -79,7 +84,8 @@ describe("POST /api/bookings", () => {
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "Missing recurringCount.",
message:
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
})
);
});
@@ -106,29 +112,71 @@ describe("POST /api/bookings", () => {
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "Invalid recurringCount.",
message:
"'invalid_type' in 'email': Required; 'invalid_type' in 'end': Required; 'invalid_type' in 'location': Required; 'invalid_type' in 'name': Required; 'invalid_type' in 'start': Required; 'invalid_type' in 'timeZone': Required; 'invalid_type' in 'language': Required; 'invalid_type' in 'customInputs': Required; 'invalid_type' in 'metadata': Required",
})
);
});
test("No available users", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
name: "test",
start: dayjs().format(),
end: dayjs().add(1, "day").format(),
eventTypeId: 2,
email: "test@example.com",
location: "Cal.com Video",
timeZone: "America/Montevideo",
language: "en",
customInputs: [],
metadata: {},
userId: 4,
},
prisma,
});
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType({ users: [] }));
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
expect(res._getStatusCode()).toBe(500);
expect(JSON.parse(res._getData())).toEqual(
expect.objectContaining({
message: "No available users found.",
})
);
});
});
describe("Success", () => {
xdescribe("Success", () => {
describe("Regular event-type", () => {
test("Creates one single booking", async () => {
const { req, res } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "POST",
body: {
title: "test",
name: "test",
start: dayjs().format(),
end: dayjs().add(1, "day").format(),
eventTypeId: 2,
startTime: dayjs().toDate(),
endTime: dayjs().add(1, "day").toDate(),
email: "test@example.com",
location: "Cal.com Video",
timeZone: "America/Montevideo",
language: "en",
customInputs: [],
metadata: {},
userId: 4,
},
prisma,
});
prismaMock.eventType.findUnique.mockResolvedValue(buildEventType());
prismaMock.eventType.findUniqueOrThrow.mockResolvedValue(buildEventType({ users: [buildUser()] }));
prismaMock.booking.findMany.mockResolvedValue([]);
await handler(req, res);
console.log({ statusCode: res._getStatusCode(), data: JSON.parse(res._getData()) });
expect(prismaMock.booking.create).toHaveBeenCalledTimes(1);
});
+1
View File
@@ -2,6 +2,7 @@
"extends": "@calcom/tsconfig/nextjs.json",
"compilerOptions": {
"strict": true,
"jsx": "react-jsx",
"baseUrl": ".",
"paths": {
"@api/*": ["pages/api/*"],