From 180e925bed62e037f068dc9b513b38c8b4869c7a Mon Sep 17 00:00:00 2001 From: Agusti Fernandez Pardo Date: Wed, 8 Jun 2022 18:52:25 +0200 Subject: [PATCH] feat: dynamic prisma almost working --- README.md | 10 ++ jest.setup.ts | 2 +- lib/helpers/customApiEndpoints.ts | 20 ---- lib/helpers/customPrisma.ts | 45 +++++++++ lib/helpers/extendRequest.ts | 6 +- lib/helpers/verifyApiKey.ts | 18 ++-- lib/helpers/withMiddleware.ts | 7 +- lib/utils/isAdmin.ts | 2 +- next.config.js | 6 ++ package.json | 4 + pages/api/users/[id]/availability.ts | 144 +++++++++++++++++++++++++++ tsconfig.json | 1 + 12 files changed, 226 insertions(+), 39 deletions(-) delete mode 100644 lib/helpers/customApiEndpoints.ts create mode 100644 lib/helpers/customPrisma.ts create mode 100644 pages/api/users/[id]/availability.ts diff --git a/README.md b/README.md index 8c39f8457f..7b9f1c7472 100644 --- a/README.md +++ b/README.md @@ -178,3 +178,13 @@ We make sure of this by not using next in dev, but next build && next start, if See . Here in dev mode OPTIONS method is hardcoded to return only GET and OPTIONS as allowed method. Running in Production mode would cause this file to be not used. This is hot-reloading logic only. To remove this limitation, we need to ensure that on local endpoints are requested by swagger at /api/v1 and not /v1 + + +## Hosted api through cal.com + +Go to console.com +Add a deployment or go to an existing one. +Activate API or Admin addon +Provide your DATABASE-URL +Store it safely, you'll get a customApiID, save it. +call api.cal.com?apiKey=your_cal_instance_apiKey&customApiId=cal_datasource_key \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts index d26c6b4183..b764cee58f 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -1,6 +1,6 @@ import prisma from "@calcom/prisma"; afterEach((done) => { - prisma().$disconnect().then(); + prisma.$disconnect().then(); done(); }); diff --git a/lib/helpers/customApiEndpoints.ts b/lib/helpers/customApiEndpoints.ts deleted file mode 100644 index 55d7dc78a2..0000000000 --- a/lib/helpers/customApiEndpoints.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextMiddleware } from "next-api-middleware"; - -import prisma from "@calcom/prisma"; - -// This replaces the prisma client for the cusotm one if the customApiId is valid -export const customApiEndpoints: NextMiddleware = async (req, res, next) => { - const { - query: { customApiId }, - } = req; - // If no custom api Id is provided, return the regular cal.com prisma client. - if (!customApiId) { - req.prisma = prisma(); - await next(); - } - // Hardcoded new database, this should be a dynamic call to console prisma/api? - const newDatabseUrl = process.env.DATABASE_PROD_URL; - req.prisma = prisma(newDatabseUrl); - - await next(); -}; diff --git a/lib/helpers/customPrisma.ts b/lib/helpers/customPrisma.ts new file mode 100644 index 0000000000..27bc3261e2 --- /dev/null +++ b/lib/helpers/customPrisma.ts @@ -0,0 +1,45 @@ +import { hash } from "bcryptjs"; +import cache from "memory-cache"; +import { NextMiddleware } from "next-api-middleware"; + +import { prismaAdmin } from "@calcom/console/modules/common/utils/prisma"; +import { asStringOrUndefined, asStringOrNull } from "@calcom/lib/asStringOrNull"; +import { PRISMA_CLIENT_CACHING_TIME } from "@calcom/lib/constants"; +import { prisma, customPrisma } from "@calcom/prisma"; + +// This replaces the prisma client for the cusotm one if the customCredentialsId is valid +export const customPrismaClient: NextMiddleware = async (req, res, next) => { + const { + query: { customCredentialsId }, + } = req; + // If no custom api Id is provided, attach to request the regular cal.com prisma client. + if (!customCredentialsId) { + req.prisma = prisma; + await next(); + } else { + const id = asStringOrUndefined(customCredentialsId); + + // If we have a customCredentialsId, we check if it is valid. + const dataCredentials = await prismaAdmin.dataCredentials.findUnique({ + where: { id }, + }); + if (!dataCredentials) { + res.status(400).json({ error: "Invalid custom credentials id" }); + return; + } + const credentials = dataCredentials?.credentials; + const hashedUrl = await hash(credentials, 12); + + const cachedPrisma = cache.get(hashedUrl); + + if (!cachedPrisma) { + cache.put( + hashedUrl, + customPrisma({ datasources: { db: { url: credentials } } }), + PRISMA_CLIENT_CACHING_TIME // Cache the prisma client for 24 hours + ); + } + req.prisma = cachedPrisma; + } + await next(); +}; diff --git a/lib/helpers/extendRequest.ts b/lib/helpers/extendRequest.ts index ac9e54d5ed..d0226a255c 100644 --- a/lib/helpers/extendRequest.ts +++ b/lib/helpers/extendRequest.ts @@ -3,14 +3,16 @@ import { NextMiddleware } from "next-api-middleware"; import type { PrismaClient } from ".prisma/client"; -/** @todo figure how to use the one from `@calcom/types`fi */ +/** @todo figure how to use the one from `@calcom/types` */ /** @todo: remove once `@calcom/types` is updated with it.*/ declare module "next" { export interface NextApiRequest extends IncomingMessage { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + body: any; userId: number; method: string; prisma: PrismaClient; - body: any; + session: { user: { id: number } }; query: { [key: string]: string | string[] }; } } diff --git a/lib/helpers/verifyApiKey.ts b/lib/helpers/verifyApiKey.ts index d350f4e1f3..9068f2be73 100644 --- a/lib/helpers/verifyApiKey.ts +++ b/lib/helpers/verifyApiKey.ts @@ -3,18 +3,6 @@ import { NextMiddleware } from "next-api-middleware"; import { hashAPIKey } from "@calcom/ee/lib/api/apiKeys"; -// // import prisma from "@calcom/prisma"; - -// /** @todo figure how to use the one from `@calcom/types`fi */ -// /** @todo: remove once `@calcom/types` is updated with it.*/ -// declare module "next" { -// export interface NextApiRequest extends IncomingMessage { -// userId: number; -// method: string; -// query: { [key: string]: string | string[] }; -// } -// } - // Used to check if the apiKey is not expired, could be extracted if reused. but not for now. export const dateNotInPast = function (date: Date) { const now = new Date(); @@ -27,12 +15,18 @@ export const dateNotInPast = function (date: Date) { export const verifyApiKey: NextMiddleware = async (req, res, next) => { const { prisma } = req; if (!req.query.apiKey) return res.status(401).json({ message: "No apiKey provided" }); + console.log("req.query.apiKey", req.query.apiKey); + // We remove the prefix from the user provided api_key. If no env set default to "cal_" const strippedApiKey = `${req.query.apiKey}`.replace(process.env.API_KEY_PREFIX || "cal_", ""); + console.log("strippedApiKey", strippedApiKey); + // Hash the key again before matching against the database records. const hashedKey = hashAPIKey(strippedApiKey); + console.log("hashedKey", hashedKey); // Check if the hashed api key exists in database. const apiKey = await prisma.apiKey.findUnique({ where: { hashedKey } }); + // console.log("apiKey", apiKey); // If we cannot find any api key. Throw a 401 Unauthorized. if (!apiKey) return res.status(401).json({ error: "Your apiKey is not valid" }); if (apiKey.expiresAt && dateNotInPast(apiKey.expiresAt)) { diff --git a/lib/helpers/withMiddleware.ts b/lib/helpers/withMiddleware.ts index 73eef171f9..05b21cbf0d 100644 --- a/lib/helpers/withMiddleware.ts +++ b/lib/helpers/withMiddleware.ts @@ -2,7 +2,7 @@ import { label } from "next-api-middleware"; import { addRequestId } from "./addRequestid"; import { captureErrors } from "./captureErrors"; -import { customApiEndpoints } from "./customApiEndpoints"; +import { customPrismaClient } from "./customPrisma"; import { extendRequest } from "./extendRequest"; import { HTTP_POST, @@ -24,11 +24,12 @@ const withMiddleware = label( HTTP_DELETE, addRequestId, verifyApiKey, - customApiEndpoints, + customPrismaClient, extendRequest, sentry: captureErrors, }, - ["sentry", "customApiEndpoints", "verifyApiKey", "addRequestId", "extendRequest"] // <-- Provide a list of middleware to call automatically + // The order here, determines the order of execution, put customPrismaClient before verifyApiKey always. + ["sentry", "customPrismaClient", "verifyApiKey", "addRequestId", "extendRequest"] // <-- Provide a list of middleware to call automatically ); export { withMiddleware }; diff --git a/lib/utils/isAdmin.ts b/lib/utils/isAdmin.ts index 4991934509..64af37367f 100644 --- a/lib/utils/isAdmin.ts +++ b/lib/utils/isAdmin.ts @@ -3,6 +3,6 @@ import { UserPermissionRole } from "@prisma/client"; import prisma from "@calcom/prisma"; export const isAdminGuard = async (userId: number) => { - const user = await prisma().user.findUnique({ where: { id: userId } }); + const user = await prisma.user.findUnique({ where: { id: userId } }); return user?.role === UserPermissionRole.ADMIN; }; diff --git a/next.config.js b/next.config.js index 5e40353edf..06f2023723 100644 --- a/next.config.js +++ b/next.config.js @@ -2,8 +2,14 @@ // This makes our @calcom/prisma package from the monorepo to be transpiled and usable by API const withTM = require("next-transpile-modules")([ "@calcom/app-store", + "@calcom/console", "@calcom/prisma", + "@calcom/types", + "@calcom/core", + "@calcom/stripe", + "@calcom/emails", "@calcom/lib", + "@calcom/ui", "@calcom/ee", ]); diff --git a/package.json b/package.json index 57da05e5e6..7475fcb6bc 100644 --- a/package.json +++ b/package.json @@ -20,13 +20,17 @@ }, "devDependencies": { "@calcom/tsconfig": "*", + "@calcom/types": "*", "babel-jest": "^28.1.0", "jest": "^28.1.0", "node-mocks-http": "^1.11.0" }, "dependencies": { + "@calcom/console": "*", "@calcom/prisma": "*", "@sentry/nextjs": "^6.19.7", + "bcryptjs": "^2.4.3", + "memory-cache": "^0.2.0", "modify-response-middleware": "^1.1.0", "next": "^12.1.6", "next-api-middleware": "^1.0.1", diff --git a/pages/api/users/[id]/availability.ts b/pages/api/users/[id]/availability.ts new file mode 100644 index 0000000000..ce57c24ea1 --- /dev/null +++ b/pages/api/users/[id]/availability.ts @@ -0,0 +1,144 @@ +// import availability from "@calcom/core/availability"; +// export default availability; +import { Prisma } from "@prisma/client"; +import dayjs from "dayjs"; +import timezone from "dayjs/plugin/timezone"; +import utc from "dayjs/plugin/utc"; +import type { NextApiRequest, NextApiResponse } from "next"; + +// import prisma from "@calcom/prisma"; +import { asNumberOrThrow, asStringOrNull } from "@calcom/lib/asStringOrNull"; +import { getWorkingHours } from "@calcom/lib/availability"; +import getBusyTimes from "@calcom/lib/getBusyTimes"; + +dayjs.extend(utc); +dayjs.extend(timezone); + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + const { prisma } = req; + const id = asNumberOrThrow(req.query.id); + const dateFrom = dayjs(asStringOrNull(req.query.dateFrom)); + const dateTo = dayjs(asStringOrNull(req.query.dateTo)); + const eventTypeId = typeof req.query.eventTypeId === "string" ? parseInt(req.query.eventTypeId) : undefined; + + if (!dateFrom.isValid() || !dateTo.isValid()) { + return res.status(400).json({ message: "Invalid time range given." }); + } + + const rawUser = await prisma.user.findUnique({ + where: { + id, + }, + select: { + credentials: true, + timeZone: true, + bufferTime: true, + availability: true, + id: true, + startTime: true, + endTime: true, + selectedCalendars: true, + schedules: { + select: { + availability: true, + timeZone: true, + id: true, + }, + }, + defaultScheduleId: true, + }, + }); + + const getEventType = (id: number) => + prisma.eventType.findUnique({ + where: { id }, + select: { + seatsPerTimeSlot: true, + timeZone: true, + schedule: { + select: { + availability: true, + timeZone: true, + }, + }, + availability: { + select: { + startTime: true, + endTime: true, + days: true, + }, + }, + }, + }); + + type EventType = Prisma.PromiseReturnType; + let eventType: EventType | null = null; + if (eventTypeId) eventType = await getEventType(eventTypeId); + + if (!rawUser) throw new Error("No user found"); + + const { selectedCalendars, ...currentUser } = rawUser; + + const busyTimes = await getBusyTimes({ + credentials: currentUser.credentials, + startTime: dateFrom.format(), + endTime: dateTo.format(), + eventTypeId, + userId: currentUser.id, + selectedCalendars, + }); + + const bufferedBusyTimes = busyTimes.map((a) => ({ + start: dayjs(a.start).subtract(currentUser.bufferTime, "minute"), + end: dayjs(a.end).add(currentUser.bufferTime, "minute"), + })); + + const schedule = eventType?.schedule + ? { ...eventType?.schedule } + : { + ...currentUser.schedules.filter( + (schedule) => !currentUser.defaultScheduleId || schedule.id === currentUser.defaultScheduleId + )[0], + }; + + const timeZone = schedule.timeZone || eventType?.timeZone || currentUser.timeZone; + + const workingHours = getWorkingHours( + { + timeZone, + }, + schedule.availability || + (eventType?.availability.length ? eventType.availability : currentUser.availability) + ); + + /* Current logic is if a booking is in a time slot mark it as busy, but seats can have more than one attendee so grab + current bookings with a seats event type and display them on the calendar, even if they are full */ + let currentSeats; + if (eventType?.seatsPerTimeSlot) { + currentSeats = await prisma.booking.findMany({ + where: { + eventTypeId: eventTypeId, + startTime: { + gte: dateFrom.format(), + lte: dateTo.format(), + }, + }, + select: { + uid: true, + startTime: true, + _count: { + select: { + attendees: true, + }, + }, + }, + }); + } + + res.status(200).json({ + busy: bufferedBusyTimes, + timeZone, + workingHours, + currentSeats, + }); +} diff --git a/tsconfig.json b/tsconfig.json index 2714b86c1c..ef00959c45 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "@calcom/tsconfig/nextjs.json", "compilerOptions": { + "target": "es2017", "strict": true, "baseUrl": ".", "paths": {