diff --git a/README.md b/README.md index 7b9f1c7472..009ac0dd0d 100644 --- a/README.md +++ b/README.md @@ -187,4 +187,19 @@ 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 +call api.cal.com?apiKey=your_cal_instance_apiKey&customApiId=cal_datasource_key +## How to deploy + +We recommend deploying API in vercel. + +There's some settings that you'll need to setup. + +Under Vercel > Your API Deployment > Settings + +In General > Build & Development Settings +BUILD COMMAND: `yarn turbo run build --scope=@calcom/api --include-dependencies --no-deps` +OUTPUT DIRECTORY: `apps/api/.next` + +See `scripts/vercel-deploy.sh` for more info on how the deployment is done. +## Environment variables +Lastly API requires an env var for `DATABASE_URL` diff --git a/lib/helpers/extendRequest.ts b/lib/helpers/extendRequest.ts index d0226a255c..52af6da544 100644 --- a/lib/helpers/extendRequest.ts +++ b/lib/helpers/extendRequest.ts @@ -1,7 +1,7 @@ import type { IncomingMessage } from "http"; import { NextMiddleware } from "next-api-middleware"; -import type { PrismaClient } from ".prisma/client"; +import type { PrismaClient } from "@calcom/prisma/client"; /** @todo figure how to use the one from `@calcom/types` */ /** @todo: remove once `@calcom/types` is updated with it.*/ @@ -14,8 +14,10 @@ declare module "next" { prisma: PrismaClient; session: { user: { id: number } }; query: { [key: string]: string | string[] }; + isAdmin: boolean; } } export const extendRequest: NextMiddleware = async (req, res, next) => { + // @note: just an empty middleware, we care about extending the next api request types only. await next(); }; diff --git a/lib/helpers/verifyApiKey.ts b/lib/helpers/verifyApiKey.ts index 9068f2be73..ec6aaf1179 100644 --- a/lib/helpers/verifyApiKey.ts +++ b/lib/helpers/verifyApiKey.ts @@ -1,8 +1,9 @@ -// import type { IncomingMessage } from "http"; import { NextMiddleware } from "next-api-middleware"; import { hashAPIKey } from "@calcom/ee/lib/api/apiKeys"; +import { isAdminGuard } from "@lib/utils/isAdmin"; + // 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(); @@ -15,15 +16,12 @@ 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); @@ -35,5 +33,8 @@ export const verifyApiKey: NextMiddleware = async (req, res, next) => { if (!apiKey.userId) return res.status(404).json({ error: "No user found for this apiKey" }); /* We save the user id in the request for later use */ req.userId = apiKey.userId; + /* We save the isAdmin boolean here for later use */ + req.isAdmin = await isAdminGuard(req.userId); + await next(); }; diff --git a/lib/utils/eventName.ts b/lib/utils/eventName.ts new file mode 100644 index 0000000000..5d7865fe94 --- /dev/null +++ b/lib/utils/eventName.ts @@ -0,0 +1,62 @@ +import { TFunction } from "next-i18next"; + +type EventNameObjectType = { + attendeeName: string; + eventType: string; + eventName?: string | null; + host: string; + location?: string; + t: TFunction; +}; + +export function getEventName(eventNameObj: EventNameObjectType, forAttendeeView = false) { + if (!eventNameObj.eventName) + return eventNameObj.t("event_between_users", { + eventName: eventNameObj.eventType, + host: eventNameObj.host, + attendeeName: eventNameObj.attendeeName, + }); + + let eventName = eventNameObj.eventName; + let locationString = ""; + + if (eventNameObj.eventName.includes("{LOCATION}")) { + switch (eventNameObj.location) { + case "inPerson": + locationString = "In Person"; + break; + case "userPhone": + case "phone": + locationString = "Phone"; + break; + case "integrations:daily": + locationString = "Cal Video"; + break; + case "integrations:zoom": + locationString = "Zoom"; + break; + case "integrations:huddle01": + locationString = "Huddle01"; + break; + case "integrations:tandem": + locationString = "Tandem"; + break; + case "integrations:office365_video": + locationString = "MS Teams"; + break; + case "integrations:jitsi": + locationString = "Jitsi"; + break; + } + eventName = eventName.replace("{LOCATION}", locationString); + } + + return ( + eventName + // Need this for compatibility with older event names + .replace("{USER}", eventNameObj.attendeeName) + .replace("{ATTENDEE}", eventNameObj.attendeeName) + .replace("{HOST}", eventNameObj.host) + .replace("{HOST/ATTENDEE}", forAttendeeView ? eventNameObj.host : eventNameObj.attendeeName) + ); +} diff --git a/lib/utils/sendPayload.ts b/lib/utils/sendPayload.ts new file mode 100644 index 0000000000..a6234a1d37 --- /dev/null +++ b/lib/utils/sendPayload.ts @@ -0,0 +1,78 @@ +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, + 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; diff --git a/lib/utils/webhookSubscriptions.ts b/lib/utils/webhookSubscriptions.ts new file mode 100644 index 0000000000..bdf9d95576 --- /dev/null +++ b/lib/utils/webhookSubscriptions.ts @@ -0,0 +1,42 @@ +import { WebhookTriggerEvents } from "@prisma/client"; + +import prisma from "@calcom/prisma"; + +export type GetSubscriberOptions = { + userId: number; + eventTypeId: number; + triggerEvent: WebhookTriggerEvents; +}; + +const getWebhooks = async (options: GetSubscriberOptions) => { + 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; diff --git a/lib/validations/booking.ts b/lib/validations/booking.ts index 67b4f1b35a..a7a8eab730 100644 --- a/lib/validations/booking.ts +++ b/lib/validations/booking.ts @@ -3,7 +3,6 @@ import { z } from "zod"; import { _BookingModel as Booking } from "@calcom/prisma/zod"; const schemaBookingBaseBodyParams = Booking.pick({ - uid: true, userId: true, eventTypeId: true, title: true, @@ -13,7 +12,6 @@ const schemaBookingBaseBodyParams = Booking.pick({ const schemaBookingCreateParams = z .object({ - uid: z.string(), eventTypeId: z.number(), title: z.string(), startTime: z.date().or(z.string()), @@ -25,7 +23,6 @@ export const schemaBookingCreateBodyParams = schemaBookingBaseBodyParams.merge(s const schemaBookingEditParams = z .object({ - uid: z.string().optional(), title: z.string().optional(), startTime: z.date().optional(), endTime: z.date().optional(), diff --git a/lib/validations/shared/queryUserId.ts b/lib/validations/shared/queryUserId.ts new file mode 100644 index 0000000000..cbe6e282d2 --- /dev/null +++ b/lib/validations/shared/queryUserId.ts @@ -0,0 +1,15 @@ +import { withValidation } from "next-validations"; +import { z } from "zod"; + +import { baseApiParams } from "./baseApiParams"; + +// Extracted out as utility function so can be reused +// at different endpoints that require this validation. +export const schemaQueryUserId = baseApiParams + .extend({ + userId: z + .string() + .regex(/^\d+$/) + .transform((id) => parseInt(id)), + }) + .strict(); diff --git a/lib/validations/user.ts b/lib/validations/user.ts index 528bc2e292..dcaafdf8d2 100644 --- a/lib/validations/user.ts +++ b/lib/validations/user.ts @@ -59,7 +59,6 @@ export const schemaUserBaseBodyParams = User.pick({ timeZone: true, weekStart: true, endTime: true, - metadata: true, bufferTime: true, theme: true, defaultScheduleId: true, @@ -93,7 +92,6 @@ const schemaUserEditParams = z.object({ .optional() .nullable(), locale: z.nativeEnum(locales).optional().nullable(), - metadata: jsonSchema, }); // @note: These are the values that are editable via PATCH method on the user Model, @@ -116,14 +114,20 @@ const schemaUserCreateParams = z.object({ .optional() .nullable(), locale: z.nativeEnum(locales).optional(), - metadata: jsonSchema, createdDate: z.string().or(z.date()).optional(), }); // @note: These are the values that are editable via PATCH method on the user Model, // merging both BaseBodyParams with RequiredParams, and omiting whatever we want at the end. -export const schemaUserEditBodyParams = schemaUserBaseBodyParams.merge(schemaUserEditParams).omit({}); -export const schemaUserCreateBodyParams = schemaUserBaseBodyParams.merge(schemaUserCreateParams).omit({}); +export const schemaUserEditBodyParams = schemaUserBaseBodyParams + .merge(schemaUserEditParams) + .omit({}) + .strict(); + +export const schemaUserCreateBodyParams = schemaUserBaseBodyParams + .merge(schemaUserCreateParams) + .omit({}) + .strict(); // @note: These are the values that are always returned when reading a user export const schemaUserReadPublic = User.pick({ @@ -134,7 +138,6 @@ export const schemaUserReadPublic = User.pick({ emailVerified: true, bio: true, avatar: true, - metadata: true, timeZone: true, weekStart: true, endTime: true, @@ -150,4 +153,6 @@ export const schemaUserReadPublic = User.pick({ createdDate: true, verified: true, invitedTo: true, -}).merge(schemaUserEditBodyParams); +}); + +export const schemaUsersReadPublic = z.array(schemaUserReadPublic); diff --git a/next.config.js b/next.config.js index 06f2023723..22ff466595 100644 --- a/next.config.js +++ b/next.config.js @@ -2,21 +2,21 @@ // 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", + "@calcom/lib", + "@calcom/prisma", + "@calcom/stripe", + "@calcom/ui", + "@calcom/emails", + "@calcom/embed-core", + "@calcom/embed-snippet", ]); module.exports = withTM({ async rewrites() { return { - beforeFiles: [ + afterFiles: [ // This redirects requests recieved at / the root to the /api/ folder. { source: "/v:version/:rest*", diff --git a/package.json b/package.json index 7475fcb6bc..dd21735779 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,10 @@ "author": "Cal.com Inc.", "private": true, "scripts": { - "build": "next build", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist", - "dev-real": "PORT=3002 next dev", - "dev": "next build && PORT=3002 next start", + "app-store:generate": "yarn workspace @calcom/app-store-cli generate", + "build": "yarn app-store:generate && next build", + "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf .next", + "dev": "PORT=3002 next dev", "lint-fix": "next lint --fix && prettier --write .", "lint": "next lint", "prebuild": "cd ../.. && yarn workspace @calcom/prisma generate-schemas", diff --git a/pages/api/attendees/[id].ts b/pages/api/attendees/[id].ts index c37e92b9ba..d7e927e36e 100644 --- a/pages/api/attendees/[id].ts +++ b/pages/api/attendees/[id].ts @@ -1,9 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; -// // import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import type { AttendeeResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; import { schemaAttendeeEditBodyParams, schemaAttendeeReadPublic } from "@lib/validations/attendee"; import { schemaQueryIdParseInt, @@ -11,10 +9,9 @@ import { } from "@lib/validations/shared/queryIdTransformParseInt"; export async function attendeeById( - { method, query, body, userId, prisma }: NextApiRequest, + { method, query, body, userId, isAdmin, prisma }: NextApiRequest, res: NextApiResponse ) { - const isAdmin = await isAdminGuard(userId); const safeQuery = schemaQueryIdParseInt.safeParse(query); if (!safeQuery.success) { res.status(400).json({ error: safeQuery.error }); @@ -34,7 +31,7 @@ export async function attendeeById( .flat() .map((attendee) => attendee.id) ); - // @note: Here we make sure to only return attendee's of the user's own bookings. + // @note: Here we make sure to only return attendee's of the user's own bookings if the user is not an admin. if (!isAdmin) { if (!userBookingsAttendeeIds.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" }); diff --git a/pages/api/attendees/index.ts b/pages/api/attendees/index.ts index 25f459823e..880c8d4d4b 100644 --- a/pages/api/attendees/index.ts +++ b/pages/api/attendees/index.ts @@ -1,16 +1,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; -// import db from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import type { AttendeeResponse, AttendeesResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; import { schemaAttendeeCreateBodyParams, schemaAttendeeReadPublic } from "@lib/validations/attendee"; async function createOrlistAllAttendees( - { method, userId, body, prisma }: NextApiRequest, + { method, userId, body, prisma, isAdmin }: NextApiRequest, res: NextApiResponse ) { - const isAdmin = await isAdminGuard(userId); let attendees; if (!isAdmin) { const userBookings = await prisma.booking.findMany({ @@ -124,7 +121,6 @@ async function createOrlistAllAttendees( } else (error: Error) => res.status(400).json({ error }); } } else { - // @todo: check real availability times before booking const data = await prisma.attendee.create({ data: { email: safePost.data.email, diff --git a/pages/api/availabilities/[id].ts b/pages/api/availabilities/[id].ts index 3dcbdbbca3..fe62182978 100644 --- a/pages/api/availabilities/[id].ts +++ b/pages/api/availabilities/[id].ts @@ -1,7 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; -// // import prisma from "@calcom/prisma"; -import { withMiddleware } from "@lib/helpers/withMiddleware"; +// import { withMiddleware } from "@lib/helpers/withMiddleware"; import type { AvailabilityResponse } from "@lib/types"; import { schemaAvailabilityEditBodyParams, diff --git a/pages/api/availabilities/index.ts b/pages/api/availabilities/index.ts index 0e45564270..ab4ae8f73b 100644 --- a/pages/api/availabilities/index.ts +++ b/pages/api/availabilities/index.ts @@ -1,6 +1,5 @@ import type { NextApiRequest, NextApiResponse } from "next"; -// import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import { AvailabilityResponse, AvailabilitiesResponse } from "@lib/types"; import { diff --git a/pages/api/availability/_get.ts b/pages/api/availability/_get.ts new file mode 100644 index 0000000000..21450f005e --- /dev/null +++ b/pages/api/availability/_get.ts @@ -0,0 +1,29 @@ +import type { NextApiRequest } from "next"; +import { z } from "zod"; + +import { getUserAvailability } from "@calcom/core/getUserAvailability"; +import { defaultResponder } from "@calcom/lib/server"; +import { stringOrNumber } from "@calcom/prisma/zod-utils"; + +const availabilitySchema = z + .object({ + userId: stringOrNumber.optional(), + username: z.string().optional(), + dateFrom: z.string(), + dateTo: z.string(), + eventTypeId: stringOrNumber.optional(), + }) + .refine((data) => !!data.username || !!data.userId, "Either username or userId should be filled in."); + +async function handler(req: NextApiRequest) { + const { username, userId, eventTypeId, dateTo, dateFrom } = availabilitySchema.parse(req.query); + return getUserAvailability({ + username, + dateFrom, + dateTo, + eventTypeId, + userId, + }); +} + +export default defaultResponder(handler); diff --git a/pages/api/availability/index.ts b/pages/api/availability/index.ts new file mode 100644 index 0000000000..fe6357f6bc --- /dev/null +++ b/pages/api/availability/index.ts @@ -0,0 +1,9 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; + +export default withMiddleware("HTTP_GET")( + defaultHandler({ + GET: import("./_get"), + }) +); diff --git a/pages/api/bookings/[id].ts b/pages/api/bookings/[id].ts index 68540fb69b..f4f11b143f 100644 --- a/pages/api/bookings/[id].ts +++ b/pages/api/bookings/[id].ts @@ -1,9 +1,7 @@ import type { NextApiRequest, NextApiResponse } from "next"; -// import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import type { BookingResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; import { schemaBookingEditBodyParams, schemaBookingReadPublic } from "@lib/validations/booking"; import { schemaQueryIdParseInt, @@ -11,7 +9,7 @@ import { } from "@lib/validations/shared/queryIdTransformParseInt"; export async function bookingById( - { method, query, body, userId, prisma }: NextApiRequest, + { method, query, body, userId, prisma, isAdmin }: NextApiRequest, res: NextApiResponse ) { const safeQuery = schemaQueryIdParseInt.safeParse(query); @@ -25,7 +23,6 @@ export async function bookingById( }); if (!userWithBookings) throw new Error("User not found"); const userBookingIds = userWithBookings.bookings.map((booking: { id: number }) => booking.id).flat(); - const isAdmin = await isAdminGuard(userId); if (!isAdmin) { if (!userBookingIds.includes(safeQuery.data.id)) res.status(401).json({ message: "Unauthorized" }); diff --git a/pages/api/bookings/index.ts b/pages/api/bookings/index.ts index f1befaeb23..fb1fd347aa 100644 --- a/pages/api/bookings/index.ts +++ b/pages/api/bookings/index.ts @@ -1,16 +1,18 @@ +import { WebhookTriggerEvents } from "@prisma/client"; import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 as uuidv4 } from "uuid"; -// import prisma from "@calcom/prisma"; import { withMiddleware } from "@lib/helpers/withMiddleware"; -import type { BookingResponse, BookingsResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; +import { BookingResponse, BookingsResponse } from "@lib/types"; +import sendPayload from "@lib/utils/sendPayload"; +import getWebhooks from "@lib/utils/webhookSubscriptions"; import { schemaBookingCreateBodyParams, schemaBookingReadPublic } from "@lib/validations/booking"; +import { schemaEventTypeReadPublic } from "@lib/validations/event-type"; async function createOrlistAllBookings( - { method, body, userId, prisma }: NextApiRequest, + { method, body, userId, isAdmin, prisma }: NextApiRequest, res: NextApiResponse ) { - const isAdmin = await isAdminGuard(userId); if (method === "GET") { /** * @swagger @@ -93,18 +95,75 @@ async function createOrlistAllBookings( } if (!isAdmin) { safe.data.userId = userId; - const data = await prisma.booking.create({ data: { ...safe.data } }); - const booking = schemaBookingReadPublic.parse(data); - - if (booking) res.status(201).json({ booking, message: "Booking created successfully" }); - else (error: Error) => res.status(400).json({ error }); - } else { - const data = await prisma.booking.create({ data: { ...safe.data } }); - const booking = schemaBookingReadPublic.parse(data); - - if (booking) res.status(201).json({ booking, message: "Booking created successfully" }); - else (error: Error) => res.status(400).json({ error }); } + const data = await prisma.booking.create({ data: { uid: uuidv4(), ...safe.data } }); + const booking = schemaBookingReadPublic.parse(data); + + if (booking) { + const eventType = await prisma.eventType + .findUnique({ where: { id: booking.eventTypeId as number } }) + .then((data) => schemaEventTypeReadPublic.parse(data)) + .catch((e: Error) => { + console.error(`Event type with ID: ${booking.eventTypeId} not found`, e); + }); + console.log(`eventType: ${eventType}`); + 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); + console.log(`subscribers: ${subscribers}`); + const bookingId = booking?.id; + const promises = subscribers.map((sub) => + sendPayload(triggerEvent, new Date().toISOString(), sub, { + ...evt, + bookingId, + }).catch((e) => { + console.error(`Error executing webhook for event: ${triggerEvent}, URL: ${sub.subscriberUrl}`, e); + }) + ); + await Promise.all(promises); + console.log("All promises resolved! About to send the response"); + res.status(201).json({ booking, message: "Booking created successfully" }); + } else + (error: Error) => { + console.log(error); + res.status(400).json({ + message: "Could not create new booking", + error, + }); + }; } else res.status(405).json({ message: `Method ${method} not allowed` }); } diff --git a/pages/api/event-types/[id].ts b/pages/api/event-types/[id].ts index 117ee7bf43..dd9ac1a4d9 100644 --- a/pages/api/event-types/[id].ts +++ b/pages/api/event-types/[id].ts @@ -2,7 +2,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { withMiddleware } from "@lib/helpers/withMiddleware"; import type { EventTypeResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; import { schemaEventTypeEditBodyParams, schemaEventTypeReadPublic } from "@lib/validations/event-type"; import { schemaQueryIdParseInt, @@ -10,10 +9,9 @@ import { } from "@lib/validations/shared/queryIdTransformParseInt"; export async function eventTypeById( - { method, query, body, userId, prisma }: NextApiRequest, + { method, query, body, userId, isAdmin, prisma }: NextApiRequest, res: NextApiResponse ) { - const isAdmin = await isAdminGuard(userId); const safeQuery = schemaQueryIdParseInt.safeParse(query); if (!safeQuery.success) { res.status(400).json({ message: "Your query was invalid" }); diff --git a/pages/api/event-types/index.ts b/pages/api/event-types/index.ts index b7939bd0c7..b3a9d74138 100644 --- a/pages/api/event-types/index.ts +++ b/pages/api/event-types/index.ts @@ -1,15 +1,13 @@ import type { NextApiRequest, NextApiResponse } from "next"; import { withMiddleware } from "@lib/helpers/withMiddleware"; -import type { EventTypeResponse, EventTypesResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; +import { EventTypeResponse, EventTypesResponse } from "@lib/types"; import { schemaEventTypeCreateBodyParams, schemaEventTypeReadPublic } from "@lib/validations/event-type"; async function createOrlistAllEventTypes( - { method, body, userId, prisma }: NextApiRequest, + { method, body, userId, isAdmin, prisma }: NextApiRequest, res: NextApiResponse ) { - const isAdmin = await isAdminGuard(userId); if (method === "GET") { /** * @swagger diff --git a/pages/api/hooks/[id].ts b/pages/api/hooks/[id].ts index e88761736c..4c0e51ab69 100644 --- a/pages/api/hooks/[id].ts +++ b/pages/api/hooks/[id].ts @@ -93,6 +93,26 @@ export async function WebhookById( return; } } + if (safeBody.data.eventTypeId) { + const team = await prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: safeBody.data.eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === userId)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + } await prisma.webhook .update({ where: { id: safeQuery.data.id }, data: safeBody.data }) .then((data) => schemaWebhookReadPublic.parse(data)) diff --git a/pages/api/hooks/index.ts b/pages/api/hooks/index.ts index 3fb8380f4c..0bdb7470bf 100644 --- a/pages/api/hooks/index.ts +++ b/pages/api/hooks/index.ts @@ -59,6 +59,26 @@ async function createOrlistAllWebhooks( res.status(400).json({ message: "Invalid request body" }); return; } + if (safe.data.eventTypeId) { + const team = await prisma.team.findFirst({ + where: { + eventTypes: { + some: { + id: safe.data.eventTypeId, + }, + }, + }, + include: { + members: true, + }, + }); + + // Team should be available and the user should be a member of the team + if (!team?.members.some((membership) => membership.userId === userId)) { + res.status(401).json({ message: "Unauthorized" }); + return; + } + } const data = await prisma.webhook.create({ data: { id: uuidv4(), ...safe.data, userId } }); if (data) res.status(201).json({ webhook: data, message: "Webhook created successfully" }); else diff --git a/pages/api/users/[id].ts b/pages/api/users/[id].ts deleted file mode 100644 index 276f015f9d..0000000000 --- a/pages/api/users/[id].ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from "next"; - -import { withMiddleware } from "@lib/helpers/withMiddleware"; -import type { UserResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; -import { - schemaQueryIdParseInt, - withValidQueryIdTransformParseInt, -} from "@lib/validations/shared/queryIdTransformParseInt"; -import { schemaUserEditBodyParams, schemaUserReadPublic } from "@lib/validations/user"; - -export async function userById( - { method, query, body, userId, prisma }: NextApiRequest, - res: NextApiResponse -) { - const safeQuery = schemaQueryIdParseInt.safeParse(query); - console.log(body); - if (!safeQuery.success) { - res.status(400).json({ message: "Your query was invalid" }); - return; - } - const isAdmin = await isAdminGuard(userId); - // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user - if (!isAdmin) { - if (safeQuery.data.id !== userId) res.status(401).json({ message: "Unauthorized" }); - } else { - switch (method) { - case "GET": - /** - * @swagger - * /users/{id}: - * get: - * summary: Find a user, returns your user if regular user. - * operationId: getUserById - * parameters: - * - in: path - * name: id - * example: 4 - * schema: - * type: integer - * required: true - * description: ID of the user to get - * tags: - * - users - * responses: - * 200: - * description: OK - * 401: - * description: Authorization information is missing or invalid. - * 404: - * description: User was not found - */ - - await prisma.user - .findUnique({ where: { id: safeQuery.data.id } }) - .then((data) => schemaUserReadPublic.parse(data)) - .then((user) => res.status(200).json({ user })) - .catch((error: Error) => - res.status(404).json({ message: `User with id: ${safeQuery.data.id} not found`, error }) - ); - break; - case "PATCH": - /** - * @swagger - * /users/{id}: - * patch: - * summary: Edit an existing user - * operationId: editUserById - * requestBody: - * description: Edit an existing attendee related to one of your bookings - * required: true - * content: - * application/json: - * schema: - * type: object - * properties: - * weekStart: - * type: string - * enum: [Monday, Sunday, Saturday] - * example: Monday - * brandColor: - * type: string - * example: "#FF000F" - * darkBrandColor: - * type: string - * example: "#000000" - * timeZone: - * type: string - * example: Europe/London - * parameters: - * - in: path - * name: id - * example: 4 - * schema: - * type: integer - * required: true - * description: ID of the user to edit - * tags: - * - users - * responses: - * 201: - * description: OK, user edited successfuly - * 400: - * description: Bad request. User body is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - - const safeBody = schemaUserEditBodyParams.safeParse(body); - if (!safeBody.success) { - res.status(400).json({ message: "Bad request", error: safeBody.error }); - - return; - } - const userSchedules = await prisma.schedule.findMany({ - where: { userId }, - }); - const userSchedulesIds = userSchedules.map((schedule) => schedule.id); - // @note: here we make sure user can only make as default his own scheudles - if ( - safeBody?.data?.defaultScheduleId && - !userSchedulesIds.includes(Number(safeBody?.data?.defaultScheduleId)) - ) { - res.status(400).json({ - message: "Bad request: Invalid default schedule id", - }); - return; - } - await prisma.user - .update({ - where: { id: userId }, - data: safeBody.data, - }) - .then((data) => schemaUserReadPublic.parse(data)) - .then((user) => res.status(200).json({ user })) - .catch((error: Error) => - res.status(404).json({ message: `User with id: ${safeQuery.data.id} not found`, error }) - ); - break; - - /** - * @swagger - * /users/{id}: - * delete: - * summary: Remove an existing user - * operationId: removeUserById - * parameters: - * - in: path - * name: id - * example: 1 - * schema: - * type: integer - * required: true - * description: ID of the user to delete - * tags: - * - users - * responses: - * 201: - * description: OK, user removed successfuly - * 400: - * description: Bad request. User id is invalid. - * 401: - * description: Authorization information is missing or invalid. - */ - - case "DELETE": - await prisma.user - .delete({ where: { id: safeQuery.data.id } }) - .then(() => - res.status(200).json({ message: `User with id: ${safeQuery.data.id} deleted successfully` }) - ) - .catch((error: Error) => - res.status(404).json({ message: `User with id: ${safeQuery.data.id} not found`, error }) - ); - break; - - default: - res.status(405).json({ message: "Method not allowed" }); - break; - } - } -} - -export default withMiddleware("HTTP_GET_DELETE_PATCH")(withValidQueryIdTransformParseInt(userById)); diff --git a/pages/api/users/[id]/availability.ts b/pages/api/users/[id]/availability.ts deleted file mode 100644 index ce57c24ea1..0000000000 --- a/pages/api/users/[id]/availability.ts +++ /dev/null @@ -1,144 +0,0 @@ -// 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/pages/api/users/[userId]/_delete.ts b/pages/api/users/[userId]/_delete.ts new file mode 100644 index 0000000000..c2d8d5db29 --- /dev/null +++ b/pages/api/users/[userId]/_delete.ts @@ -0,0 +1,48 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "@lib/utils/isAdmin"; +import { schemaQueryUserId } from "@lib/validations/shared/queryUserId"; + +/** + * @swagger + * /users/{id}: + * delete: + * summary: Remove an existing user + * operationId: removeUserById + * parameters: + * - in: path + * name: id + * example: 1 + * schema: + * type: integer + * required: true + * description: ID of the user to delete + * tags: + * - users + * responses: + * 201: + * description: OK, user removed successfuly + * 400: + * description: Bad request. User id is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function deleteHandler(req: NextApiRequest) { + const query = schemaQueryUserId.parse(req.query); + const isAdmin = await isAdminGuard(req.userId); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isAdmin && query.userId !== req.userId) + throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + + const user = await prisma.user.findUnique({ where: { id: query.userId } }); + if (!user) throw new HttpError({ statusCode: 404, message: "User not found" }); + + await prisma.user.delete({ where: { id: user.id } }); + return { message: `User with id: ${user.id} deleted successfully` }; +} + +export default defaultResponder(deleteHandler); diff --git a/pages/api/users/[userId]/_get.ts b/pages/api/users/[userId]/_get.ts new file mode 100644 index 0000000000..5c76c4e315 --- /dev/null +++ b/pages/api/users/[userId]/_get.ts @@ -0,0 +1,46 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "@lib/utils/isAdmin"; +import { schemaQueryUserId } from "@lib/validations/shared/queryUserId"; +import { schemaUserReadPublic } from "@lib/validations/user"; + +/** + * @swagger + * /users/{id}: + * get: + * summary: Find a user, returns your user if regular user. + * operationId: getUserById + * parameters: + * - in: path + * name: id + * example: 4 + * schema: + * type: integer + * required: true + * description: ID of the user to get + * tags: + * - users + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: User was not found + */ +export async function getHandler(req: NextApiRequest) { + const query = schemaQueryUserId.parse(req.query); + const isAdmin = await isAdminGuard(req.userId); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isAdmin && query.userId !== req.userId) + throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + const data = await prisma.user.findUnique({ where: { id: query.userId } }); + const user = schemaUserReadPublic.parse(data); + return { user }; +} + +export default defaultResponder(getHandler); diff --git a/pages/api/users/[userId]/_patch.ts b/pages/api/users/[userId]/_patch.ts new file mode 100644 index 0000000000..9f32e920c4 --- /dev/null +++ b/pages/api/users/[userId]/_patch.ts @@ -0,0 +1,83 @@ +import type { NextApiRequest } from "next"; + +import { HttpError } from "@calcom/lib/http-error"; +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "@lib/utils/isAdmin"; +import { schemaQueryUserId } from "@lib/validations/shared/queryUserId"; +import { schemaUserEditBodyParams, schemaUserReadPublic } from "@lib/validations/user"; + +/** + * @swagger + * /users/{id}: + * patch: + * summary: Edit an existing user + * operationId: editUserById + * requestBody: + * description: Edit an existing attendee related to one of your bookings + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * weekStart: + * type: string + * enum: [Monday, Sunday, Saturday] + * example: Monday + * brandColor: + * type: string + * example: "#FF000F" + * darkBrandColor: + * type: string + * example: "#000000" + * timeZone: + * type: string + * example: Europe/London + * parameters: + * - in: path + * name: id + * example: 4 + * schema: + * type: integer + * required: true + * description: ID of the user to edit + * tags: + * - users + * responses: + * 201: + * description: OK, user edited successfuly + * 400: + * description: Bad request. User body is invalid. + * 401: + * description: Authorization information is missing or invalid. + */ +export async function patchHandler(req: NextApiRequest) { + const query = schemaQueryUserId.parse(req.query); + const isAdmin = await isAdminGuard(req.userId); + // Here we only check for ownership of the user if the user is not admin, otherwise we let ADMIN's edit any user + if (!isAdmin && query.userId !== req.userId) + throw new HttpError({ statusCode: 401, message: "Unauthorized" }); + + const body = schemaUserEditBodyParams.parse(req.body); + const userSchedules = await prisma.schedule.findMany({ + where: { userId: req.userId }, + }); + const userSchedulesIds = userSchedules.map((schedule) => schedule.id); + // @note: here we make sure user can only make as default his own scheudles + if (body.defaultScheduleId && !userSchedulesIds.includes(Number(body.defaultScheduleId))) { + throw new HttpError({ + statusCode: 400, + message: "Bad request: Invalid default schedule id", + }); + } + const data = await prisma.user.update({ + where: { id: req.userId }, + data: body, + }); + const user = schemaUserReadPublic.parse(data); + return { user }; +} + +export default defaultResponder(patchHandler); diff --git a/pages/api/users/[userId]/availability/index.ts b/pages/api/users/[userId]/availability/index.ts new file mode 100644 index 0000000000..1a27360f81 --- /dev/null +++ b/pages/api/users/[userId]/availability/index.ts @@ -0,0 +1,9 @@ +import { defaultHandler } from "@calcom/lib/server"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; + +export default withMiddleware("HTTP_GET")( + defaultHandler({ + GET: import("@api/availability/_get"), + }) +); diff --git a/pages/api/users/[userId]/index.ts b/pages/api/users/[userId]/index.ts new file mode 100644 index 0000000000..7d8887e5d1 --- /dev/null +++ b/pages/api/users/[userId]/index.ts @@ -0,0 +1,14 @@ +import { defaultHandler } from "@/../../packages/lib/server"; + +import { withMiddleware } from "@lib/helpers/withMiddleware"; +import { withValidQueryIdTransformParseInt } from "@lib/validations/shared/queryIdTransformParseInt"; + +export default withMiddleware("HTTP_GET_DELETE_PATCH")( + withValidQueryIdTransformParseInt( + defaultHandler({ + GET: import("./_get"), + PATCH: import("./_patch"), + DELETE: import("./_delete"), + }) + ) +); diff --git a/pages/api/users/_get.ts b/pages/api/users/_get.ts new file mode 100644 index 0000000000..4c4aadaecd --- /dev/null +++ b/pages/api/users/_get.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "@lib/utils/isAdmin"; +import { schemaUsersReadPublic } from "@lib/validations/user"; + +import { Prisma } from ".prisma/client"; + +/** + * @swagger + * /users: + * get: + * operationId: listUsers + * summary: Find all users. + * tags: + * - users + * responses: + * 200: + * description: OK + * 401: + * description: Authorization information is missing or invalid. + * 404: + * description: No users were found + */ +async function getHandler({ userId }: NextApiRequest) { + const isAdmin = await isAdminGuard(userId); + const where: Prisma.UserWhereInput = {}; + // If user is not ADMIN, return only his data. + if (!isAdmin) where.id = userId; + const data = await prisma.user.findMany({ where }); + const users = schemaUsersReadPublic.parse(data); + return { users }; +} + +export default defaultResponder(getHandler); diff --git a/pages/api/users/_post.ts b/pages/api/users/_post.ts new file mode 100644 index 0000000000..908a9c4bcc --- /dev/null +++ b/pages/api/users/_post.ts @@ -0,0 +1,20 @@ +import { HttpError } from "@/../../packages/lib/http-error"; +import type { NextApiRequest } from "next"; + +import { defaultResponder } from "@calcom/lib/server"; +import prisma from "@calcom/prisma"; + +import { isAdminGuard } from "@lib/utils/isAdmin"; +import { schemaUserCreateBodyParams } from "@lib/validations/user"; + +async function postHandler(req: NextApiRequest) { + const isAdmin = await isAdminGuard(req.userId); + // If user is not ADMIN, return unauthorized. + if (!isAdmin) throw new HttpError({ statusCode: 401, message: "You are not authorized" }); + const data = schemaUserCreateBodyParams.parse(req.body); + const user = await prisma.user.create({ data }); + req.statusCode = 201; + return { user }; +} + +export default defaultResponder(postHandler); diff --git a/pages/api/users/index.ts b/pages/api/users/index.ts index cbb0eab69a..28231b487a 100644 --- a/pages/api/users/index.ts +++ b/pages/api/users/index.ts @@ -1,10 +1,12 @@ +<<<<<<< HEAD import type { NextApiRequest, NextApiResponse } from "next"; +======= +import { defaultHandler } from "@calcom/lib/server"; +>>>>>>> main import { withMiddleware } from "@lib/helpers/withMiddleware"; -import { UserResponse, UsersResponse } from "@lib/types"; -import { isAdminGuard } from "@lib/utils/isAdmin"; -import { schemaUserReadPublic, schemaUserCreateBodyParams } from "@lib/validations/user"; +<<<<<<< HEAD /** * @swagger * /users: @@ -56,3 +58,11 @@ async function getAllorCreateUser( } export default withMiddleware("HTTP_GET_OR_POST")(getAllorCreateUser); +======= +export default withMiddleware("HTTP_GET_OR_POST")( + defaultHandler({ + GET: import("./_get"), + POST: import("./_post"), + }) +); +>>>>>>> main