Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
df91dacc3f | ||
|
|
396b81af58 | ||
|
|
8467a9c53b | ||
|
|
e99e8b169a | ||
|
|
7b8902a769 | ||
|
|
2408338ed4 | ||
|
|
8f0ff58bcf | ||
|
|
fac47544d1 | ||
|
|
b09f3e5c8d | ||
|
|
8bb051ac4e | ||
|
|
fd3fffd7dd | ||
|
|
312a3f2543 | ||
|
|
f52f2f32fa | ||
|
|
fdd0a5a03d | ||
|
|
ca3e3600f7 | ||
|
|
ba944b471a | ||
|
|
7978f0317a | ||
|
|
d918f34ae8 | ||
|
|
1fed08fa9b | ||
|
|
aabfd3b3f6 | ||
|
|
60e0bbf4b7 | ||
|
|
0a7f65cd9b | ||
|
|
f41f4b01a9 | ||
|
|
a7b38b1b58 | ||
|
|
a0f1ceba19 | ||
|
|
5c6e104ff5 | ||
|
|
29b1f7bf51 | ||
|
|
7ec3d04acc | ||
|
|
13a765e50a |
@@ -69,7 +69,7 @@ jobs:
|
||||
body="${body//'%'/'%25'}"
|
||||
body="${body//$'\n'/'%0A'}"
|
||||
body="${body//$'\r'/'%0D'}"
|
||||
echo "{body}=${body}" >> $GITHUB_OUTPUT
|
||||
echo ::set-output name=body::$body
|
||||
|
||||
- name: Find Comment
|
||||
uses: peter-evans/find-comment@v2
|
||||
|
||||
@@ -448,7 +448,7 @@ Don't code but still want to contribute? Join our [discord](https://go.cal.com/d
|
||||
3. Enable the selected API.
|
||||
4. Next, go to the [OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent) from the side pane. Select the app type (Internal or External) and enter the basic app details on the first page.
|
||||
5. In the second page on Scopes, select Add or Remove Scopes. Search for Calendar.event and select the scope with scope value `.../auth/calendar.events`, `.../auth/calendar.readonly` and select Update.
|
||||
6. In the third page (Test Users), add the Google account(s) you'll using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
||||
6. In the third page (Test Users), add the Google account(s) you'll be using. Make sure the details are correct on the last page of the wizard and your consent screen will be configured.
|
||||
7. Now select [Credentials](https://console.cloud.google.com/apis/credentials) from the side pane and then select Create Credentials. Select the OAuth Client ID option.
|
||||
8. Select Web Application as the Application Type.
|
||||
9. Under Authorized redirect URI's, select Add URI and then add the URI `<Cal.com URL>/api/integrations/googlecalendar/callback` and `<Cal.com URL>/api/auth/callback/google` replacing Cal.com URL with the URI at which your application runs.
|
||||
|
||||
@@ -22,6 +22,8 @@ const fetchBookings = async ({
|
||||
const params = {
|
||||
apiKey,
|
||||
userId: userId.toString(),
|
||||
dateFrom: from,
|
||||
dateTo: to,
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(params);
|
||||
@@ -40,11 +42,9 @@ const fetchBookings = async ({
|
||||
|
||||
const bookings: Booking[] = data.bookings
|
||||
.filter((booking: Booking) => {
|
||||
const afterFrom = new Date(booking.startTime).getTime() > new Date(from).getTime();
|
||||
const beforeTo = new Date(booking.endTime).getTime() < new Date(to).getTime();
|
||||
const notCancelled = booking.status !== BOOKING_STATUS.CANCELLED;
|
||||
|
||||
return afterFrom && beforeTo && notCancelled;
|
||||
return notCancelled;
|
||||
})
|
||||
.map(({ endTime, eventTypeId, id, startTime, status, title }: Booking) => ({
|
||||
endTime,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { User, UserList } from "../types/user";
|
||||
import type { WorkingHours } from "../types/workingHours";
|
||||
import now from "./now";
|
||||
|
||||
const gptModel = "gpt-4";
|
||||
const gptModel = "gpt-4-0613";
|
||||
|
||||
/**
|
||||
* Core of the Cal.ai booking agent: a LangChain Agent Executor.
|
||||
@@ -58,7 +58,14 @@ If you can't find a referenced user, ask the user for their email or @username.
|
||||
|
||||
The primary user's id is: ${userId}
|
||||
The primary user's username is: ${user.username}
|
||||
The current time in the primary user's timezone is: ${now(user.timeZone)}
|
||||
The current time in the primary user's timezone is: ${now(user.timeZone, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
})}
|
||||
The primary user's time zone is: ${user.timeZone}
|
||||
The primary user's event types are: ${user.eventTypes
|
||||
.map((e: EventType) => `ID: ${e.id}, Slug: ${e.slug}, Title: ${e.title}, Length: ${e.length};`)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export default function now(timeZone: string) {
|
||||
export default function now(timeZone: string, options: Intl.DateTimeFormatOptions = {}) {
|
||||
return new Date().toLocaleString("en-US", {
|
||||
timeZone,
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ const schemaBookingBaseBodyParams = Booking.pick({
|
||||
|
||||
export const schemaBookingCreateBodyParams = extendedBookingCreateBody.merge(schemaQueryUserId.partial());
|
||||
|
||||
export const schemaBookingGetParams = z.object({
|
||||
dateFrom: iso8601.optional(),
|
||||
dateTo: iso8601.optional(),
|
||||
});
|
||||
|
||||
const schemaBookingEditParams = z
|
||||
.object({
|
||||
title: z.string().optional(),
|
||||
|
||||
@@ -5,14 +5,23 @@ import { _TeamModel as Team } from "@calcom/prisma/zod";
|
||||
export const schemaTeamBaseBodyParams = Team.omit({ id: true, createdAt: true }).partial({
|
||||
hideBranding: true,
|
||||
metadata: true,
|
||||
pendingPayment: true,
|
||||
});
|
||||
|
||||
const schemaTeamRequiredParams = z.object({});
|
||||
const schemaTeamRequiredParams = z.object({
|
||||
name: z.string().max(255),
|
||||
});
|
||||
|
||||
export const schemaTeamBodyParams = schemaTeamBaseBodyParams.merge(schemaTeamRequiredParams).strict();
|
||||
|
||||
export const schemaTeamUpdateBodyParams = schemaTeamBodyParams.partial();
|
||||
|
||||
const schemaOwnerId = z.object({
|
||||
ownerId: z.number().optional(),
|
||||
});
|
||||
|
||||
export const schemaTeamCreateBodyParams = schemaTeamBodyParams.merge(schemaOwnerId).strict();
|
||||
|
||||
export const schemaTeamReadPublic = Team.omit({});
|
||||
|
||||
export const schemaTeamsReadPublic = z.array(schemaTeamReadPublic);
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { NextApiRequest } from "next";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
|
||||
import { schemaBookingReadPublic } from "~/lib/validations/booking";
|
||||
import { schemaBookingGetParams, schemaBookingReadPublic } from "~/lib/validations/booking";
|
||||
import { schemaQuerySingleOrMultipleAttendeeEmails } from "~/lib/validations/shared/queryAttendeeEmail";
|
||||
import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/queryUserId";
|
||||
|
||||
@@ -119,13 +119,13 @@ import { schemaQuerySingleOrMultipleUserIds } from "~/lib/validations/shared/que
|
||||
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
|
||||
*/
|
||||
function buildWhereClause(
|
||||
userId: number,
|
||||
userId: number | null,
|
||||
attendeeEmails: string[],
|
||||
userIds: number[] = [],
|
||||
userEmails: string[] = []
|
||||
) {
|
||||
const filterByAttendeeEmails = attendeeEmails.length > 0;
|
||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : { userId };
|
||||
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
|
||||
let whereClause = {};
|
||||
if (filterByAttendeeEmails) {
|
||||
whereClause = {
|
||||
@@ -162,6 +162,9 @@ function buildWhereClause(
|
||||
|
||||
async function handler(req: NextApiRequest) {
|
||||
const { userId, isAdmin, prisma } = req;
|
||||
|
||||
const { dateFrom, dateTo } = schemaBookingGetParams.parse(req.query);
|
||||
|
||||
const args: Prisma.BookingFindManyArgs = {};
|
||||
args.include = {
|
||||
attendees: true,
|
||||
@@ -189,7 +192,7 @@ async function handler(req: NextApiRequest) {
|
||||
const userEmails = users.map((u) => u.email);
|
||||
args.where = buildWhereClause(userId, attendeeEmails, userIds, userEmails);
|
||||
} else if (filterByAttendeeEmails) {
|
||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
||||
args.where = buildWhereClause(null, attendeeEmails, [], []);
|
||||
}
|
||||
} else {
|
||||
const user = await prisma.user.findUnique({
|
||||
@@ -203,6 +206,20 @@ async function handler(req: NextApiRequest) {
|
||||
}
|
||||
args.where = buildWhereClause(userId, attendeeEmails, [], []);
|
||||
}
|
||||
|
||||
if (dateFrom) {
|
||||
args.where = {
|
||||
...args.where,
|
||||
startTime: { gte: dateFrom },
|
||||
};
|
||||
}
|
||||
if (dateTo) {
|
||||
args.where = {
|
||||
...args.where,
|
||||
endTime: { lte: dateTo },
|
||||
};
|
||||
}
|
||||
|
||||
const data = await prisma.booking.findMany(args);
|
||||
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
import { getStripeCustomerIdFromUserId } from "@calcom/app-store/stripepayment/lib/customer";
|
||||
import stripe from "@calcom/app-store/stripepayment/lib/server";
|
||||
import { IS_TEAM_BILLING_ENABLED, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { schemaMembershipPublic } from "~/lib/validations/membership";
|
||||
import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/team";
|
||||
import { schemaTeamCreateBodyParams, schemaTeamReadPublic } from "~/lib/validations/team";
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
@@ -31,6 +33,12 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/te
|
||||
* required:
|
||||
* - name
|
||||
* - slug
|
||||
* - hideBookATeamMember
|
||||
* - brandColor
|
||||
* - darkBrandColor
|
||||
* - timeZone
|
||||
* - weekStart
|
||||
* - isPrivate
|
||||
* properties:
|
||||
* name:
|
||||
* type: string
|
||||
@@ -38,6 +46,27 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/te
|
||||
* slug:
|
||||
* type: string
|
||||
* description: A unique slug that works as path for the team public page
|
||||
* hideBookATeamMember:
|
||||
* type: boolean
|
||||
* description: Flag to hide or show the book a team member option
|
||||
* brandColor:
|
||||
* type: string
|
||||
* description: Primary brand color for the team
|
||||
* darkBrandColor:
|
||||
* type: string
|
||||
* description: Dark variant of the primary brand color for the team
|
||||
* timeZone:
|
||||
* type: string
|
||||
* description: Time zone of the team
|
||||
* weekStart:
|
||||
* type: string
|
||||
* description: Starting day of the week for the team
|
||||
* isPrivate:
|
||||
* type: boolean
|
||||
* description: Flag indicating if the team is private
|
||||
* ownerId:
|
||||
* type: number
|
||||
* description: ID of the team owner - only admins can set this.
|
||||
* tags:
|
||||
* - teams
|
||||
* responses:
|
||||
@@ -49,23 +78,23 @@ import { schemaTeamBodyParams, schemaTeamReadPublic } from "~/lib/validations/te
|
||||
* description: Authorization information is missing or invalid.
|
||||
*/
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const { prisma, body, userId } = req;
|
||||
const data = schemaTeamBodyParams.parse(body);
|
||||
const { prisma, body, userId, isAdmin } = req;
|
||||
const { ownerId, ...data } = schemaTeamCreateBodyParams.parse(body);
|
||||
|
||||
await checkPermissions(req);
|
||||
|
||||
const effectiveUserId = isAdmin && ownerId ? ownerId : userId;
|
||||
|
||||
if (data.slug) {
|
||||
const alreadyExist = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: data.slug,
|
||||
slug: {
|
||||
mode: "insensitive",
|
||||
equals: data.slug,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (alreadyExist) throw new HttpError({ statusCode: 409, message: "Team slug already exists" });
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
// Setting slug in metadata, so it can be published later
|
||||
data.metadata = {
|
||||
requestedSlug: data.slug,
|
||||
};
|
||||
delete data.slug;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if parentId is related to this user
|
||||
@@ -87,24 +116,102 @@ async function postHandler(req: NextApiRequest) {
|
||||
...data,
|
||||
metadata: data.metadata === null ? {} : data.metadata || undefined,
|
||||
};
|
||||
const team = await prisma.team.create({
|
||||
|
||||
if (!IS_TEAM_BILLING_ENABLED) {
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
...cloneData,
|
||||
members: {
|
||||
create: { userId: effectiveUserId, role: MembershipRole.OWNER, accepted: true },
|
||||
},
|
||||
},
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
req.statusCode = 201;
|
||||
|
||||
return {
|
||||
team: schemaTeamReadPublic.parse(team),
|
||||
owner: schemaMembershipPublic.parse(team.members[0]),
|
||||
message: `Team created successfully. We also made user with ID=${ownerId} the owner of this team.`,
|
||||
};
|
||||
}
|
||||
|
||||
const pendingPaymentTeam = await prisma.team.create({
|
||||
data: {
|
||||
...cloneData,
|
||||
createdAt: new Date(),
|
||||
members: {
|
||||
// We're also creating the relation membership of team ownership in this call.
|
||||
create: { userId, role: MembershipRole.OWNER, accepted: true },
|
||||
},
|
||||
pendingPayment: true,
|
||||
},
|
||||
include: { members: true },
|
||||
});
|
||||
req.statusCode = 201;
|
||||
// We are also returning the new ownership relation as owner besides team.
|
||||
|
||||
const checkoutSession = await generateTeamCheckoutSession({
|
||||
pendingPaymentTeamId: pendingPaymentTeam.id,
|
||||
ownerId: effectiveUserId,
|
||||
});
|
||||
|
||||
return {
|
||||
team: schemaTeamReadPublic.parse(team),
|
||||
owner: schemaMembershipPublic.parse(team.members[0]),
|
||||
message: "Team created successfully, we also made you the owner of this team",
|
||||
message:
|
||||
"Your team will be created once we receive your payment. Please complete the payment using the payment link.",
|
||||
paymentLink: checkoutSession.url,
|
||||
pendingTeam: {
|
||||
...schemaTeamReadPublic.parse(pendingPaymentTeam),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function checkPermissions(req: NextApiRequest) {
|
||||
const { isAdmin } = req;
|
||||
const body = schemaTeamCreateBodyParams.parse(req.body);
|
||||
|
||||
/* Non-admin users can only create teams for themselves */
|
||||
if (!isAdmin && body.ownerId)
|
||||
throw new HttpError({
|
||||
statusCode: 401,
|
||||
message: "ADMIN required for `ownerId`",
|
||||
});
|
||||
}
|
||||
|
||||
const generateTeamCheckoutSession = async ({
|
||||
pendingPaymentTeamId,
|
||||
ownerId,
|
||||
}: {
|
||||
pendingPaymentTeamId: number;
|
||||
ownerId: number;
|
||||
}) => {
|
||||
const customer = await getStripeCustomerIdFromUserId(ownerId);
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer,
|
||||
mode: "subscription",
|
||||
allow_promotion_codes: true,
|
||||
success_url: `${WEBAPP_URL}/api/teams/api/create?session_id={CHECKOUT_SESSION_ID}`,
|
||||
cancel_url: `${WEBAPP_URL}/settings/my-account/profile`,
|
||||
line_items: [
|
||||
{
|
||||
/** We only need to set the base price and we can upsell it directly on Stripe's checkout */
|
||||
price: process.env.STRIPE_TEAM_MONTHLY_PRICE_ID,
|
||||
/**Initially it will be just the team owner */
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
customer_update: {
|
||||
address: "auto",
|
||||
},
|
||||
automatic_tax: {
|
||||
enabled: true,
|
||||
},
|
||||
metadata: {
|
||||
pendingPaymentTeamId,
|
||||
ownerId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url)
|
||||
throw new HttpError({
|
||||
statusCode: 500,
|
||||
message: "Failed generating a checkout session URL.",
|
||||
});
|
||||
|
||||
return session;
|
||||
};
|
||||
|
||||
export default defaultResponder(postHandler);
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
import LegacyPage from "@pages/connect-and-join";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
export default WithLayout({ getLayout: null, Page: LegacyPage })<"P">;
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type PageProps } from "@pages/org/[orgSlug]/[user]/[type]";
|
||||
import Page from "@pages/org/[orgSlug]/[user]/[type]/embed";
|
||||
import { withAppDir } from "app/AppDirSSRHOC";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
const getData = withAppDir<PageProps>(getServerSideProps);
|
||||
|
||||
const getEmbedData = withEmbedSsrAppDir(getData);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page });
|
||||
@@ -0,0 +1,9 @@
|
||||
import Page, { type PageProps } from "@pages/org/[orgSlug]/[user]/[type]";
|
||||
import { withAppDir } from "app/AppDirSSRHOC";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
import { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
const getData = withAppDir<PageProps>(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData, isBookingPage: true, Page });
|
||||
@@ -0,0 +1,10 @@
|
||||
import { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]";
|
||||
import Page from "@pages/org/[orgSlug]/[user]/embed";
|
||||
import { withAppDir } from "app/AppDirSSRHOC";
|
||||
import withEmbedSsrAppDir from "app/WithEmbedSSR";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
const getData = withAppDir<PageProps>(getServerSideProps);
|
||||
const getEmbedData = withEmbedSsrAppDir(getData);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData: getEmbedData, isBookingPage: true, Page });
|
||||
@@ -0,0 +1,7 @@
|
||||
import Page, { getServerSideProps, type PageProps } from "@pages/org/[orgSlug]/[user]";
|
||||
import { withAppDir } from "app/AppDirSSRHOC";
|
||||
import { WithLayout } from "app/layoutHOC";
|
||||
|
||||
const getData = withAppDir<PageProps>(getServerSideProps);
|
||||
|
||||
export default WithLayout({ getLayout: null, getData, isBookingPage: true, Page });
|
||||
@@ -10,9 +10,15 @@ type WithLayoutParams<T extends Record<string, any>> = {
|
||||
getLayout: ((page: React.ReactElement) => React.ReactNode) | null;
|
||||
Page?: (props: T) => React.ReactElement | null;
|
||||
getData?: (arg: GetServerSidePropsContext) => Promise<T>;
|
||||
isBookingPage?: boolean;
|
||||
};
|
||||
|
||||
export function WithLayout<T extends Record<string, any>>({ getLayout, getData, Page }: WithLayoutParams<T>) {
|
||||
export function WithLayout<T extends Record<string, any>>({
|
||||
getLayout,
|
||||
getData,
|
||||
Page,
|
||||
isBookingPage,
|
||||
}: WithLayoutParams<T>) {
|
||||
return async <P extends "P" | "L">(p: P extends "P" ? PageProps : LayoutProps) => {
|
||||
const h = headers();
|
||||
const nonce = h.get("x-nonce") ?? undefined;
|
||||
@@ -23,7 +29,13 @@ export function WithLayout<T extends Record<string, any>>({ getLayout, getData,
|
||||
const children = "children" in p ? p.children : null;
|
||||
|
||||
return (
|
||||
<PageWrapper getLayout={getLayout} requiresLicense={false} nonce={nonce} themeBasis={null} {...props}>
|
||||
<PageWrapper
|
||||
getLayout={getLayout}
|
||||
requiresLicense={false}
|
||||
nonce={nonce}
|
||||
themeBasis={null}
|
||||
isBookingPage={isBookingPage}
|
||||
{...props}>
|
||||
{Page ? <Page {...props} /> : children}
|
||||
</PageWrapper>
|
||||
);
|
||||
|
||||
@@ -147,6 +147,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
? [
|
||||
{
|
||||
id: "confirm",
|
||||
bookingId: booking.id,
|
||||
label: (isTabRecurring || isTabUnconfirmed) && isRecurring ? t("confirm_all") : t("confirm"),
|
||||
onClick: () => {
|
||||
bookingConfirm(true);
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { type GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
if (!org) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernames,
|
||||
},
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: usernames.join("+"),
|
||||
eventSlug: slug,
|
||||
org,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: {
|
||||
...eventData.metadata,
|
||||
multipleDuration: [15, 30, 60],
|
||||
},
|
||||
},
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
away: false,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: false,
|
||||
isSEOIndexable: true,
|
||||
themeBasis: null,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
let outOfOffice = false;
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: userOrgQuery(context.req, context.params?.orgSlug),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
// If user is found, quickly verify bookingRedirects
|
||||
const result = await handleTypeRedirection({
|
||||
userId: user.id,
|
||||
username,
|
||||
slug,
|
||||
});
|
||||
if (result && result.outOfOffice) {
|
||||
outOfOffice = true;
|
||||
}
|
||||
if (result && result.redirect?.destination) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we can show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username,
|
||||
eventSlug: slug,
|
||||
org,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
},
|
||||
away: outOfOffice,
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
isSEOIndexable: user?.allowSEOIndexing,
|
||||
themeBasis: username,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
user: z.string().transform((s) => getUsernameList(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side, to determine early
|
||||
// whether the page should show an away state or dynamic booking not allowed.
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { user } = paramsSchema.parse(context.params);
|
||||
const isDynamicGroup = user.length > 1;
|
||||
|
||||
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
|
||||
};
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { getServerSideProps as GSSUserTypePage } from "@lib/[user]/[type]/getServerSideProps";
|
||||
import { getServerSideProps as GSSTeamTypePage } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
orgSlug: z.string().transform((s) => slugify(s)),
|
||||
user: z.string(),
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const { user: teamOrUserSlugOrDynamicGroup, orgSlug, type } = paramsSchema.parse(ctx.params);
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: slugify(teamOrUserSlugOrDynamicGroup),
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: getSlugOrRequestedSlug(orgSlug),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (team) {
|
||||
const params = { slug: teamOrUserSlugOrDynamicGroup, type };
|
||||
return GSSTeamTypePage({
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
...params,
|
||||
},
|
||||
query: {
|
||||
...ctx.query,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
||||
const params = { user: teamOrUserSlugOrDynamicGroup, type };
|
||||
return GSSUserTypePage({
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
...params,
|
||||
},
|
||||
query: {
|
||||
...ctx.query,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
import { getServerSideProps as GSSUserPage } from "@pages/[user]";
|
||||
import { getServerSideProps as GSSTeamPage } from "@pages/team/[slug]";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: ctx.query.user as string,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (team) {
|
||||
return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
|
||||
}
|
||||
return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } });
|
||||
};
|
||||
@@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { MembershipRole, Prisma } from "@calcom/prisma/client";
|
||||
import { RedirectType } from "@calcom/prisma/enums";
|
||||
import type { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
@@ -1096,6 +1098,142 @@ describe("orgMigration", () => {
|
||||
orgSlug: data.targetOrg.slug,
|
||||
});
|
||||
});
|
||||
|
||||
it(`should remove a team from an organization with it's members`, async () => {
|
||||
const data = {
|
||||
teamToUnmigrate: {
|
||||
name: "Team 1",
|
||||
slug: "team1",
|
||||
members: [
|
||||
{
|
||||
username: "user1-example",
|
||||
email: "user1@example.com",
|
||||
usernameBeforeMovingToOrg: "user1",
|
||||
},
|
||||
],
|
||||
},
|
||||
targetOrg: {
|
||||
name: "Org 1",
|
||||
slug: "org1",
|
||||
},
|
||||
};
|
||||
|
||||
const targetOrg = await prismock.team.create({
|
||||
data: {
|
||||
slug: data.targetOrg.slug,
|
||||
name: data.targetOrg.name,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { id: teamToUnMigrateId } = await prismock.team.create({
|
||||
data: {
|
||||
slug: data.teamToUnmigrate.slug,
|
||||
name: data.teamToUnmigrate.name,
|
||||
parent: {
|
||||
connect: {
|
||||
id: targetOrg.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const teamToUnmigrate = await prismock.team.findUnique({
|
||||
where: {
|
||||
id: teamToUnMigrateId,
|
||||
},
|
||||
include: {
|
||||
parent: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!teamToUnmigrate?.parent || !teamToUnmigrate.parentId) {
|
||||
throw new Error(`Couldn't setup team to unmigrate properly ID:${teamToUnMigrateId}`);
|
||||
}
|
||||
|
||||
const member1OfTeam = await createUserInsideTheOrg(
|
||||
{
|
||||
email: data.teamToUnmigrate.members[0].email,
|
||||
username: data.teamToUnmigrate.members[0].username,
|
||||
metadata: {
|
||||
migratedToOrgFrom: {
|
||||
username: data.teamToUnmigrate.members[0].usernameBeforeMovingToOrg,
|
||||
},
|
||||
},
|
||||
},
|
||||
targetOrg.id
|
||||
);
|
||||
|
||||
addMemberShipOfUserWithOrg({
|
||||
userId: member1OfTeam.id,
|
||||
teamId: targetOrg.id,
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
addMemberShipOfUserWithTeam({
|
||||
teamId: teamToUnmigrate.id,
|
||||
userId: member1OfTeam.id,
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
});
|
||||
|
||||
expectUserToBeAPartOfOrg({
|
||||
userId: member1OfTeam.id,
|
||||
orgId: targetOrg.id,
|
||||
usernameInOrg: data.teamToUnmigrate.members[0].username,
|
||||
expectedMembership: {
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
expectUserToBeAPartOfTeam({
|
||||
userId: member1OfTeam.id,
|
||||
teamId: teamToUnmigrate.id,
|
||||
expectedMembership: {
|
||||
role: "MEMBER",
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
await removeTeamFromOrg({
|
||||
teamId: teamToUnMigrateId,
|
||||
targetOrgId: targetOrg.id,
|
||||
});
|
||||
|
||||
await expectTeamToBeNotPartOfAnyOrganization({
|
||||
teamId: teamToUnMigrateId,
|
||||
});
|
||||
|
||||
expectTeamRedirectToBeNotEnabled({
|
||||
from: {
|
||||
teamSlug: data.teamToUnmigrate.slug,
|
||||
},
|
||||
to: data.teamToUnmigrate.slug,
|
||||
orgSlug: data.targetOrg.slug,
|
||||
});
|
||||
|
||||
expectUserRedirectToBeNotEnabled({
|
||||
from: {
|
||||
username: data.teamToUnmigrate.members[0].usernameBeforeMovingToOrg,
|
||||
},
|
||||
});
|
||||
|
||||
expectUserToBeNotAPartOfTheOrg({
|
||||
userId: member1OfTeam.id,
|
||||
orgId: targetOrg.id,
|
||||
username: data.teamToUnmigrate.members[0].usernameBeforeMovingToOrg,
|
||||
});
|
||||
|
||||
expectUserToBeNotAPartOfTheTeam({
|
||||
userId: member1OfTeam.id,
|
||||
teamId: targetOrg.id,
|
||||
username: data.teamToUnmigrate.members[0].usernameBeforeMovingToOrg,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1184,6 +1322,22 @@ async function expectUserToBeNotAPartOfTheOrg({
|
||||
userId: number;
|
||||
orgId: number;
|
||||
username: string;
|
||||
}) {
|
||||
expectUserToBeNotAPartOfTheTeam({
|
||||
userId,
|
||||
teamId: orgId,
|
||||
username,
|
||||
});
|
||||
}
|
||||
|
||||
async function expectUserToBeNotAPartOfTheTeam({
|
||||
userId,
|
||||
teamId,
|
||||
username,
|
||||
}: {
|
||||
userId: number;
|
||||
teamId: number;
|
||||
username: string;
|
||||
}) {
|
||||
const user = await prismock.user.findUnique({
|
||||
where: {
|
||||
@@ -1201,7 +1355,7 @@ async function expectUserToBeNotAPartOfTheOrg({
|
||||
expect(user.organizationId).toBe(null);
|
||||
|
||||
const membership = user.teams.find(
|
||||
(membership) => membership.teamId === orgId && membership.userId === userId
|
||||
(membership) => membership.teamId === teamId && membership.userId === userId
|
||||
);
|
||||
|
||||
expect(membership).toBeUndefined();
|
||||
@@ -1456,6 +1610,13 @@ async function createUserInsideTheOrg(
|
||||
if (!org) {
|
||||
throw new Error(`Org with id ${orgId} does not exist`);
|
||||
}
|
||||
logger.debug(
|
||||
`Creating user inside org`,
|
||||
safeStringify({
|
||||
orgId,
|
||||
data,
|
||||
})
|
||||
);
|
||||
return await prismock.user.create({
|
||||
data: {
|
||||
...data,
|
||||
|
||||
@@ -232,9 +232,13 @@ export async function moveTeamToOrg({
|
||||
*/
|
||||
export async function removeTeamFromOrg({ targetOrgId, teamId }: { targetOrgId: number; teamId: number }) {
|
||||
const removedTeam = await dbRemoveTeamFromOrg({ teamId });
|
||||
|
||||
await removeTeamRedirect(removedTeam.slug);
|
||||
|
||||
for (const membership of removedTeam.members) {
|
||||
await removeUserFromOrg({
|
||||
userId: membership.userId,
|
||||
targetOrgId,
|
||||
});
|
||||
}
|
||||
log.debug(`Successfully removed team ${teamId} from org ${targetOrgId}`);
|
||||
}
|
||||
|
||||
@@ -745,8 +749,8 @@ async function dbRemoveTeamFromOrg({ teamId }: { teamId: number }) {
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
include: {
|
||||
members: true,
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "@lib/getTemporaryOrgRedirect";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side:
|
||||
// 1. Check if team exists, to show 404
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const session = await getServerSession(context);
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration, isInstantMeeting: queryIsInstantMeeting } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
teamId: team.id,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
|
||||
themeBasis: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.7.0",
|
||||
"version": "3.7.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -1,28 +1,19 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { handleTypeRedirection } from "@calcom/features/booking-redirect/handle-type";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getBookingForSeatedEvent } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getServerSideProps } from "@lib/[user]/[type]/getServerSideProps";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
export type PageProps = Omit<inferSSRProps<typeof getServerSideProps>, "trpcState"> & EmbedProps;
|
||||
export { getServerSideProps };
|
||||
|
||||
export const getMultipleDurationValue = (
|
||||
multipleDurationConfig: number[] | undefined,
|
||||
@@ -81,202 +72,3 @@ export default function Type({
|
||||
|
||||
Type.isBookingPage = true;
|
||||
Type.PageWrapper = PageWrapper;
|
||||
|
||||
async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
if (!org) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernames,
|
||||
},
|
||||
organization: isValidOrgDomain
|
||||
? {
|
||||
slug: currentOrgDomain,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
select: {
|
||||
allowDynamicBooking: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: usernames.join("+"),
|
||||
eventSlug: slug,
|
||||
org,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: {
|
||||
...eventData.metadata,
|
||||
multipleDuration: [15, 30, 60],
|
||||
},
|
||||
},
|
||||
booking,
|
||||
user: usernames.join("+"),
|
||||
slug,
|
||||
away: false,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: false,
|
||||
isSEOIndexable: true,
|
||||
themeBasis: null,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
let outOfOffice = false;
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: usernames,
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
username,
|
||||
organization: userOrgQuery(context.req, context.params?.orgSlug),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
// If user is found, quickly verify bookingRedirects
|
||||
const result = await handleTypeRedirection({
|
||||
userId: user.id,
|
||||
username,
|
||||
slug,
|
||||
});
|
||||
if (result && result.outOfOffice) {
|
||||
outOfOffice = true;
|
||||
}
|
||||
if (result && result.redirect?.destination) {
|
||||
return result;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
} else if (bookingUid) {
|
||||
booking = await getBookingForSeatedEvent(`${bookingUid}`);
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we can show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username,
|
||||
eventSlug: slug,
|
||||
org,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
booking,
|
||||
eventData: {
|
||||
entity: eventData.entity,
|
||||
length: eventData.length,
|
||||
metadata: eventData.metadata,
|
||||
},
|
||||
away: outOfOffice,
|
||||
user: username,
|
||||
slug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: user?.hideBranding,
|
||||
isSEOIndexable: user?.allowSEOIndexing,
|
||||
themeBasis: username,
|
||||
bookingUid: bookingUid ? `${bookingUid}` : null,
|
||||
rescheduleUid: rescheduleUid ? `${rescheduleUid}` : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
user: z.string().transform((s) => getUsernameList(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side, to determine early
|
||||
// whether the page should show an away state or dynamic booking not allowed.
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { user } = paramsSchema.parse(context.params);
|
||||
const isDynamicGroup = user.length > 1;
|
||||
|
||||
return isDynamicGroup ? await getDynamicGroupPageProps(context) : await getUserPageProps(context);
|
||||
};
|
||||
|
||||
@@ -20,13 +20,16 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
||||
const data: RecurringBookingCreateBody[] = req.body;
|
||||
const session = await getServerSession({ req, res });
|
||||
const createdBookings: BookingResponse[] = [];
|
||||
const allRecurringDates: string[] = data.map((booking) => booking.start);
|
||||
const allRecurringDates: { start: string | undefined; end: string | undefined }[] = data.map((booking) => {
|
||||
return { start: booking.start, end: booking.end };
|
||||
});
|
||||
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||
|
||||
/* To mimic API behavior and comply with types */
|
||||
req.userId = session?.user?.id || -1;
|
||||
const numSlotsToCheckForAvailability = 2;
|
||||
|
||||
let thirdPartyRecurringEventId = null;
|
||||
for (let key = 0; key < data.length; key++) {
|
||||
const booking = data[key];
|
||||
// Disable AppStatus in Recurring Booking Email as it requires us to iterate backwards to be able to compute the AppsStatus for all the bookings except the very first slot and then send that slot's email with statuses
|
||||
@@ -53,16 +56,34 @@ async function handler(req: NextApiRequest & { userId?: number }, res: NextApiRe
|
||||
...booking,
|
||||
appsStatus,
|
||||
allRecurringDates,
|
||||
isFirstRecurringSlot: key == 0,
|
||||
thirdPartyRecurringEventId,
|
||||
numSlotsToCheckForAvailability,
|
||||
currentRecurringIndex: key,
|
||||
noEmail: key !== 0,
|
||||
};
|
||||
|
||||
const eachRecurringBooking = await handleNewBooking(recurringEventReq, {
|
||||
isNotAnApiCall: true,
|
||||
skipAvailabilityCheck: key >= numSlotsToCheckForAvailability,
|
||||
});
|
||||
const promiseEachRecurringBooking: ReturnType<typeof handleNewBooking> = handleNewBooking(
|
||||
recurringEventReq,
|
||||
{
|
||||
isNotAnApiCall: true,
|
||||
}
|
||||
);
|
||||
|
||||
const eachRecurringBooking = await promiseEachRecurringBooking;
|
||||
|
||||
createdBookings.push(eachRecurringBooking);
|
||||
|
||||
if (!thirdPartyRecurringEventId) {
|
||||
if (eachRecurringBooking.references && eachRecurringBooking.references.length > 0) {
|
||||
for (const reference of eachRecurringBooking.references!) {
|
||||
if (reference.thirdPartyRecurringEventId) {
|
||||
thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return createdBookings;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import type Stripe from "stripe";
|
||||
import { z } from "zod";
|
||||
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler, defaultResponder } from "@calcom/lib/server";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { _MembershipModel as Membership, _TeamModel as Team } from "@calcom/prisma/zod";
|
||||
|
||||
const querySchema = z.object({
|
||||
session_id: z.string().min(1),
|
||||
});
|
||||
|
||||
const checkoutSessionMetadataSchema = z.object({
|
||||
pendingPaymentTeamId: z.string().transform(Number),
|
||||
ownerId: z.string().transform(Number),
|
||||
});
|
||||
|
||||
type CheckoutSessionMetadata = z.infer<typeof checkoutSessionMetadataSchema>;
|
||||
|
||||
export const schemaTeamReadPublic = Team.omit({});
|
||||
export const schemaMembershipPublic = Membership.merge(z.object({ team: Team }).partial());
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const checkoutSession = await getCheckoutSession(req);
|
||||
validateCheckoutSession(checkoutSession);
|
||||
const checkoutSessionSubscription = getCheckoutSessionSubscription(checkoutSession);
|
||||
const checkoutSessionMetadata = getCheckoutSessionMetadata(checkoutSession);
|
||||
|
||||
const finalizedTeam = await prisma.team.update({
|
||||
where: { id: checkoutSessionMetadata.pendingPaymentTeamId },
|
||||
data: {
|
||||
pendingPayment: false,
|
||||
members: {
|
||||
create: {
|
||||
userId: checkoutSessionMetadata.ownerId as number,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
paymentId: checkoutSession.id,
|
||||
subscriptionId: checkoutSessionSubscription.id || null,
|
||||
subscriptionItemId: checkoutSessionSubscription.items.data[0].id || null,
|
||||
},
|
||||
},
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
const response = JSON.stringify(
|
||||
{
|
||||
message: `Team created successfully. We also made user with ID=${checkoutSessionMetadata.ownerId} the owner of this team.`,
|
||||
team: schemaTeamReadPublic.parse(finalizedTeam),
|
||||
owner: schemaMembershipPublic.parse(finalizedTeam.members[0]),
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
return res.status(200).send(response);
|
||||
}
|
||||
|
||||
async function getCheckoutSession(req: NextApiRequest) {
|
||||
const { session_id } = querySchema.parse(req.query);
|
||||
|
||||
const checkoutSession = await stripe.checkout.sessions.retrieve(session_id, {
|
||||
expand: ["subscription"],
|
||||
});
|
||||
if (!checkoutSession) throw new HttpError({ statusCode: 404, message: "Checkout session not found" });
|
||||
|
||||
return checkoutSession;
|
||||
}
|
||||
|
||||
function validateCheckoutSession(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
|
||||
if (checkoutSession.payment_status !== "paid")
|
||||
throw new HttpError({ statusCode: 402, message: "Payment required" });
|
||||
}
|
||||
|
||||
function getCheckoutSessionSubscription(checkoutSession: Stripe.Response<Stripe.Checkout.Session>) {
|
||||
if (!checkoutSession.subscription) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Can't publish team/org without subscription",
|
||||
});
|
||||
}
|
||||
|
||||
return checkoutSession.subscription as Stripe.Subscription;
|
||||
}
|
||||
|
||||
function getCheckoutSessionMetadata(
|
||||
checkoutSession: Stripe.Response<Stripe.Checkout.Session>
|
||||
): CheckoutSessionMetadata {
|
||||
const parseCheckoutSessionMetadata = checkoutSessionMetadataSchema.safeParse(checkoutSession.metadata);
|
||||
|
||||
if (!parseCheckoutSessionMetadata.success) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Incorrect metadata in checkout session. Error: ${parseCheckoutSessionMetadata.error}`,
|
||||
});
|
||||
}
|
||||
|
||||
const checkoutSessionMetadata = parseCheckoutSessionMetadata.data;
|
||||
|
||||
return checkoutSessionMetadata;
|
||||
}
|
||||
|
||||
export default defaultHandler({
|
||||
GET: Promise.resolve({ default: defaultResponder(handler) }),
|
||||
});
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { useSession } from "next-auth/react";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
|
||||
@@ -1,69 +1,17 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import z from "zod";
|
||||
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
"use client";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import type { PageProps as UserTypePageProps } from "../../../[user]/[type]";
|
||||
import UserTypePage, { getServerSideProps as GSSUserTypePage } from "../../../[user]/[type]";
|
||||
import UserTypePage from "../../../[user]/[type]";
|
||||
import type { PageProps as TeamTypePageProps } from "../../../team/[slug]/[type]";
|
||||
import TeamTypePage, { getServerSideProps as GSSTeamTypePage } from "../../../team/[slug]/[type]";
|
||||
import TeamTypePage from "../../../team/[slug]/[type]";
|
||||
|
||||
const paramsSchema = z.object({
|
||||
orgSlug: z.string().transform((s) => slugify(s)),
|
||||
user: z.string(),
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
export { getServerSideProps } from "@lib/org/[orgSlug]/[user]/[type]/getServerSideProps";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const { user: teamOrUserSlugOrDynamicGroup, orgSlug, type } = paramsSchema.parse(ctx.params);
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: slugify(teamOrUserSlugOrDynamicGroup),
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: getSlugOrRequestedSlug(orgSlug),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
export type PageProps = UserTypePageProps | TeamTypePageProps;
|
||||
|
||||
if (team) {
|
||||
const params = { slug: teamOrUserSlugOrDynamicGroup, type };
|
||||
return GSSTeamTypePage({
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
...params,
|
||||
},
|
||||
query: {
|
||||
...ctx.query,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
}
|
||||
const params = { user: teamOrUserSlugOrDynamicGroup, type };
|
||||
return GSSUserTypePage({
|
||||
...ctx,
|
||||
params: {
|
||||
...ctx.params,
|
||||
...params,
|
||||
},
|
||||
query: {
|
||||
...ctx.query,
|
||||
...params,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type Props = UserTypePageProps | TeamTypePageProps;
|
||||
|
||||
export default function Page(props: Props) {
|
||||
export default function Page(props: PageProps) {
|
||||
if ((props as TeamTypePageProps)?.teamId) return <TeamTypePage {...(props as TeamTypePageProps)} />;
|
||||
return <UserTypePage {...(props as UserTypePageProps)} />;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[user]";
|
||||
|
||||
@@ -1,37 +1,17 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import prisma from "@calcom/prisma";
|
||||
"use client";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import type { UserPageProps } from "../../../[user]";
|
||||
import UserPage, { getServerSideProps as GSSUserPage } from "../../../[user]";
|
||||
import UserPage from "../../../[user]";
|
||||
import type { PageProps as TeamPageProps } from "../../../team/[slug]";
|
||||
import TeamPage, { getServerSideProps as GSSTeamPage } from "../../../team/[slug]";
|
||||
import TeamPage from "../../../team/[slug]";
|
||||
|
||||
export const getServerSideProps = async (ctx: GetServerSidePropsContext) => {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
slug: ctx.query.user as string,
|
||||
parentId: {
|
||||
not: null,
|
||||
},
|
||||
parent: getSlugOrRequestedSlug(ctx.query.orgSlug as string),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
if (team) {
|
||||
return GSSTeamPage({ ...ctx, query: { slug: ctx.query.user } });
|
||||
}
|
||||
return GSSUserPage({ ...ctx, query: { user: ctx.query.user, redirect: ctx.query.redirect } });
|
||||
};
|
||||
export { getServerSideProps } from "@lib/org/[orgSlug]/[user]/getServerSideProps";
|
||||
|
||||
type Props = UserPageProps | TeamPageProps;
|
||||
export type PageProps = UserPageProps | TeamPageProps;
|
||||
|
||||
export default function Page(props: Props) {
|
||||
export default function Page(props: PageProps) {
|
||||
if ((props as TeamPageProps)?.team) return <TeamPage {...(props as TeamPageProps)} />;
|
||||
return <UserPage {...(props as UserPageProps)} />;
|
||||
}
|
||||
|
||||
@@ -31,8 +31,11 @@ function AdminView() {
|
||||
ref={usernameRef}
|
||||
hint={t("impersonate_user_tip")}
|
||||
defaultValue={undefined}
|
||||
data-testid="admin-impersonation-input"
|
||||
/>
|
||||
<Button type="submit">{t("impersonate")}</Button>
|
||||
<Button type="submit" data-testid="impersonation-submit">
|
||||
{t("impersonate")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
|
||||
@@ -520,12 +520,13 @@ export default function Signup({
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed dark:border-0 lg:block lg:py-[6px] lg:pl-[6px]"
|
||||
style={{
|
||||
backgroundColor: "rgba(236,237,239,0.9)",
|
||||
}}>
|
||||
<img src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
|
||||
<div className="border-default hidden rounded-bl-2xl rounded-br-none rounded-tl-2xl border border-r-0 border-dashed bg-black/[3%] dark:bg-white/5 lg:block lg:py-[6px] lg:pl-[6px]">
|
||||
<img className="block dark:hidden" src="/mock-event-type-list.svg" alt="Cal.com Booking Page" />
|
||||
<img
|
||||
className="hidden dark:block"
|
||||
src="/mock-event-type-list-dark.svg"
|
||||
alt="Cal.com Booking Page"
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||
{FEATURES.map((feature) => (
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
"use client";
|
||||
|
||||
import { Booker } from "@calcom/atoms";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getBookerWrapperClasses } from "@calcom/features/bookings/Booker/utils/getBookerWrapperClasses";
|
||||
import { BookerSeo } from "@calcom/features/bookings/components/BookerSeo";
|
||||
import { getBookingForReschedule, getMultipleDurationValue } from "@calcom/features/bookings/lib/get-booking";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getServerSideProps } from "@lib/team/[slug]/[type]/getServerSideProps";
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export { getServerSideProps };
|
||||
|
||||
export default function Type({
|
||||
slug,
|
||||
user,
|
||||
@@ -61,92 +53,3 @@ export default function Type({
|
||||
|
||||
Type.PageWrapper = PageWrapper;
|
||||
Type.isBookingPage = true;
|
||||
|
||||
const paramsSchema = z.object({
|
||||
type: z.string().transform((s) => slugify(s)),
|
||||
slug: z.string().transform((s) => slugify(s)),
|
||||
});
|
||||
|
||||
// Booker page fetches a tiny bit of data server side:
|
||||
// 1. Check if team exists, to show 404
|
||||
// 2. If rescheduling, get the booking details
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const session = await getServerSession(context);
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration, isInstantMeeting: queryIsInstantMeeting } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slugs: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
hideBranding: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!team) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
}
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
// We use this to both prefetch the query on the server,
|
||||
// as well as to check if the event exist, so we c an show a 404 otherwise.
|
||||
const eventData = await ssr.viewer.public.event.fetch({
|
||||
username: teamSlug,
|
||||
eventSlug: meetingSlug,
|
||||
isTeamEvent: true,
|
||||
org,
|
||||
});
|
||||
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
props: {
|
||||
entity: eventData.entity,
|
||||
duration: getMultipleDurationValue(
|
||||
eventData.metadata?.multipleDuration,
|
||||
queryDuration,
|
||||
eventData.length
|
||||
),
|
||||
booking,
|
||||
away: false,
|
||||
user: teamSlug,
|
||||
teamId: team.id,
|
||||
slug: meetingSlug,
|
||||
trpcState: ssr.dehydrate(),
|
||||
isBrandingHidden: team?.hideBranding,
|
||||
isInstantMeeting: eventData.isInstantEvent && queryIsInstantMeeting ? true : false,
|
||||
themeBasis: null,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useState, useEffect, useRef } from "react";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
|
||||
import classNames from "@calcom/lib/classNames";
|
||||
import { APP_NAME, SEO_IMG_OGIMG_VIDEO, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/date-fns";
|
||||
@@ -332,15 +333,13 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
bookRef.meetingPassword = null;
|
||||
});
|
||||
}
|
||||
|
||||
const videoReferences = bookingObj.references.filter((reference) => reference.type.includes("_video"));
|
||||
const latestVideoReference = videoReferences[videoReferences.length - 1];
|
||||
const videoReference = getCalVideoReference(bookingObj.references);
|
||||
|
||||
return {
|
||||
props: {
|
||||
meetingUrl: latestVideoReference.meetingUrl ?? "",
|
||||
...(typeof latestVideoReference.meetingPassword === "string" && {
|
||||
meetingPassword: latestVideoReference.meetingPassword,
|
||||
meetingUrl: videoReference.meetingUrl ?? "",
|
||||
...(typeof videoReference.meetingPassword === "string" && {
|
||||
meetingPassword: videoReference.meetingPassword,
|
||||
}),
|
||||
booking: {
|
||||
...bookingObj,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
|
||||
import { randomString } from "@calcom/lib/random";
|
||||
@@ -8,7 +7,6 @@ import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewSeatedEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
createUserWithSeatedEventAndAttendees,
|
||||
@@ -29,75 +27,8 @@ test.describe("Booking with Seats", () => {
|
||||
await expect(page.locator(`text=Event type updated successfully`)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Multiple Attendees can book a seated event time slot", async ({ users, page }) => {
|
||||
const slug = "my-2-seated-event";
|
||||
const user = await users.create({
|
||||
name: "Seated event user",
|
||||
eventTypes: [
|
||||
{
|
||||
title: "My 2-seated event",
|
||||
slug,
|
||||
length: 60,
|
||||
seatsPerTimeSlot: 2,
|
||||
seatsShowAttendees: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
|
||||
let bookingUrl = "";
|
||||
|
||||
await test.step("Attendee #1 can book a seated event time slot", async () => {
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #2 can book the same seated event time slot", async () => {
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.waitForURL(/bookingUid/);
|
||||
bookingUrl = page.url();
|
||||
await bookTimeSlot(page, { email: "jane.doe@example.com", name: "Jane Doe" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot click on the same seated event time slot", async () => {
|
||||
await page.goto(`/${user.username}/${slug}`);
|
||||
|
||||
await page.click('[data-testid="incrementMonth"]');
|
||||
|
||||
// TODO: Find out why the first day is always booked on tests
|
||||
await page.locator('[data-testid="day"][data-disabled="false"]').nth(0).click();
|
||||
await expect(page.locator('[data-testid="time"][data-disabled="true"]')).toBeVisible();
|
||||
});
|
||||
await test.step("Attendee #3 cannot book the same seated event time slot accessing via url", async () => {
|
||||
await page.goto(bookingUrl);
|
||||
|
||||
await bookTimeSlot(page, { email: "rick@example.com", name: "Rick" });
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("User owner should have only 1 booking with 3 attendees", async () => {
|
||||
// Make sure user owner has only 1 booking with 3 attendees
|
||||
const bookings = await prisma.booking.findMany({
|
||||
where: { eventTypeId: user.eventTypes.find((e) => e.slug === slug)?.id },
|
||||
select: {
|
||||
id: true,
|
||||
attendees: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(bookings).toHaveLength(1);
|
||||
expect(bookings[0].attendees).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
test(`Attendees can cancel a seated event time slot`, async ({ page, users, bookings }) => {
|
||||
const { booking, user } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
test(`Prevent attendees from cancel when having invalid URL params`, async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: "third+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
@@ -120,30 +51,6 @@ test.describe("Booking with Seats", () => {
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
await test.step("Attendee #1 should be able to cancel their booking", async () => {
|
||||
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[0].referenceUid}`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
|
||||
// Old booking should still exist, with one less attendee
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { id: bookingSeats[0].bookingId },
|
||||
include: { attendees: true },
|
||||
});
|
||||
|
||||
const attendeeIds = updatedBooking?.attendees.map(({ id }) => id);
|
||||
expect(attendeeIds).toHaveLength(2);
|
||||
expect(attendeeIds).not.toContain(bookingAttendees[0].id);
|
||||
});
|
||||
|
||||
await test.step("Attendee #2 shouldn't be able to cancel booking using only booking/uid", async () => {
|
||||
await page.goto(`/booking/${booking.uid}`);
|
||||
|
||||
@@ -156,29 +63,6 @@ test.describe("Booking with Seats", () => {
|
||||
// expect cancel button to don't be in the page
|
||||
await expect(page.locator("[text=Cancel]")).toHaveCount(0);
|
||||
});
|
||||
|
||||
await test.step("All attendees cancelling should delete the booking for the user", async () => {
|
||||
// The remaining 2 attendees cancel
|
||||
for (let i = 1; i < bookingSeats.length; i++) {
|
||||
await page.goto(`/booking/${booking.uid}?seatReferenceUid=${bookingSeats[i].referenceUid}`);
|
||||
|
||||
await page.locator('[data-testid="cancel"]').click();
|
||||
await page.fill('[data-testid="cancel_reason"]', "Double booked!");
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
const cancelledHeadline = page.locator('[data-testid="cancelled-headline"]');
|
||||
await expect(cancelledHeadline).toBeVisible();
|
||||
}
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { id: bookingSeats[0].bookingId },
|
||||
});
|
||||
expect(updatedBooking).not.toBeNull();
|
||||
expect(updatedBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
});
|
||||
|
||||
test("Owner shouldn't be able to cancel booking without login in", async ({ page, bookings, users }) => {
|
||||
@@ -224,181 +108,6 @@ test.describe("Booking with Seats", () => {
|
||||
});
|
||||
|
||||
test.describe("Reschedule for booking with seats", () => {
|
||||
test("Should reschedule booking with seats", async ({ page, users, bookings }) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: `first+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: `second+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
{ name: "John Third", email: `third+seats-${uuid()}@cal.com`, timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[2].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[2].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
// expect input to be filled with attendee number 3 data
|
||||
const thirdAttendeeElement = await page.locator("input[name=name]");
|
||||
const attendeeName = await thirdAttendeeElement.inputValue();
|
||||
expect(attendeeName).toBe("John Third");
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
// should wait for URL but that path starts with booking/
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect new booking to be created for John Third
|
||||
const newBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
attendees: {
|
||||
some: { email: bookingAttendees[2].email },
|
||||
},
|
||||
},
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
expect(newBooking?.status).toBe(BookingStatus.PENDING);
|
||||
expect(newBooking?.attendees.length).toBe(1);
|
||||
expect(newBooking?.attendees[0].name).toBe("John Third");
|
||||
expect(newBooking?.seatsReferences.length).toBe(1);
|
||||
|
||||
// Should expect old booking to be accepted with two attendees
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
expect(oldBooking?.attendees.length).toBe(2);
|
||||
expect(oldBooking?.seatsReferences.length).toBe(2);
|
||||
});
|
||||
|
||||
test("Should reschedule booking with seats and if everyone rescheduled it should be deleted", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const { booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
const references = await prisma.bookingSeat.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
});
|
||||
|
||||
await page.goto(`/reschedule/${references[0].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
await page.goto(`/reschedule/${references[1].referenceUid}`);
|
||||
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
|
||||
// Using waitForUrl here fails the assertion `expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);` probably because waitForUrl is considered complete before waitForNavigation and till that time the booking is not cancelled
|
||||
await page.waitForURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: {
|
||||
seatsReferences: true,
|
||||
attendees: true,
|
||||
eventType: {
|
||||
include: { users: true, hosts: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(oldBooking?.status).toBe(BookingStatus.CANCELLED);
|
||||
});
|
||||
|
||||
test("Should cancel with seats and have no attendees and cancelled", async ({ page, users, bookings }) => {
|
||||
const { user, booking } = await createUserWithSeatedEventAndAttendees({ users, bookings }, [
|
||||
{ name: "John First", email: "first+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
{ name: "Jane Second", email: "second+seats@cal.com", timeZone: "Europe/Berlin" },
|
||||
]);
|
||||
await user.apiLogin();
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
const bookingAttendees = await prisma.attendee.findMany({
|
||||
where: { bookingId: booking.id },
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
const bookingSeats = [
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[0].id, referenceUid: uuidv4() },
|
||||
{ bookingId: booking.id, attendeeId: bookingAttendees[1].id, referenceUid: uuidv4() },
|
||||
];
|
||||
|
||||
await prisma.bookingSeat.createMany({
|
||||
data: bookingSeats,
|
||||
});
|
||||
|
||||
// Now we cancel the booking as the organizer
|
||||
await page.goto(`/booking/${booking.uid}?cancel=true`);
|
||||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/booking\/.*/);
|
||||
|
||||
// Should expect old booking to be cancelled
|
||||
const updatedBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
include: { seatsReferences: true, attendees: true },
|
||||
});
|
||||
|
||||
expect(oldBooking?.startTime).not.toBe(updatedBooking?.startTime);
|
||||
});
|
||||
|
||||
test("If rescheduled/cancelled booking with seats it should display the correct number of seats", async ({
|
||||
page,
|
||||
users,
|
||||
@@ -457,7 +166,7 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
expect(await page.locator("text=9 / 10 Seats available").count()).toEqual(0);
|
||||
});
|
||||
|
||||
test("Should cancel with seats but event should be still accesible and with one less attendee/seat", async ({
|
||||
test("Should cancel with seats but event should be still accessible and with one less attendee/seat", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { localize } from "../lib/testUtils";
|
||||
import type { createUsersFixture } from "./users";
|
||||
@@ -179,6 +180,14 @@ export async function loginUser(users: UserFixture) {
|
||||
await pro.apiLogin();
|
||||
}
|
||||
|
||||
export async function loginUserWithTeam(users: UserFixture, role: MembershipRole) {
|
||||
const pro = await users.create(
|
||||
{ name: "testuser" },
|
||||
{ hasTeam: true, teamRole: role, isOrg: true, hasSubteam: true }
|
||||
);
|
||||
await pro.apiLogin();
|
||||
}
|
||||
|
||||
const goToNextMonthIfNoAvailabilities = async (eventTypePage: Page) => {
|
||||
try {
|
||||
if (isLastDayOfMonth()) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, SchedulingType, TimeUnit, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { Schedule } from "@calcom/types/schedule";
|
||||
|
||||
@@ -27,6 +27,7 @@ type UserFixture = ReturnType<typeof createUserFixture>;
|
||||
|
||||
const userIncludes = PrismaType.validator<PrismaType.UserInclude>()({
|
||||
eventTypes: true,
|
||||
workflows: true,
|
||||
credentials: true,
|
||||
routingForms: true,
|
||||
});
|
||||
@@ -42,6 +43,19 @@ const seededForm = {
|
||||
|
||||
type UserWithIncludes = PrismaType.UserGetPayload<typeof userWithEventTypes>;
|
||||
|
||||
const createTeamWorkflow = async (user: { id: number }, team: { id: number }) => {
|
||||
return await prisma.workflow.create({
|
||||
data: {
|
||||
name: "Team Workflow",
|
||||
trigger: WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
time: 24,
|
||||
timeUnit: TimeUnit.HOUR,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createTeamEventType = async (
|
||||
user: { id: number },
|
||||
team: { id: number },
|
||||
@@ -120,6 +134,7 @@ const createTeamAndAddUser = async (
|
||||
if (isOrg && hasSubteam) {
|
||||
const team = await createTeamAndAddUser({ user }, workerInfo);
|
||||
await createTeamEventType(user, team);
|
||||
await createTeamWorkflow(user, team);
|
||||
data.children = { connect: [{ id: team.id }] };
|
||||
}
|
||||
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
|
||||
@@ -170,6 +185,7 @@ export const createUsersFixture = (
|
||||
scenario: {
|
||||
seedRoutingForms?: boolean;
|
||||
hasTeam?: true;
|
||||
teamRole?: MembershipRole;
|
||||
teammates?: CustomUserOpts[];
|
||||
schedulingType?: SchedulingType;
|
||||
teamEventTitle?: string;
|
||||
@@ -201,6 +217,18 @@ export const createUsersFixture = (
|
||||
});
|
||||
}
|
||||
|
||||
const workflows: SupportedTestWorkflows[] = [
|
||||
{ name: "Default Workflow", trigger: "NEW_EVENT" },
|
||||
{ name: "Test Workflow", trigger: "EVENT_CANCELLED" },
|
||||
...(opts?.workflows || []),
|
||||
];
|
||||
for (const workflowData of workflows) {
|
||||
workflowData.user = { connect: { id: _user.id } };
|
||||
await prisma.workflow.create({
|
||||
data: workflowData,
|
||||
});
|
||||
}
|
||||
|
||||
if (scenario.seedRoutingForms) {
|
||||
await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
@@ -324,7 +352,12 @@ export const createUsersFixture = (
|
||||
if (scenario.hasTeam) {
|
||||
const team = await createTeamAndAddUser(
|
||||
{
|
||||
user: { id: user.id, email: user.email, username: user.username, role: "OWNER" },
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
role: scenario.teamRole || "OWNER",
|
||||
},
|
||||
isUnpublished: scenario.isUnpublished,
|
||||
isOrg: scenario.isOrg,
|
||||
isOrgVerified: scenario.isOrgVerified,
|
||||
@@ -555,6 +588,9 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
|
||||
type SupportedTestWorkflows = PrismaType.WorkflowCreateInput;
|
||||
|
||||
type CustomUserOptsKeys =
|
||||
| "username"
|
||||
| "password"
|
||||
@@ -563,10 +599,13 @@ type CustomUserOptsKeys =
|
||||
| "name"
|
||||
| "email"
|
||||
| "organizationId"
|
||||
| "twoFactorEnabled"
|
||||
| "disableImpersonation"
|
||||
| "role";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
workflows?: SupportedTestWorkflows[];
|
||||
// ignores adding the worker-index after username
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
@@ -594,6 +633,8 @@ const createUser = (
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
locale: opts?.locale ?? "en",
|
||||
role: opts?.role ?? "USER",
|
||||
twoFactorEnabled: opts?.twoFactorEnabled ?? false,
|
||||
disableImpersonation: opts?.disableImpersonation ?? false,
|
||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { Locator } from "@playwright/test";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import { localize } from "../lib/testUtils";
|
||||
|
||||
type CreateWorkflowProps = {
|
||||
name?: string;
|
||||
isTeam?: true;
|
||||
trigger?: WorkflowTriggerEvents;
|
||||
};
|
||||
|
||||
export function createWorkflowPageFixture(page: Page) {
|
||||
const createWorkflow = async (props: CreateWorkflowProps) => {
|
||||
const { name, isTeam, trigger } = props;
|
||||
if (isTeam) {
|
||||
await page.getByTestId("create-button-dropdown").click();
|
||||
await page.getByTestId("option-team-1").click();
|
||||
} else {
|
||||
await page.getByTestId("create-button").click();
|
||||
}
|
||||
if (name) {
|
||||
await fillNameInput(name);
|
||||
}
|
||||
if (trigger) {
|
||||
page.locator("div").filter({ hasText: WorkflowTriggerEvents.BEFORE_EVENT }).nth(1);
|
||||
page.getByText(trigger);
|
||||
await selectEventType("30 min");
|
||||
}
|
||||
await saveWorkflow();
|
||||
};
|
||||
|
||||
const saveWorkflow = async () => {
|
||||
await page.getByTestId("save-workflow").click();
|
||||
};
|
||||
|
||||
const assertListCount = async (count: number) => {
|
||||
const workflowListCount = await page.locator('[data-testid="workflow-list"] > li');
|
||||
await expect(workflowListCount).toHaveCount(count);
|
||||
};
|
||||
|
||||
const fillNameInput = async (name: string) => {
|
||||
await page.getByTestId("workflow-name").fill(name);
|
||||
};
|
||||
|
||||
const editSelectedWorkflow = async (name: string) => {
|
||||
const selectedWorkflow = page.getByTestId("workflow-list").getByTestId(nameToTestId(name));
|
||||
const editButton = selectedWorkflow.getByRole("button").nth(0);
|
||||
|
||||
await editButton.click();
|
||||
};
|
||||
|
||||
const hasWorkflowInList = async (name: string, negate?: true) => {
|
||||
const selectedWorkflow = page.getByTestId("workflow-list").getByTestId(nameToTestId(name));
|
||||
|
||||
if (negate) {
|
||||
await expect(selectedWorkflow).toBeHidden();
|
||||
} else {
|
||||
await expect(selectedWorkflow).toBeVisible();
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAndConfirm = async (workflow: Locator) => {
|
||||
const deleteButton = workflow.getByTestId("delete-button");
|
||||
const confirmDeleteText = (await localize("en"))("confirm_delete_workflow");
|
||||
|
||||
await deleteButton.click();
|
||||
await page.getByRole("button", { name: confirmDeleteText }).click();
|
||||
};
|
||||
|
||||
const selectEventType = async (name: string) => {
|
||||
await page.getByText("Select...").click();
|
||||
await page.getByText(name, { exact: true }).click();
|
||||
};
|
||||
|
||||
const hasReadonlyBadge = async () => {
|
||||
const readOnlyBadge = page.getByText((await localize("en"))("readonly"));
|
||||
await expect(readOnlyBadge).toBeVisible();
|
||||
};
|
||||
|
||||
const selectedWorkflowPage = async (name: string) => {
|
||||
await page.getByTestId("workflow-list").getByTestId(nameToTestId(name)).click();
|
||||
};
|
||||
|
||||
const workflowOptionsAreDisabled = async (workflow: string, negate?: boolean) => {
|
||||
const getWorkflowButton = async (buttonTestId: string) =>
|
||||
page.getByTestId(nameToTestId(workflow)).getByTestId(buttonTestId);
|
||||
const [editButton, deleteButton] = await Promise.all([
|
||||
getWorkflowButton("edit-button"),
|
||||
getWorkflowButton("delete-button"),
|
||||
]);
|
||||
|
||||
expect(editButton.isDisabled()).toBeTruthy();
|
||||
expect(deleteButton.isDisabled()).toBeTruthy();
|
||||
};
|
||||
|
||||
const assertWorkflowReminders = async (eventTypeId: number, count: number) => {
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventTypeId,
|
||||
},
|
||||
});
|
||||
const workflowReminders = await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
bookingUid: booking?.uid ?? "",
|
||||
},
|
||||
});
|
||||
expect(workflowReminders).toHaveLength(count);
|
||||
};
|
||||
|
||||
function nameToTestId(name: string) {
|
||||
return `workflow-${name.split(" ").join("-").toLowerCase()}`;
|
||||
}
|
||||
|
||||
return {
|
||||
createWorkflow,
|
||||
saveWorkflow,
|
||||
assertListCount,
|
||||
fillNameInput,
|
||||
editSelectedWorkflow,
|
||||
hasWorkflowInList,
|
||||
deleteAndConfirm,
|
||||
selectEventType,
|
||||
hasReadonlyBadge,
|
||||
selectedWorkflowPage,
|
||||
workflowOptionsAreDisabled,
|
||||
assertWorkflowReminders,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("Users can impersonate", async () => {
|
||||
test.afterAll(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
test("App Admin can impersonate users with impersonation enabled", async ({ page, users }) => {
|
||||
// log in trail user
|
||||
const user = await users.create({
|
||||
role: "ADMIN",
|
||||
password: "ADMINadmin2022!",
|
||||
});
|
||||
|
||||
const userToImpersonate = await users.create({ disableImpersonation: false });
|
||||
|
||||
await user.apiLogin();
|
||||
await page.waitForLoadState();
|
||||
|
||||
await page.goto("/settings/admin/impersonation");
|
||||
await page.waitForLoadState();
|
||||
const adminInput = page.getByTestId("admin-impersonation-input");
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore the username does exist
|
||||
await adminInput.fill(userToImpersonate.username);
|
||||
await page.getByTestId("impersonation-submit").click();
|
||||
|
||||
// // Wait for sign in to complete
|
||||
await page.waitForURL("/settings/my-account/profile");
|
||||
|
||||
const stopImpersonatingButton = page.getByTestId("stop-impersonating-button");
|
||||
|
||||
const impersonatedUsernameInput = page.locator("input[name='username']");
|
||||
const impersonatedUser = await impersonatedUsernameInput.inputValue();
|
||||
|
||||
await expect(stopImpersonatingButton).toBeVisible();
|
||||
await expect(impersonatedUser).toBe(userToImpersonate.username);
|
||||
|
||||
await stopImpersonatingButton.click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Return to user
|
||||
const ogUser = await impersonatedUsernameInput.inputValue();
|
||||
|
||||
expect(ogUser).toBe(user.username);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ import { createBookingPageFixture } from "../fixtures/regularBookings";
|
||||
import { createRoutingFormsFixture } from "../fixtures/routingForms";
|
||||
import { createServersFixture } from "../fixtures/servers";
|
||||
import { createUsersFixture } from "../fixtures/users";
|
||||
import { createWorkflowPageFixture } from "../fixtures/workflows";
|
||||
|
||||
export interface Fixtures {
|
||||
page: Page;
|
||||
@@ -28,6 +29,7 @@ export interface Fixtures {
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
routingForms: ReturnType<typeof createRoutingFormsFixture>;
|
||||
bookingPage: ReturnType<typeof createBookingPageFixture>;
|
||||
workflowPage: ReturnType<typeof createWorkflowPageFixture>;
|
||||
features: ReturnType<typeof createFeatureFixture>;
|
||||
eventTypePage: ReturnType<typeof createEventTypeFixture>;
|
||||
}
|
||||
@@ -94,6 +96,10 @@ export const test = base.extend<Fixtures>({
|
||||
await features.init();
|
||||
await use(features);
|
||||
},
|
||||
workflowPage: async ({ page }, use) => {
|
||||
const workflowPage = createWorkflowPageFixture(page);
|
||||
await use(workflowPage);
|
||||
},
|
||||
eventTypePage: async ({ page }, use) => {
|
||||
const eventTypePage = createEventTypeFixture(page);
|
||||
await use(eventTypePage);
|
||||
|
||||
@@ -113,7 +113,10 @@ export async function selectSecondAvailableTimeSlotNextMonth(page: Page) {
|
||||
await page.locator('[data-testid="time"]').nth(0).click();
|
||||
}
|
||||
|
||||
async function bookEventOnThisPage(page: Page) {
|
||||
export async function bookEventOnThisPage(page: Page, goTo?: string) {
|
||||
if (goTo) {
|
||||
await page.goto(goTo);
|
||||
}
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
|
||||
|
||||
@@ -3,9 +3,10 @@ import { expect } from "@playwright/test";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils";
|
||||
import { selectFirstAvailableTimeSlotNextMonth, bookTimeSlot } from "./lib/testUtils";
|
||||
|
||||
const IS_STRIPE_ENABLED = !!(
|
||||
process.env.STRIPE_CLIENT_ID &&
|
||||
@@ -270,4 +271,64 @@ test.describe("Reschedule Tests", async () => {
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
});
|
||||
test("Should load Valid Cal video url after rescheduling Opt in events", async ({
|
||||
page,
|
||||
users,
|
||||
bookings,
|
||||
}) => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const eventType = user.eventTypes.find((e) => e.slug === "opt-in")!;
|
||||
|
||||
const confirmBooking = async (bookingId: number) => {
|
||||
await user.apiLogin();
|
||||
await page.goto("/bookings/upcoming");
|
||||
const elem = await page.locator(`[data-bookingid="${bookingId}"][data-testid="confirm"]`);
|
||||
await elem.click();
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
await user.logout();
|
||||
};
|
||||
|
||||
await page.goto(`/${user.username}/${eventType.slug}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
await bookTimeSlot(page);
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
const pageUrl = new URL(page.url());
|
||||
const pathSegments = pageUrl.pathname.split("/");
|
||||
const bookingUID = pathSegments[pathSegments.length - 1];
|
||||
|
||||
const currentBooking = await prisma.booking.findFirst({ where: { uid: bookingUID } });
|
||||
expect(currentBooking).not.toBeUndefined();
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (currentBooking) {
|
||||
await confirmBooking(currentBooking.id);
|
||||
|
||||
await page.goto(`/${user.username}/${eventType.slug}?rescheduleUid=${currentBooking.uid}`);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await page.locator('[data-testid="confirm-reschedule-button"]').click();
|
||||
await expect(page).toHaveURL(/.*booking/);
|
||||
|
||||
const newBooking = await prisma.booking.findFirst({ where: { fromReschedule: currentBooking.uid } });
|
||||
expect(newBooking).not.toBeUndefined();
|
||||
expect(newBooking?.status).toBe(BookingStatus.PENDING);
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (newBooking) {
|
||||
await confirmBooking(newBooking?.id);
|
||||
|
||||
const booking = await prisma.booking.findFirst({ where: { id: newBooking.id } });
|
||||
expect(booking).not.toBeUndefined();
|
||||
expect(booking?.status).toBe(BookingStatus.ACCEPTED);
|
||||
const locationVideoCallUrl = bookingMetadataSchema.parse(booking?.metadata || {})?.videoCallUrl;
|
||||
expect(locationVideoCallUrl).not.toBeUndefined();
|
||||
|
||||
// eslint-disable-next-line playwright/no-conditional-in-test
|
||||
if (booking && locationVideoCallUrl) {
|
||||
await page.goto(locationVideoCallUrl);
|
||||
await expect(page.frameLocator("iFrame").locator('text="Continue"')).toBeVisible();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,80 +1,77 @@
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { WorkflowMethods } from "@calcom/prisma/enums";
|
||||
import { MembershipRole, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
|
||||
import { loginUser, loginUserWithTeam } from "./fixtures/regularBookings";
|
||||
import { test } from "./lib/fixtures";
|
||||
import { selectSecondAvailableTimeSlotNextMonth, todo } from "./lib/testUtils";
|
||||
import { bookEventOnThisPage } from "./lib/testUtils";
|
||||
|
||||
test.afterEach(({ users }) => users.deleteAll());
|
||||
test.describe("Workflow Tab - Event Type", () => {
|
||||
test.describe("Check the functionalities of the Workflow Tab", () => {
|
||||
test.describe("User Workflows", () => {
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/workflows");
|
||||
});
|
||||
|
||||
test.describe("Workflow tests", () => {
|
||||
test.describe("User Workflows", () => {
|
||||
// Fixme: This test is failing because the listing isn't immediately updated after the workflow is created
|
||||
test.fixme(
|
||||
"Create default reminder workflow & trigger when event type is booked",
|
||||
async ({ page, users }) => {
|
||||
const user = await users.create();
|
||||
test("Creating a new workflow", async ({ workflowPage }) => {
|
||||
const { createWorkflow, assertListCount } = workflowPage;
|
||||
|
||||
await createWorkflow({ name: "" });
|
||||
await assertListCount(3);
|
||||
});
|
||||
|
||||
test("Editing an existing workflow", async ({ workflowPage }) => {
|
||||
const { saveWorkflow, fillNameInput, editSelectedWorkflow, hasWorkflowInList } = workflowPage;
|
||||
|
||||
await editSelectedWorkflow("Test Workflow");
|
||||
await fillNameInput("Edited Workflow");
|
||||
await saveWorkflow();
|
||||
await hasWorkflowInList("Edited Workflow");
|
||||
});
|
||||
|
||||
test("Deleting an existing workflow", async ({ page, workflowPage }) => {
|
||||
const { hasWorkflowInList, deleteAndConfirm, assertListCount } = workflowPage;
|
||||
const firstWorkflow = page
|
||||
.getByTestId("workflow-list")
|
||||
.getByTestId(/workflow/)
|
||||
.first();
|
||||
|
||||
await deleteAndConfirm(firstWorkflow);
|
||||
await hasWorkflowInList("Edited Workflow", true);
|
||||
await assertListCount(1);
|
||||
});
|
||||
|
||||
test("Create an action and check if workflow is triggered", async ({ page, users, workflowPage }) => {
|
||||
const { createWorkflow, assertWorkflowReminders } = workflowPage;
|
||||
const [user] = users.get();
|
||||
const [eventType] = user.eventTypes;
|
||||
await user.apiLogin();
|
||||
await page.goto(`/workflows`);
|
||||
|
||||
await page.click('[data-testid="create-button"]');
|
||||
await createWorkflow({ name: "A New Workflow", trigger: WorkflowTriggerEvents.NEW_EVENT });
|
||||
await bookEventOnThisPage(page, `/${user.username}/${eventType.slug}`);
|
||||
await assertWorkflowReminders(eventType.id, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// select first event type
|
||||
await page.getByText("Select...").click();
|
||||
await page.getByText(eventType.title, { exact: true }).click();
|
||||
test.describe("Team Workflows", () => {
|
||||
test("Admin user", async ({ page, users, workflowPage }) => {
|
||||
const { createWorkflow, assertListCount } = workflowPage;
|
||||
|
||||
// name workflow
|
||||
await page.fill('[data-testid="workflow-name"]', "Test workflow");
|
||||
await loginUserWithTeam(users, MembershipRole.ADMIN);
|
||||
await page.goto("/workflows");
|
||||
|
||||
// save workflow
|
||||
await page.click('[data-testid="save-workflow"]');
|
||||
await createWorkflow({ name: "A New Workflow", isTeam: true });
|
||||
await assertListCount(4);
|
||||
});
|
||||
|
||||
// check if workflow is saved
|
||||
await expect(page.locator('[data-testid="workflow-list"] > li')).toHaveCount(1);
|
||||
test("Member user", async ({ page, users, workflowPage }) => {
|
||||
const { hasReadonlyBadge, selectedWorkflowPage, workflowOptionsAreDisabled } = workflowPage;
|
||||
|
||||
// book event type
|
||||
await page.goto(`/${user.username}/${eventType.slug}`);
|
||||
await selectSecondAvailableTimeSlotNextMonth(page);
|
||||
await loginUserWithTeam(users, MembershipRole.MEMBER);
|
||||
await page.goto("/workflows");
|
||||
|
||||
await page.fill('[name="name"]', "Test");
|
||||
await page.fill('[name="email"]', "test@example.com");
|
||||
await page.press('[name="email"]', "Enter");
|
||||
|
||||
// Make sure booking is completed
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
eventTypeId: eventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
// check if workflow triggered
|
||||
const workflowReminders = await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
bookingUid: booking?.uid ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
expect(workflowReminders).toHaveLength(1);
|
||||
|
||||
const scheduledDate = dayjs(booking?.startTime).subtract(1, "day").toDate();
|
||||
|
||||
expect(workflowReminders[0].method).toBe(WorkflowMethods.EMAIL);
|
||||
expect(workflowReminders[0].scheduledDate.toISOString()).toBe(scheduledDate.toISOString());
|
||||
}
|
||||
);
|
||||
|
||||
// add all other actions to this workflow and test if they triggered
|
||||
// cancel booking and test if workflow reminders are deleted
|
||||
// test all other triggers
|
||||
});
|
||||
|
||||
test.describe("Team Workflows", () => {
|
||||
todo("Admin can create and update team workflow");
|
||||
todo("Members can not create and update team workflows");
|
||||
await workflowOptionsAreDisabled("Team Workflow");
|
||||
await selectedWorkflowPage("Team Workflow");
|
||||
await hasReadonlyBadge();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 342 KiB |
@@ -1287,6 +1287,7 @@
|
||||
"default_calendar_selected": "Default calendar",
|
||||
"hide_from_profile": "Hide from profile",
|
||||
"event_setup_tab_title": "Event Setup",
|
||||
"availability_not_found_in_schedule_error":"No availability found in schedule",
|
||||
"event_limit_tab_title": "Limits",
|
||||
"event_limit_tab_description": "How often you can be booked",
|
||||
"event_advanced_tab_description": "Calendar settings & more...",
|
||||
@@ -2193,6 +2194,11 @@
|
||||
"uprade_to_create_instant_bookings": "Upgrade to Enterprise and let guests join an instant call that attendees can jump straight into. This is only for team event types",
|
||||
"dont_want_to_wait": "Don't want to wait?",
|
||||
"meeting_started": "Meeting Started",
|
||||
"booking_not_found_error": "Could not find booking",
|
||||
"booking_seats_full_error": "Booking seats are full",
|
||||
"missing_payment_credential_error": "Missing payment credentials",
|
||||
"missing_payment_app_id_error": "Missing payment app id",
|
||||
"not_enough_available_seats_error": "Booking does not have enough available seats",
|
||||
"user_redirect_title": "{{username}} is currently away for a brief period of time.",
|
||||
"user_redirect_description": "In the meantime, {{profile.username}} will be in charge of all the new scheduled meetings on behalf of {{username}}.",
|
||||
"out_of_office": "Out of office",
|
||||
|
||||
@@ -891,6 +891,7 @@
|
||||
"toggle_calendars_conflict": "סמן/י את לוחות השנה שבהם ברצונך לבדוק אם יש התנגשויות כדי למנוע כפל הזמנות.",
|
||||
"connect_additional_calendar": "חיבור לוח שנה נוסף",
|
||||
"calendar_updated_successfully": "עדכון לוח השנה בוצע בהצלחה",
|
||||
"check_here": "נא לבדוק כאן",
|
||||
"conferencing": "שיחות ועידה",
|
||||
"calendar": "לוח שנה",
|
||||
"payments": "תשלומים",
|
||||
@@ -1286,6 +1287,7 @@
|
||||
"default_calendar_selected": "לוח השנה המוגדר כברירת מחדל",
|
||||
"hide_from_profile": "הסתר מהפרופיל",
|
||||
"event_setup_tab_title": "הגדרת ארוע",
|
||||
"availability_not_found_in_schedule_error": "לא נמצא חלון זמינות בלוח הזמנים",
|
||||
"event_limit_tab_title": "מגבלות",
|
||||
"event_limit_tab_description": "באיזו תדירות ניתן להזמין אותך",
|
||||
"event_advanced_tab_description": "הגדרות לוח שנה ועוד...",
|
||||
@@ -1306,6 +1308,7 @@
|
||||
"do_this": "עשה את זה",
|
||||
"turn_off": "כבה",
|
||||
"turn_on": "הפעלה",
|
||||
"cancelled_bookings_cannot_be_rescheduled": "אי אפשר לתזמן מחדש הזמנה שבוטלה",
|
||||
"settings_updated_successfully": "עדכון ההגדרות בוצע בהצלחה",
|
||||
"error_updating_settings": "שגיאה בעדכון ההגדרות",
|
||||
"personal_cal_url": "כתובת ה-URL האישית שלי של {{appName}}",
|
||||
@@ -2116,6 +2119,7 @@
|
||||
"workflow_automation": "אוטומצית תהליך עבודה",
|
||||
"workflow_automation_description": "אפשר לכוון את חוויית התזמון שלך עם תהליכי עבודה",
|
||||
"scheduling_for_your_team": "אוטומצית תהליך עבודה",
|
||||
"scheduling_for_your_team_description": "עריכת תזמון לצוות שלך באופן שיתופי ולפי התור",
|
||||
"no_members_found": "לא נמצא אף חבר",
|
||||
"event_setup_length_error": "הגדרת אירוע: משך הזמן חייב להיות לפחות דקה אחת.",
|
||||
"availability_schedules": "לוחות זמנים לזמינוּת",
|
||||
@@ -2156,11 +2160,13 @@
|
||||
"manage_availability_schedules": "ניהול לוחות זמנים לזמינות",
|
||||
"lock_timezone_toggle_on_booking_page": "נעילת אזור הזמן בדף ההזמנות",
|
||||
"description_lock_timezone_toggle_on_booking_page": "כדי לנעול את אזור הזמן בדף ההזמנות – שימושי לאירועים אישיים.",
|
||||
"event_setup_multiple_payment_apps_error": "יכול להיות רק יישומון תשלום אחד פעיל לכל סוג אירוע.",
|
||||
"number_in_international_format": "נא למלא מספר בתבנית בינלאומית.",
|
||||
"install_calendar": "התקנת לוח שנה",
|
||||
"branded_subdomain": "תת־תחום ממותג",
|
||||
"branded_subdomain_description": "קבלת תת־תחום ממותג משלך, כגון acme.cal.com",
|
||||
"org_insights": "תובנות כלל־ארגוניות",
|
||||
"org_insights_description": "הפקת תובנות בנוגע לאופן בו הארגון שלך משקיע את זמנו",
|
||||
"extensive_whitelabeling": "תהליך הטמעה והנדסת תמיכה אישי",
|
||||
"extensive_whitelabeling_description": "אפשר לעצב את חוויית הזימון שלך עם לוגו, צבעים ופריטי עיצוב נוספים משלך",
|
||||
"unlimited_teams": "כמות בלתי מוגבלת של צוותים",
|
||||
@@ -2173,6 +2179,7 @@
|
||||
"create_your_org": "יצירת הארגון שלך",
|
||||
"create_your_org_description": "אפשר לשדרג לרישיון תאגידי ולקבל תת־תחום, חיוב מאוחד, תובנות, שינוי מיתוג נרחב ועוד",
|
||||
"other_payment_app_enabled": "אפשר להפעיל רק יישומון תשלום אחד לכל סוג אירוע",
|
||||
"admin_delete_organization_description": "<ul><li>צוותים שחברים בארגון הזה יימחקו יחד עם סוגי האירועים שלהם</li><li>משתמשים שהיו חלק מהארגון לא יימחקו וסוג האירועים שלהם יישארו ללא שינוי.</li><li>שמות המשתמשים ישתנו כדי לאפשר להם להתקיים מחוץ לארגון שהיו חברים בו</li></ul>",
|
||||
"admin_delete_organization_title": "למחוק את הארגון?",
|
||||
"published": "מפורסם",
|
||||
"unpublished": "לא מפורסם",
|
||||
@@ -2187,6 +2194,12 @@
|
||||
"uprade_to_create_instant_bookings": "ניתן לשדג לרישיון התאגידי ולאפשר למשתמשים להצטרף לשיחה מיידית שמשתתפים יכולים לקפוץ ישירות אליה. זה מיועד רק לסוגי אירועים של צוותים",
|
||||
"dont_want_to_wait": "לא רוצה להמתין?",
|
||||
"meeting_started": "הפגישה החלה",
|
||||
"booking_not_found_error": "לא ניתן למצוא הזמנה",
|
||||
"booking_seats_full_error": "חלונות ההזמנה מלאים",
|
||||
"missing_payment_credential_error": "פרטי התשלום חסרים",
|
||||
"missing_payment_app_id_error": "מזהה יישום תשלום חסר",
|
||||
"not_enough_available_seats_error": "להזמנה אין מספיק חלונות פנויים",
|
||||
"user_redirect_title": "{{username}} לא כאן כרגע לזמן קצר.",
|
||||
"user_redirect_description": "בינתיים, האחריות על כל ההזמנות המתוזמנות החדשות בשם {{username}} תהיה על כתפי {{profile.username}}.",
|
||||
"out_of_office": "מחוץ למשרד",
|
||||
"out_of_office_description": "הגדרת פעולות בפרופיל שלך כשאינך במשרד.",
|
||||
|
||||
@@ -19,6 +19,7 @@ import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { WorkflowActions, WorkflowTemplates, WorkflowTriggerEvents } from "@calcom/prisma/client";
|
||||
import type { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { userMetadataType } from "@calcom/prisma/zod-utils";
|
||||
import type { AppMeta } from "@calcom/types/App";
|
||||
import type { NewCalendarEventType } from "@calcom/types/Calendar";
|
||||
import type { EventBusyDate, IntervalLimit } from "@calcom/types/Calendar";
|
||||
@@ -128,6 +129,8 @@ export type InputEventType = {
|
||||
durationLimits?: IntervalLimit;
|
||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||
|
||||
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||
|
||||
type WhiteListedBookingProps = {
|
||||
id?: number;
|
||||
uid?: string;
|
||||
@@ -137,11 +140,15 @@ type WhiteListedBookingProps = {
|
||||
endTime: string;
|
||||
title?: string;
|
||||
status: BookingStatus;
|
||||
attendees?: { email: string }[];
|
||||
attendees?: {
|
||||
email: string;
|
||||
bookingSeat?: AttendeeBookingSeatInput | null;
|
||||
}[];
|
||||
references?: (Omit<ReturnType<typeof getMockBookingReference>, "credentialId"> & {
|
||||
// TODO: Make sure that all references start providing credentialId and then remove this intersection of optional credentialId
|
||||
credentialId?: number | null;
|
||||
})[];
|
||||
bookingSeat?: Prisma.BookingSeatCreateInput[];
|
||||
};
|
||||
|
||||
type InputBooking = Partial<Omit<Booking, keyof WhiteListedBookingProps>> & WhiteListedBookingProps;
|
||||
@@ -334,7 +341,7 @@ async function addBookings(bookings: InputBooking[]) {
|
||||
);
|
||||
}
|
||||
return {
|
||||
uid: uuidv4(),
|
||||
uid: booking.uid || uuidv4(),
|
||||
workflowReminders: [],
|
||||
references: [],
|
||||
title: "Test Booking Title",
|
||||
@@ -361,10 +368,23 @@ async function addBookings(bookings: InputBooking[]) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
createMany: {
|
||||
data: booking.attendees,
|
||||
data: booking.attendees.map((attendee) => {
|
||||
if (attendee.bookingSeat) {
|
||||
const { bookingSeat, ...attendeeWithoutBookingSeat } = attendee;
|
||||
return {
|
||||
...attendeeWithoutBookingSeat,
|
||||
bookingSeat: {
|
||||
create: { ...bookingSeat, bookingId: booking.id },
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return attendee;
|
||||
}
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return bookingCreate;
|
||||
})
|
||||
);
|
||||
@@ -887,6 +907,7 @@ export function getOrganizer({
|
||||
weekStart = "Sunday",
|
||||
teams,
|
||||
organizationId,
|
||||
metadata,
|
||||
}: {
|
||||
name: string;
|
||||
email: string;
|
||||
@@ -899,6 +920,7 @@ export function getOrganizer({
|
||||
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
|
||||
weekStart?: WeekDays;
|
||||
teams?: InputUser["teams"];
|
||||
metadata?: userMetadataType;
|
||||
}) {
|
||||
return {
|
||||
...TestData.users.example,
|
||||
@@ -913,6 +935,7 @@ export function getOrganizer({
|
||||
weekStart,
|
||||
teams,
|
||||
organizationId,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1394,13 +1417,18 @@ export function getMockBookingReference(
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockBookingAttendee(attendee: Omit<Attendee, "bookingId">) {
|
||||
export function getMockBookingAttendee(
|
||||
attendee: Omit<Attendee, "bookingId"> & {
|
||||
bookingSeat?: AttendeeBookingSeatInput;
|
||||
}
|
||||
) {
|
||||
return {
|
||||
id: attendee.id,
|
||||
timeZone: attendee.timeZone,
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
locale: attendee.locale,
|
||||
bookingSeat: attendee.bookingSeat || null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/alby/webhook";
|
||||
export { default as webhook, config } from "./webhook";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { calendar_v3 } from "googleapis";
|
||||
import { google } from "googleapis";
|
||||
import { RRule } from "rrule";
|
||||
|
||||
import { MeetLocationType } from "@calcom/app-store/locations";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
@@ -209,6 +210,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
payload["location"] = getLocation(calEventRaw);
|
||||
}
|
||||
|
||||
if (calEventRaw.recurringEvent) {
|
||||
const rule = new RRule({
|
||||
freq: calEventRaw.recurringEvent.freq,
|
||||
interval: calEventRaw.recurringEvent.interval,
|
||||
count: calEventRaw.recurringEvent.count,
|
||||
});
|
||||
|
||||
payload["recurrence"] = [rule.toString()];
|
||||
}
|
||||
|
||||
if (calEventRaw.conferenceData && calEventRaw.location === MeetLocationType) {
|
||||
payload["conferenceData"] = calEventRaw.conferenceData;
|
||||
}
|
||||
@@ -220,22 +231,70 @@ export default class GoogleCalendarService implements Calendar {
|
||||
"primary";
|
||||
|
||||
try {
|
||||
const event = await calendar.events.insert({
|
||||
calendarId: selectedCalendar,
|
||||
requestBody: payload,
|
||||
conferenceDataVersion: 1,
|
||||
sendUpdates: "none",
|
||||
});
|
||||
let event;
|
||||
let recurringEventId = null;
|
||||
if (calEventRaw.existingRecurringEvent) {
|
||||
recurringEventId = calEventRaw.existingRecurringEvent.recurringEventId;
|
||||
const recurringEventInstances = await calendar.events.instances({
|
||||
calendarId: selectedCalendar,
|
||||
eventId: calEventRaw.existingRecurringEvent.recurringEventId,
|
||||
});
|
||||
if (recurringEventInstances.data.items) {
|
||||
const calComEventStartTime = dayjs(calEventRaw.startTime).format();
|
||||
for (let i = 0; i < recurringEventInstances.data.items.length; i++) {
|
||||
const instance = recurringEventInstances.data.items[i];
|
||||
const instanceStartTime = dayjs(instance.start?.dateTime)
|
||||
.tz(instance.start?.timeZone == null ? undefined : instance.start?.timeZone)
|
||||
.format();
|
||||
|
||||
if (event && event.data.id && event.data.hangoutLink) {
|
||||
if (instanceStartTime === calComEventStartTime) {
|
||||
event = instance;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!event) {
|
||||
event = recurringEventInstances.data.items[0];
|
||||
this.log.error(
|
||||
"Unable to find matching event amongst recurring event instances",
|
||||
safeStringify({ selectedCalendar, credentialId })
|
||||
);
|
||||
}
|
||||
await calendar.events.patch({
|
||||
calendarId: selectedCalendar,
|
||||
eventId: event.id || "",
|
||||
requestBody: {
|
||||
description: getRichDescription({
|
||||
...calEventRaw,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const eventResponse = await calendar.events.insert({
|
||||
calendarId: selectedCalendar,
|
||||
requestBody: payload,
|
||||
conferenceDataVersion: 1,
|
||||
sendUpdates: "none",
|
||||
});
|
||||
event = eventResponse.data;
|
||||
if (event.recurrence) {
|
||||
if (event.recurrence.length > 0) {
|
||||
recurringEventId = event.id;
|
||||
event = await this.getFirstEventInRecurrence(recurringEventId, selectedCalendar, calendar);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event && event.id && event.hangoutLink) {
|
||||
await calendar.events.patch({
|
||||
// Update the same event but this time we know the hangout link
|
||||
calendarId: selectedCalendar,
|
||||
eventId: event.data.id || "",
|
||||
eventId: event.id || "",
|
||||
requestBody: {
|
||||
description: getRichDescription({
|
||||
...calEventRaw,
|
||||
additionalInformation: { hangoutLink: event.data.hangoutLink },
|
||||
additionalInformation: { hangoutLink: event.hangoutLink },
|
||||
}),
|
||||
},
|
||||
});
|
||||
@@ -243,14 +302,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
|
||||
return {
|
||||
uid: "",
|
||||
...event.data,
|
||||
id: event.data.id || "",
|
||||
...event,
|
||||
id: event?.id || "",
|
||||
thirdPartyRecurringEventId: recurringEventId,
|
||||
additionalInfo: {
|
||||
hangoutLink: event.data.hangoutLink || "",
|
||||
hangoutLink: event?.hangoutLink || "",
|
||||
},
|
||||
type: "google_calendar",
|
||||
password: "",
|
||||
url: "",
|
||||
iCalUID: event?.iCalUID,
|
||||
};
|
||||
} catch (error) {
|
||||
this.log.error(
|
||||
@@ -260,6 +321,22 @@ export default class GoogleCalendarService implements Calendar {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async getFirstEventInRecurrence(
|
||||
recurringEventId: string | null | undefined,
|
||||
selectedCalendar: string,
|
||||
calendar: calendar_v3.Calendar
|
||||
): Promise<calendar_v3.Schema$Event> {
|
||||
const recurringEventInstances = await calendar.events.instances({
|
||||
calendarId: selectedCalendar,
|
||||
eventId: recurringEventId || "",
|
||||
});
|
||||
|
||||
if (recurringEventInstances.data.items) {
|
||||
return recurringEventInstances.data.items[0];
|
||||
} else {
|
||||
return {} as calendar_v3.Schema$Event;
|
||||
}
|
||||
}
|
||||
|
||||
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
|
||||
const payload: calendar_v3.Schema$Event = {
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export { default as add } from "./add";
|
||||
export { default as webhook, config } from "@calcom/web/pages/api/integrations/paypal/webhook";
|
||||
export { default as webhook, config } from "./webhook";
|
||||
export { default as capture } from "./capture";
|
||||
|
||||
@@ -173,6 +173,7 @@ export default class EventManager {
|
||||
|
||||
// References can be any type: calendar/video
|
||||
const referencesToCreate = results.map((result) => {
|
||||
let thirdPartyRecurringEventId;
|
||||
let createdEventObj: createdEventSchema | null = null;
|
||||
if (typeof result?.createdEvent === "string") {
|
||||
createdEventObj = createdEventSchema.parse(JSON.parse(result.createdEvent));
|
||||
@@ -180,11 +181,13 @@ export default class EventManager {
|
||||
const isCalendarType = isCalendarResult(result);
|
||||
if (isCalendarType) {
|
||||
evt.iCalUID = result.iCalUID || event.iCalUID || undefined;
|
||||
thirdPartyRecurringEventId = result.createdEvent?.thirdPartyRecurringEventId;
|
||||
}
|
||||
|
||||
return {
|
||||
type: result.type,
|
||||
uid: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString() ?? "",
|
||||
thirdPartyRecurringEventId: isCalendarType ? thirdPartyRecurringEventId : undefined,
|
||||
meetingId: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString(),
|
||||
meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password,
|
||||
meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
AdditionalInformation,
|
||||
CalendarEvent,
|
||||
ConferenceData,
|
||||
ExistingRecurringEvent,
|
||||
Person,
|
||||
VideoCallData,
|
||||
} from "@calcom/types/Calendar";
|
||||
@@ -22,6 +23,7 @@ class CalendarEventClass implements CalendarEvent {
|
||||
conferenceData?: ConferenceData;
|
||||
additionalInformation?: AdditionalInformation;
|
||||
uid?: string | null;
|
||||
existingRecurringEvent?: ExistingRecurringEvent | null;
|
||||
videoCallData?: VideoCallData;
|
||||
paymentInfo?: any;
|
||||
destinationCalendar?: DestinationCalendar[] | null;
|
||||
|
||||
@@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
|
||||
import { parseBookingLimit, parseDurationLimit } from "@calcom/lib";
|
||||
import { getWorkingHours } from "@calcom/lib/availability";
|
||||
import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
|
||||
import logger from "@calcom/lib/logger";
|
||||
@@ -258,6 +259,7 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
|
||||
|
||||
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
|
||||
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
|
||||
|
||||
log.debug(
|
||||
"Using schedule:",
|
||||
safeStringify({
|
||||
@@ -272,8 +274,14 @@ const _getUserAvailability = async function getUsersWorkingHoursLifeTheUniverseA
|
||||
|
||||
const timeZone = schedule?.timeZone || eventType?.timeZone || user.timeZone;
|
||||
|
||||
if (
|
||||
!(schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability))
|
||||
) {
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.AvailabilityNotFoundInSchedule });
|
||||
}
|
||||
|
||||
const availability = (
|
||||
schedule.availability || (eventType?.availability.length ? eventType.availability : user.availability)
|
||||
schedule?.availability || (eventType?.availability.length ? eventType.availability : user.availability)
|
||||
).map((a) => ({
|
||||
...a,
|
||||
userId: user.id,
|
||||
|
||||
@@ -41,7 +41,7 @@ export default class AttendeeScheduledEmail extends BaseEmail {
|
||||
? this.t("your_event_has_been_scheduled_recurring")
|
||||
: this.t("your_event_has_been_scheduled"),
|
||||
role: "attendee",
|
||||
subtitle: "emailed_you_and_any_other_attendees",
|
||||
subtitle: this.t("emailed_you_and_any_other_attendees"),
|
||||
status: "CONFIRMED",
|
||||
}),
|
||||
method: "REQUEST",
|
||||
|
||||
@@ -72,13 +72,30 @@ export async function getServerSession(options: {
|
||||
email_verified: user.emailVerified !== null,
|
||||
role: user.role,
|
||||
image: `${CAL_URL}/${user.username}/avatar.png`,
|
||||
impersonatedByUID: token.impersonatedByUID ?? undefined,
|
||||
belongsToActiveTeam: token.belongsToActiveTeam,
|
||||
org: token.org,
|
||||
locale: user.locale ?? undefined,
|
||||
},
|
||||
};
|
||||
|
||||
if (token?.impersonatedBy?.id) {
|
||||
const impersonatedByUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: token.impersonatedBy.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
if (impersonatedByUser) {
|
||||
session.user.impersonatedBy = {
|
||||
id: impersonatedByUser?.id,
|
||||
role: impersonatedByUser.role,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
CACHE.set(JSON.stringify(token), session);
|
||||
|
||||
return session;
|
||||
|
||||
@@ -508,7 +508,7 @@ export const AUTH_OPTIONS: AuthOptions = {
|
||||
username: user.username,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
impersonatedByUID: user?.impersonatedByUID,
|
||||
impersonatedBy: user.impersonatedBy,
|
||||
belongsToActiveTeam: user?.belongsToActiveTeam,
|
||||
org: user?.org,
|
||||
locale: user?.locale,
|
||||
@@ -547,7 +547,7 @@ export const AUTH_OPTIONS: AuthOptions = {
|
||||
username: existingUser.username,
|
||||
email: existingUser.email,
|
||||
role: existingUser.role,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
impersonatedBy: token.impersonatedBy,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
org: token?.org,
|
||||
locale: existingUser.locale,
|
||||
@@ -567,7 +567,7 @@ export const AUTH_OPTIONS: AuthOptions = {
|
||||
name: token.name,
|
||||
username: token.username as string,
|
||||
role: token.role as UserPermissionRole,
|
||||
impersonatedByUID: token.impersonatedByUID as number,
|
||||
impersonatedBy: token.impersonatedBy,
|
||||
belongsToActiveTeam: token?.belongsToActiveTeam as boolean,
|
||||
org: token?.org,
|
||||
locale: token.locale,
|
||||
|
||||
@@ -59,6 +59,7 @@ async function getBookingToDelete(id: number | undefined, uid: string | undefine
|
||||
type: true,
|
||||
externalCalendarId: true,
|
||||
credentialId: true,
|
||||
thirdPartyRecurringEventId: true,
|
||||
},
|
||||
},
|
||||
payment: true,
|
||||
@@ -458,24 +459,37 @@ async function handler(req: CustomRequest) {
|
||||
bookingToDelete.recurringEventId &&
|
||||
allRemainingBookings
|
||||
) {
|
||||
const promises = bookingToDelete.user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||
.map(async (credential) => {
|
||||
const calendar = await getCalendar(credential);
|
||||
for (const updBooking of updatedBookings) {
|
||||
const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
|
||||
if (bookingRef) {
|
||||
const { uid, externalCalendarId } = bookingRef;
|
||||
const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId);
|
||||
apiDeletes.push(deletedEvent);
|
||||
let thirdPartyRecurringEventId;
|
||||
for (const reference of bookingToDelete.references) {
|
||||
if (reference.thirdPartyRecurringEventId) {
|
||||
thirdPartyRecurringEventId = reference.thirdPartyRecurringEventId;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (thirdPartyRecurringEventId) {
|
||||
apiDeletes.push(
|
||||
calendar?.deleteEvent(thirdPartyRecurringEventId, evt, externalCalendarId) as Promise<unknown>
|
||||
);
|
||||
} else {
|
||||
const promises = bookingToDelete.user.credentials
|
||||
.filter((credential) => credential.type.endsWith("_calendar"))
|
||||
.map(async (credential) => {
|
||||
const calendar = await getCalendar(credential);
|
||||
for (const updBooking of updatedBookings) {
|
||||
const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
|
||||
if (bookingRef) {
|
||||
const { uid, externalCalendarId } = bookingRef;
|
||||
const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId);
|
||||
apiDeletes.push(deletedEvent);
|
||||
}
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message);
|
||||
}
|
||||
});
|
||||
try {
|
||||
await Promise.all(promises);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
logger.error(error.message);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -666,7 +680,8 @@ async function handler(req: CustomRequest) {
|
||||
const prismaPromises: Promise<unknown>[] = [bookingReferenceDeletes];
|
||||
|
||||
try {
|
||||
const settled = await Promise.allSettled(prismaPromises.concat(apiDeletes));
|
||||
const temp = prismaPromises.concat(apiDeletes);
|
||||
const settled = await Promise.allSettled(temp);
|
||||
const rejected = settled.filter(({ status }) => status === "rejected") as PromiseRejectedResult[];
|
||||
if (rejected.length) {
|
||||
throw new Error(`Reasons: ${rejected.map(({ reason }) => reason)}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { App, Attendee, DestinationCalendar, EventTypeCustomInput } from "@prisma/client";
|
||||
import type { App, DestinationCalendar, EventTypeCustomInput } from "@prisma/client";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import async from "async";
|
||||
import type { IncomingMessage } from "http";
|
||||
@@ -12,7 +12,6 @@ import type { Logger } from "tslog";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import z from "zod";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { metadata as GoogleMeetMetadata } from "@calcom/app-store/googlevideo/_metadata";
|
||||
import type { LocationObject } from "@calcom/app-store/locations";
|
||||
import {
|
||||
@@ -25,19 +24,16 @@ import { getAppFromSlug } from "@calcom/app-store/utils";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { getEventName } from "@calcom/core/event";
|
||||
import { getUserAvailability } from "@calcom/core/getUserAvailability";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { scheduleMandatoryReminder } from "@calcom/ee/workflows/lib/reminders/scheduleMandatoryReminder";
|
||||
import {
|
||||
sendAttendeeRequestEmail,
|
||||
sendOrganizerRequestEmail,
|
||||
sendRescheduledEmails,
|
||||
sendRescheduledSeatEmail,
|
||||
sendRoundRobinCancelledEmails,
|
||||
sendRoundRobinRescheduledEmails,
|
||||
sendRoundRobinScheduledEmails,
|
||||
sendScheduledEmails,
|
||||
sendScheduledSeatsEmails,
|
||||
} from "@calcom/emails";
|
||||
import getICalUID from "@calcom/emails/lib/getICalUID";
|
||||
import { getBookingFieldsWithSystemFields } from "@calcom/features/bookings/lib/getBookingFields";
|
||||
@@ -99,7 +95,8 @@ import type { EventResult, PartialReference } from "@calcom/types/EventManager";
|
||||
|
||||
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
|
||||
import getBookingDataSchema from "./getBookingDataSchema";
|
||||
import type { BookingSeat } from "./handleSeats";
|
||||
import handleSeats from "./handleSeats/handleSeats";
|
||||
import type { BookingSeat } from "./handleSeats/types";
|
||||
|
||||
const translator = short();
|
||||
const log = logger.getSubLogger({ prefix: ["[api] book:user"] });
|
||||
@@ -861,7 +858,7 @@ export function getCustomInputsResponses(
|
||||
*
|
||||
* @returns updated evt with video call data
|
||||
*/
|
||||
export const addVideoCallDataToEvt = (bookingReferences: BookingReference[], evt: CalendarEvent) => {
|
||||
export const addVideoCallDataToEvent = (bookingReferences: BookingReference[], evt: CalendarEvent) => {
|
||||
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
|
||||
|
||||
if (videoCallReference) {
|
||||
@@ -892,7 +889,7 @@ export function handleAppsStatus(
|
||||
reqAppsStatus: ReqAppsStatus
|
||||
) {
|
||||
// Taking care of apps status
|
||||
let resultStatus: AppsStatus[] = results.map((app) => ({
|
||||
const resultStatus: AppsStatus[] = results.map((app) => ({
|
||||
appName: app.appName,
|
||||
type: app.type,
|
||||
success: app.success ? 1 : 0,
|
||||
@@ -920,8 +917,7 @@ export function handleAppsStatus(
|
||||
}
|
||||
return prev;
|
||||
}, {} as { [key: string]: AppsStatus });
|
||||
resultStatus = Object.values(calcAppsStatus);
|
||||
return resultStatus;
|
||||
return Object.values(calcAppsStatus);
|
||||
}
|
||||
|
||||
function getICalSequence(originalRescheduledBooking: BookingType | null) {
|
||||
@@ -989,13 +985,10 @@ async function handler(
|
||||
req: NextApiRequest & { userId?: number | undefined },
|
||||
{
|
||||
isNotAnApiCall = false,
|
||||
skipAvailabilityCheck = false,
|
||||
}: {
|
||||
isNotAnApiCall?: boolean;
|
||||
skipAvailabilityCheck?: boolean;
|
||||
} = {
|
||||
isNotAnApiCall: false,
|
||||
skipAvailabilityCheck: false,
|
||||
}
|
||||
) {
|
||||
const { userId } = req;
|
||||
@@ -1065,7 +1058,6 @@ async function handler(
|
||||
isTeamEventType,
|
||||
eventType: getPiiFreeEventType(eventType),
|
||||
dynamicUserList,
|
||||
skipAvailabilityCheck,
|
||||
paymentAppData: {
|
||||
enabled: paymentAppData.enabled,
|
||||
price: paymentAppData.price,
|
||||
@@ -1202,7 +1194,7 @@ async function handler(
|
||||
|
||||
let originalRescheduledBooking: BookingType = null;
|
||||
|
||||
//this gets the orginal rescheduled booking
|
||||
//this gets the original rescheduled booking
|
||||
if (rescheduleUid) {
|
||||
// rescheduleUid can be bookingUid and bookingSeatUid
|
||||
bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
@@ -1234,58 +1226,85 @@ async function handler(
|
||||
}
|
||||
|
||||
//checks what users are available
|
||||
if (!eventType.seatsPerTimeSlot && !skipAvailabilityCheck) {
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
{
|
||||
...eventType,
|
||||
users: users as IsFixedAwareUser[],
|
||||
...(eventType.recurringEvent && {
|
||||
recurringEvent: {
|
||||
...eventType.recurringEvent,
|
||||
count: recurringCount || eventType.recurringEvent.count,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
|
||||
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
|
||||
timeZone: reqBody.timeZone,
|
||||
originalRescheduledBooking,
|
||||
},
|
||||
loggerWithEventDetails
|
||||
);
|
||||
|
||||
const luckyUsers: typeof users = [];
|
||||
const luckyUserPool = availableUsers.filter((user) => !user.isFixed);
|
||||
loggerWithEventDetails.debug(
|
||||
"Computed available users",
|
||||
safeStringify({
|
||||
availableUsers: availableUsers.map((user) => user.id),
|
||||
luckyUserPool: luckyUserPool.map((user) => user.id),
|
||||
})
|
||||
);
|
||||
// loop through all non-fixed hosts and get the lucky users
|
||||
while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) {
|
||||
const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
// find a lucky user that is not already in the luckyUsers array
|
||||
availableUsers: luckyUserPool.filter(
|
||||
(user) => !luckyUsers.find((existing) => existing.id === user.id)
|
||||
),
|
||||
eventTypeId: eventType.id,
|
||||
});
|
||||
if (!newLuckyUser) {
|
||||
break; // prevent infinite loop
|
||||
if (!eventType.seatsPerTimeSlot) {
|
||||
const eventTypeWithUsers: Awaited<ReturnType<typeof getEventTypesFromDB>> & {
|
||||
users: IsFixedAwareUser[];
|
||||
} = {
|
||||
...eventType,
|
||||
users: users as IsFixedAwareUser[],
|
||||
...(eventType.recurringEvent && {
|
||||
recurringEvent: {
|
||||
...eventType.recurringEvent,
|
||||
count: recurringCount || eventType.recurringEvent.count,
|
||||
},
|
||||
}),
|
||||
};
|
||||
if (req.body.allRecurringDates) {
|
||||
if (req.body.isFirstRecurringSlot) {
|
||||
for (
|
||||
let i = 0;
|
||||
i < req.body.allRecurringDates.length && i < req.body.numSlotsToCheckForAvailability;
|
||||
i++
|
||||
) {
|
||||
const start = req.body.allRecurringDates[i].start;
|
||||
const end = req.body.allRecurringDates[i].end;
|
||||
await ensureAvailableUsers(
|
||||
eventTypeWithUsers,
|
||||
{
|
||||
dateFrom: dayjs(start).tz(reqBody.timeZone).format(),
|
||||
dateTo: dayjs(end).tz(reqBody.timeZone).format(),
|
||||
timeZone: reqBody.timeZone,
|
||||
originalRescheduledBooking,
|
||||
},
|
||||
loggerWithEventDetails
|
||||
);
|
||||
}
|
||||
}
|
||||
luckyUsers.push(newLuckyUser);
|
||||
}
|
||||
// ALL fixed users must be available
|
||||
if (
|
||||
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length
|
||||
) {
|
||||
throw new Error(ErrorCode.HostsUnavailableForBooking);
|
||||
if (!req.body.allRecurringDates || req.body.isFirstRecurringSlot) {
|
||||
const availableUsers = await ensureAvailableUsers(
|
||||
eventTypeWithUsers,
|
||||
{
|
||||
dateFrom: dayjs(reqBody.start).tz(reqBody.timeZone).format(),
|
||||
dateTo: dayjs(reqBody.end).tz(reqBody.timeZone).format(),
|
||||
timeZone: reqBody.timeZone,
|
||||
originalRescheduledBooking,
|
||||
},
|
||||
loggerWithEventDetails
|
||||
);
|
||||
|
||||
const luckyUsers: typeof users = [];
|
||||
const luckyUserPool = availableUsers.filter((user) => !user.isFixed);
|
||||
loggerWithEventDetails.debug(
|
||||
"Computed available users",
|
||||
safeStringify({
|
||||
availableUsers: availableUsers.map((user) => user.id),
|
||||
luckyUserPool: luckyUserPool.map((user) => user.id),
|
||||
})
|
||||
);
|
||||
// loop through all non-fixed hosts and get the lucky users
|
||||
while (luckyUserPool.length > 0 && luckyUsers.length < 1 /* TODO: Add variable */) {
|
||||
const newLuckyUser = await getLuckyUser("MAXIMIZE_AVAILABILITY", {
|
||||
// find a lucky user that is not already in the luckyUsers array
|
||||
availableUsers: luckyUserPool.filter(
|
||||
(user) => !luckyUsers.find((existing) => existing.id === user.id)
|
||||
),
|
||||
eventTypeId: eventType.id,
|
||||
});
|
||||
if (!newLuckyUser) {
|
||||
break; // prevent infinite loop
|
||||
}
|
||||
luckyUsers.push(newLuckyUser);
|
||||
}
|
||||
// ALL fixed users must be available
|
||||
if (
|
||||
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length
|
||||
) {
|
||||
throw new Error(ErrorCode.HostsUnavailableForBooking);
|
||||
}
|
||||
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
|
||||
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
|
||||
}
|
||||
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
|
||||
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];
|
||||
}
|
||||
|
||||
const [organizerUser] = users;
|
||||
@@ -1325,7 +1344,7 @@ async function handler(
|
||||
organizerOrFirstDynamicGroupMemberDefaultLocationUrl =
|
||||
organizerMetadata?.defaultConferencingApp?.appLink;
|
||||
} else {
|
||||
locationBodyString = "";
|
||||
locationBodyString = "integrations:daily";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1463,61 +1482,16 @@ async function handler(
|
||||
iCalSequence,
|
||||
};
|
||||
|
||||
if (req.body.thirdPartyRecurringEventId) {
|
||||
evt.existingRecurringEvent = {
|
||||
recurringEventId: req.body.thirdPartyRecurringEventId,
|
||||
};
|
||||
}
|
||||
|
||||
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") {
|
||||
evt.destinationCalendar?.push(...teamDestinationCalendars);
|
||||
}
|
||||
|
||||
/* Check if the original booking has no more attendees, if so delete the booking
|
||||
and any calendar or video integrations */
|
||||
const lastAttendeeDeleteBooking = async (
|
||||
originalRescheduledBooking: Awaited<ReturnType<typeof getOriginalRescheduledBooking>>,
|
||||
filteredAttendees: Partial<Attendee>[],
|
||||
originalBookingEvt?: CalendarEvent
|
||||
) => {
|
||||
let deletedReferences = false;
|
||||
if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) {
|
||||
const integrationsToDelete = [];
|
||||
|
||||
for (const reference of originalRescheduledBooking.references) {
|
||||
if (reference.credentialId) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: reference.credentialId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
if (reference.type.includes("_video")) {
|
||||
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
|
||||
}
|
||||
if (reference.type.includes("_calendar") && originalBookingEvt) {
|
||||
const calendar = await getCalendar(credential);
|
||||
if (calendar) {
|
||||
integrationsToDelete.push(
|
||||
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(integrationsToDelete).then(async () => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: originalRescheduledBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
deletedReferences = true;
|
||||
}
|
||||
return deletedReferences;
|
||||
};
|
||||
|
||||
// data needed for triggering webhooks
|
||||
const eventTypeInfo: EventTypeInfo = {
|
||||
eventTitle: eventType.title,
|
||||
@@ -1557,640 +1531,39 @@ async function handler(
|
||||
triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
|
||||
teamId,
|
||||
};
|
||||
const handleSeats = async () => {
|
||||
let resultBooking:
|
||||
| (Partial<Booking> & {
|
||||
appsStatus?: AppsStatus[];
|
||||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
| null = null;
|
||||
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
uid: rescheduleUid || reqBody.bookingUid,
|
||||
},
|
||||
{
|
||||
eventTypeId: eventType.id,
|
||||
startTime: evt.startTime,
|
||||
},
|
||||
],
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
id: true,
|
||||
attendees: { include: { bookingSeat: true } },
|
||||
userId: true,
|
||||
references: true,
|
||||
startTime: true,
|
||||
user: true,
|
||||
status: true,
|
||||
smsReminderNumber: true,
|
||||
endTime: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find booking" });
|
||||
}
|
||||
|
||||
// See if attendee is already signed up for timeslot
|
||||
if (
|
||||
booking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
|
||||
dayjs.utc(booking.startTime).format() === evt.startTime
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking });
|
||||
}
|
||||
|
||||
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
|
||||
if (rescheduleUid) {
|
||||
// See if the new date has a booking already
|
||||
const newTimeSlotBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
startTime: evt.startTime,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
|
||||
if (!originalRescheduledBooking) {
|
||||
// typescript isn't smart enough;
|
||||
throw new Error("Internal Error.");
|
||||
}
|
||||
|
||||
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
|
||||
(filteredAttendees, attendee) => {
|
||||
if (attendee.email === bookerEmail) {
|
||||
return filteredAttendees; // skip current booker, as we know the language already.
|
||||
}
|
||||
filteredAttendees.push({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
});
|
||||
return filteredAttendees;
|
||||
},
|
||||
[] as Person[]
|
||||
);
|
||||
|
||||
// If original booking has video reference we need to add the videoCallData to the new evt
|
||||
const videoReference = originalRescheduledBooking.references.find((reference) =>
|
||||
reference.type.includes("_video")
|
||||
);
|
||||
|
||||
const originalBookingEvt = {
|
||||
...evt,
|
||||
title: originalRescheduledBooking.title,
|
||||
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
|
||||
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
|
||||
attendees: updatedBookingAttendees,
|
||||
// If the location is a video integration then include the videoCallData
|
||||
...(videoReference && {
|
||||
videoCallData: {
|
||||
type: videoReference.type,
|
||||
id: videoReference.meetingId,
|
||||
password: videoReference.meetingPassword,
|
||||
url: videoReference.meetingUrl,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (!bookingSeat) {
|
||||
// if no bookingSeat is given and the userId != owner, 401.
|
||||
// TODO: Next step; Evaluate ownership, what about teams?
|
||||
if (booking.user?.id !== req.userId) {
|
||||
throw new HttpError({ statusCode: 401 });
|
||||
}
|
||||
|
||||
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
|
||||
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
};
|
||||
});
|
||||
|
||||
// If owner reschedules the event we want to update the entire booking
|
||||
// Also if owner is rescheduling there should be no bookingSeat
|
||||
|
||||
// If there is no booking during the new time slot then update the current booking to the new date
|
||||
if (!newTimeSlotBooking) {
|
||||
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
startTime: evt.startTime,
|
||||
endTime: evt.endTime,
|
||||
cancellationReason: rescheduleReason,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
references: true,
|
||||
payment: true,
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
evt = addVideoCallDataToEvt(newBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
|
||||
|
||||
// @NOTE: This code is duplicated and should be moved to a function
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
|
||||
|
||||
if (results.length > 0 && results.some((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
loggerWithEventDetails.error(
|
||||
`Booking ${organizerUser.name} failed`,
|
||||
JSON.stringify({ error, results })
|
||||
);
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
|
||||
? results[0].updatedEvent
|
||||
: [results[0].updatedEvent];
|
||||
if (updatedEvent) {
|
||||
metadata.hangoutLink = updatedEvent.hangoutLink;
|
||||
metadata.conferenceData = updatedEvent.conferenceData;
|
||||
metadata.entryPoints = updatedEvent.entryPoints;
|
||||
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
const copyEvent = cloneDeep(evt);
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
const foundBooking = await findBookingQuery(newBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking, appsStatus: newBooking.appsStatus };
|
||||
} else {
|
||||
// Merge two bookings together
|
||||
const attendeesToMove = [],
|
||||
attendeesToDelete = [];
|
||||
|
||||
for (const attendee of booking.attendees) {
|
||||
// If the attendee already exists on the new booking then delete the attendee record of the old booking
|
||||
if (
|
||||
newTimeSlotBooking.attendees.some(
|
||||
(newBookingAttendee) => newBookingAttendee.email === attendee.email
|
||||
)
|
||||
) {
|
||||
attendeesToDelete.push(attendee.id);
|
||||
// If the attendee does not exist on the new booking then move that attendee record to the new booking
|
||||
} else {
|
||||
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm that the new event will have enough available seats
|
||||
if (
|
||||
!eventType.seatsPerTimeSlot ||
|
||||
attendeesToMove.length +
|
||||
newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
|
||||
eventType.seatsPerTimeSlot
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" });
|
||||
}
|
||||
|
||||
const moveAttendeeCalls = [];
|
||||
for (const attendeeToMove of attendeesToMove) {
|
||||
moveAttendeeCalls.push(
|
||||
prisma.attendee.update({
|
||||
where: {
|
||||
id: attendeeToMove.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
bookingSeat: {
|
||||
upsert: {
|
||||
create: {
|
||||
referenceUid: uuid(),
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
update: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...moveAttendeeCalls,
|
||||
// Delete any attendees that are already a part of that new time slot booking
|
||||
prisma.attendee.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: attendeesToDelete,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedNewBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: newTimeSlotBooking.id,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
references: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedNewBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
|
||||
}
|
||||
|
||||
// Update the evt object with the new attendees
|
||||
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
|
||||
const evtAttendee = {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
||||
};
|
||||
return evtAttendee;
|
||||
});
|
||||
|
||||
evt.attendees = updatedBookingAttendees;
|
||||
|
||||
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(
|
||||
copyEvent,
|
||||
rescheduleUid,
|
||||
newTimeSlotBooking.id
|
||||
);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
// TODO send reschedule emails to attendees of the old booking
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
|
||||
// Update the old booking with the cancelled status
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
}
|
||||
|
||||
// seatAttendee is null when the organizer is rescheduling.
|
||||
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
|
||||
if (seatAttendee) {
|
||||
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
|
||||
|
||||
// If there is no booking then remove the attendee from the old booking and create a new one
|
||||
if (!newTimeSlotBooking) {
|
||||
await prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatAttendee?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the original calendar event by removing the attendee that is rescheduling
|
||||
if (originalBookingEvt && originalRescheduledBooking) {
|
||||
// Event would probably be deleted so we first check than instead of updating references
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
const deletedReference = await lastAttendeeDeleteBooking(
|
||||
originalRescheduledBooking,
|
||||
filteredAttendees,
|
||||
originalBookingEvt
|
||||
);
|
||||
|
||||
if (!deletedReference) {
|
||||
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to trigger rescheduling logic of the original booking
|
||||
originalRescheduledBooking = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
|
||||
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
|
||||
if (seatAttendee?.id && bookingSeat?.id) {
|
||||
await Promise.all([
|
||||
await prisma.attendee.update({
|
||||
where: {
|
||||
id: seatAttendee.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
await prisma.bookingSeat.update({
|
||||
where: {
|
||||
id: bookingSeat.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
|
||||
}
|
||||
} else {
|
||||
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
|
||||
const bookingAttendees = booking.attendees.map((attendee) => {
|
||||
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
|
||||
});
|
||||
|
||||
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
|
||||
|
||||
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= booking.attendees.length) {
|
||||
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
|
||||
}
|
||||
|
||||
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
|
||||
|
||||
if (videoCallReference) {
|
||||
evt.videoCallData = {
|
||||
type: videoCallReference.type,
|
||||
id: videoCallReference.meetingId,
|
||||
password: videoCallReference?.meetingPassword,
|
||||
url: videoCallReference.meetingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const attendeeUniqueId = uuid();
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: reqBody.bookingUid,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
create: {
|
||||
email: invitee[0].email,
|
||||
name: invitee[0].name,
|
||||
timeZone: invitee[0].timeZone,
|
||||
locale: invitee[0].language.locale,
|
||||
bookingSeat: {
|
||||
create: {
|
||||
referenceUid: attendeeUniqueId,
|
||||
data: {
|
||||
description: additionalNotes,
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
|
||||
},
|
||||
});
|
||||
|
||||
evt.attendeeSeatId = attendeeUniqueId;
|
||||
|
||||
const newSeat = booking.attendees.length !== 0;
|
||||
|
||||
/**
|
||||
* Remember objects are passed into functions as references
|
||||
* so if you modify it in a inner function it will be modified in the outer function
|
||||
* deep cloning evt to avoid this
|
||||
*/
|
||||
if (!evt?.uid) {
|
||||
evt.uid = booking?.uid ?? null;
|
||||
}
|
||||
const copyEvent = cloneDeep(evt);
|
||||
copyEvent.uid = booking.uid;
|
||||
if (noEmail !== true) {
|
||||
let isHostConfirmationEmailsDisabled = false;
|
||||
let isAttendeeConfirmationEmailDisabled = false;
|
||||
|
||||
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
|
||||
|
||||
if (eventType.workflows) {
|
||||
isHostConfirmationEmailsDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
|
||||
isAttendeeConfirmationEmailDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
|
||||
|
||||
if (isHostConfirmationEmailsDisabled) {
|
||||
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
|
||||
}
|
||||
|
||||
if (isAttendeeConfirmationEmailDisabled) {
|
||||
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
|
||||
}
|
||||
}
|
||||
await sendScheduledSeatsEmails(
|
||||
copyEvent,
|
||||
invitee[0],
|
||||
newSeat,
|
||||
!!eventType.seatsShowAttendees,
|
||||
isHostConfirmationEmailsDisabled,
|
||||
isAttendeeConfirmationEmailDisabled
|
||||
);
|
||||
}
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
await eventManager.updateCalendarAttendees(evt, booking);
|
||||
|
||||
const foundBooking = await findBookingQuery(booking.id);
|
||||
|
||||
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) {
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
...(paymentAppData.credentialId
|
||||
? { id: paymentAppData.credentialId }
|
||||
: { userId: organizerUser.id }),
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: ["payment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
||||
return credential.appId === paymentAppData.appId;
|
||||
});
|
||||
|
||||
if (!eventTypePaymentAppCredential) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
}
|
||||
if (!eventTypePaymentAppCredential?.appId) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
|
||||
}
|
||||
|
||||
const payment = await handlePayment(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking,
|
||||
fullName,
|
||||
bookerEmail
|
||||
);
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
resultBooking["message"] = "Payment required";
|
||||
resultBooking["paymentUid"] = payment?.uid;
|
||||
resultBooking["id"] = payment?.id;
|
||||
} else {
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
|
||||
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
|
||||
}
|
||||
|
||||
// Here we should handle every after action that needs to be done after booking creation
|
||||
|
||||
// Obtain event metadata that includes videoCallUrl
|
||||
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
|
||||
try {
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
isNotConfirmed: rescheduleUid ? false : evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
|
||||
});
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
const webhookData = {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
uid: resultBooking?.uid || uid,
|
||||
bookingId: booking?.id,
|
||||
rescheduleId: originalRescheduledBooking?.id || undefined,
|
||||
rescheduleUid,
|
||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||
: undefined,
|
||||
rescheduleEndTime: originalRescheduledBooking?.endTime
|
||||
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
||||
: undefined,
|
||||
metadata: { ...metadata, ...reqBody.metadata },
|
||||
eventTypeId,
|
||||
status: "ACCEPTED",
|
||||
smsReminderNumber: booking?.smsReminderNumber || undefined,
|
||||
};
|
||||
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
// For seats, if the booking already exists then we want to add the new attendee to the existing booking
|
||||
if (eventType.seatsPerTimeSlot && (reqBody.bookingUid || rescheduleUid)) {
|
||||
const newBooking = await handleSeats();
|
||||
const newBooking = await handleSeats({
|
||||
rescheduleUid,
|
||||
reqBookingUid: reqBody.bookingUid,
|
||||
eventType,
|
||||
evt,
|
||||
invitee,
|
||||
allCredentials,
|
||||
organizerUser,
|
||||
originalRescheduledBooking,
|
||||
bookerEmail,
|
||||
tAttendees,
|
||||
bookingSeat,
|
||||
reqUserId: req.userId,
|
||||
rescheduleReason,
|
||||
reqBodyUser: reqBody.user,
|
||||
noEmail,
|
||||
isConfirmedByDefault,
|
||||
additionalNotes,
|
||||
reqAppsStatus,
|
||||
attendeeLanguage,
|
||||
paymentAppData,
|
||||
fullName,
|
||||
smsReminderNumber,
|
||||
eventTypeInfo,
|
||||
uid,
|
||||
eventTypeId,
|
||||
reqBodyMetadata: reqBody.metadata,
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
});
|
||||
if (newBooking) {
|
||||
req.statusCode = 201;
|
||||
return newBooking;
|
||||
@@ -2219,7 +1592,9 @@ async function handler(
|
||||
let results: EventResult<AdditionalInformation & { url?: string; iCalUID?: string }>[] = [];
|
||||
let referencesToCreate: PartialReference[] = [];
|
||||
|
||||
let booking: (Booking & { appsStatus?: AppsStatus[] }) | null = null;
|
||||
let booking: (Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number }) | null =
|
||||
null;
|
||||
|
||||
loggerWithEventDetails.debug(
|
||||
"Going to create booking in DB now",
|
||||
safeStringify({
|
||||
@@ -2321,7 +1696,7 @@ async function handler(
|
||||
);
|
||||
}
|
||||
|
||||
addVideoCallDataToEvt(originalRescheduledBooking.references, evt);
|
||||
evt = addVideoCallDataToEvent(originalRescheduledBooking.references, evt);
|
||||
|
||||
//update original rescheduled booking (no seats event)
|
||||
if (!eventType.seatsPerTimeSlot) {
|
||||
@@ -2909,6 +2284,7 @@ async function handler(
|
||||
req.statusCode = 201;
|
||||
return {
|
||||
...booking,
|
||||
references: referencesToCreate,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
};
|
||||
}
|
||||
|
||||
+207
@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { OrganizerDefaultConferencingAppType } from "@calcom/app-store/locations";
|
||||
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
@@ -1087,6 +1088,212 @@ describe("handleNewBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
`When event type location is Organizer Default App and user metadata is empty, default to Cal Video`,
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const otherTeamMembers = [
|
||||
{
|
||||
name: "Other Team Member 1",
|
||||
username: "other-team-member-1",
|
||||
timeZone: Timezones["+5:30"],
|
||||
defaultScheduleId: 1001,
|
||||
email: "other-team-member-1@example.com",
|
||||
id: 102,
|
||||
schedules: [
|
||||
{
|
||||
...TestData.schedules.IstWorkHours,
|
||||
// Specify an ID directly here because we want to be able to use that ID in defaultScheduleId above.
|
||||
id: 1001,
|
||||
},
|
||||
],
|
||||
credentials: [getGoogleCalendarCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "other-team-member-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [
|
||||
{
|
||||
id: 2,
|
||||
...getGoogleCalendarCredential(),
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
...getZoomAppCredential(),
|
||||
},
|
||||
],
|
||||
metadata: {},
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "organizer@google-calendar.com",
|
||||
},
|
||||
});
|
||||
|
||||
const { eventTypes } = await createBookingScenario(
|
||||
getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 45,
|
||||
schedulingType: SchedulingType.COLLECTIVE,
|
||||
length: 45,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
{
|
||||
id: 102,
|
||||
},
|
||||
],
|
||||
locations: [
|
||||
{
|
||||
type: "conferencing",
|
||||
},
|
||||
],
|
||||
destinationCalendar: {
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
usersApartFromOrganizer: otherTeamMembers,
|
||||
apps: [
|
||||
TestData.apps["google-calendar"],
|
||||
TestData.apps["daily-video"],
|
||||
TestData.apps["zoomvideo"],
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: appStoreMetadata.dailyvideo.dirName,
|
||||
videoMeetingData: {
|
||||
id: "MOCK_ID",
|
||||
password: "MOCK_PASS",
|
||||
url: `http://mock-dailyvideo.example.com/meeting-1`,
|
||||
},
|
||||
});
|
||||
|
||||
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
|
||||
create: {
|
||||
id: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
},
|
||||
});
|
||||
|
||||
const mockBookingData = getMockRequestDataForBooking({
|
||||
data: {
|
||||
start: `${getDate({ dateIncrement: 1 }).dateString}T05:00:00.000Z`,
|
||||
end: `${getDate({ dateIncrement: 1 }).dateString}T05:30:00.000Z`,
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
location: { optionValue: "", value: OrganizerDefaultConferencingAppType },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: mockBookingData,
|
||||
});
|
||||
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
|
||||
await expectBookingToBeInDatabase({
|
||||
description: "",
|
||||
location: BookingLocations.CalVideo,
|
||||
responses: expect.objectContaining({
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
uid: createdBooking.uid!,
|
||||
eventTypeId: mockBookingData.eventTypeId,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
references: [
|
||||
{
|
||||
type: appStoreMetadata.dailyvideo.type,
|
||||
uid: "MOCK_ID",
|
||||
meetingId: "MOCK_ID",
|
||||
meetingPassword: "MOCK_PASS",
|
||||
meetingUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
},
|
||||
{
|
||||
type: TestData.apps["google-calendar"].type,
|
||||
uid: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingId: "MOCKED_GOOGLE_CALENDAR_EVENT_ID",
|
||||
meetingPassword: "MOCK_PASSWORD",
|
||||
meetingUrl: "https://UNUSED_URL",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// expectWorkflowToBeTriggered();
|
||||
|
||||
expectSuccessfulCalendarEventCreationInCalendar(calendarMock, {
|
||||
destinationCalendars: [
|
||||
{
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "event-type-1@google-calendar.com",
|
||||
},
|
||||
{
|
||||
integration: TestData.apps["google-calendar"].type,
|
||||
externalId: "other-team-member-1@google-calendar.com",
|
||||
},
|
||||
],
|
||||
videoCallUrl: "http://mock-dailyvideo.example.com/meeting-1",
|
||||
});
|
||||
|
||||
expectSuccessfulBookingCreationEmails({
|
||||
booking: {
|
||||
uid: createdBooking.uid!,
|
||||
},
|
||||
booker,
|
||||
organizer,
|
||||
otherTeamMembers,
|
||||
emails,
|
||||
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
|
||||
});
|
||||
|
||||
expectBookingCreatedWebhookToHaveBeenFired({
|
||||
booker,
|
||||
organizer,
|
||||
location: OrganizerDefaultConferencingAppType,
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
videoCallUrl: `${WEBAPP_URL}/video/${createdBooking.uid}`,
|
||||
});
|
||||
},
|
||||
timeout
|
||||
);
|
||||
|
||||
describe("Team(T1) not part of any org but the organizer is part of an organization(O1)", () => {
|
||||
test(
|
||||
`succesfully creates a booking when all the hosts are free as per their schedules
|
||||
|
||||
@@ -1,796 +0,0 @@
|
||||
import type { Prisma, Attendee } from "@prisma/client";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import type { TFunction } from "next-i18next";
|
||||
import type short from "short-uuid";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { sendRescheduledEmails, sendRescheduledSeatEmail, sendScheduledSeatsEmails } from "@calcom/emails";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import type { getFullName } from "@calcom/features/form-builder/utils";
|
||||
import type { GetSubscriberOptions } from "@calcom/features/webhooks/lib/getWebhooks";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { AdditionalInformation, AppsStatus, CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import type { EventTypeInfo } from "../../webhooks/lib/sendPayload";
|
||||
import {
|
||||
refreshCredentials,
|
||||
addVideoCallDataToEvt,
|
||||
createLoggerWithEventDetails,
|
||||
handleAppsStatus,
|
||||
findBookingQuery,
|
||||
} from "./handleNewBooking";
|
||||
import type {
|
||||
Booking,
|
||||
Invitee,
|
||||
NewBookingEventType,
|
||||
getAllCredentials,
|
||||
OrganizerUser,
|
||||
OriginalRescheduledBooking,
|
||||
RescheduleReason,
|
||||
NoEmail,
|
||||
IsConfirmedByDefault,
|
||||
AdditionalNotes,
|
||||
ReqAppsStatus,
|
||||
PaymentAppData,
|
||||
IEventTypePaymentCredentialType,
|
||||
SmsReminderNumber,
|
||||
EventTypeId,
|
||||
ReqBodyMetadata,
|
||||
} from "./handleNewBooking";
|
||||
|
||||
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
|
||||
|
||||
/* Check if the original booking has no more attendees, if so delete the booking
|
||||
and any calendar or video integrations */
|
||||
const lastAttendeeDeleteBooking = async (
|
||||
originalRescheduledBooking: OriginalRescheduledBooking,
|
||||
filteredAttendees: Partial<Attendee>[],
|
||||
originalBookingEvt?: CalendarEvent
|
||||
) => {
|
||||
let deletedReferences = false;
|
||||
if (filteredAttendees && filteredAttendees.length === 0 && originalRescheduledBooking) {
|
||||
const integrationsToDelete = [];
|
||||
|
||||
for (const reference of originalRescheduledBooking.references) {
|
||||
if (reference.credentialId) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: reference.credentialId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
if (reference.type.includes("_video")) {
|
||||
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
|
||||
}
|
||||
if (reference.type.includes("_calendar") && originalBookingEvt) {
|
||||
const calendar = await getCalendar(credential);
|
||||
if (calendar) {
|
||||
integrationsToDelete.push(
|
||||
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(integrationsToDelete).then(async () => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: originalRescheduledBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
deletedReferences = true;
|
||||
}
|
||||
return deletedReferences;
|
||||
};
|
||||
|
||||
const handleSeats = async ({
|
||||
rescheduleUid,
|
||||
reqBookingUid,
|
||||
eventType,
|
||||
evt,
|
||||
invitee,
|
||||
allCredentials,
|
||||
organizerUser,
|
||||
originalRescheduledBooking,
|
||||
bookerEmail,
|
||||
tAttendees,
|
||||
bookingSeat,
|
||||
reqUserId,
|
||||
rescheduleReason,
|
||||
reqBodyUser,
|
||||
noEmail,
|
||||
isConfirmedByDefault,
|
||||
additionalNotes,
|
||||
reqAppsStatus,
|
||||
attendeeLanguage,
|
||||
paymentAppData,
|
||||
fullName,
|
||||
smsReminderNumber,
|
||||
eventTypeInfo,
|
||||
uid,
|
||||
eventTypeId,
|
||||
reqBodyMetadata,
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
}: {
|
||||
rescheduleUid: string;
|
||||
reqBookingUid: string;
|
||||
eventType: NewBookingEventType;
|
||||
evt: CalendarEvent;
|
||||
invitee: Invitee;
|
||||
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
|
||||
organizerUser: OrganizerUser;
|
||||
originalRescheduledBooking: OriginalRescheduledBooking;
|
||||
bookerEmail: string;
|
||||
tAttendees: TFunction;
|
||||
bookingSeat: BookingSeat;
|
||||
reqUserId: number | undefined;
|
||||
rescheduleReason: RescheduleReason;
|
||||
reqBodyUser: string | string[] | undefined;
|
||||
noEmail: NoEmail;
|
||||
isConfirmedByDefault: IsConfirmedByDefault;
|
||||
additionalNotes: AdditionalNotes;
|
||||
reqAppsStatus: ReqAppsStatus;
|
||||
attendeeLanguage: string | null;
|
||||
paymentAppData: PaymentAppData;
|
||||
fullName: ReturnType<typeof getFullName>;
|
||||
smsReminderNumber: SmsReminderNumber;
|
||||
eventTypeInfo: EventTypeInfo;
|
||||
uid: short.SUUID;
|
||||
eventTypeId: EventTypeId;
|
||||
reqBodyMetadata: ReqBodyMetadata;
|
||||
subscriberOptions: GetSubscriberOptions;
|
||||
eventTrigger: WebhookTriggerEvents;
|
||||
}) => {
|
||||
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
|
||||
|
||||
let resultBooking:
|
||||
| (Partial<Booking> & {
|
||||
appsStatus?: AppsStatus[];
|
||||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
| null = null;
|
||||
|
||||
const booking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
uid: rescheduleUid || reqBookingUid,
|
||||
},
|
||||
{
|
||||
eventTypeId: eventType.id,
|
||||
startTime: evt.startTime,
|
||||
},
|
||||
],
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
id: true,
|
||||
attendees: { include: { bookingSeat: true } },
|
||||
userId: true,
|
||||
references: true,
|
||||
startTime: true,
|
||||
user: true,
|
||||
status: true,
|
||||
smsReminderNumber: true,
|
||||
endTime: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!booking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Could not find booking" });
|
||||
}
|
||||
|
||||
// See if attendee is already signed up for timeslot
|
||||
if (
|
||||
booking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
|
||||
dayjs.utc(booking.startTime).format() === evt.startTime
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: "Already signed up for this booking." });
|
||||
}
|
||||
|
||||
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
|
||||
if (rescheduleUid) {
|
||||
// See if the new date has a booking already
|
||||
const newTimeSlotBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
startTime: evt.startTime,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
|
||||
if (!originalRescheduledBooking) {
|
||||
// typescript isn't smart enough;
|
||||
throw new Error("Internal Error.");
|
||||
}
|
||||
|
||||
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
|
||||
(filteredAttendees, attendee) => {
|
||||
if (attendee.email === bookerEmail) {
|
||||
return filteredAttendees; // skip current booker, as we know the language already.
|
||||
}
|
||||
filteredAttendees.push({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
});
|
||||
return filteredAttendees;
|
||||
},
|
||||
[] as Person[]
|
||||
);
|
||||
|
||||
// If original booking has video reference we need to add the videoCallData to the new evt
|
||||
const videoReference = originalRescheduledBooking.references.find((reference) =>
|
||||
reference.type.includes("_video")
|
||||
);
|
||||
|
||||
const originalBookingEvt = {
|
||||
...evt,
|
||||
title: originalRescheduledBooking.title,
|
||||
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
|
||||
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
|
||||
attendees: updatedBookingAttendees,
|
||||
// If the location is a video integration then include the videoCallData
|
||||
...(videoReference && {
|
||||
videoCallData: {
|
||||
type: videoReference.type,
|
||||
id: videoReference.meetingId,
|
||||
password: videoReference.meetingPassword,
|
||||
url: videoReference.meetingUrl,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (!bookingSeat) {
|
||||
// if no bookingSeat is given and the userId != owner, 401.
|
||||
// TODO: Next step; Evaluate ownership, what about teams?
|
||||
if (booking.user?.id !== reqUserId) {
|
||||
throw new HttpError({ statusCode: 401 });
|
||||
}
|
||||
|
||||
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
|
||||
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
};
|
||||
});
|
||||
|
||||
// If owner reschedules the event we want to update the entire booking
|
||||
// Also if owner is rescheduling there should be no bookingSeat
|
||||
|
||||
// If there is no booking during the new time slot then update the current booking to the new date
|
||||
if (!newTimeSlotBooking) {
|
||||
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
startTime: evt.startTime,
|
||||
endTime: evt.endTime,
|
||||
cancellationReason: rescheduleReason,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
references: true,
|
||||
payment: true,
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
evt = addVideoCallDataToEvt(newBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
|
||||
|
||||
// @NOTE: This code is duplicated and should be moved to a function
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
|
||||
|
||||
if (results.length > 0 && results.some((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
loggerWithEventDetails.error(
|
||||
`Booking ${organizerUser.name} failed`,
|
||||
JSON.stringify({ error, results })
|
||||
);
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
|
||||
? results[0].updatedEvent
|
||||
: [results[0].updatedEvent];
|
||||
if (updatedEvent) {
|
||||
metadata.hangoutLink = updatedEvent.hangoutLink;
|
||||
metadata.conferenceData = updatedEvent.conferenceData;
|
||||
metadata.entryPoints = updatedEvent.entryPoints;
|
||||
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
const copyEvent = cloneDeep(evt);
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
const foundBooking = await findBookingQuery(newBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking, appsStatus: newBooking.appsStatus };
|
||||
} else {
|
||||
// Merge two bookings together
|
||||
const attendeesToMove = [],
|
||||
attendeesToDelete = [];
|
||||
|
||||
for (const attendee of booking.attendees) {
|
||||
// If the attendee already exists on the new booking then delete the attendee record of the old booking
|
||||
if (
|
||||
newTimeSlotBooking.attendees.some(
|
||||
(newBookingAttendee) => newBookingAttendee.email === attendee.email
|
||||
)
|
||||
) {
|
||||
attendeesToDelete.push(attendee.id);
|
||||
// If the attendee does not exist on the new booking then move that attendee record to the new booking
|
||||
} else {
|
||||
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm that the new event will have enough available seats
|
||||
if (
|
||||
!eventType.seatsPerTimeSlot ||
|
||||
attendeesToMove.length +
|
||||
newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
|
||||
eventType.seatsPerTimeSlot
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: "Booking does not have enough available seats" });
|
||||
}
|
||||
|
||||
const moveAttendeeCalls = [];
|
||||
for (const attendeeToMove of attendeesToMove) {
|
||||
moveAttendeeCalls.push(
|
||||
prisma.attendee.update({
|
||||
where: {
|
||||
id: attendeeToMove.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
bookingSeat: {
|
||||
upsert: {
|
||||
create: {
|
||||
referenceUid: uuid(),
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
update: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
...moveAttendeeCalls,
|
||||
// Delete any attendees that are already a part of that new time slot booking
|
||||
prisma.attendee.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: attendeesToDelete,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedNewBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: newTimeSlotBooking.id,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
references: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedNewBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
|
||||
}
|
||||
|
||||
// Update the evt object with the new attendees
|
||||
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
|
||||
const evtAttendee = {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
||||
};
|
||||
return evtAttendee;
|
||||
});
|
||||
|
||||
evt.attendees = updatedBookingAttendees;
|
||||
|
||||
evt = addVideoCallDataToEvt(updatedNewBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
// TODO send reschedule emails to attendees of the old booking
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
|
||||
// Update the old booking with the cancelled status
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: booking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
}
|
||||
|
||||
// seatAttendee is null when the organizer is rescheduling.
|
||||
const seatAttendee: Partial<Person> | null = bookingSeat?.attendee || null;
|
||||
if (seatAttendee) {
|
||||
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
|
||||
|
||||
// If there is no booking then remove the attendee from the old booking and create a new one
|
||||
if (!newTimeSlotBooking) {
|
||||
await prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatAttendee?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the original calendar event by removing the attendee that is rescheduling
|
||||
if (originalBookingEvt && originalRescheduledBooking) {
|
||||
// Event would probably be deleted so we first check than instead of updating references
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
const deletedReference = await lastAttendeeDeleteBooking(
|
||||
originalRescheduledBooking,
|
||||
filteredAttendees,
|
||||
originalBookingEvt
|
||||
);
|
||||
|
||||
if (!deletedReference) {
|
||||
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to trigger rescheduling logic of the original booking
|
||||
originalRescheduledBooking = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
|
||||
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
|
||||
if (seatAttendee?.id && bookingSeat?.id) {
|
||||
await Promise.all([
|
||||
await prisma.attendee.update({
|
||||
where: {
|
||||
id: seatAttendee.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
await prisma.bookingSeat.update({
|
||||
where: {
|
||||
id: bookingSeat.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
resultBooking = { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
|
||||
}
|
||||
} else {
|
||||
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
|
||||
const bookingAttendees = booking.attendees.map((attendee) => {
|
||||
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
|
||||
});
|
||||
|
||||
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
|
||||
|
||||
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= booking.attendees.length) {
|
||||
throw new HttpError({ statusCode: 409, message: "Booking seats are full" });
|
||||
}
|
||||
|
||||
const videoCallReference = booking.references.find((reference) => reference.type.includes("_video"));
|
||||
|
||||
if (videoCallReference) {
|
||||
evt.videoCallData = {
|
||||
type: videoCallReference.type,
|
||||
id: videoCallReference.meetingId,
|
||||
password: videoCallReference?.meetingPassword,
|
||||
url: videoCallReference.meetingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const attendeeUniqueId = uuid();
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: reqBookingUid,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
create: {
|
||||
email: invitee[0].email,
|
||||
name: invitee[0].name,
|
||||
timeZone: invitee[0].timeZone,
|
||||
locale: invitee[0].language.locale,
|
||||
bookingSeat: {
|
||||
create: {
|
||||
referenceUid: attendeeUniqueId,
|
||||
data: {
|
||||
description: additionalNotes,
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: booking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(booking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
|
||||
},
|
||||
});
|
||||
|
||||
evt.attendeeSeatId = attendeeUniqueId;
|
||||
|
||||
const newSeat = booking.attendees.length !== 0;
|
||||
|
||||
/**
|
||||
* Remember objects are passed into functions as references
|
||||
* so if you modify it in a inner function it will be modified in the outer function
|
||||
* deep cloning evt to avoid this
|
||||
*/
|
||||
if (!evt?.uid) {
|
||||
evt.uid = booking?.uid ?? null;
|
||||
}
|
||||
const copyEvent = cloneDeep(evt);
|
||||
copyEvent.uid = booking.uid;
|
||||
if (noEmail !== true) {
|
||||
let isHostConfirmationEmailsDisabled = false;
|
||||
let isAttendeeConfirmationEmailDisabled = false;
|
||||
|
||||
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
|
||||
|
||||
if (eventType.workflows) {
|
||||
isHostConfirmationEmailsDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
|
||||
isAttendeeConfirmationEmailDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
|
||||
|
||||
if (isHostConfirmationEmailsDisabled) {
|
||||
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
|
||||
}
|
||||
|
||||
if (isAttendeeConfirmationEmailDisabled) {
|
||||
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
|
||||
}
|
||||
}
|
||||
await sendScheduledSeatsEmails(
|
||||
copyEvent,
|
||||
invitee[0],
|
||||
newSeat,
|
||||
!!eventType.seatsShowAttendees,
|
||||
isHostConfirmationEmailsDisabled,
|
||||
isAttendeeConfirmationEmailDisabled
|
||||
);
|
||||
}
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
await eventManager.updateCalendarAttendees(evt, booking);
|
||||
|
||||
const foundBooking = await findBookingQuery(booking.id);
|
||||
|
||||
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!booking) {
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
...(paymentAppData.credentialId
|
||||
? { id: paymentAppData.credentialId }
|
||||
: { userId: organizerUser.id }),
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: ["payment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
||||
return credential.appId === paymentAppData.appId;
|
||||
});
|
||||
|
||||
if (!eventTypePaymentAppCredential) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment credentials" });
|
||||
}
|
||||
if (!eventTypePaymentAppCredential?.appId) {
|
||||
throw new HttpError({ statusCode: 400, message: "Missing payment app id" });
|
||||
}
|
||||
|
||||
const payment = await handlePayment(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
booking,
|
||||
fullName,
|
||||
bookerEmail
|
||||
);
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
resultBooking["message"] = "Payment required";
|
||||
resultBooking["paymentUid"] = payment?.uid;
|
||||
resultBooking["id"] = payment?.id;
|
||||
} else {
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
|
||||
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
|
||||
}
|
||||
|
||||
// Here we should handle every after action that needs to be done after booking creation
|
||||
|
||||
// Obtain event metadata that includes videoCallUrl
|
||||
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
|
||||
try {
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
isNotConfirmed: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
|
||||
});
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
const webhookData = {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
uid: resultBooking?.uid || uid,
|
||||
bookingId: booking?.id,
|
||||
rescheduleUid,
|
||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||
: undefined,
|
||||
rescheduleEndTime: originalRescheduledBooking?.endTime
|
||||
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
||||
: undefined,
|
||||
metadata: { ...metadata, ...reqBodyMetadata },
|
||||
eventTypeId,
|
||||
status: "ACCEPTED",
|
||||
smsReminderNumber: booking?.smsReminderNumber || undefined,
|
||||
};
|
||||
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default handleSeats;
|
||||
@@ -0,0 +1,204 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import { sendScheduledSeatsEmails } from "@calcom/emails";
|
||||
import {
|
||||
allowDisablingAttendeeConfirmationEmails,
|
||||
allowDisablingHostConfirmationEmails,
|
||||
} from "@calcom/features/ee/workflows/lib/allowDisablingStandardEmails";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { handlePayment } from "@calcom/lib/payment/handlePayment";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { IEventTypePaymentCredentialType } from "../../handleNewBooking";
|
||||
import { refreshCredentials, findBookingQuery } from "../../handleNewBooking";
|
||||
import type { SeatedBooking, NewSeatedBookingObject, HandleSeatsResultBooking } from "../types";
|
||||
|
||||
const createNewSeat = async (
|
||||
rescheduleSeatedBookingObject: NewSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking
|
||||
) => {
|
||||
const {
|
||||
tAttendees,
|
||||
attendeeLanguage,
|
||||
invitee,
|
||||
eventType,
|
||||
reqBookingUid,
|
||||
additionalNotes,
|
||||
noEmail,
|
||||
paymentAppData,
|
||||
allCredentials,
|
||||
organizerUser,
|
||||
fullName,
|
||||
bookerEmail,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
let resultBooking: HandleSeatsResultBooking;
|
||||
// Need to add translation for attendees to pass type checks. Since these values are never written to the db we can just use the new attendee language
|
||||
const bookingAttendees = seatedBooking.attendees.map((attendee) => {
|
||||
return { ...attendee, language: { translate: tAttendees, locale: attendeeLanguage ?? "en" } };
|
||||
});
|
||||
|
||||
evt = { ...evt, attendees: [...bookingAttendees, invitee[0]] };
|
||||
|
||||
if (eventType.seatsPerTimeSlot && eventType.seatsPerTimeSlot <= seatedBooking.attendees.length) {
|
||||
throw new HttpError({ statusCode: 409, message: ErrorCode.BookingSeatsFull });
|
||||
}
|
||||
|
||||
const videoCallReference = seatedBooking.references.find((reference) => reference.type.includes("_video"));
|
||||
|
||||
if (videoCallReference) {
|
||||
evt.videoCallData = {
|
||||
type: videoCallReference.type,
|
||||
id: videoCallReference.meetingId,
|
||||
password: videoCallReference?.meetingPassword,
|
||||
url: videoCallReference.meetingUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const attendeeUniqueId = uuid();
|
||||
|
||||
const inviteeToAdd = invitee[0];
|
||||
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
uid: reqBookingUid,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
},
|
||||
data: {
|
||||
attendees: {
|
||||
create: {
|
||||
email: inviteeToAdd.email,
|
||||
name: inviteeToAdd.name,
|
||||
timeZone: inviteeToAdd.timeZone,
|
||||
locale: inviteeToAdd.language.locale,
|
||||
bookingSeat: {
|
||||
create: {
|
||||
referenceUid: attendeeUniqueId,
|
||||
data: {
|
||||
description: additionalNotes,
|
||||
},
|
||||
booking: {
|
||||
connect: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(seatedBooking.status === BookingStatus.CANCELLED && { status: BookingStatus.ACCEPTED }),
|
||||
},
|
||||
});
|
||||
|
||||
evt.attendeeSeatId = attendeeUniqueId;
|
||||
|
||||
const newSeat = seatedBooking.attendees.length !== 0;
|
||||
|
||||
/**
|
||||
* Remember objects are passed into functions as references
|
||||
* so if you modify it in a inner function it will be modified in the outer function
|
||||
* deep cloning evt to avoid this
|
||||
*/
|
||||
if (!evt?.uid) {
|
||||
evt.uid = seatedBooking?.uid ?? null;
|
||||
}
|
||||
const copyEvent = cloneDeep(evt);
|
||||
copyEvent.uid = seatedBooking.uid;
|
||||
if (noEmail !== true) {
|
||||
let isHostConfirmationEmailsDisabled = false;
|
||||
let isAttendeeConfirmationEmailDisabled = false;
|
||||
|
||||
const workflows = eventType.workflows.map((workflow) => workflow.workflow);
|
||||
|
||||
if (eventType.workflows) {
|
||||
isHostConfirmationEmailsDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.host || false;
|
||||
isAttendeeConfirmationEmailDisabled =
|
||||
eventType.metadata?.disableStandardEmails?.confirmation?.attendee || false;
|
||||
|
||||
if (isHostConfirmationEmailsDisabled) {
|
||||
isHostConfirmationEmailsDisabled = allowDisablingHostConfirmationEmails(workflows);
|
||||
}
|
||||
|
||||
if (isAttendeeConfirmationEmailDisabled) {
|
||||
isAttendeeConfirmationEmailDisabled = allowDisablingAttendeeConfirmationEmails(workflows);
|
||||
}
|
||||
}
|
||||
await sendScheduledSeatsEmails(
|
||||
copyEvent,
|
||||
inviteeToAdd,
|
||||
newSeat,
|
||||
!!eventType.seatsShowAttendees,
|
||||
isHostConfirmationEmailsDisabled,
|
||||
isAttendeeConfirmationEmailDisabled
|
||||
);
|
||||
}
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
await eventManager.updateCalendarAttendees(evt, seatedBooking);
|
||||
|
||||
const foundBooking = await findBookingQuery(seatedBooking.id);
|
||||
|
||||
if (!Number.isNaN(paymentAppData.price) && paymentAppData.price > 0 && !!seatedBooking) {
|
||||
const credentialPaymentAppCategories = await prisma.credential.findMany({
|
||||
where: {
|
||||
...(paymentAppData.credentialId ? { id: paymentAppData.credentialId } : { userId: organizerUser.id }),
|
||||
app: {
|
||||
categories: {
|
||||
hasSome: ["payment"],
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
key: true,
|
||||
appId: true,
|
||||
app: {
|
||||
select: {
|
||||
categories: true,
|
||||
dirName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const eventTypePaymentAppCredential = credentialPaymentAppCategories.find((credential) => {
|
||||
return credential.appId === paymentAppData.appId;
|
||||
});
|
||||
|
||||
if (!eventTypePaymentAppCredential) {
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentCredential });
|
||||
}
|
||||
if (!eventTypePaymentAppCredential?.appId) {
|
||||
throw new HttpError({ statusCode: 400, message: ErrorCode.MissingPaymentAppId });
|
||||
}
|
||||
|
||||
const payment = await handlePayment(
|
||||
evt,
|
||||
eventType,
|
||||
eventTypePaymentAppCredential as IEventTypePaymentCredentialType,
|
||||
seatedBooking,
|
||||
fullName,
|
||||
bookerEmail
|
||||
);
|
||||
|
||||
resultBooking = { ...foundBooking };
|
||||
resultBooking["message"] = "Payment required";
|
||||
resultBooking["paymentUid"] = payment?.uid;
|
||||
resultBooking["id"] = payment?.id;
|
||||
} else {
|
||||
resultBooking = { ...foundBooking };
|
||||
}
|
||||
|
||||
resultBooking["seatReferenceUid"] = evt.attendeeSeatId;
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default createNewSeat;
|
||||
@@ -0,0 +1,134 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { handleWebhookTrigger } from "@calcom/features/bookings/lib/handleWebhookTrigger";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import { createLoggerWithEventDetails } from "../handleNewBooking";
|
||||
import createNewSeat from "./create/createNewSeat";
|
||||
import rescheduleSeatedBooking from "./reschedule/rescheduleSeatedBooking";
|
||||
import type { NewSeatedBookingObject, SeatedBooking, HandleSeatsResultBooking } from "./types";
|
||||
|
||||
const handleSeats = async (newSeatedBookingObject: NewSeatedBookingObject) => {
|
||||
const {
|
||||
eventType,
|
||||
reqBodyUser,
|
||||
rescheduleUid,
|
||||
reqBookingUid,
|
||||
invitee,
|
||||
bookerEmail,
|
||||
smsReminderNumber,
|
||||
eventTypeInfo,
|
||||
uid,
|
||||
originalRescheduledBooking,
|
||||
reqBodyMetadata,
|
||||
eventTypeId,
|
||||
subscriberOptions,
|
||||
eventTrigger,
|
||||
} = newSeatedBookingObject;
|
||||
const { evt } = newSeatedBookingObject;
|
||||
const loggerWithEventDetails = createLoggerWithEventDetails(eventType.id, reqBodyUser, eventType.slug);
|
||||
|
||||
let resultBooking: HandleSeatsResultBooking = null;
|
||||
|
||||
const seatedBooking: SeatedBooking | null = await prisma.booking.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
uid: rescheduleUid || reqBookingUid,
|
||||
},
|
||||
{
|
||||
eventTypeId: eventType.id,
|
||||
startTime: evt.startTime,
|
||||
},
|
||||
],
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
uid: true,
|
||||
id: true,
|
||||
attendees: { include: { bookingSeat: true } },
|
||||
userId: true,
|
||||
references: true,
|
||||
startTime: true,
|
||||
user: true,
|
||||
status: true,
|
||||
smsReminderNumber: true,
|
||||
endTime: true,
|
||||
scheduledJobs: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!seatedBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: ErrorCode.BookingNotFound });
|
||||
}
|
||||
|
||||
// See if attendee is already signed up for timeslot
|
||||
if (
|
||||
seatedBooking.attendees.find((attendee) => attendee.email === invitee[0].email) &&
|
||||
dayjs.utc(seatedBooking.startTime).format() === evt.startTime
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: ErrorCode.AlreadySignedUpForBooking });
|
||||
}
|
||||
|
||||
// There are two paths here, reschedule a booking with seats and booking seats without reschedule
|
||||
if (rescheduleUid) {
|
||||
resultBooking = await rescheduleSeatedBooking(
|
||||
// Assert that the rescheduleUid is defined
|
||||
{ ...newSeatedBookingObject, rescheduleUid },
|
||||
seatedBooking,
|
||||
resultBooking,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
} else {
|
||||
resultBooking = await createNewSeat(newSeatedBookingObject, seatedBooking);
|
||||
}
|
||||
|
||||
// If the resultBooking is defined we should trigger workflows else, trigger in handleNewBooking
|
||||
if (resultBooking) {
|
||||
// Obtain event metadata that includes videoCallUrl
|
||||
const metadata = evt.videoCallData?.url ? { videoCallUrl: evt.videoCallData.url } : undefined;
|
||||
try {
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: eventType.workflows,
|
||||
smsReminderNumber: smsReminderNumber || null,
|
||||
calendarEvent: { ...evt, ...{ metadata, eventType: { slug: eventType.slug } } },
|
||||
isNotConfirmed: evt.requiresConfirmation || false,
|
||||
isRescheduleEvent: !!rescheduleUid,
|
||||
isFirstRecurringEvent: true,
|
||||
emailAttendeeSendToOverride: bookerEmail,
|
||||
seatReferenceUid: evt.attendeeSeatId,
|
||||
eventTypeRequiresConfirmation: eventType.requiresConfirmation,
|
||||
});
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error("Error while scheduling workflow reminders", JSON.stringify({ error }));
|
||||
}
|
||||
|
||||
const webhookData = {
|
||||
...evt,
|
||||
...eventTypeInfo,
|
||||
uid: resultBooking?.uid || uid,
|
||||
bookingId: seatedBooking?.id,
|
||||
rescheduleUid,
|
||||
rescheduleStartTime: originalRescheduledBooking?.startTime
|
||||
? dayjs(originalRescheduledBooking?.startTime).utc().format()
|
||||
: undefined,
|
||||
rescheduleEndTime: originalRescheduledBooking?.endTime
|
||||
? dayjs(originalRescheduledBooking?.endTime).utc().format()
|
||||
: undefined,
|
||||
metadata: { ...metadata, ...reqBodyMetadata },
|
||||
eventTypeId,
|
||||
status: "ACCEPTED",
|
||||
smsReminderNumber: seatedBooking?.smsReminderNumber || undefined,
|
||||
};
|
||||
|
||||
await handleWebhookTrigger({ subscriberOptions, eventTrigger, webhookData });
|
||||
}
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default handleSeats;
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Attendee } from "@prisma/client";
|
||||
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { getCalendar } from "@calcom/app-store/_utils/getCalendar";
|
||||
import { deleteMeeting } from "@calcom/core/videoClient";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import type { OriginalRescheduledBooking } from "../../handleNewBooking";
|
||||
|
||||
/* Check if the original booking has no more attendees, if so delete the booking
|
||||
and any calendar or video integrations */
|
||||
const lastAttendeeDeleteBooking = async (
|
||||
originalRescheduledBooking: OriginalRescheduledBooking,
|
||||
filteredAttendees: Partial<Attendee>[] | undefined,
|
||||
originalBookingEvt?: CalendarEvent
|
||||
) => {
|
||||
let deletedReferences = false;
|
||||
if ((!filteredAttendees || filteredAttendees.length === 0) && originalRescheduledBooking) {
|
||||
const integrationsToDelete = [];
|
||||
|
||||
for (const reference of originalRescheduledBooking.references) {
|
||||
if (reference.credentialId) {
|
||||
const credential = await prisma.credential.findUnique({
|
||||
where: {
|
||||
id: reference.credentialId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (credential) {
|
||||
if (reference.type.includes("_video")) {
|
||||
integrationsToDelete.push(deleteMeeting(credential, reference.uid));
|
||||
}
|
||||
if (reference.type.includes("_calendar") && originalBookingEvt) {
|
||||
const calendar = await getCalendar(credential);
|
||||
if (calendar) {
|
||||
integrationsToDelete.push(
|
||||
calendar?.deleteEvent(reference.uid, originalBookingEvt, reference.externalCalendarId)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(integrationsToDelete).then(async () => {
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: originalRescheduledBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
});
|
||||
deletedReferences = true;
|
||||
}
|
||||
return deletedReferences;
|
||||
};
|
||||
|
||||
export default lastAttendeeDeleteBooking;
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
import { sendRescheduledSeatEmail } from "@calcom/emails";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { Person, CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { findBookingQuery } from "../../../handleNewBooking";
|
||||
import lastAttendeeDeleteBooking from "../../lib/lastAttendeeDeleteBooking";
|
||||
import type { RescheduleSeatedBookingObject, SeatAttendee, NewTimeSlotBooking } from "../../types";
|
||||
|
||||
const attendeeRescheduleSeatedBooking = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatAttendee: SeatAttendee,
|
||||
newTimeSlotBooking: NewTimeSlotBooking | null,
|
||||
originalBookingEvt: CalendarEvent,
|
||||
eventManager: EventManager
|
||||
) => {
|
||||
const { tAttendees, bookingSeat, bookerEmail, rescheduleUid, evt } = rescheduleSeatedBookingObject;
|
||||
let { originalRescheduledBooking } = rescheduleSeatedBookingObject;
|
||||
|
||||
seatAttendee["language"] = { translate: tAttendees, locale: bookingSeat?.attendee.locale ?? "en" };
|
||||
|
||||
// If there is no booking then remove the attendee from the old booking and create a new one
|
||||
if (!newTimeSlotBooking) {
|
||||
await prisma.attendee.delete({
|
||||
where: {
|
||||
id: seatAttendee?.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the original calendar event by removing the attendee that is rescheduling
|
||||
if (originalBookingEvt && originalRescheduledBooking) {
|
||||
// Event would probably be deleted so we first check than instead of updating references
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
const deletedReference = await lastAttendeeDeleteBooking(
|
||||
originalRescheduledBooking,
|
||||
filteredAttendees,
|
||||
originalBookingEvt
|
||||
);
|
||||
|
||||
if (!deletedReference) {
|
||||
await eventManager.updateCalendarAttendees(originalBookingEvt, originalRescheduledBooking);
|
||||
}
|
||||
}
|
||||
|
||||
// We don't want to trigger rescheduling logic of the original booking
|
||||
originalRescheduledBooking = null;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Need to change the new seat reference and attendee record to remove it from the old booking and add it to the new booking
|
||||
// https://stackoverflow.com/questions/4980963/database-insert-new-rows-or-update-existing-ones
|
||||
if (seatAttendee?.id && bookingSeat?.id) {
|
||||
await prisma.$transaction([
|
||||
prisma.attendee.update({
|
||||
where: {
|
||||
id: seatAttendee.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
prisma.bookingSeat.update({
|
||||
where: {
|
||||
id: bookingSeat.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
await sendRescheduledSeatEmail(copyEvent, seatAttendee as Person);
|
||||
const filteredAttendees = originalRescheduledBooking?.attendees.filter((attendee) => {
|
||||
return attendee.email !== bookerEmail;
|
||||
});
|
||||
await lastAttendeeDeleteBooking(originalRescheduledBooking, filteredAttendees, originalBookingEvt);
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
return { ...foundBooking, seatReferenceUid: bookingSeat?.referenceUid };
|
||||
};
|
||||
|
||||
export default attendeeRescheduleSeatedBooking;
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
import { sendRescheduledEmails } from "@calcom/emails";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
|
||||
import { addVideoCallDataToEvent, findBookingQuery } from "../../../handleNewBooking";
|
||||
import type { SeatedBooking, RescheduleSeatedBookingObject, NewTimeSlotBooking } from "../../types";
|
||||
|
||||
const combineTwoSeatedBookings = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
newTimeSlotBooking: NewTimeSlotBooking,
|
||||
eventManager: EventManager,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const {
|
||||
eventType,
|
||||
tAttendees,
|
||||
attendeeLanguage,
|
||||
rescheduleUid,
|
||||
noEmail,
|
||||
isConfirmedByDefault,
|
||||
additionalNotes,
|
||||
rescheduleReason,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
// Merge two bookings together
|
||||
const attendeesToMove = [],
|
||||
attendeesToDelete = [];
|
||||
|
||||
for (const attendee of seatedBooking.attendees) {
|
||||
// If the attendee already exists on the new booking then delete the attendee record of the old booking
|
||||
if (
|
||||
newTimeSlotBooking.attendees.some((newBookingAttendee) => newBookingAttendee.email === attendee.email)
|
||||
) {
|
||||
attendeesToDelete.push(attendee.id);
|
||||
// If the attendee does not exist on the new booking then move that attendee record to the new booking
|
||||
} else {
|
||||
attendeesToMove.push({ id: attendee.id, seatReferenceId: attendee.bookingSeat?.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm that the new event will have enough available seats
|
||||
if (
|
||||
!eventType.seatsPerTimeSlot ||
|
||||
attendeesToMove.length + newTimeSlotBooking.attendees.filter((attendee) => attendee.bookingSeat).length >
|
||||
eventType.seatsPerTimeSlot
|
||||
) {
|
||||
throw new HttpError({ statusCode: 409, message: ErrorCode.NotEnoughAvailableSeats });
|
||||
}
|
||||
|
||||
const moveAttendeeCalls = [];
|
||||
for (const attendeeToMove of attendeesToMove) {
|
||||
moveAttendeeCalls.push(
|
||||
prisma.attendee.update({
|
||||
where: {
|
||||
id: attendeeToMove.id,
|
||||
},
|
||||
data: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
bookingSeat: {
|
||||
upsert: {
|
||||
create: {
|
||||
referenceUid: uuid(),
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
update: {
|
||||
bookingId: newTimeSlotBooking.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
...moveAttendeeCalls,
|
||||
// Delete any attendees that are already a part of that new time slot booking
|
||||
prisma.attendee.deleteMany({
|
||||
where: {
|
||||
id: {
|
||||
in: attendeesToDelete,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const updatedNewBooking = await prisma.booking.findUnique({
|
||||
where: {
|
||||
id: newTimeSlotBooking.id,
|
||||
},
|
||||
include: {
|
||||
attendees: true,
|
||||
references: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!updatedNewBooking) {
|
||||
throw new HttpError({ statusCode: 404, message: "Updated booking not found" });
|
||||
}
|
||||
|
||||
// Update the evt object with the new attendees
|
||||
const updatedBookingAttendees = updatedNewBooking.attendees.map((attendee) => {
|
||||
const evtAttendee = {
|
||||
...attendee,
|
||||
language: { translate: tAttendees, locale: attendeeLanguage ?? "en" },
|
||||
};
|
||||
return evtAttendee;
|
||||
});
|
||||
|
||||
evt.attendees = updatedBookingAttendees;
|
||||
|
||||
evt = addVideoCallDataToEvent(updatedNewBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newTimeSlotBooking.id);
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = Array.isArray(calendarResult?.updatedEvent)
|
||||
? calendarResult?.updatedEvent[0]?.iCalUID
|
||||
: calendarResult?.updatedEvent?.iCalUID || undefined;
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
// TODO send reschedule emails to attendees of the old booking
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
|
||||
// Update the old booking with the cancelled status
|
||||
await prisma.booking.update({
|
||||
where: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
data: {
|
||||
status: BookingStatus.CANCELLED,
|
||||
},
|
||||
});
|
||||
|
||||
const foundBooking = await findBookingQuery(newTimeSlotBooking.id);
|
||||
|
||||
return { ...foundBooking };
|
||||
};
|
||||
|
||||
export default combineTwoSeatedBookings;
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { cloneDeep } from "lodash";
|
||||
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
import { sendRescheduledEmails } from "@calcom/emails";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { AdditionalInformation, AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import { addVideoCallDataToEvent, handleAppsStatus, findBookingQuery } from "../../../handleNewBooking";
|
||||
import type { Booking, createLoggerWithEventDetails } from "../../../handleNewBooking";
|
||||
import type { SeatedBooking, RescheduleSeatedBookingObject } from "../../types";
|
||||
|
||||
const moveSeatedBookingToNewTimeSlot = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
eventManager: EventManager,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const {
|
||||
rescheduleReason,
|
||||
rescheduleUid,
|
||||
eventType,
|
||||
organizerUser,
|
||||
reqAppsStatus,
|
||||
noEmail,
|
||||
isConfirmedByDefault,
|
||||
additionalNotes,
|
||||
} = rescheduleSeatedBookingObject;
|
||||
let { evt } = rescheduleSeatedBookingObject;
|
||||
|
||||
const newBooking: (Booking & { appsStatus?: AppsStatus[] }) | null = await prisma.booking.update({
|
||||
where: {
|
||||
id: seatedBooking.id,
|
||||
},
|
||||
data: {
|
||||
startTime: evt.startTime,
|
||||
endTime: evt.endTime,
|
||||
cancellationReason: rescheduleReason,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
references: true,
|
||||
payment: true,
|
||||
attendees: true,
|
||||
},
|
||||
});
|
||||
|
||||
evt = addVideoCallDataToEvent(newBooking.references, evt);
|
||||
|
||||
const copyEvent = cloneDeep(evt);
|
||||
|
||||
const updateManager = await eventManager.reschedule(copyEvent, rescheduleUid, newBooking.id);
|
||||
|
||||
// @NOTE: This code is duplicated and should be moved to a function
|
||||
// This gets overridden when updating the event - to check if notes have been hidden or not. We just reset this back
|
||||
// to the default description when we are sending the emails.
|
||||
evt.description = eventType.description;
|
||||
|
||||
const results = updateManager.results;
|
||||
|
||||
const calendarResult = results.find((result) => result.type.includes("_calendar"));
|
||||
|
||||
evt.iCalUID = calendarResult?.updatedEvent.iCalUID || undefined;
|
||||
|
||||
if (results.length > 0 && results.some((res) => !res.success)) {
|
||||
const error = {
|
||||
errorCode: "BookingReschedulingMeetingFailed",
|
||||
message: "Booking Rescheduling failed",
|
||||
};
|
||||
loggerWithEventDetails.error(`Booking ${organizerUser.name} failed`, JSON.stringify({ error, results }));
|
||||
} else {
|
||||
const metadata: AdditionalInformation = {};
|
||||
if (results.length) {
|
||||
// TODO: Handle created event metadata more elegantly
|
||||
const [updatedEvent] = Array.isArray(results[0].updatedEvent)
|
||||
? results[0].updatedEvent
|
||||
: [results[0].updatedEvent];
|
||||
if (updatedEvent) {
|
||||
metadata.hangoutLink = updatedEvent.hangoutLink;
|
||||
metadata.conferenceData = updatedEvent.conferenceData;
|
||||
metadata.entryPoints = updatedEvent.entryPoints;
|
||||
evt.appsStatus = handleAppsStatus(results, newBooking, reqAppsStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (noEmail !== true && isConfirmedByDefault) {
|
||||
const copyEvent = cloneDeep(evt);
|
||||
loggerWithEventDetails.debug("Emails: Sending reschedule emails - handleSeats");
|
||||
await sendRescheduledEmails({
|
||||
...copyEvent,
|
||||
additionalNotes, // Resets back to the additionalNote input and not the override value
|
||||
cancellationReason: `$RCH$${rescheduleReason ? rescheduleReason : ""}`, // Removable code prefix to differentiate cancellation from rescheduling for email
|
||||
});
|
||||
}
|
||||
const foundBooking = await findBookingQuery(newBooking.id);
|
||||
|
||||
return { ...foundBooking, appsStatus: newBooking.appsStatus };
|
||||
};
|
||||
|
||||
export default moveSeatedBookingToNewTimeSlot;
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type EventManager from "@calcom/core/EventManager";
|
||||
|
||||
import type { createLoggerWithEventDetails } from "../../../handleNewBooking";
|
||||
import type {
|
||||
NewTimeSlotBooking,
|
||||
SeatedBooking,
|
||||
RescheduleSeatedBookingObject,
|
||||
HandleSeatsResultBooking,
|
||||
} from "../../types";
|
||||
import combineTwoSeatedBookings from "./combineTwoSeatedBookings";
|
||||
import moveSeatedBookingToNewTimeSlot from "./moveSeatedBookingToNewTimeSlot";
|
||||
|
||||
const ownerRescheduleSeatedBooking = async (
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
newTimeSlotBooking: NewTimeSlotBooking | null,
|
||||
seatedBooking: SeatedBooking,
|
||||
resultBooking: HandleSeatsResultBooking | null,
|
||||
eventManager: EventManager,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const { originalRescheduledBooking, tAttendees } = rescheduleSeatedBookingObject;
|
||||
const { evt } = rescheduleSeatedBookingObject;
|
||||
evt.attendees = originalRescheduledBooking?.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
};
|
||||
});
|
||||
|
||||
// If there is no booking during the new time slot then update the current booking to the new date
|
||||
if (!newTimeSlotBooking) {
|
||||
resultBooking = await moveSeatedBookingToNewTimeSlot(
|
||||
rescheduleSeatedBookingObject,
|
||||
seatedBooking,
|
||||
eventManager,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
} else {
|
||||
// If a booking already exists during the new time slot then merge the two bookings together
|
||||
resultBooking = await combineTwoSeatedBookings(
|
||||
rescheduleSeatedBookingObject,
|
||||
seatedBooking,
|
||||
newTimeSlotBooking,
|
||||
eventManager,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
}
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default ownerRescheduleSeatedBooking;
|
||||
@@ -0,0 +1,140 @@
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import EventManager from "@calcom/core/EventManager";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
import type { Person } from "@calcom/types/Calendar";
|
||||
|
||||
import { refreshCredentials } from "../../handleNewBooking";
|
||||
import type { createLoggerWithEventDetails } from "../../handleNewBooking";
|
||||
import type {
|
||||
HandleSeatsResultBooking,
|
||||
SeatedBooking,
|
||||
RescheduleSeatedBookingObject,
|
||||
SeatAttendee,
|
||||
} from "../types";
|
||||
import attendeeRescheduleSeatedBooking from "./attendee/attendeeRescheduleSeatedBooking";
|
||||
import ownerRescheduleSeatedBooking from "./owner/ownerRescheduleSeatedBooking";
|
||||
|
||||
const rescheduleSeatedBooking = async (
|
||||
// If this function is being called then rescheduleUid is defined
|
||||
rescheduleSeatedBookingObject: RescheduleSeatedBookingObject,
|
||||
seatedBooking: SeatedBooking,
|
||||
resultBooking: HandleSeatsResultBooking | null,
|
||||
loggerWithEventDetails: ReturnType<typeof createLoggerWithEventDetails>
|
||||
) => {
|
||||
const { evt, eventType, allCredentials, organizerUser, bookerEmail, tAttendees, bookingSeat, reqUserId } =
|
||||
rescheduleSeatedBookingObject;
|
||||
|
||||
const { originalRescheduledBooking } = rescheduleSeatedBookingObject;
|
||||
|
||||
// See if the new date has a booking already
|
||||
const newTimeSlotBooking = await prisma.booking.findFirst({
|
||||
where: {
|
||||
startTime: dayjs(evt.startTime).toDate(),
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.ACCEPTED,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
uid: true,
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const credentials = await refreshCredentials(allCredentials);
|
||||
const eventManager = new EventManager({ ...organizerUser, credentials });
|
||||
|
||||
if (!originalRescheduledBooking) {
|
||||
// typescript isn't smart enough;
|
||||
throw new Error("Internal Error.");
|
||||
}
|
||||
|
||||
const updatedBookingAttendees = originalRescheduledBooking.attendees.reduce(
|
||||
(filteredAttendees, attendee) => {
|
||||
if (attendee.email === bookerEmail) {
|
||||
return filteredAttendees; // skip current booker, as we know the language already.
|
||||
}
|
||||
filteredAttendees.push({
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
});
|
||||
return filteredAttendees;
|
||||
},
|
||||
[] as Person[]
|
||||
);
|
||||
|
||||
// If original booking has video reference we need to add the videoCallData to the new evt
|
||||
const videoReference = originalRescheduledBooking.references.find((reference) =>
|
||||
reference.type.includes("_video")
|
||||
);
|
||||
|
||||
const originalBookingEvt = {
|
||||
...evt,
|
||||
title: originalRescheduledBooking.title,
|
||||
startTime: dayjs(originalRescheduledBooking.startTime).utc().format(),
|
||||
endTime: dayjs(originalRescheduledBooking.endTime).utc().format(),
|
||||
attendees: updatedBookingAttendees,
|
||||
// If the location is a video integration then include the videoCallData
|
||||
...(videoReference && {
|
||||
videoCallData: {
|
||||
type: videoReference.type,
|
||||
id: videoReference.meetingId,
|
||||
password: videoReference.meetingPassword,
|
||||
url: videoReference.meetingUrl,
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
if (!bookingSeat) {
|
||||
// if no bookingSeat is given and the userId != owner, 401.
|
||||
// TODO: Next step; Evaluate ownership, what about teams?
|
||||
if (seatedBooking.user?.id !== reqUserId) {
|
||||
throw new HttpError({ statusCode: 401 });
|
||||
}
|
||||
|
||||
// Moving forward in this block is the owner making changes to the booking. All attendees should be affected
|
||||
evt.attendees = originalRescheduledBooking.attendees.map((attendee) => {
|
||||
return {
|
||||
name: attendee.name,
|
||||
email: attendee.email,
|
||||
timeZone: attendee.timeZone,
|
||||
language: { translate: tAttendees, locale: attendee.locale ?? "en" },
|
||||
};
|
||||
});
|
||||
|
||||
// If owner reschedules the event we want to update the entire booking
|
||||
// Also if owner is rescheduling there should be no bookingSeat
|
||||
resultBooking = await ownerRescheduleSeatedBooking(
|
||||
rescheduleSeatedBookingObject,
|
||||
newTimeSlotBooking,
|
||||
seatedBooking,
|
||||
resultBooking,
|
||||
eventManager,
|
||||
loggerWithEventDetails
|
||||
);
|
||||
}
|
||||
|
||||
// seatAttendee is null when the organizer is rescheduling.
|
||||
const seatAttendee: SeatAttendee | null = bookingSeat?.attendee || null;
|
||||
if (seatAttendee) {
|
||||
resultBooking = await attendeeRescheduleSeatedBooking(
|
||||
rescheduleSeatedBookingObject,
|
||||
seatAttendee,
|
||||
newTimeSlotBooking,
|
||||
originalBookingEvt,
|
||||
eventManager
|
||||
);
|
||||
}
|
||||
|
||||
return resultBooking;
|
||||
};
|
||||
|
||||
export default rescheduleSeatedBooking;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,80 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
|
||||
import type { AppsStatus } from "@calcom/types/Calendar";
|
||||
|
||||
import type { Booking, NewBookingEventType, OriginalRescheduledBooking } from "../handleNewBooking";
|
||||
|
||||
export type BookingSeat = Prisma.BookingSeatGetPayload<{ include: { booking: true; attendee: true } }> | null;
|
||||
|
||||
export type NewSeatedBookingObject = {
|
||||
rescheduleUid: string | undefined;
|
||||
reqBookingUid: string | undefined;
|
||||
eventType: NewBookingEventType;
|
||||
evt: CalendarEvent;
|
||||
invitee: Invitee;
|
||||
allCredentials: Awaited<ReturnType<typeof getAllCredentials>>;
|
||||
organizerUser: OrganizerUser;
|
||||
originalRescheduledBooking: OriginalRescheduledBooking;
|
||||
bookerEmail: string;
|
||||
tAttendees: TFunction;
|
||||
bookingSeat: BookingSeat;
|
||||
reqUserId: number | undefined;
|
||||
rescheduleReason: RescheduleReason;
|
||||
reqBodyUser: string | string[] | undefined;
|
||||
noEmail: NoEmail;
|
||||
isConfirmedByDefault: IsConfirmedByDefault;
|
||||
additionalNotes: AdditionalNotes;
|
||||
reqAppsStatus: ReqAppsStatus;
|
||||
attendeeLanguage: string | null;
|
||||
paymentAppData: PaymentAppData;
|
||||
fullName: ReturnType<typeof getFullName>;
|
||||
smsReminderNumber: SmsReminderNumber;
|
||||
eventTypeInfo: EventTypeInfo;
|
||||
uid: short.SUUID;
|
||||
eventTypeId: EventTypeId;
|
||||
reqBodyMetadata: ReqBodyMetadata;
|
||||
subscriberOptions: GetSubscriberOptions;
|
||||
eventTrigger: WebhookTriggerEvents;
|
||||
};
|
||||
|
||||
export type RescheduleSeatedBookingObject = NewSeatedBookingObject & { rescheduleUid: string };
|
||||
|
||||
export type SeatedBooking = Prisma.BookingGetPayload<{
|
||||
select: {
|
||||
uid: true;
|
||||
id: true;
|
||||
attendees: { include: { bookingSeat: true } };
|
||||
userId: true;
|
||||
references: true;
|
||||
startTime: true;
|
||||
user: true;
|
||||
status: true;
|
||||
smsReminderNumber: true;
|
||||
endTime: true;
|
||||
scheduledJobs: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
export type HandleSeatsResultBooking =
|
||||
| (Partial<Booking> & {
|
||||
appsStatus?: AppsStatus[];
|
||||
seatReferenceUid?: string;
|
||||
paymentUid?: string;
|
||||
message?: string;
|
||||
paymentId?: number;
|
||||
})
|
||||
| null;
|
||||
|
||||
export type NewTimeSlotBooking = Prisma.BookingGetPayload<{
|
||||
select: {
|
||||
id: true;
|
||||
uid: true;
|
||||
attendees: {
|
||||
include: {
|
||||
bookingSeat: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
export type SeatAttendee = Partial<Person>;
|
||||
@@ -25,7 +25,7 @@ export type RecurringBookingCreateBody = BookingCreateBody & {
|
||||
noEmail?: boolean;
|
||||
recurringCount?: number;
|
||||
appsStatus?: AppsStatus[] | undefined;
|
||||
allRecurringDates?: string[];
|
||||
allRecurringDates?: Record<string, string>[];
|
||||
currentRecurringIndex?: number;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { SessionContextValue } from "next-auth/react";
|
||||
import { signIn } from "next-auth/react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TopBanner } from "@calcom/ui";
|
||||
@@ -8,7 +9,10 @@ export type ImpersonatingBannerProps = { data: SessionContextValue["data"] };
|
||||
function ImpersonatingBanner({ data }: ImpersonatingBannerProps) {
|
||||
const { t } = useLocale();
|
||||
|
||||
if (!data?.user.impersonatedByUID) return null;
|
||||
if (!data?.user.impersonatedBy) return null;
|
||||
const returnToId = data.user.impersonatedBy.id;
|
||||
|
||||
const canReturnToSelf = data.user.impersonatedBy.role == "ADMIN" || data.user?.org?.id;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -16,9 +20,21 @@ function ImpersonatingBanner({ data }: ImpersonatingBannerProps) {
|
||||
text={t("impersonating_user_warning", { user: data.user.username })}
|
||||
variant="warning"
|
||||
actions={
|
||||
<a className="border-b border-b-black" href="/auth/logout">
|
||||
{t("impersonating_stop_instructions")}
|
||||
</a>
|
||||
canReturnToSelf ? (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
signIn("impersonation-auth", { returnToId });
|
||||
}}>
|
||||
<button className="text-emphasis hover:underline" data-testid="stop-impersonating-button">
|
||||
{t("impersonating_stop_instructions")}
|
||||
</button>
|
||||
</form>
|
||||
) : (
|
||||
<a className="border-b border-b-black" href="/auth/logout">
|
||||
{t("impersonating_stop_instructions")}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
parseTeamId,
|
||||
checkSelfImpersonation,
|
||||
checkUserIdentifier,
|
||||
checkPermission,
|
||||
checkGlobalPermission,
|
||||
} from "./ImpersonationProvider";
|
||||
|
||||
const session: Session = {
|
||||
@@ -65,17 +65,17 @@ describe("checkUserIdentifier", () => {
|
||||
describe("checkPermission", () => {
|
||||
it("should throw an error if the user is not an admin and team impersonation is disabled", () => {
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
|
||||
expect(() => checkPermission(session)).toThrow();
|
||||
expect(() => checkGlobalPermission(session)).toThrow();
|
||||
});
|
||||
|
||||
it("should not throw an error if the user is an admin and team impersonation is disabled", () => {
|
||||
const modifiedSession = { ...session, user: { ...session.user, role: UserPermissionRole.ADMIN } };
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "false";
|
||||
expect(() => checkPermission(modifiedSession)).not.toThrow();
|
||||
expect(() => checkGlobalPermission(modifiedSession)).not.toThrow();
|
||||
});
|
||||
|
||||
it("should not throw an error if the user is not an admin but team impersonation is enabled", () => {
|
||||
process.env.NEXT_PUBLIC_TEAM_IMPERSONATION = "true";
|
||||
expect(() => checkPermission(session)).not.toThrow();
|
||||
expect(() => checkGlobalPermission(session)).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,8 @@ const teamIdschema = z.object({
|
||||
const auditAndReturnNextUser = async (
|
||||
impersonatedUser: Pick<User, "id" | "username" | "email" | "name" | "role" | "organizationId" | "locale">,
|
||||
impersonatedByUID: number,
|
||||
hasTeam?: boolean
|
||||
hasTeam?: boolean,
|
||||
isReturningToSelf?: boolean
|
||||
) => {
|
||||
// Log impersonations for audit purposes
|
||||
await prisma.impersonations.create({
|
||||
@@ -38,16 +39,36 @@ const auditAndReturnNextUser = async (
|
||||
email: impersonatedUser.email,
|
||||
name: impersonatedUser.name,
|
||||
role: impersonatedUser.role,
|
||||
impersonatedByUID,
|
||||
belongsToActiveTeam: hasTeam,
|
||||
organizationId: impersonatedUser.organizationId,
|
||||
locale: impersonatedUser.locale,
|
||||
};
|
||||
|
||||
if (!isReturningToSelf) {
|
||||
const impersonatedByUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: impersonatedByUID,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
role: true,
|
||||
},
|
||||
});
|
||||
if (!impersonatedByUser) throw new Error("This user does not exist.");
|
||||
|
||||
return {
|
||||
...obj,
|
||||
impersonatedBy: {
|
||||
id: impersonatedByUser?.id,
|
||||
role: impersonatedByUser?.role,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return obj;
|
||||
};
|
||||
|
||||
type Credentials = Record<"username" | "teamId", string> | undefined;
|
||||
type Credentials = Record<"username" | "teamId" | "returnToId", string> | undefined;
|
||||
|
||||
export function parseTeamId(creds: Partial<Credentials>) {
|
||||
return creds?.teamId ? teamIdschema.parse({ teamId: creds.teamId }).teamId : undefined;
|
||||
@@ -60,10 +81,13 @@ export function checkSelfImpersonation(session: Session | null, creds: Partial<C
|
||||
}
|
||||
|
||||
export function checkUserIdentifier(creds: Partial<Credentials>) {
|
||||
if (!creds?.username) throw new Error("User identifier must be present");
|
||||
if (!creds?.username) {
|
||||
if (creds?.returnToId) return;
|
||||
throw new Error("User identifier must be present");
|
||||
}
|
||||
}
|
||||
|
||||
export function checkPermission(session: Session | null) {
|
||||
export function checkGlobalPermission(session: Session | null) {
|
||||
if (
|
||||
(session?.user.role !== "ADMIN" && process.env.NEXT_PUBLIC_TEAM_IMPERSONATION === "false") ||
|
||||
!session?.user
|
||||
@@ -133,6 +157,60 @@ async function getImpersonatedUser({
|
||||
return impersonatedUser;
|
||||
}
|
||||
|
||||
async function isReturningToSelf({ session, creds }: { session: Session | null; creds: Credentials | null }) {
|
||||
const impersonatedByUID = session?.user.impersonatedBy?.id;
|
||||
if (!impersonatedByUID || !creds?.returnToId) return;
|
||||
const returnToId = parseInt(creds?.returnToId, 10);
|
||||
|
||||
// Ensure session impersonatedUID + the returnToId is the same so we cant take over a random account
|
||||
if (impersonatedByUID !== returnToId) return;
|
||||
|
||||
const returningUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
id: returnToId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
email: true,
|
||||
name: true,
|
||||
role: true,
|
||||
organizationId: true,
|
||||
locale: true,
|
||||
teams: {
|
||||
where: {
|
||||
accepted: true, // Ensure they are apart of the team and not just invited.
|
||||
},
|
||||
select: {
|
||||
teamId: true,
|
||||
disableImpersonation: true,
|
||||
role: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (returningUser) {
|
||||
// Skip for none org users
|
||||
if (returningUser.role !== "ADMIN" && !returningUser.organizationId) return;
|
||||
|
||||
const hasTeams = returningUser.teams.length >= 1;
|
||||
return {
|
||||
user: {
|
||||
id: returningUser.id,
|
||||
email: returningUser.email,
|
||||
locale: returningUser.locale,
|
||||
name: returningUser.name,
|
||||
organizationId: returningUser.organizationId,
|
||||
role: returningUser.role,
|
||||
username: returningUser.username,
|
||||
},
|
||||
impersonatedByUID,
|
||||
hasTeams,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ImpersonationProvider = CredentialsProvider({
|
||||
id: "impersonation-auth",
|
||||
name: "Impersonation",
|
||||
@@ -140,6 +218,7 @@ const ImpersonationProvider = CredentialsProvider({
|
||||
credentials: {
|
||||
username: { type: "text" },
|
||||
teamId: { type: "text" },
|
||||
returnToId: { type: "text" },
|
||||
},
|
||||
async authorize(creds, req) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -148,7 +227,19 @@ const ImpersonationProvider = CredentialsProvider({
|
||||
const teamId = parseTeamId(creds);
|
||||
checkSelfImpersonation(session, creds);
|
||||
checkUserIdentifier(creds);
|
||||
checkPermission(session);
|
||||
|
||||
// Returning to target and UID is self without having to do perm checks.
|
||||
const returnToUser = await isReturningToSelf({ session, creds });
|
||||
if (returnToUser) {
|
||||
return auditAndReturnNextUser(
|
||||
returnToUser.user,
|
||||
returnToUser.impersonatedByUID,
|
||||
returnToUser.hasTeams,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
checkGlobalPermission(session);
|
||||
|
||||
const impersonatedUser = await getImpersonatedUser({ session, teamId, creds });
|
||||
|
||||
|
||||
@@ -102,9 +102,11 @@ export default function WorkflowListPage({ workflows }: Props) {
|
||||
{workflows.map((workflow, index) => {
|
||||
const firstItem = workflows[0];
|
||||
const lastItem = workflows[workflows.length - 1];
|
||||
const dataTestId = `workflow-${workflow.name.toLowerCase().replaceAll(" ", "-")}`;
|
||||
return (
|
||||
<li
|
||||
key={workflow.id}
|
||||
data-testid={dataTestId}
|
||||
className="group flex w-full max-w-full items-center justify-between overflow-hidden">
|
||||
{!(firstItem && firstItem.id === workflow.id) && (
|
||||
<ArrowButton onClick={() => moveWorkflow(index, -1)} arrowDirection="up" />
|
||||
@@ -234,6 +236,7 @@ export default function WorkflowListPage({ workflows }: Props) {
|
||||
StartIcon={Edit2}
|
||||
disabled={workflow.readOnly}
|
||||
onClick={async () => await router.replace(`/workflows/${workflow.id}`)}
|
||||
data-testid="edit-button"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip content={t("delete") as string}>
|
||||
@@ -246,6 +249,7 @@ export default function WorkflowListPage({ workflows }: Props) {
|
||||
variant="icon"
|
||||
disabled={workflow.readOnly}
|
||||
StartIcon={Trash2}
|
||||
data-testid="delete-button"
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { getCalVideoReference } from "./get-cal-video-reference";
|
||||
|
||||
describe("Cal Video", () => {
|
||||
it("should load latest cal video reference", () => {
|
||||
expect(
|
||||
getCalVideoReference([
|
||||
{
|
||||
uid: "UID1",
|
||||
type: "daily_video",
|
||||
meetingUrl: "ID1",
|
||||
meetingPassword: "P1",
|
||||
},
|
||||
{
|
||||
uid: "UID2",
|
||||
type: "daily_video",
|
||||
meetingUrl: "ID2",
|
||||
meetingPassword: "P2",
|
||||
},
|
||||
])
|
||||
).toEqual({
|
||||
uid: "UID2",
|
||||
type: "daily_video",
|
||||
meetingUrl: "ID2",
|
||||
meetingPassword: "P2",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
export type Reference = {
|
||||
uid: string;
|
||||
type: string;
|
||||
meetingUrl: string | null;
|
||||
meetingPassword: string | null;
|
||||
};
|
||||
|
||||
export const getCalVideoReference = (references: Reference[]) => {
|
||||
const videoReferences = references.filter((reference) => reference.type.includes("_video"));
|
||||
const latestVideoReference = videoReferences[videoReferences.length - 1];
|
||||
return latestVideoReference;
|
||||
};
|
||||
@@ -56,7 +56,7 @@ export default function TimezoneChangeDialog() {
|
||||
|
||||
const { data } = useSession();
|
||||
|
||||
if (data?.user.impersonatedByUID) return null;
|
||||
if (data?.user.impersonatedBy) return null;
|
||||
|
||||
const ONE_DAY = 60 * 60 * 24; // 1 day in seconds (60 seconds * 60 minutes * 24 hours)
|
||||
const THREE_MONTHS = ONE_DAY * 90; // 90 days in seconds (90 days * 1 day in seconds)
|
||||
|
||||
@@ -200,7 +200,7 @@ const useBanners = () => {
|
||||
if (isLoading || !userSession) return null;
|
||||
|
||||
const isUserInactiveAdmin = userSession?.user.role === "INACTIVE_ADMIN";
|
||||
const userImpersonatedByUID = userSession?.user.impersonatedByUID;
|
||||
const userImpersonatedByUID = userSession?.user.impersonatedBy?.id;
|
||||
|
||||
const userSessionBanners = {
|
||||
adminPasswordBanner: isUserInactiveAdmin ? userSession : null,
|
||||
@@ -892,7 +892,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
|
||||
<div className="flex h-full flex-col justify-between py-3 lg:pt-4">
|
||||
<header className="todesktop:-mt-3 todesktop:flex-col-reverse todesktop:[-webkit-app-region:drag] items-center justify-between md:hidden lg:flex">
|
||||
{orgBranding ? (
|
||||
<Link href="/settings/organizations/profile" className="mt-3 w-full px-1.5">
|
||||
<Link href="/settings/organizations/profile" className="w-full px-1.5">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Avatar
|
||||
alt={`${orgBranding.name} logo`}
|
||||
|
||||
@@ -6,5 +6,11 @@ export enum ErrorCode {
|
||||
AlreadySignedUpForBooking = "already_signed_up_for_this_booking_error",
|
||||
HostsUnavailableForBooking = "hosts_unavailable_for_booking",
|
||||
EventTypeNotFound = "event_type_not_found_error",
|
||||
BookingNotFound = "booking_not_found_error",
|
||||
BookingSeatsFull = "booking_seats_full_error",
|
||||
MissingPaymentCredential = "missing_payment_credential_error",
|
||||
MissingPaymentAppId = "missing_payment_app_id_error",
|
||||
NotEnoughAvailableSeats = "not_enough_available_seats_error",
|
||||
AvailabilityNotFoundInSchedule = "availability_not_found_in_schedule_error",
|
||||
CancelledBookingsCannotBeRescheduled = "cancelled_bookings_cannot_be_rescheduled",
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import type { DefaultArgs, InternalArgs } from "@prisma/client/runtime/library";
|
||||
|
||||
export function excludePendingPaymentsExtension() {
|
||||
return Prisma.defineExtension({
|
||||
query: {
|
||||
team: {
|
||||
async findUnique({ args, query }) {
|
||||
return excludePendingPayments(args, query);
|
||||
},
|
||||
async findFirst({ args, query }) {
|
||||
return excludePendingPayments(args, query);
|
||||
},
|
||||
async findMany({ args, query }) {
|
||||
return excludePendingPayments(args, query);
|
||||
},
|
||||
async findUniqueOrThrow({ args, query }) {
|
||||
return excludePendingPayments(args, query);
|
||||
},
|
||||
async findFirstOrThrow({ args, query }) {
|
||||
return excludePendingPayments(args, query);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function excludePendingPayments(
|
||||
args:
|
||||
| Prisma.TeamFindUniqueArgs<InternalArgs & DefaultArgs>
|
||||
| Prisma.TeamFindFirstArgs<InternalArgs & DefaultArgs>
|
||||
| Prisma.TeamFindManyArgs<InternalArgs & DefaultArgs>
|
||||
| Prisma.TeamFindUniqueOrThrowArgs<InternalArgs & DefaultArgs>
|
||||
| Prisma.TeamFindFirstOrThrowArgs<InternalArgs & DefaultArgs>,
|
||||
query: <T>(args: T) => Promise<unknown>
|
||||
) {
|
||||
args.where = args.where || {};
|
||||
if (args.where.pendingPayment === undefined) {
|
||||
args.where.pendingPayment = false;
|
||||
}
|
||||
return query(args);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Prisma } from "@prisma/client";
|
||||
import { PrismaClient as PrismaClientWithoutExtension } from "@prisma/client";
|
||||
import { withAccelerate } from "@prisma/extension-accelerate";
|
||||
|
||||
import { excludePendingPaymentsExtension } from "./extensions/exclude-pending-payment-teams";
|
||||
import { bookingReferenceMiddleware } from "./middleware";
|
||||
|
||||
const prismaOptions: Prisma.PrismaClientOptions = {};
|
||||
@@ -18,7 +19,9 @@ const prismaWithoutClientExtensions =
|
||||
globalForPrisma.prismaWithoutClientExtensions || new PrismaClientWithoutExtension(prismaOptions);
|
||||
|
||||
export const customPrisma = (options?: Prisma.PrismaClientOptions) =>
|
||||
new PrismaClientWithoutExtension({ ...prismaOptions, ...options }).$extends(withAccelerate());
|
||||
new PrismaClientWithoutExtension({ ...prismaOptions, ...options })
|
||||
.$extends(excludePendingPaymentsExtension())
|
||||
.$extends(withAccelerate());
|
||||
|
||||
// If any changed on middleware server restart is required
|
||||
// TODO: Migrate it to $extends
|
||||
@@ -27,35 +30,8 @@ bookingReferenceMiddleware(prismaWithoutClientExtensions);
|
||||
// FIXME: Due to some reason, there are types failing in certain places due to the $extends. Fix it and then enable it
|
||||
// Specifically we get errors like `Type 'string | Date | null | undefined' is not assignable to type 'Exact<string | Date | null | undefined, string | Date | null | undefined>'`
|
||||
const prismaWithClientExtensions = prismaWithoutClientExtensions
|
||||
//
|
||||
.$extends(excludePendingPaymentsExtension())
|
||||
.$extends(withAccelerate());
|
||||
// .$extends({
|
||||
// query: {
|
||||
// $allModels: {
|
||||
// async $allOperations({ model, operation, args, query }) {
|
||||
// const start = performance.now();
|
||||
// /* your custom logic here */
|
||||
// const res = await query(args);
|
||||
// const end = performance.now();
|
||||
// logger.debug("Query Perf: ", `${model}.${operation} took ${(end - start).toFixed(2)}ms\n`);
|
||||
// return res;
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// });
|
||||
// .$extends({
|
||||
// name: "teamUpdateWithMetadata",
|
||||
// query: {
|
||||
// team: {
|
||||
// async update({ model, operation, args, query }) {
|
||||
// if (args.data.metadata) {
|
||||
// // Prepare args.data with merged metadata
|
||||
// }
|
||||
// return query(args);
|
||||
// },
|
||||
// },
|
||||
// },
|
||||
// })
|
||||
|
||||
export const prisma = globalForPrisma.prismaWithClientExtensions || prismaWithClientExtensions;
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "BookingReference" ADD COLUMN "thirdPartyRecurringEventId" TEXT;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Team" ADD COLUMN "pendingPayment" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -316,6 +316,7 @@ model Team {
|
||||
credentials Credential[]
|
||||
accessCodes AccessCode[]
|
||||
instantMeetingTokens InstantMeetingToken[]
|
||||
pendingPayment Boolean @default(false)
|
||||
|
||||
@@unique([slug, parentId])
|
||||
}
|
||||
@@ -373,19 +374,20 @@ model InstantMeetingToken {
|
||||
}
|
||||
|
||||
model BookingReference {
|
||||
id Int @id @default(autoincrement())
|
||||
id Int @id @default(autoincrement())
|
||||
/// @zod.min(1)
|
||||
type String
|
||||
type String
|
||||
/// @zod.min(1)
|
||||
uid String
|
||||
meetingId String?
|
||||
meetingPassword String?
|
||||
meetingUrl String?
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
bookingId Int?
|
||||
externalCalendarId String?
|
||||
deleted Boolean?
|
||||
credentialId Int?
|
||||
uid String
|
||||
meetingId String?
|
||||
thirdPartyRecurringEventId String?
|
||||
meetingPassword String?
|
||||
meetingUrl String?
|
||||
booking Booking? @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
bookingId Int?
|
||||
externalCalendarId String?
|
||||
deleted Boolean?
|
||||
credentialId Int?
|
||||
|
||||
@@index([bookingId])
|
||||
@@index([credentialId])
|
||||
|
||||
@@ -250,7 +250,14 @@ export const extendedBookingCreateBody = bookingCreateBodySchema.merge(
|
||||
z.object({
|
||||
noEmail: z.boolean().optional(),
|
||||
recurringCount: z.number().optional(),
|
||||
allRecurringDates: z.string().array().optional(),
|
||||
allRecurringDates: z
|
||||
.array(
|
||||
z.object({
|
||||
start: z.string(),
|
||||
end: z.string(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
currentRecurringIndex: z.number().optional(),
|
||||
appsStatus: z
|
||||
.array(
|
||||
@@ -301,6 +308,7 @@ export const vitalSettingsUpdateSchema = z.object({
|
||||
export const createdEventSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
thirdPartyRecurringEventId: z.string(),
|
||||
password: z.union([z.string(), z.undefined()]),
|
||||
onlineMeetingUrl: z.string().nullable(),
|
||||
iCalUID: z.string().optional(),
|
||||
@@ -324,6 +332,8 @@ export const userMetadata = z
|
||||
})
|
||||
.nullable();
|
||||
|
||||
export type userMetadataType = z.infer<typeof userMetadata>;
|
||||
|
||||
export const teamMetadataSchema = z
|
||||
.object({
|
||||
requestedSlug: z.string(),
|
||||
|
||||
@@ -26,6 +26,12 @@ export const verifyCodeHandler = async ({ ctx, input }: VerifyCodeOptions) => {
|
||||
logger.warn(`Skipping code verification in dev/E2E environment`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (user.role === "ADMIN") {
|
||||
logger.warn(`Skipping code verification for instance admin`);
|
||||
return true;
|
||||
}
|
||||
|
||||
await checkRateLimitAndThrowError({
|
||||
rateLimitingType: "core",
|
||||
identifier: email,
|
||||
|
||||
Vendored
+6
@@ -63,6 +63,7 @@ export type AdditionalInfo = Record<string, unknown> & { calWarnings?: string[]
|
||||
export type NewCalendarEventType = {
|
||||
uid: string;
|
||||
id: string;
|
||||
thirdPartyRecurringEventId?: string | null;
|
||||
type: string;
|
||||
password: string;
|
||||
url: string;
|
||||
@@ -143,6 +144,10 @@ export type CalEventResponses = Record<
|
||||
}
|
||||
>;
|
||||
|
||||
export interface ExistingRecurringEvent {
|
||||
recurringEventId: string;
|
||||
}
|
||||
|
||||
// If modifying this interface, probably should update builders/calendarEvent files
|
||||
export interface CalendarEvent {
|
||||
// Instead of sending this per event.
|
||||
@@ -167,6 +172,7 @@ export interface CalendarEvent {
|
||||
conferenceData?: ConferenceData;
|
||||
additionalInformation?: AdditionalInformation;
|
||||
uid?: string | null;
|
||||
existingRecurringEvent?: ExistingRecurringEvent | null;
|
||||
bookingId?: number;
|
||||
videoCallData?: VideoCallData;
|
||||
paymentInfo?: PaymentInfo | null;
|
||||
|
||||
Vendored
+1
@@ -6,6 +6,7 @@ export interface PartialReference {
|
||||
uid: string;
|
||||
meetingId?: string | null;
|
||||
meetingPassword?: string | null;
|
||||
thirdPartyRecurringEventId?: string | null;
|
||||
meetingUrl?: string | null;
|
||||
externalCalendarId?: string | null;
|
||||
credentialId?: number | null;
|
||||
|
||||
Vendored
+8
-2
@@ -14,7 +14,10 @@ declare module "next-auth" {
|
||||
id: PrismaUser["id"];
|
||||
emailVerified?: PrismaUser["emailVerified"];
|
||||
email_verified?: boolean;
|
||||
impersonatedByUID?: number;
|
||||
impersonatedBy?: {
|
||||
id: number;
|
||||
role: PrismaUser["role"];
|
||||
};
|
||||
belongsToActiveTeam?: boolean;
|
||||
org?: {
|
||||
id: number;
|
||||
@@ -36,7 +39,10 @@ declare module "next-auth/jwt" {
|
||||
username?: string | null;
|
||||
email?: string | null;
|
||||
role?: UserPermissionRole | "INACTIVE_ADMIN" | null;
|
||||
impersonatedByUID?: number | null;
|
||||
impersonatedBy?: {
|
||||
id: number;
|
||||
role: PrismaUser["role"];
|
||||
};
|
||||
belongsToActiveTeam?: boolean;
|
||||
org?: {
|
||||
id: number;
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
border-radius: 0.375rem;
|
||||
border-color: #e5e7eb;
|
||||
border-color: var(--cal-border);
|
||||
width: 360px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
@@ -152,7 +152,7 @@
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
border-width: 1px;
|
||||
border-color: #e5e7eb;
|
||||
border-color: var(--cal-border);
|
||||
width: 360px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -248,7 +248,7 @@ button.react-calendar__tile.react-calendar__month-view__days__day:hover,
|
||||
}
|
||||
|
||||
.react-daterange-picker > .react-daterange-picker__wrapper:hover {
|
||||
border-color: var(--cal-bg-subtle);
|
||||
border-color: var(--cal-bg-inverted);
|
||||
}
|
||||
|
||||
.react-daterange-picker.react-daterange-picker--disabled > .react-daterange-picker__wrapper:hover {
|
||||
|
||||
@@ -21,6 +21,7 @@ export type ActionType = {
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
color?: ButtonBaseProps["color"];
|
||||
bookingId?: number;
|
||||
} & (
|
||||
| { href: string; onClick?: never; actions?: never }
|
||||
| { href?: never; onClick: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void; actions?: never }
|
||||
@@ -61,6 +62,7 @@ export const DropdownActions = ({
|
||||
data-testid={action.id}
|
||||
StartIcon={action.icon}
|
||||
href={action.href}
|
||||
data-bookingid={action.bookingId}
|
||||
onClick={action.onClick || defaultAction}>
|
||||
{action.label}
|
||||
</DropdownItem>
|
||||
@@ -87,6 +89,7 @@ export const TableActions: FC<Props> = ({ actions }) => {
|
||||
StartIcon={action.icon}
|
||||
{...(action?.actions ? { EndIcon: ChevronDown } : null)}
|
||||
disabled={action.disabled}
|
||||
data-bookingid={action.bookingId}
|
||||
color={action.color || "secondary"}>
|
||||
{action.label}
|
||||
</Button>
|
||||
|
||||
@@ -4233,6 +4233,15 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/mock-payment-app@workspace:packages/app-store/mock-payment-app":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/mock-payment-app@workspace:packages/app-store/mock-payment-app"
|
||||
dependencies:
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/types": "*"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@calcom/n8n@workspace:packages/app-store/n8n":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@calcom/n8n@workspace:packages/app-store/n8n"
|
||||
@@ -4592,7 +4601,7 @@ __metadata:
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/trpc": "*"
|
||||
"@calcom/tsconfig": "*"
|
||||
"@formkit/auto-animate": ^0.8.1
|
||||
"@formkit/auto-animate": 1.0.0-beta.5
|
||||
"@radix-ui/react-checkbox": ^1.0.4
|
||||
"@radix-ui/react-dialog": ^1.0.4
|
||||
"@radix-ui/react-popover": ^1.0.2
|
||||
@@ -4675,7 +4684,7 @@ __metadata:
|
||||
"@calcom/types": "*"
|
||||
"@calcom/ui": "*"
|
||||
"@daily-co/daily-js": ^0.37.0
|
||||
"@formkit/auto-animate": ^0.8.1
|
||||
"@formkit/auto-animate": 1.0.0-beta.5
|
||||
"@glidejs/glide": ^3.5.2
|
||||
"@hookform/error-message": ^2.0.0
|
||||
"@hookform/resolvers": ^2.9.7
|
||||
@@ -6237,10 +6246,10 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@formkit/auto-animate@npm:^0.8.1":
|
||||
version: 0.8.1
|
||||
resolution: "@formkit/auto-animate@npm:0.8.1"
|
||||
checksum: ffc1a3432ce81bcf7abd4360fabf05b7aee01005e64ce519f57d134e68a5e15a81f295a7b2979678896bfd7d2048202a9fa2dbd09781267899e9056e918120c1
|
||||
"@formkit/auto-animate@npm:1.0.0-beta.5":
|
||||
version: 1.0.0-beta.5
|
||||
resolution: "@formkit/auto-animate@npm:1.0.0-beta.5"
|
||||
checksum: 479a4f694d91e7272b2d8bdf2051fd1ae2dd5fbb522612406eaef55d88b7131402a7d5f683df8eb4e7c00810ebec8d3048483a774b0864c299d2d7af81262eb2
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
Reference in New Issue
Block a user