Compare commits

..
29 Commits
Author SHA1 Message Date
Keith Williams df91dacc3f v3.7.2
release.yml / release (push) Canceled after 0s
2024-01-17 10:59:04 -03:00
Hariom Balhara 396b81af58 Skip code verification for admin (#13278) 2024-01-17 13:21:30 +00:00
DmytroHryshyn 8467a9c53b fix: fix build error caused by 'export' keyword before getData (#13276) 2024-01-17 12:46:45 +00:00
DmytroHryshyn e99e8b169a chore: [app-router-migration 28] Migrate org/[orgSlug]/[user] page group (#13100)
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

Fixes # (issue)

<!-- Please provide a loom video for visual changes to speed up reviews
 Loom Video: https://www.loom.com/
-->

## Requirement/Documentation

<!-- Please provide all documents that are important to understand the reason of that PR. -->

- If there is a requirement document, please, share it here.
- If there is ab UI/UX design document, please, share it here.

## Type of change

<!-- Please delete bullets that are not relevant. -->

- Bug fix (non-breaking change which fixes an issue)
- Chore (refactoring code, technical debt, workflow improvements)
- New feature (non-breaking change which adds functionality)
- Breaking change (fix or feature that would cause existing functionality to not work as expected)
- Tests (Unit/Integration/E2E or any other test)
- This change requires a documentation update

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Mandatory Tasks

- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my PR needs changes to the documentation
- I haven't checked if my changes generate no new warnings
- I haven't added tests that prove my fix is effective or that my feature works
- I haven't checked if new and existing unit tests pass locally with my changes
2024-01-16 13:17:58 -07:00
zomars 7b8902a769 Update yarn.lock
release.yml / release (push) Canceled after 0s
2024-01-16 12:21:07 -07:00
zomars 2408338ed4 Revert "chore: Chore team metadata table + isOrganization migration from metadata (#12828)"
This reverts commit aabfd3b3f6.
2024-01-16 12:20:51 -07:00
Manavpreet SinghandManavpreet Singh 8f0ff58bcf fix: datepicker border color fix (#13262)
Co-authored-by: Manavpreet Singh <119919977+manavpreetsingh@users.noreply.github.com>
2024-01-16 18:42:08 +00:00
Omar López fac47544d1 v3.7.1 2024-01-16 11:39:25 -07:00
Benny Joo b09f3e5c8d chore: [app-router-migration 37] migrate connect and join page (#13205)
* Migrate Connect And Join page

* make legacy page client
2024-01-16 18:28:19 +00:00
Riddhesh Mahajan 8bb051ac4e fix: improve slug filtering to prevent same slug with diff case (#13074) 2024-01-16 18:27:34 +00:00
Dexter Storey fd3fffd7dd feat: add booking date range filters and freeze GPT-4 version (#12508)
* add booking date range filters

* fix dateFrom, dateTo strings

* simplify temporal filters

* Freeze GPT-4-0613

* parse and validate booking dateFrom and dateTo fields

* add weekday to agent prompt
2024-01-16 18:26:39 +00:00
Lauris SkraucisandOmar López 312a3f2543 refactor: API POST /teams (#13211)
Co-authored-by: Omar López <zomars@me.com>
2024-01-16 17:47:46 +00:00
Peer RichelsenandKeith Williams f52f2f32fa chore: added dark mode image for signup (#13251)
* added dark mode image for signup

* fixed dark mode border

---------

Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-16 17:01:08 +00:00
Crowdin Bot fdd0a5a03d New Crowdin translations by Github Action 2024-01-16 16:15:13 +00:00
ca3e3600f7 test: Check the workflow tab and your functionalities (teste2e-workflow) (#12474)
* test: Check the workflow tab

* fix type

---------

Co-authored-by: gitstart-calcom <gitstart-calcom@users.noreply.github.com>
Co-authored-by: GitStart-Cal.com <121884634+gitstart-calcom@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
2024-01-16 11:11:36 -05:00
Morgan ba944b471a refactor: app-store imports webhook.ts from @calcom/web (#13257) 2024-01-16 15:10:58 +00:00
Keith Williams 7978f0317a chore: Revert bundle analysis text formatting (#13256) 2024-01-16 14:57:45 +00:00
emmanuel d918f34ae8 Update README.md (#13246) 2024-01-16 07:39:42 -03:00
Udit Takkar 1fed08fa9b fix: untranslated string in event (#13247) 2024-01-16 07:20:33 -03:00
aabfd3b3f6 chore: Chore team metadata table + isOrganization migration from metadata (#12828)
* Migration+schema changes

* Remove org utils from schema

* Users fixture

* Auth signup handlers

* checkIsOrg fn

* use team.isOrganization > metadata

* Add isOrganization to getTeamWithMembers query

* list handler + optimise queries

* Update trpc handlers to use team.isOrganization && orgSettings table

* Update fixture

* Fix typo in prisma.schema

* next auth options use organizationSettings instread of tema metadata

* Admin tooling

* Updating components to use orgsettings over metadata

* Add unique index + fix org lib files

* validateUsername

* Add a seperate unique constraint between isOrg and slug

* Invite Member Logic

* fix resend file

* force isOrg to exist

* Team settings

* update createOrUpdateMemberships

* fix: admin get handler

* fix: team list handler - improve select

* fix: update admin update handler

* fix: settings layout - team get select

* organziationSettings Schema + adminupdate

* organziationSettings Schema + adminupdate

* typefix onSubtmi

* Fix:team repo throwing error if no metadata is found

* fix: add onDelete to be cascade instead of restri

* fix: org - team invite e2e

* fix: team invites not working in subteam context

* rename to organizationID

* fix user fixture team names

* Fix the organization created in tests

* fix: update organization metadata

* variable rename

* Reuse isOrganization

* variable rename

* Remove unnecessary optional chaining

* Update packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Update packages/trpc/server/routers/viewer/organizations/adminUpdate.handler.ts

Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>

* Update packages/trpc/server/routers/viewer/organizations/create.handler.ts

---------

Co-authored-by: Hariom <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2024-01-16 10:18:17 +00:00
sean-brydonandUdit Takkar 60e0bbf4b7 feat: impersonation improvements (#13066)
* fix: rename checkPermission to be more fitting

* feat:return to user

* nits: cleanup plus comments

* cleanup

* test: Add e2e

* fix: fix fixtures

* nit: cleanup logs

* chore: move to impersonatedBy in session

* fix: can return to self

* Update apps/web/playwright/impersonation.e2e.ts

* fix: typecheck

---------

Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
2024-01-16 08:45:48 +00:00
0a7f65cd9b fix: Use Google recurring events protocol (#12651)
* working create event

--wip-- [skip ci]

fix bug related to adding event

--wip-- [skip ci]

--wip-- [skip ci]

add delete all

remove console.log

remove console.log

add patching of instances

fix yarn.lock

remove comments

* fixed typing

* use rrule

* rollback tsconfig changes

* fix comments

* fix comments

* rename db column

* fix delete all booking

* Update apps/web/pages/api/book/recurring-event.ts

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>

* remove non-null assertion

* attempt to fix type errors

* fix recurring event logic

* remove skipAvailabilityCheck

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Hariom <hariombalhara@gmail.com>
2024-01-16 13:49:54 +05:30
Vishal Bisht f41f4b01a9 Fixed [CAL-2930] Org branding isnt centered in shell #13225 (#13237) 2024-01-16 03:02:46 +00:00
Keith Williams a7b38b1b58 feat: Allow admins to filter bookings only by attendee emails (#13236)
* chore: Allow admins to filter bookings only by attendee emails

* Fix type error
2024-01-15 23:36:39 +00:00
a0f1ceba19 refactor: Refactor seats logic (#12905)
* Refactor createBooking

* Type fix

* Abstract handleSeats

* Create Invitee type

* Create OrganizerUser type

* Abstract addVideoCallDataToEvt

* Abstract createLoggerWithEventDetails

* Abstract `handleAppStatus` from handler

* Create ReqAppsStatus type

* Move `deleteMeeting` and `getCalendar`

* Set parameters for `handleSeats`

* Typescript refactor

* Change function params from req

* Type fix

* Move handleSeats

* Abstract lastAttendeeDeleteBooking

* Create function for rescheduling seated events

* Fix imports on reschedule seats function

* Fix imports

* Import handleSeats function

* Fix rescheduleUid type

* Refactor owner reschedule to new time slot

* Refactor combine two booking times together

* Reschedule as an attendee

* Refactor createNewSeat

* Remove old handleSeats

* Remove lastAttendeeDeleteBooking from handleNewBooking

* Test for new attendee right params are passed

* Unit test params for reschedule

* Typo fix

* Clean up

* Create new seat test

* Test when attendee already signs up for booking

* Type fix

* Test reschedule move attendee to existing booking

* On reschedule create new booking

* Test on last attendee cancel booking

* Owner reschedule to new time slot

* Owner rescheduling, merge two bookings together

* Test: when merging more than available seats, then fail

* Test: fail when event is full

* Remove duplicate E2E tests

* Clean up

* Rename `addVideoCallDataToEvt` to `addVideoCallDataToEvent`

* Refactor `calcAppsStatus`

* Assign `evt` to resutl of `addVideoCallDataToEvent`

* Use prisma.transaction when moving attendees

* Clean create seat call

* Use ErrorCode enum

* Use attendeeRescheduledSeatedBooking function

* Await function

* Prevent double triggering of workflows

* Use inviteeToAdd in createNewSeat

* Remove unused error code

* Remove old handleSeats file

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

* Type fix

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Erik <erik@erosemberg.com>
2024-01-15 17:04:13 -05:00
Hariom Balhara 5c6e104ff5 fix: Team revert with members (#13228) 2024-01-15 21:26:55 +00:00
Udit Takkar 29b1f7bf51 chore: add error message for no availability (#13230)
* chore: add error message for no default user availability

* chore: check only availability

* chore: change message

* chore: add eventType
2024-01-15 16:04:12 -05:00
Udit TakkarandCarina Wollendorfer 7ec3d04acc test: add test for invalid cal video (#13103)
* test: add test for invalid cal video

* chore: add confirm

* chore: fix type error

* chore: update code

* test: add unit test

* chore: type error

* chore

* chore: type

* chore: fix test

* chore

---------

Co-authored-by: Carina Wollendorfer <30310907+CarinaWolli@users.noreply.github.com>
2024-01-15 15:07:58 -05:00
Udit Takkar 13a765e50a test: In Team Event type when location is Organizer Default App and U… (#12939)
* test: In Team Event type when location is Organizer Default App and User Metadata is emoty

* chore: use variable

* fix: type error
2024-01-15 19:38:16 +00:00
94 changed files with 5226 additions and 2481 deletions
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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.
+3 -3
View File
@@ -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,
+9 -2
View File
@@ -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};`)
+2 -1
View File
@@ -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,
});
}
+5
View File
@@ -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(),
+10 -1
View File
@@ -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);
+21 -4
View File
@@ -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)) };
}
+131 -24
View File
@@ -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 });
+14 -2
View File
@@ -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 } });
};
+162 -1
View File
@@ -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,
+8 -4
View File
@@ -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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.7.0",
"version": "3.7.2",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+5 -213
View File
@@ -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);
};
+26 -5
View File
@@ -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;
}
+111
View File
@@ -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) }),
});
+2
View File
@@ -1,3 +1,5 @@
"use client";
import { useSession } from "next-auth/react";
import { Trans } from "next-i18next";
import Link from "next/link";
+6 -58
View File
@@ -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]";
+6 -26
View File
@@ -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>
</>
+7 -6
View File
@@ -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) => (
+4 -101
View File
@@ -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,
},
};
};
+5 -6
View File
@@ -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,
+3 -294
View File
@@ -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()) {
+43 -2
View File
@@ -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
+131
View File
@@ -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,
};
}
+50
View File
@@ -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);
});
});
+6
View File
@@ -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);
+4 -1
View File
@@ -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);
+62 -1
View File
@@ -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();
}
}
}
});
});
+63 -66
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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";
+3
View File
@@ -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;
+9 -1
View File
@@ -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",
+18 -1
View File
@@ -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)}`);
+125 -749
View File
@@ -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,
};
}
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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
+80
View File
@@ -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>;
+1 -1
View File
@@ -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)
+2 -2
View File
@@ -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
View File
@@ -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);
}
+5 -29
View File
@@ -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;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Team" ADD COLUMN "pendingPayment" BOOLEAN NOT NULL DEFAULT false;
+13 -11
View File
@@ -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])
+11 -1
View File
@@ -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,
+6
View File
@@ -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;
+1
View File
@@ -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;
+8 -2
View File
@@ -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>
+15 -6
View File
@@ -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