Compare commits
41
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8f5bd6fe9 | ||
|
|
a1c725bb35 | ||
|
|
e4eeb11a61 | ||
|
|
3b0bb25251 | ||
|
|
d27490e6f2 | ||
|
|
b3c191b553 | ||
|
|
0b977e615d | ||
|
|
979e1fd28b | ||
|
|
3437c4331d | ||
|
|
616d62094c | ||
|
|
ce7ff1c43c | ||
|
|
e27e41bc16 | ||
|
|
93e183e6c3 | ||
|
|
88a2bd3317 | ||
|
|
45caaa55b0 | ||
|
|
8c9b7a701d | ||
|
|
c47b992125 | ||
|
|
493c4f89dc | ||
|
|
27234dc809 | ||
|
|
5690b8e1d5 | ||
|
|
61274bc7ef | ||
|
|
e66be23996 | ||
|
|
3c43ce4165 | ||
|
|
5060824795 | ||
|
|
c4941f25a2 | ||
|
|
e7c219905f | ||
|
|
499cbd08ca | ||
|
|
0a3b3a69e3 | ||
|
|
e8607845e2 | ||
|
|
02ef4db0eb | ||
|
|
f8d48a341d | ||
|
|
251393101a | ||
|
|
b50430114f | ||
|
|
b0999abc68 | ||
|
|
2bbd92db13 | ||
|
|
64092abdca | ||
|
|
b7b631f0b0 | ||
|
|
c10f15e75b | ||
|
|
6983179b2f | ||
|
|
aea0661303 | ||
|
|
3fd9ef366f |
@@ -7,7 +7,7 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"ignore": ["@calcom/platform-libraries"],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
fix saving event type settings
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/DBA
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
- "@calcom/prisma"
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node ./index.js"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -37,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
|
||||
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.verbose(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (throttleOptions) {
|
||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||
}
|
||||
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
${JSON.stringify(rateLimit, null, 2)}
|
||||
`);
|
||||
`);*/
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
${cachedRateLimits}
|
||||
`);
|
||||
`);*/
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (rateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);*/
|
||||
}
|
||||
|
||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.verbose(
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||
);
|
||||
this.logger.verbose(
|
||||
);*/
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||
);
|
||||
);*/
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ export class OAuthClientUsersService {
|
||||
}
|
||||
|
||||
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
if (userEmail.includes(`+${oAuthClientId}@`)) {
|
||||
return userEmail;
|
||||
}
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
|
||||
import { TooltipExample } from "./avatar.tooltip"
|
||||
import { LinkExample } from "./avatar.link"
|
||||
import { IndicatorExample } from "./avatar.indicator"
|
||||
import { ShapeExample } from "./avatar.shape"
|
||||
|
||||
## Size Variations
|
||||
|
||||
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
|
||||
|
||||
<SizesExample/>
|
||||
|
||||
## Shape Variations
|
||||
|
||||
Avatars can be displayed in either circular or square shapes.
|
||||
|
||||
<ShapeExample/>
|
||||
|
||||
## With and Without Image
|
||||
|
||||
Avatars can display either an image or a fallback placeholder.
|
||||
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
|
||||
Avatars can include status indicators or badges.
|
||||
|
||||
<IndicatorExample/>
|
||||
|
||||
## Usage Example
|
||||
|
||||
Here's a complete example showing how to use the Avatar component with all its props:
|
||||
|
||||
```tsx
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Basic Avatar */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
/>
|
||||
|
||||
{/* Avatar with Link */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
href="/profile"
|
||||
/>
|
||||
|
||||
{/* Avatar with Tooltip and Indicator */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
|
||||
/>
|
||||
|
||||
{/* Avatar with Fallback */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
fallback={<span>UN</span>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
export function ShapeExample() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
alt="Circle Avatar"
|
||||
title="Circle Avatar"
|
||||
size="lg"
|
||||
shape="circle"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
<Avatar
|
||||
alt="Square Avatar"
|
||||
title="Square Avatar"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const bookerUrl = useBookerUrl();
|
||||
const session = useSession();
|
||||
const teamMemberIdsMatchingAttributeLogic =
|
||||
teamMembersMatchingAttributeLogic?.data
|
||||
?.map((member) => member.id)
|
||||
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
|
||||
booking,
|
||||
currentResponse,
|
||||
searchParams: new URLSearchParams({
|
||||
// rescheduleReason
|
||||
rescheduledBy: session?.data?.user?.email ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
|
||||
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||
createBookingMutation.mutate({
|
||||
rescheduleUid: booking.uid,
|
||||
rescheduledBy: session?.data?.user?.email ?? undefined,
|
||||
// rescheduleReason,
|
||||
reroutingFormResponses: reroutingFormResponses,
|
||||
...getTimeslotFields(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
|
||||
|
||||
vi.stubGlobal("open", mockOpen);
|
||||
|
||||
const mockSession = {
|
||||
expires: new Date(Date.now() + 2 * 86400).toISOString(),
|
||||
user: {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
},
|
||||
} as Session;
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||
return (
|
||||
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New Routing tests", () => {
|
||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByText("verify_new_route"));
|
||||
|
||||
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New tab rescheduling", () => {
|
||||
test("new tab is closed when new booking is rerouted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
|
||||
|
||||
test("Rescheduling with same timeslot works", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||
|
||||
@@ -117,8 +117,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
// A booking that has been rescheduled to a new booking will also have a status of CANCELLED
|
||||
const isDisabledRescheduling = booking.eventType?.disableRescheduling;
|
||||
// This comes from query param and thus is considered forced
|
||||
const canRescheduleCancelledBooking =
|
||||
isForcedRescheduleForCancelledBooking || booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const canBookThroughCancelledBookingRescheduleLink = booking.eventType?.allowReschedulingCancelledBookings;
|
||||
const isNonRescheduleableBooking =
|
||||
booking.status === BookingStatus.CANCELLED || booking.status === BookingStatus.REJECTED;
|
||||
|
||||
@@ -131,8 +130,9 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
};
|
||||
}
|
||||
|
||||
if (isNonRescheduleableBooking) {
|
||||
const canReschedule = booking.status === BookingStatus.CANCELLED && canRescheduleCancelledBooking;
|
||||
if (isNonRescheduleableBooking && !isForcedRescheduleForCancelledBooking) {
|
||||
const canReschedule =
|
||||
booking.status === BookingStatus.CANCELLED && canBookThroughCancelledBookingRescheduleLink;
|
||||
return {
|
||||
redirect: {
|
||||
destination: canReschedule ? eventUrl : `/booking/${uid}`,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
@@ -44,34 +45,6 @@ type RecurringInfo = {
|
||||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: "upcoming_bookings",
|
||||
recurring: "recurring_bookings",
|
||||
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Generate dynamic tabs that preserve query parameters
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
|
||||
const baseTabConfigs = [
|
||||
{
|
||||
name: "upcoming",
|
||||
path: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
path: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
path: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
path: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
path: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return baseTabConfigs.map((tabConfig) => ({
|
||||
name: tabConfig.name,
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
"data-testid": tabConfig["data-testid"],
|
||||
}));
|
||||
}, [searchParams?.toString()]);
|
||||
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
|
||||
@@ -691,25 +691,23 @@ export default function Signup({
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||
{FEATURES.map((feature) => (
|
||||
<>
|
||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{FEATURES.map((feature, index) => (
|
||||
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.7",
|
||||
"version": "5.4.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
|
||||
@@ -12,7 +12,7 @@ export default function Router({ form, message, errorMessage }: inferSSRProps<ty
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>{form.name} | Cal.com Forms</title>
|
||||
<title>{form?.name} | Cal.com Forms</title>
|
||||
</Head>
|
||||
<div className="mx-auto my-0 max-w-3xl md:my-24">
|
||||
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
|
||||
|
||||
@@ -267,6 +267,8 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
"is_still_available": "is still available.",
|
||||
@@ -1710,6 +1712,8 @@
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"questions": "Questions",
|
||||
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||
"platform_members": "Platform members",
|
||||
@@ -2835,7 +2839,7 @@
|
||||
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
|
||||
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
|
||||
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
|
||||
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
|
||||
"salesforce_change_record_owner_on_booking": "Change record owner on booking",
|
||||
"mass_assign_attributes": "Mass assign attributes",
|
||||
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
|
||||
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
|
||||
@@ -2888,6 +2892,7 @@
|
||||
"yes_reassign": "Yes, reassign",
|
||||
"filter_by_attributes": "Filter by attributes",
|
||||
"reassign_reason": "Reason for reassigning",
|
||||
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
|
||||
"reassigned_by": "Reassigned by",
|
||||
"row": "row",
|
||||
"rows": "rows",
|
||||
@@ -2933,7 +2938,7 @@
|
||||
"routing_form_insights_assignment_reason": "Assignment Reason",
|
||||
"access_denied": "Access Denied",
|
||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||
"salesforce_add_attendees_as":"Add new attendees as",
|
||||
"salesforce_add_attendees_as": "Add new attendees as",
|
||||
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
||||
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||
"salesforce_option": "Salesforce Option",
|
||||
@@ -2996,7 +3001,7 @@
|
||||
"shortfall": "Shortfall",
|
||||
"routing_form": "Routing Form",
|
||||
"go_back_and_save": "Go back and save",
|
||||
"save_changes":"Save changes",
|
||||
"save_changes": "Save changes",
|
||||
"leave_without_saving": "Leave without saving",
|
||||
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
||||
"something_unexpected_occurred": "Something unexpected occurred",
|
||||
@@ -3031,9 +3036,9 @@
|
||||
"authorize": "Authorize",
|
||||
"next": "Next",
|
||||
"hide": "Hide",
|
||||
"show":"Show",
|
||||
"utm_params":"UTM parameters",
|
||||
"no_utm_params":"No UTM parameters available.",
|
||||
"show": "Show",
|
||||
"utm_params": "UTM parameters",
|
||||
"no_utm_params": "No UTM parameters available.",
|
||||
"asc": "Asc",
|
||||
"desc": "Desc",
|
||||
"verify_email_change": "Verify email change",
|
||||
@@ -3103,7 +3108,7 @@
|
||||
"user_has_no_team_yet": "You don't have a team yet",
|
||||
"no_team_members": "You don't have team members yet",
|
||||
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||
"segment": "Segment",
|
||||
@@ -3181,8 +3186,8 @@
|
||||
"resend": "Resend",
|
||||
"verification_email_sent": "Verification email sent",
|
||||
"select_all_members": "Select all members",
|
||||
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.",
|
||||
"form_settings":"Form Settings",
|
||||
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
|
||||
"form_settings": "Form Settings",
|
||||
"remove_whitelist_status": "Remove whitelist status",
|
||||
"whitelist_user_workflows": "Whitelist user workflows",
|
||||
"user_workflows_whitelisted": "User workflows whitelisted",
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import { wrapGetServerSidePropsWithSentry } from "@sentry/nextjs";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
|
||||
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
|
||||
import { getRoutedUrl, hasEmbedPath } from "@calcom/lib/server/getRoutedUrl";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
|
||||
export const getServerSideProps = wrapGetServerSidePropsWithSentry(async function getServerSideProps(
|
||||
context: GetServerSidePropsContext
|
||||
) {
|
||||
return await getRoutedUrl(context);
|
||||
try {
|
||||
return await getRoutedUrl(context);
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError && error.code === "TOO_MANY_REQUESTS") {
|
||||
context.res.statusCode = 429;
|
||||
const isEmbed = hasEmbedPath(context.req.url || "");
|
||||
|
||||
return {
|
||||
props: {
|
||||
isEmbed,
|
||||
form: null,
|
||||
message: null,
|
||||
errorMessage: error.message,
|
||||
},
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
"/router");
|
||||
|
||||
+2
-1
@@ -80,8 +80,9 @@
|
||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||
"changesets-add": "yarn changeset add",
|
||||
"changesets-version": "yarn changeset version",
|
||||
"changesets-release": "turbo run build && yarn changeset publish"
|
||||
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
|
||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
|
||||
* What it does
|
||||
* - It automatically refreshes the token if needed when making a request.
|
||||
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
*
|
||||
* What it doesn't do yet
|
||||
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
|
||||
*
|
||||
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||
*/
|
||||
@@ -89,7 +94,13 @@ export class OAuthManager {
|
||||
currentTokenObject,
|
||||
fetchNewTokenObject,
|
||||
updateTokenObject,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isTokenObjectUnusable,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isAccessTokenUnusable,
|
||||
invalidateTokenObject,
|
||||
expireAccessToken,
|
||||
|
||||
@@ -2,15 +2,9 @@ import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import {
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
SCOPE_USERINFO_PROFILE,
|
||||
WEBAPP_URL,
|
||||
WEBAPP_URL_FOR_OAUTH,
|
||||
} from "@calcom/lib/constants";
|
||||
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
@@ -101,9 +95,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
}
|
||||
// TODO: Use the avatarUrl when setting the profile picture
|
||||
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
// }
|
||||
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session.user.id,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MultiOptionInput,
|
||||
} from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -36,6 +37,7 @@ function Field({
|
||||
moveUp,
|
||||
moveDown,
|
||||
appUrl,
|
||||
disableTypeChange,
|
||||
}: {
|
||||
fieldIndex: number;
|
||||
hookForm: HookForm;
|
||||
@@ -53,6 +55,7 @@ function Field({
|
||||
fn: () => void;
|
||||
};
|
||||
appUrl: string;
|
||||
disableTypeChange: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -123,32 +126,53 @@ function Field({
|
||||
defaultValue={routerField?.type}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||
return (
|
||||
<SelectField
|
||||
maxMenuHeight={200}
|
||||
styles={{
|
||||
singleValue: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
option: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
}}
|
||||
label="Type"
|
||||
isDisabled={!!router}
|
||||
containerClassName="data-testid-field-type"
|
||||
options={FieldTypes}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onChange(option.value);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
if (disableTypeChange) {
|
||||
return (
|
||||
<div className="data-testid-field-type">
|
||||
<Label htmlFor="field-type-button">{t("type")}</Label>
|
||||
<Tooltip content={t("field_type_change_suggestion")}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled
|
||||
color="secondary"
|
||||
className={classNames(
|
||||
"h-8 w-full justify-between text-left text-sm",
|
||||
!!router && "bg-subtle cursor-not-allowed"
|
||||
)}>
|
||||
<span className="text-default">{defaultValue?.label || "Select field type"}</span>
|
||||
<Icon name="chevron-down" className="text-default h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SelectField
|
||||
maxMenuHeight={200}
|
||||
styles={{
|
||||
singleValue: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
option: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
}}
|
||||
label="Type"
|
||||
isDisabled={!!router}
|
||||
containerClassName="data-testid-field-type"
|
||||
options={FieldTypes}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onChange(option.value);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -213,6 +237,7 @@ const FormEdit = ({
|
||||
} = useFieldArray({
|
||||
control: hookForm.control,
|
||||
name: fieldsNamespace,
|
||||
keyName: "_id",
|
||||
});
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
@@ -236,12 +261,15 @@ const FormEdit = ({
|
||||
<div className="w-full py-4 lg:py-8">
|
||||
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
||||
{hookFormFields.map((field, key) => {
|
||||
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
|
||||
const hasFormResponses = (form._count?.responses ?? 0) > 0;
|
||||
return (
|
||||
<Field
|
||||
appUrl={appUrl}
|
||||
fieldIndex={key}
|
||||
hookForm={hookForm}
|
||||
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
||||
disableTypeChange={existingField && hasFormResponses}
|
||||
deleteField={{
|
||||
check: () => hookFormFields.length > 1,
|
||||
fn: () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
|
||||
}: GetAttributesForTeamHandlerOptions) {
|
||||
const { teamId } = input;
|
||||
const { user } = ctx;
|
||||
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
|
||||
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
|
||||
|
||||
if (!isMemberOfTeam) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
|
||||
state,
|
||||
};
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret } = await getZoomAppKeys();
|
||||
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`);
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
const result = await fetch(
|
||||
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
@@ -65,12 +66,12 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
|
||||
export const zoomUserSettingsSchema = z.object({
|
||||
recording: z
|
||||
.object({
|
||||
auto_recording: z.string(),
|
||||
auto_recording: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
schedule_meeting: z
|
||||
.object({
|
||||
default_password_for_scheduled_meetings: z.string(),
|
||||
default_password_for_scheduled_meetings: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
@@ -154,6 +155,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
};
|
||||
};
|
||||
|
||||
// Zoom agenda field has a 2000 character limit; we set maxLength to 1900 to leave a safety buffer
|
||||
const truncateAgenda = (description?: string | null) => {
|
||||
if (!description) return description;
|
||||
|
||||
const maxLength = 1900;
|
||||
const trimmed = description.trimEnd();
|
||||
if (trimmed.length > maxLength) {
|
||||
return `${trimmed.substring(0, maxLength).trimEnd()}...`;
|
||||
}
|
||||
return trimmed;
|
||||
};
|
||||
|
||||
const userSettings = await getUserSettings();
|
||||
const recurrence = getRecurrence(event);
|
||||
// Documentation at: https://marketplace.zoom.us/docs/api-reference/zoom-api/meetings/meetingcreate
|
||||
@@ -165,7 +178,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
//schedule_for: "string", TODO: Used when scheduling the meeting for someone else (needed?)
|
||||
timezone: event.organizer.timeZone,
|
||||
password: userSettings?.schedule_meeting?.default_password_for_scheduled_meetings ?? undefined,
|
||||
agenda: event.description,
|
||||
agenda: truncateAgenda(event.description),
|
||||
settings: {
|
||||
host_video: true,
|
||||
participant_video: true,
|
||||
@@ -185,6 +198,32 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Zoom is known to return xml response in some cases.
|
||||
* e.g. Wrong request or some special case of invalid token
|
||||
*/
|
||||
const handleZoomResponseJsonParseError = async ({
|
||||
error,
|
||||
clonedResponse,
|
||||
}: {
|
||||
error: unknown;
|
||||
clonedResponse: Response;
|
||||
}) => {
|
||||
// In some cases, Zoom responds with xml response, so we log the response for debugging
|
||||
// We need to see why that error occurs exactly and then later we decide if mark the access token and token object unusable or not
|
||||
log.error(
|
||||
"Error in JSON parsing Zoom API response",
|
||||
safeStringify({
|
||||
error: safeStringify(error),
|
||||
// Log Raw response body here.
|
||||
responseBody: await clonedResponse.text(),
|
||||
status: clonedResponse.status,
|
||||
})
|
||||
);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
|
||||
const auth = new OAuthManager({
|
||||
credentialSyncVariables: {
|
||||
@@ -220,9 +259,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
isTokenObjectUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (e) {
|
||||
return await handleZoomResponseJsonParseError({
|
||||
error: e,
|
||||
clonedResponse: responseToUseInCaseOfError,
|
||||
});
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
@@ -233,11 +281,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
isAccessTokenUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (e) {
|
||||
return await handleZoomResponseJsonParseError({
|
||||
error: e,
|
||||
clonedResponse: responseToUseInCaseOfError,
|
||||
});
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
// 124 is the error code for invalid access token from Zoom API
|
||||
if (responseBody.code === 124) {
|
||||
return { reason: responseBody.message ?? "" };
|
||||
}
|
||||
@@ -284,7 +341,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error("Failed to get availability", safeStringify(err));
|
||||
/* Prevents booking failure when Zoom Token is expired */
|
||||
return [];
|
||||
}
|
||||
@@ -311,8 +368,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
}
|
||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error("Zoom meeting creation failed", safeStringify({ error: err, event }));
|
||||
log.error(
|
||||
"Zoom meeting creation failed",
|
||||
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
|
||||
);
|
||||
/* Prevents meeting creation failure when Zoom Token is expired */
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
@@ -347,7 +406,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
url: result.join_url,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("Failed to update meeting", safeStringify(err));
|
||||
log.error(
|
||||
"Failed to update meeting",
|
||||
safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) })
|
||||
);
|
||||
return Promise.reject(new Error("Failed to update meeting"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -164,4 +164,354 @@ describe("checkForConflicts", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comprehensive edge cases and boundary conditions", () => {
|
||||
it("should return false for empty busy array", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot that starts exactly when busy period ends", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot that ends exactly when busy period starts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T08:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot start exactly matching busy period start (inclusive)", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle slot end exactly matching busy period end (inclusive)", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:15:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very short event length (1 minute)", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00Z"),
|
||||
eventLength: 1,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:30Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:01:30Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very long event length (8 hours)", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00Z"),
|
||||
eventLength: 480, // 8 hours
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T13:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple busy periods scenarios", () => {
|
||||
it("should return true when first busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when last busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T10:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:15:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when middle busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T10:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle many non-overlapping busy periods", () => {
|
||||
const busyPeriods = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const hour = (8 + i * 2) % 24; // Wrap around to prevent invalid hours
|
||||
busyPeriods.push({
|
||||
start: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:00:00Z`).toDate(),
|
||||
end: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:30:00Z`).toDate(),
|
||||
});
|
||||
}
|
||||
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T07:00:00Z"),
|
||||
busy: busyPeriods,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle overlapping busy periods", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentSeats comprehensive scenarios", () => {
|
||||
it("should return false when currentSeats has exact time match", () => {
|
||||
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: testTime.toDate(),
|
||||
_count: { attendees: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: testTime,
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when currentSeats has no matching time but busy period conflicts", () => {
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T09:00:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple currentSeats with one matching", () => {
|
||||
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
{
|
||||
uid: "booking-2",
|
||||
startTime: testTime.toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: testTime,
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty currentSeats array", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T09:00:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats: [],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timezone and date handling", () => {
|
||||
it("should handle different timezone inputs correctly", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00-05:00"), // EST
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T14:15:00Z").toDate(), // UTC equivalent of 9:15 EST
|
||||
end: dayjs.utc("2023-01-01T14:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle cross-day boundaries", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T23:45:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-02T00:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-02T00:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance and stress scenarios", () => {
|
||||
it("should handle large number of busy periods efficiently", () => {
|
||||
const busyPeriods = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const hour = (8 + (i % 12)) % 24; // Wrap around to prevent invalid hours
|
||||
const minute = i % 60;
|
||||
const endMinute = ((i % 60) + 15) % 60;
|
||||
const endHour = endMinute < minute ? (hour + 1) % 24 : hour; // Handle minute overflow
|
||||
|
||||
busyPeriods.push({
|
||||
start: dayjs
|
||||
.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:00Z`)
|
||||
.toDate(),
|
||||
end: dayjs
|
||||
.utc(
|
||||
`2023-01-01T${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}:00Z`
|
||||
)
|
||||
.toDate(),
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T07:00:00Z"),
|
||||
busy: busyPeriods,
|
||||
});
|
||||
const endTime = performance.now();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(endTime - startTime).toBeLessThan(100); // Should complete in under 100ms
|
||||
});
|
||||
|
||||
it("should handle zero-duration busy periods", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(), // Zero duration
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,34 +26,25 @@ export function checkForConflicts({
|
||||
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
|
||||
return false;
|
||||
}
|
||||
const slotStartDate = time.utc().toDate();
|
||||
const slotEndDate = time.add(eventLength, "minutes").utc().toDate();
|
||||
// handle busy times.
|
||||
return busy.some((busyTime) => {
|
||||
const busyStartDate = dayjs.utc(busyTime.start).toDate();
|
||||
const busyEndDate = dayjs.utc(busyTime.end).toDate();
|
||||
// First check if there's any overlap at all
|
||||
// If busy period ends before slot starts or starts after slot ends, there's no overlap
|
||||
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) {
|
||||
return false;
|
||||
const slotStart = time.valueOf();
|
||||
const slotEnd = slotStart + eventLength * 60 * 1000;
|
||||
|
||||
const sortedBusyTimes = busy
|
||||
.map((busyTime) => ({
|
||||
start: dayjs.utc(busyTime.start).valueOf(),
|
||||
end: dayjs.utc(busyTime.end).valueOf(),
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
for (const busyTime of sortedBusyTimes) {
|
||||
if (busyTime.start >= slotEnd) {
|
||||
break;
|
||||
}
|
||||
// Now check all possible overlap scenarios:
|
||||
// 1. Slot start falls within busy period (inclusive start, exclusive end)
|
||||
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
|
||||
return true;
|
||||
if (busyTime.end <= slotStart) {
|
||||
continue;
|
||||
}
|
||||
// 2. Slot end falls within busy period (exclusive start, inclusive end)
|
||||
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) {
|
||||
return true;
|
||||
}
|
||||
// 3. Busy period completely contained within slot
|
||||
if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
// 4. Slot completely contained within busy period
|
||||
if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: !isEmailFieldOptional,
|
||||
editable: isOrgTeamEvent ? "system-but-optional" : "system",
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
defaultLabel: "location",
|
||||
type: "radioInput",
|
||||
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
],
|
||||
},
|
||||
];
|
||||
if (isOrgTeamEvent) {
|
||||
systemBeforeFields.splice(2, 0, {
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// These fields should be added after other user fields
|
||||
const systemAfterFields: typeof bookingFields = [
|
||||
|
||||
@@ -1396,6 +1396,8 @@ async function handler(
|
||||
routingFormResponseId,
|
||||
organizerId: organizerUser.id,
|
||||
teamId,
|
||||
isRerouting: !!reroutingFormResponses,
|
||||
reroutedByEmail: reqBody.rescheduledBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -23,7 +23,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
@@ -256,7 +255,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
`should fail recurring booking if second slot is already booked`,
|
||||
async ({}) => {
|
||||
const booker = getBooker({
|
||||
@@ -372,11 +371,12 @@ describe("handleNewRecurringBooking", () => {
|
||||
};
|
||||
});
|
||||
|
||||
await expect(() =>
|
||||
handleNewRecurringBooking({
|
||||
bookingData: bookingDataArray,
|
||||
userId: -1,
|
||||
})
|
||||
await expect(
|
||||
async () =>
|
||||
await handleNewRecurringBooking({
|
||||
bookingData: bookingDataArray,
|
||||
userId: -1,
|
||||
})
|
||||
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
||||
},
|
||||
timeout
|
||||
@@ -384,7 +384,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
});
|
||||
|
||||
describe("Round robin event type:", () => {
|
||||
test("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
||||
test.skip("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
|
||||
@@ -256,7 +256,7 @@ describe("handleNewBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
`should fail recurring booking if second slot is already booked`,
|
||||
async ({}) => {
|
||||
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
||||
@@ -822,7 +822,7 @@ describe("handleNewBooking", () => {
|
||||
);
|
||||
});
|
||||
describe("Round robin event type:", () => {
|
||||
test("should when when a fixed host is not available on the second slot", async () => {
|
||||
test.skip("should when when a fixed host is not available on the second slot", async () => {
|
||||
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
||||
.handleRecurringEventBooking;
|
||||
const booker = getBooker({
|
||||
|
||||
@@ -29,7 +29,7 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
|
||||
});
|
||||
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||
|
||||
const numSlotsToCheckForAvailability = 2;
|
||||
const numSlotsToCheckForAvailability = 1;
|
||||
|
||||
let thirdPartyRecurringEventId = null;
|
||||
|
||||
|
||||
@@ -127,6 +127,24 @@ export function TimezoneSelectComponent({
|
||||
}}
|
||||
onInputChange={handleInputChange}
|
||||
{...props}
|
||||
onChange={(selectedOption) => {
|
||||
if (!props.onChange) return;
|
||||
|
||||
if (!selectedOption) {
|
||||
props.onChange(selectedOption);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fix inconsistent timezone naming formats
|
||||
const corrections: Record<string, string> = {
|
||||
"America/Port_Of_Spain": "America/Port_of_Spain",
|
||||
"Africa/Porto-novo": "Africa/Porto-Novo",
|
||||
"Africa/Dar_Es_Salaam": "Africa/Dar_es_Salaam",
|
||||
};
|
||||
|
||||
const correctedValue = corrections[selectedOption.value] || selectedOption.value;
|
||||
props.onChange({ ...selectedOption, value: correctedValue });
|
||||
}}
|
||||
formatOptionLabel={(option) => (
|
||||
<p className="truncate">{(option as ITimezoneOption).value.replace(/_/g, " ")}</p>
|
||||
)}
|
||||
|
||||
@@ -28,11 +28,15 @@ export default class AssignmentReasonRecorder {
|
||||
routingFormResponseId,
|
||||
organizerId,
|
||||
teamId,
|
||||
isRerouting,
|
||||
reroutedByEmail,
|
||||
}: {
|
||||
bookingId: number;
|
||||
routingFormResponseId: number;
|
||||
organizerId: number;
|
||||
teamId: number;
|
||||
isRerouting: boolean;
|
||||
reroutedByEmail?: string | null;
|
||||
}) {
|
||||
// Get the routing form data
|
||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||
@@ -113,8 +117,12 @@ export default class AssignmentReasonRecorder {
|
||||
}
|
||||
}
|
||||
|
||||
const reasonEnum = AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
||||
const reasonString = attributeValues.join(", ");
|
||||
const reasonEnum = isRerouting
|
||||
? AssignmentReasonEnum.REROUTED
|
||||
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
||||
const reasonString = `${
|
||||
isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : ""
|
||||
} ${attributeValues.join(", ")}`;
|
||||
|
||||
await prisma.assignmentReason.create({
|
||||
data: {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
|
||||
import { getEventName } from "@calcom/lib/event";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
@@ -181,6 +182,12 @@ export const roundRobinManualReassignment = async ({
|
||||
userPrimaryEmail: newUser.email,
|
||||
reassignReason,
|
||||
reassignById: reassignedById,
|
||||
idempotencyKey: IdempotencyKeyService.generate({
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
userId: newUser.id,
|
||||
reassignedById,
|
||||
}),
|
||||
},
|
||||
select: bookingSelect,
|
||||
});
|
||||
|
||||
@@ -21,6 +21,8 @@ import {
|
||||
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
|
||||
} from "@calcom/lib/delegationCredential/server";
|
||||
import { getEventName } from "@calcom/lib/event";
|
||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
||||
@@ -238,6 +240,12 @@ export const roundRobinReassignment = async ({
|
||||
userId: reassignedRRHost.id,
|
||||
userPrimaryEmail: reassignedRRHost.email,
|
||||
title: newBookingTitle,
|
||||
idempotencyKey: IdempotencyKeyService.generate({
|
||||
startTime: booking.startTime,
|
||||
endTime: booking.endTime,
|
||||
userId: reassignedRRHost.id,
|
||||
reassignedById,
|
||||
}),
|
||||
},
|
||||
select: bookingSelect,
|
||||
});
|
||||
|
||||
@@ -157,6 +157,8 @@ export async function handler(req: NextRequest) {
|
||||
}
|
||||
|
||||
if (message?.length && message?.length > 0 && sendTo) {
|
||||
const smsMessageWithoutOptOut = message;
|
||||
|
||||
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
|
||||
message = await WorkflowOptOutService.addOptOutMessage(message, locale || "en");
|
||||
}
|
||||
@@ -167,6 +169,7 @@ export async function handler(req: NextRequest) {
|
||||
body: message,
|
||||
scheduledDate: reminder.scheduledDate,
|
||||
sender: senderID,
|
||||
bodyWithoutOptOut: smsMessageWithoutOptOut,
|
||||
bookingUid: reminder.booking.uid,
|
||||
userId,
|
||||
teamId,
|
||||
|
||||
@@ -14,6 +14,7 @@ export async function sendSmsOrFallbackEmail(props: {
|
||||
phoneNumber: string;
|
||||
body: string;
|
||||
sender: string;
|
||||
bodyWithoutOptOut?: string;
|
||||
bookingUid?: string | null;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
@@ -40,7 +41,7 @@ export async function sendSmsOrFallbackEmail(props: {
|
||||
await sendOrScheduleWorkflowEmails({
|
||||
to: [fallbackData.email],
|
||||
subject: fallbackData.t("notification_about_your_booking"),
|
||||
html: twilioData.body,
|
||||
html: twilioData.bodyWithoutOptOut || twilioData.body,
|
||||
replyTo: fallbackData.replyTo,
|
||||
});
|
||||
}
|
||||
@@ -60,6 +61,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
|
||||
body: string;
|
||||
scheduledDate: Date;
|
||||
sender: string;
|
||||
bodyWithoutOptOut?: string;
|
||||
bookingUid?: string | null;
|
||||
userId?: number | null;
|
||||
teamId?: number | null;
|
||||
@@ -96,7 +98,7 @@ export async function scheduleSmsOrFallbackEmail(props: {
|
||||
await sendOrScheduleWorkflowEmails({
|
||||
to: [fallbackData.email],
|
||||
subject: fallbackData.t("notification_about_your_booking"),
|
||||
html: twilioData.body,
|
||||
html: twilioData.bodyWithoutOptOut || twilioData.body,
|
||||
replyTo: fallbackData.replyTo,
|
||||
sendAt: twilioData.scheduledDate,
|
||||
referenceUid: reminder.uuid || undefined,
|
||||
|
||||
@@ -176,6 +176,8 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
|
||||
}
|
||||
|
||||
if (smsMessage.length > 0) {
|
||||
const smsMessageWithoutOptOut = smsMessage;
|
||||
|
||||
if (process.env.TWILIO_OPT_OUT_ENABLED === "true") {
|
||||
smsMessage = await WorkflowOptOutService.addOptOutMessage(
|
||||
smsMessage,
|
||||
@@ -196,6 +198,7 @@ export const scheduleSMSReminder = async (args: ScheduleTextReminderArgs) => {
|
||||
phoneNumber: reminderPhone,
|
||||
body: smsMessage,
|
||||
sender: senderID,
|
||||
bodyWithoutOptOut: smsMessageWithoutOptOut,
|
||||
bookingUid: evt.uid,
|
||||
userId,
|
||||
teamId,
|
||||
|
||||
@@ -244,7 +244,7 @@ export default function Booker( props : BookerProps ) {
|
||||
namespace: string;
|
||||
}) => {
|
||||
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
|
||||
elementOrSelector:"#my-cal-inline",
|
||||
elementOrSelector:"#my-cal-inline-${namespace}",
|
||||
config: ${JSON.stringify(previewState.config)},
|
||||
calLink: "${calLink}",
|
||||
});
|
||||
|
||||
@@ -59,7 +59,7 @@ export const tabs = [
|
||||
embedType === "inline"
|
||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||
previewState.inline.height
|
||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
||||
)};overflow:scroll" id="my-cal-inline-${namespace}"></div>\n`
|
||||
: ""
|
||||
}<script type="text/javascript">
|
||||
${embedSnippetString}
|
||||
|
||||
@@ -577,27 +577,38 @@ export const EventAdvancedTab = ({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: {
|
||||
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
|
||||
source: { label: "Location" },
|
||||
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
<div className="border-subtle bg-muted rounded-lg border p-1">
|
||||
<div className="p-5">
|
||||
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
|
||||
{t("booking_questions_title")}
|
||||
</div>
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("booking_questions_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle bg-default rounded-lg border p-5">
|
||||
<FormBuilder
|
||||
showPhoneAndEmailToggle
|
||||
title={t("confirmation")}
|
||||
description={t("what_booker_should_provide")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: {
|
||||
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
|
||||
source: { label: "Location" },
|
||||
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
shouldConsiderRequired={(field: BookingField) => {
|
||||
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
|
||||
return field.name === "location" ? true : field.required;
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
shouldConsiderRequired={(field: BookingField) => {
|
||||
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
|
||||
return field.name === "location" ? true : field.required;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
|
||||
@@ -24,7 +24,6 @@ import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { weekStartNum } from "@calcom/lib/weekstart";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -121,6 +120,7 @@ type EventTypeScheduleProps = {
|
||||
isSchedulePending?: boolean;
|
||||
isRestrictionSchedulePending?: boolean;
|
||||
restrictionScheduleRedirectUrl?: string;
|
||||
isRestrictionScheduleEnabled?: boolean;
|
||||
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
|
||||
Omit<EventTypeTeamScheduleProps, "customClassNames">;
|
||||
|
||||
@@ -810,6 +810,7 @@ const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null
|
||||
const UseTeamEventScheduleSettingsToggle = ({
|
||||
eventType,
|
||||
customClassNames,
|
||||
isRestrictionScheduleEnabled,
|
||||
...rest
|
||||
}: UseTeamEventScheduleSettingsToggle) => {
|
||||
const { t } = useLocale();
|
||||
@@ -819,18 +820,6 @@ const UseTeamEventScheduleSettingsToggle = ({
|
||||
eventType.restrictionScheduleId
|
||||
);
|
||||
|
||||
// Check if team has restriction schedule feature enabled
|
||||
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
|
||||
{
|
||||
teamId: eventType.team?.id || 0,
|
||||
feature: "restriction-schedule",
|
||||
},
|
||||
{
|
||||
enabled: !!eventType.team?.id,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import {
|
||||
Switch,
|
||||
CheckboxField,
|
||||
@@ -59,6 +60,7 @@ export const FormBuilder = function FormBuilder({
|
||||
LockedIcon,
|
||||
dataStore,
|
||||
shouldConsiderRequired,
|
||||
showPhoneAndEmailToggle = false,
|
||||
}: {
|
||||
formProp: string;
|
||||
title: string;
|
||||
@@ -66,6 +68,7 @@ export const FormBuilder = function FormBuilder({
|
||||
addFieldLabel: string;
|
||||
disabled: boolean;
|
||||
LockedIcon: false | JSX.Element;
|
||||
showPhoneAndEmailToggle?: boolean;
|
||||
/**
|
||||
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
|
||||
*/
|
||||
@@ -129,7 +132,68 @@ export const FormBuilder = function FormBuilder({
|
||||
{title}
|
||||
{LockedIcon}
|
||||
</div>
|
||||
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
||||
{showPhoneAndEmailToggle && (
|
||||
<ToggleGroup
|
||||
value={(() => {
|
||||
const phoneField = fields.find((field) => field.name === "attendeePhoneNumber");
|
||||
const emailField = fields.find((field) => field.name === "email");
|
||||
|
||||
if (phoneField && !phoneField.hidden && phoneField.required && !emailField?.required) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
return "email";
|
||||
})()}
|
||||
options={[
|
||||
{
|
||||
value: "email",
|
||||
label: "Email",
|
||||
iconLeft: <Icon name="mail" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "phone",
|
||||
label: "Phone",
|
||||
iconLeft: <Icon name="phone" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => {
|
||||
const phoneFieldIndex = fields.findIndex((field) => field.name === "attendeePhoneNumber");
|
||||
const emailFieldIndex = fields.findIndex((field) => field.name === "email");
|
||||
if (value === "email") {
|
||||
update(emailFieldIndex, {
|
||||
...fields[emailFieldIndex],
|
||||
hidden: false,
|
||||
required: true,
|
||||
});
|
||||
update(phoneFieldIndex, {
|
||||
...fields[phoneFieldIndex],
|
||||
hidden: true,
|
||||
required: false,
|
||||
});
|
||||
} else if (value === "phone") {
|
||||
update(emailFieldIndex, {
|
||||
...fields[emailFieldIndex],
|
||||
hidden: true,
|
||||
required: false,
|
||||
});
|
||||
update(phoneFieldIndex, {
|
||||
...fields[phoneFieldIndex],
|
||||
hidden: false,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-default mt-5 text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
|
||||
{t("questions")}
|
||||
</p>
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("all_info_your_booker_provide")}
|
||||
</p>
|
||||
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
|
||||
{fields.map((field, index) => {
|
||||
let options = field.options ?? null;
|
||||
@@ -480,6 +544,7 @@ function FieldEditDialog({
|
||||
const variantsConfig = fieldForm.watch("variantsConfig");
|
||||
|
||||
const fieldTypes = Object.values(fieldTypesConfigMap);
|
||||
const fieldName = fieldForm.getValues("name");
|
||||
|
||||
return (
|
||||
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange} modal={false}>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
export class IdempotencyKeyService {
|
||||
static generate({
|
||||
startTime,
|
||||
endTime,
|
||||
userId,
|
||||
reassignedById,
|
||||
}: {
|
||||
startTime: Date | string;
|
||||
endTime: Date | string;
|
||||
userId?: number;
|
||||
reassignedById?: number | null;
|
||||
}) {
|
||||
return uuidv5(
|
||||
`${startTime.valueOf()}.${endTime.valueOf()}.${userId}${reassignedById ? `.${reassignedById}` : ""}`,
|
||||
uuidv5.URL
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import "@calcom/lib/__mocks__/logger";
|
||||
|
||||
import { createHash } from "crypto";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -12,12 +13,14 @@ import { substituteVariables } from "@calcom/app-store/routing-forms/lib/substit
|
||||
import { getUrlSearchParamsToForward } from "@calcom/app-store/routing-forms/pages/routing-link/getUrlSearchParamsToForward";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isAuthorizedToViewFormOnOrgDomain } from "@calcom/features/routing-forms/lib/isAuthorizedToViewForm";
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import { RoutingFormRepository } from "@calcom/lib/server/repository/routingForm";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
|
||||
import { getRoutedUrl } from "./getRoutedUrl";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
|
||||
vi.mock("@calcom/app-store/routing-forms/lib/handleResponse");
|
||||
vi.mock("@calcom/lib/server/repository/routingForm");
|
||||
vi.mock("@calcom/lib/server/repository/user");
|
||||
@@ -112,6 +115,7 @@ describe("getRoutedUrl", () => {
|
||||
it("should return notFound if form is not found", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(null);
|
||||
const context = mockContext({});
|
||||
|
||||
const result = await getRoutedUrl(context);
|
||||
expect(result).toEqual({ notFound: true });
|
||||
expect(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).toHaveBeenCalledWith("form-id");
|
||||
@@ -259,6 +263,19 @@ describe("getRoutedUrl", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw an error if rate limit is exceeded", async () => {
|
||||
vi.mocked(checkRateLimitAndThrowError).mockRejectedValue(new Error("Rate limit exceeded"));
|
||||
const context = mockContext({ email: "test@cal.com" });
|
||||
const expectedHash = createHash("sha256")
|
||||
.update(JSON.stringify({ email: "test@cal.com" }))
|
||||
.digest("hex");
|
||||
|
||||
await expect(getRoutedUrl(context)).rejects.toThrow("Rate limit exceeded");
|
||||
expect(checkRateLimitAndThrowError).toHaveBeenCalledWith({
|
||||
identifier: `form:form-id:hash:${expectedHash}`,
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dry Run", () => {
|
||||
it("should call handleResponse with isPreview:true", async () => {
|
||||
vi.mocked(RoutingFormRepository.findFormByIdIncludeUserTeamAndOrg).mockResolvedValue(mockForm as never);
|
||||
|
||||
@@ -44,7 +44,7 @@ const getDeterministicHashForResponse = (fieldsResponses: Record<string, unknown
|
||||
return hash;
|
||||
};
|
||||
|
||||
function hasEmbedPath(pathWithQuery: string) {
|
||||
export function hasEmbedPath(pathWithQuery: string) {
|
||||
const onlyPath = pathWithQuery.split("?")[0];
|
||||
return onlyPath.endsWith("/embed") || onlyPath.endsWith("/embed/");
|
||||
}
|
||||
|
||||
@@ -263,7 +263,7 @@ export class MembershipRepository {
|
||||
});
|
||||
}
|
||||
|
||||
static async findFirstByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
static async findUniqueByUserIdAndTeamId({ userId, teamId }: { userId: number; teamId: number }) {
|
||||
return await prisma.membership.findUnique({
|
||||
where: {
|
||||
userId_teamId: {
|
||||
@@ -398,4 +398,24 @@ export class MembershipRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async findAllByTeamIds({
|
||||
teamIds,
|
||||
select = { userId: true },
|
||||
}: {
|
||||
teamIds: number[];
|
||||
select?: Prisma.MembershipSelect;
|
||||
}) {
|
||||
return prisma.membership.findMany({
|
||||
where: {
|
||||
team: {
|
||||
id: {
|
||||
in: teamIds,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
},
|
||||
select,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,6 +186,39 @@ export class TeamRepository {
|
||||
return getParsedTeam(team);
|
||||
}
|
||||
|
||||
static async findAllByParentId({
|
||||
parentId,
|
||||
select = teamSelect,
|
||||
}: {
|
||||
parentId: number;
|
||||
select?: Prisma.TeamSelect;
|
||||
}) {
|
||||
return await prisma.team.findMany({
|
||||
where: {
|
||||
parentId,
|
||||
},
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
static async findByIdAndParentId({
|
||||
id,
|
||||
parentId,
|
||||
select = teamSelect,
|
||||
}: {
|
||||
id: number;
|
||||
parentId: number;
|
||||
select?: Prisma.TeamSelect;
|
||||
}) {
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
id,
|
||||
parentId,
|
||||
},
|
||||
select,
|
||||
});
|
||||
}
|
||||
|
||||
static async deleteById({ id }: { id: number }) {
|
||||
try {
|
||||
await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id);
|
||||
@@ -360,4 +393,18 @@ export class TeamRepository {
|
||||
inviteToken: inviteTokens.find((token) => token.identifier === `invite-link-for-teamId-${team.id}`),
|
||||
}));
|
||||
}
|
||||
|
||||
static async findTeamWithOrganizationSettings(teamId: number) {
|
||||
return await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
parent: {
|
||||
select: {
|
||||
isOrganization: true,
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
import type { Team, User, Membership } from "@prisma/client";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsBookingService } from "../../service/insightsBooking";
|
||||
|
||||
// Helper function to create unique test data
|
||||
async function createTestData({
|
||||
teamRole = MembershipRole.MEMBER,
|
||||
orgRole = MembershipRole.MEMBER,
|
||||
moreTeamsAndUsers = [],
|
||||
}: {
|
||||
teamRole?: MembershipRole;
|
||||
orgRole?: MembershipRole;
|
||||
moreTeamsAndUsers?: MembershipRole[][]; // Array of teams, each team has array of user roles
|
||||
} = {}) {
|
||||
const timestamp = Date.now();
|
||||
const randomSuffix = Math.random().toString(36).substring(7);
|
||||
|
||||
// Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `test-user-${timestamp}-${randomSuffix}@example.com`,
|
||||
username: `testuser-${timestamp}-${randomSuffix}`,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
// Create test organization
|
||||
const org = await prisma.team.create({
|
||||
data: {
|
||||
name: `Test Org ${timestamp}-${randomSuffix}`,
|
||||
slug: `test-org-${timestamp}-${randomSuffix}`,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test team under the organization
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: `Test Team ${timestamp}-${randomSuffix}`,
|
||||
slug: `test-team-${timestamp}-${randomSuffix}`,
|
||||
isOrganization: false,
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test event type
|
||||
const eventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: "test-event-type",
|
||||
length: 60,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test booking
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${timestamp}-${randomSuffix}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
role: teamRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: org.id,
|
||||
role: orgRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create additional teams and users based on moreTeamsAndUsers
|
||||
const additionalTeams: Team[] = [];
|
||||
const additionalUsers: User[] = [];
|
||||
const additionalMemberships: Membership[] = [];
|
||||
|
||||
for (let teamIndex = 0; teamIndex < moreTeamsAndUsers.length; teamIndex++) {
|
||||
const teamUserRoles = moreTeamsAndUsers[teamIndex];
|
||||
|
||||
// Create the team
|
||||
const additionalTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: `Test Team ${teamIndex + 2} ${timestamp}-${randomSuffix}`,
|
||||
slug: `test-team-${teamIndex + 2}-${timestamp}-${randomSuffix}`,
|
||||
isOrganization: false,
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
additionalTeams.push(additionalTeam);
|
||||
|
||||
// Create users for this team
|
||||
for (let userIndex = 0; userIndex < teamUserRoles.length; userIndex++) {
|
||||
const userRole = teamUserRoles[userIndex];
|
||||
|
||||
// Create the user
|
||||
const additionalUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `test-user-team${teamIndex + 2}-${userIndex + 1}-${timestamp}-${randomSuffix}@example.com`,
|
||||
username: `testuser-team${teamIndex + 2}-${userIndex + 1}-${timestamp}-${randomSuffix}`,
|
||||
name: `Test User Team${teamIndex + 2}-${userIndex + 1}`,
|
||||
},
|
||||
});
|
||||
additionalUsers.push(additionalUser);
|
||||
|
||||
// Create the membership
|
||||
const additionalMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: additionalUser.id,
|
||||
teamId: additionalTeam.id,
|
||||
role: userRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
additionalMemberships.push(additionalMembership);
|
||||
}
|
||||
}
|
||||
|
||||
// Return test data and cleanup function
|
||||
return {
|
||||
user,
|
||||
org,
|
||||
orgMembership,
|
||||
team,
|
||||
eventType,
|
||||
booking,
|
||||
membership,
|
||||
additionalTeams,
|
||||
additionalUsers,
|
||||
additionalMemberships,
|
||||
cleanup: async () => {
|
||||
// Clean up additional memberships
|
||||
for (const membership of additionalMemberships) {
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up additional users
|
||||
for (const user of additionalUsers) {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up additional teams
|
||||
for (const team of additionalTeams) {
|
||||
await prisma.team.delete({
|
||||
where: { id: team.id },
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up main test data
|
||||
await prisma.booking.deleteMany({
|
||||
where: { id: booking.id },
|
||||
});
|
||||
await prisma.eventType.deleteMany({
|
||||
where: { id: eventType.id },
|
||||
});
|
||||
await prisma.membership.deleteMany({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
await prisma.membership.delete({
|
||||
where: { id: orgMembership.id },
|
||||
});
|
||||
await prisma.team.deleteMany({
|
||||
where: { id: team.id },
|
||||
});
|
||||
await prisma.team.deleteMany({
|
||||
where: { id: org.id },
|
||||
});
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: user.id },
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("InsightsBookingService Integration Tests", () => {
|
||||
describe("Authorization Conditions", () => {
|
||||
it("should return NOTHING for invalid options", async () => {
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: null as any,
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
});
|
||||
|
||||
it("should return NOTHING for non-owner/admin user", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
// Create a regular user who is not an owner/admin
|
||||
const regularUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `regular-user-${Date.now()}-${Math.random().toString(36).substring(7)}@example.com`,
|
||||
username: `regularuser-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
||||
name: "Regular User",
|
||||
},
|
||||
});
|
||||
|
||||
// Add the user as a regular member (not owner/admin) to the organization
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: regularUser.id,
|
||||
teamId: testData.org.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: regularUser.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
|
||||
// Clean up
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: regularUser.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build user scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
userId: testData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build team scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "team",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: testData.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: testData.team.id,
|
||||
isTeamBooking: true,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: [testData.user.id],
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build org scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
moreTeamsAndUsers: [
|
||||
[MembershipRole.MEMBER], // Team 2: 1 user with MEMBER role
|
||||
[MembershipRole.MEMBER], // Team 3: 1 user with MEMBER role
|
||||
],
|
||||
});
|
||||
|
||||
// Get references to additional data
|
||||
const user2 = testData.additionalUsers[0]; // First user from first team
|
||||
const user3 = testData.additionalUsers[1]; // First user from second team
|
||||
const team2 = testData.additionalTeams[0]; // First additional team
|
||||
const team3 = testData.additionalTeams[1]; // Second additional team
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [
|
||||
{
|
||||
teamId: {
|
||||
in: [testData.org.id, testData.team.id, team2.id, team3.id],
|
||||
},
|
||||
isTeamBooking: true,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: [testData.user.id, user2.id, user3.id],
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filter Conditions", () => {
|
||||
it("should return null when no filters are provided", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toBeNull();
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build eventTypeId filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build memberUserId filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
memberUserId: testData.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
userId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should combine multiple filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
memberUserId: testData.user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
|
||||
},
|
||||
{
|
||||
userId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching", () => {
|
||||
it("should cache authorization conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getAuthorizationConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
userId: testData.user.id,
|
||||
teamId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getAuthorizationConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should cache filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: testData.eventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getFilterConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
OR: [{ eventTypeId: testData.eventType.id }, { eventParentId: testData.eventType.id }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getFilterConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
it("should combine authorization and filter conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create a user-scoped event type (not team-scoped)
|
||||
const userEventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "User Event Type",
|
||||
slug: "user-event-type",
|
||||
length: 60,
|
||||
userId: testData.user.id,
|
||||
teamId: null, // User-scoped, not team-scoped
|
||||
},
|
||||
});
|
||||
|
||||
// Create a booking for the user-scoped event type
|
||||
const userBooking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `user-booking-${Date.now()}-${Math.random().toString(36).substring(7)}`,
|
||||
title: "User Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: testData.user.id,
|
||||
eventTypeId: userEventType.id,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsBookingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
filters: {
|
||||
eventTypeId: userEventType.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return the user booking since it matches both conditions
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(userBooking.id);
|
||||
|
||||
// Clean up
|
||||
await prisma.booking.delete({
|
||||
where: { id: userBooking.id },
|
||||
});
|
||||
await prisma.eventType.delete({
|
||||
where: { id: userEventType.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,705 @@
|
||||
import type { Team, User, Membership } from "@prisma/client";
|
||||
import { randomUUID } from "crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus, MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { InsightsRoutingService } from "../../service/insightsRouting";
|
||||
|
||||
// Helper function to create unique test data
|
||||
async function createTestData({
|
||||
teamRole = MembershipRole.MEMBER,
|
||||
orgRole = MembershipRole.MEMBER,
|
||||
moreTeamsAndUsers = [],
|
||||
}: {
|
||||
teamRole?: MembershipRole;
|
||||
orgRole?: MembershipRole;
|
||||
moreTeamsAndUsers?: MembershipRole[][]; // Array of teams, each team has array of user roles
|
||||
} = {}) {
|
||||
// Create test user
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email: `test-user-${randomUUID()}@example.com`,
|
||||
username: `testuser-${randomUUID()}`,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
|
||||
// Create test organization
|
||||
const org = await prisma.team.create({
|
||||
data: {
|
||||
name: "Test Org",
|
||||
slug: `test-org-${randomUUID()}`,
|
||||
isOrganization: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test team under the organization
|
||||
const team = await prisma.team.create({
|
||||
data: {
|
||||
name: "Test Team",
|
||||
slug: `test-team-${randomUUID()}`,
|
||||
isOrganization: false,
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test event type
|
||||
const eventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event Type",
|
||||
slug: `test-event-type-${randomUUID()}`,
|
||||
length: 60,
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test booking
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `test-booking-${randomUUID()}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test routing form
|
||||
const form = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Routing Form",
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
fields: [
|
||||
{
|
||||
id: `text-field-id-${randomUUID()}`,
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: `email-field-id-${randomUUID()}`,
|
||||
type: "email",
|
||||
label: "Email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Create test form response
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `test-filler-${randomUUID()}`,
|
||||
formId: form.id,
|
||||
response: {
|
||||
[`text-field-id-${randomUUID()}`]: {
|
||||
label: "Name",
|
||||
value: "Test Name",
|
||||
},
|
||||
[`email-field-id-${randomUUID()}`]: {
|
||||
label: "Email",
|
||||
value: "test@example.com",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: booking.uid,
|
||||
},
|
||||
});
|
||||
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: team.id,
|
||||
role: teamRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const orgMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
teamId: org.id,
|
||||
role: orgRole,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create additional teams and users based on moreTeamsAndUsers
|
||||
const additionalTeams: Team[] = [];
|
||||
const additionalUsers: User[] = [];
|
||||
const additionalMemberships: Membership[] = [];
|
||||
|
||||
for (let i = 0; i < moreTeamsAndUsers.length; i++) {
|
||||
const teamRoles = moreTeamsAndUsers[i];
|
||||
const additionalTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: `Additional Team ${i + 1}`,
|
||||
slug: `additional-team-${i + 1}-${randomUUID()}`,
|
||||
isOrganization: false,
|
||||
parentId: org.id,
|
||||
},
|
||||
});
|
||||
additionalTeams.push(additionalTeam);
|
||||
|
||||
for (let j = 0; j < teamRoles.length; j++) {
|
||||
const role = teamRoles[j];
|
||||
const additionalUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `additional-user-${i}-${j}-${randomUUID()}@example.com`,
|
||||
username: `additionaluser-${i}-${j}-${randomUUID()}`,
|
||||
name: `Additional User ${i}-${j}`,
|
||||
},
|
||||
});
|
||||
additionalUsers.push(additionalUser);
|
||||
|
||||
const additionalMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: additionalUser.id,
|
||||
teamId: additionalTeam.id,
|
||||
role: role,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
additionalMemberships.push(additionalMembership);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async () => {
|
||||
// Delete in reverse order to avoid foreign key constraints
|
||||
for (const membership of additionalMemberships) {
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
}
|
||||
for (const user of additionalUsers) {
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
}
|
||||
for (const team of additionalTeams) {
|
||||
await prisma.team.delete({
|
||||
where: { id: team.id },
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.membership.delete({
|
||||
where: { id: orgMembership.id },
|
||||
});
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: formResponse.id },
|
||||
});
|
||||
await prisma.booking.delete({
|
||||
where: { id: booking.id },
|
||||
});
|
||||
await prisma.eventType.delete({
|
||||
where: { id: eventType.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: form.id },
|
||||
});
|
||||
await prisma.team.delete({
|
||||
where: { id: team.id },
|
||||
});
|
||||
await prisma.team.delete({
|
||||
where: { id: org.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: user.id },
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
user,
|
||||
org,
|
||||
team,
|
||||
eventType,
|
||||
booking,
|
||||
form,
|
||||
formResponse,
|
||||
membership,
|
||||
orgMembership,
|
||||
additionalTeams,
|
||||
additionalUsers,
|
||||
additionalMemberships,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
describe("InsightsRoutingService Integration Tests", () => {
|
||||
describe("Authorization Conditions", () => {
|
||||
it("should return NOTHING for invalid options", async () => {
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: null as any,
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
});
|
||||
|
||||
it("should return NOTHING for non-owner/admin user", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
// Create a regular user who is not an owner/admin
|
||||
const regularUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `regular-user-${randomUUID()}@example.com`,
|
||||
username: `regularuser-${randomUUID()}`,
|
||||
name: "Regular User",
|
||||
},
|
||||
});
|
||||
|
||||
// Add the user as a regular member (not owner/admin) to the organization
|
||||
const membership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: regularUser.id,
|
||||
teamId: testData.org.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: regularUser.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ id: -1 });
|
||||
|
||||
// Clean up
|
||||
await prisma.membership.delete({
|
||||
where: { id: membership.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: regularUser.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build user scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formUserId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build team scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "team",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: testData.team.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formTeamId: testData.team.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should build org scope conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
moreTeamsAndUsers: [
|
||||
[MembershipRole.MEMBER], // Team 2: 1 user with MEMBER role
|
||||
[MembershipRole.MEMBER], // Team 3: 1 user with MEMBER role
|
||||
],
|
||||
});
|
||||
|
||||
// Get references to additional data
|
||||
const team2 = testData.additionalTeams[0]; // First additional team
|
||||
const team3 = testData.additionalTeams[1]; // Second additional team
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
|
||||
expect(conditions).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formTeamId: {
|
||||
in: [testData.org.id, testData.team.id, team2.id, team3.id],
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should return NOTHING for team scope when team is not child of org", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create a team that is not a child of the org
|
||||
const unrelatedTeam = await prisma.team.create({
|
||||
data: {
|
||||
name: "Unrelated Team",
|
||||
slug: `unrelated-team-${randomUUID()}`,
|
||||
isOrganization: false,
|
||||
parentId: null, // Not part of the org
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "team",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
teamId: unrelatedTeam.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getAuthorizationConditions();
|
||||
expect(conditions).toEqual({ AND: [{ id: -1 }] });
|
||||
|
||||
// Clean up
|
||||
await prisma.team.delete({
|
||||
where: { id: unrelatedTeam.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Filter Conditions", () => {
|
||||
it("should return null when no filters are provided", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const conditions = await service.getFilterConditions();
|
||||
expect(conditions).toBeNull();
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Caching", () => {
|
||||
it("should cache authorization conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getAuthorizationConditions();
|
||||
expect(conditions1).toEqual({
|
||||
AND: [
|
||||
{
|
||||
formUserId: testData.user.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getAuthorizationConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
// Clean up
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should cache filter conditions", async () => {
|
||||
const testData = await createTestData();
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
// First call should build conditions
|
||||
const conditions1 = await service.getFilterConditions();
|
||||
expect(conditions1).toBeNull();
|
||||
|
||||
// Second call should use cached conditions
|
||||
const conditions2 = await service.getFilterConditions();
|
||||
expect(conditions2).toEqual(conditions1);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
describe("findMany", () => {
|
||||
it("should combine authorization and filter conditions", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return the user form response since it matches the authorization conditions
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should filter out unauthorized responses", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create another user and form
|
||||
const otherUser = await prisma.user.create({
|
||||
data: {
|
||||
email: `other-user-${randomUUID()}@example.com`,
|
||||
username: `otheruser-${randomUUID()}`,
|
||||
name: "Other User",
|
||||
},
|
||||
});
|
||||
|
||||
const otherForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Other User Form",
|
||||
userId: otherUser.id,
|
||||
teamId: null,
|
||||
fields: [
|
||||
{
|
||||
id: "text-field-id",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const otherFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `other-form-filler-${randomUUID()}`,
|
||||
formId: otherForm.id,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Other Form Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "user",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should only return the authorized user's form response
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0]?.id).toBe(testData.formResponse.id);
|
||||
expect(results[0]?.id).not.toBe(otherFormResponse.id);
|
||||
|
||||
// Clean up
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: otherFormResponse.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: otherForm.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: otherUser.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
|
||||
it("should fetch all forms in org scope when user is in same team", async () => {
|
||||
const testData = await createTestData({
|
||||
teamRole: MembershipRole.OWNER,
|
||||
orgRole: MembershipRole.OWNER,
|
||||
});
|
||||
|
||||
// Create another user in the same team
|
||||
const teamMember = await prisma.user.create({
|
||||
data: {
|
||||
email: `team-member-${randomUUID()}@example.com`,
|
||||
username: `teammember-${randomUUID()}`,
|
||||
name: "Team Member",
|
||||
},
|
||||
});
|
||||
|
||||
// Add the team member to the same team
|
||||
const teamMembership = await prisma.membership.create({
|
||||
data: {
|
||||
userId: teamMember.id,
|
||||
teamId: testData.team.id,
|
||||
role: MembershipRole.MEMBER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create a form under the team member
|
||||
const teamMemberForm = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Team Member Form",
|
||||
userId: teamMember.id,
|
||||
teamId: testData.team.id, // Same team as the original user
|
||||
fields: [
|
||||
{
|
||||
id: `text-field-id-${randomUUID()}`,
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Create a form response for the team member's form
|
||||
const teamMemberFormResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: `team-member-form-filler-${randomUUID()}`,
|
||||
formId: teamMemberForm.id,
|
||||
response: {
|
||||
[`text-field-id-${randomUUID()}`]: {
|
||||
label: "Name",
|
||||
value: "Team Member Form Response",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const service = new InsightsRoutingService({
|
||||
prisma,
|
||||
options: {
|
||||
scope: "org",
|
||||
userId: testData.user.id,
|
||||
orgId: testData.org.id,
|
||||
},
|
||||
});
|
||||
|
||||
const results = await service.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
formName: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Should return both form responses (original user's and team member's)
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
const responseIds = results.map((r) => r.id).sort();
|
||||
const expectedIds = [testData.formResponse.id, teamMemberFormResponse.id].sort();
|
||||
expect(responseIds).toEqual(expectedIds);
|
||||
|
||||
// Clean up
|
||||
await prisma.app_RoutingForms_FormResponse.delete({
|
||||
where: { id: teamMemberFormResponse.id },
|
||||
});
|
||||
await prisma.app_RoutingForms_Form.delete({
|
||||
where: { id: teamMemberForm.id },
|
||||
});
|
||||
await prisma.membership.delete({
|
||||
where: { id: teamMembership.id },
|
||||
});
|
||||
await prisma.user.delete({
|
||||
where: { id: teamMember.id },
|
||||
});
|
||||
await testData.cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { MembershipRepository } from "../repository/membership";
|
||||
import { TeamRepository } from "../repository/team";
|
||||
|
||||
export const insightsBookingServiceOptionsSchema = z.discriminatedUnion("scope", [
|
||||
z.object({
|
||||
scope: z.literal("user"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal("org"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal("team"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
teamId: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightsBookingServiceOptions = z.infer<typeof insightsBookingServiceOptionsSchema>;
|
||||
|
||||
export type InsightsBookingServiceFilterOptions = {
|
||||
eventTypeId?: number;
|
||||
memberUserId?: number;
|
||||
};
|
||||
|
||||
const NOTHING = {
|
||||
id: -1,
|
||||
} as const;
|
||||
|
||||
export class InsightsBookingService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsBookingServiceOptions | null;
|
||||
private filters?: InsightsBookingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput;
|
||||
private cachedFilterConditions?: Prisma.BookingTimeStatusDenormalizedWhereInput | null;
|
||||
|
||||
constructor({
|
||||
prisma,
|
||||
options,
|
||||
filters,
|
||||
}: {
|
||||
prisma: typeof readonlyPrisma;
|
||||
options: InsightsBookingServiceOptions;
|
||||
filters?: InsightsBookingServiceFilterOptions;
|
||||
}) {
|
||||
this.prisma = prisma;
|
||||
const validation = insightsBookingServiceOptionsSchema.safeParse(options);
|
||||
this.options = validation.success ? validation.data : null;
|
||||
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async findMany(findManyArgs: Prisma.BookingTimeStatusDenormalizedFindManyArgs) {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
return this.prisma.bookingTimeStatusDenormalized.findMany({
|
||||
...findManyArgs,
|
||||
where: {
|
||||
...findManyArgs.where,
|
||||
AND: [authConditions, filterConditions].filter(
|
||||
(c): c is Prisma.BookingTimeStatusDenormalizedWhereInput => c !== null
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthorizationConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
if (this.cachedAuthConditions === undefined) {
|
||||
this.cachedAuthConditions = await this.buildAuthorizationConditions();
|
||||
}
|
||||
return this.cachedAuthConditions;
|
||||
}
|
||||
|
||||
async getFilterConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput | null> {
|
||||
if (this.cachedFilterConditions === undefined) {
|
||||
this.cachedFilterConditions = await this.buildFilterConditions();
|
||||
}
|
||||
return this.cachedFilterConditions;
|
||||
}
|
||||
|
||||
async buildFilterConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput | null> {
|
||||
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
|
||||
|
||||
if (!this.filters) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this.filters.eventTypeId) {
|
||||
conditions.push({
|
||||
OR: [{ eventTypeId: this.filters.eventTypeId }, { eventParentId: this.filters.eventTypeId }],
|
||||
});
|
||||
}
|
||||
|
||||
if (this.filters.memberUserId) {
|
||||
conditions.push({
|
||||
userId: this.filters.memberUserId,
|
||||
});
|
||||
}
|
||||
|
||||
return conditions.length > 0 ? { AND: conditions } : null;
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
if (!this.options) {
|
||||
return NOTHING;
|
||||
}
|
||||
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
|
||||
if (!isOwnerOrAdmin) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
const conditions: Prisma.BookingTimeStatusDenormalizedWhereInput[] = [];
|
||||
|
||||
if (this.options.scope === "user") {
|
||||
conditions.push({
|
||||
userId: this.options.userId,
|
||||
teamId: null,
|
||||
});
|
||||
} else if (this.options.scope === "org") {
|
||||
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
|
||||
} else if (this.options.scope === "team") {
|
||||
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
|
||||
} else {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
return {
|
||||
AND: conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildOrgAuthorizationCondition(
|
||||
options: Extract<InsightsBookingServiceOptions, { scope: "org" }>
|
||||
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
// Get all teams from the organization
|
||||
const teamsFromOrg = await TeamRepository.findAllByParentId({
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
const teamIds = [options.orgId, ...teamsFromOrg.map((t) => t.id)];
|
||||
|
||||
// Get all users from the organization
|
||||
const userIdsFromOrg =
|
||||
teamsFromOrg.length > 0
|
||||
? (await MembershipRepository.findAllByTeamIds({ teamIds, select: { userId: true } })).map(
|
||||
(m) => m.userId
|
||||
)
|
||||
: [];
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
teamId: {
|
||||
in: teamIds,
|
||||
},
|
||||
isTeamBooking: true,
|
||||
},
|
||||
...(userIdsFromOrg.length > 0
|
||||
? [
|
||||
{
|
||||
userId: {
|
||||
in: Array.from(new Set(userIdsFromOrg)),
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTeamAuthorizationCondition(
|
||||
options: Extract<InsightsBookingServiceOptions, { scope: "team" }>
|
||||
): Promise<Prisma.BookingTimeStatusDenormalizedWhereInput> {
|
||||
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
|
||||
id: options.teamId,
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
if (!childTeamOfOrg) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
const usersFromTeam = await MembershipRepository.findAllByTeamIds({
|
||||
teamIds: [options.teamId],
|
||||
select: { userId: true },
|
||||
});
|
||||
const userIdsFromTeam = usersFromTeam.map((u) => u.userId);
|
||||
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
teamId: options.teamId,
|
||||
isTeamBooking: true,
|
||||
},
|
||||
{
|
||||
userId: {
|
||||
in: userIdsFromTeam,
|
||||
},
|
||||
isTeamBooking: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
||||
// Check if the user is an owner or admin of the organization
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
return Boolean(
|
||||
membership &&
|
||||
membership.accepted &&
|
||||
membership.role &&
|
||||
([MembershipRole.OWNER, MembershipRole.ADMIN] as const).includes(membership.role)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { readonlyPrisma } from "@calcom/prisma";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { MembershipRepository } from "../repository/membership";
|
||||
import { TeamRepository } from "../repository/team";
|
||||
|
||||
export const insightsRoutingServiceOptionsSchema = z.discriminatedUnion("scope", [
|
||||
z.object({
|
||||
scope: z.literal("user"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal("org"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal("team"),
|
||||
userId: z.number(),
|
||||
orgId: z.number(),
|
||||
teamId: z.number(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type InsightsRoutingServiceOptions = z.infer<typeof insightsRoutingServiceOptionsSchema>;
|
||||
|
||||
export type InsightsRoutingServiceFilterOptions = {
|
||||
// Empty for now
|
||||
};
|
||||
|
||||
const NOTHING = {
|
||||
id: -1,
|
||||
} as const;
|
||||
|
||||
export class InsightsRoutingService {
|
||||
private prisma: typeof readonlyPrisma;
|
||||
private options: InsightsRoutingServiceOptions | null;
|
||||
private filters?: InsightsRoutingServiceFilterOptions;
|
||||
private cachedAuthConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput;
|
||||
private cachedFilterConditions?: Prisma.RoutingFormResponseDenormalizedWhereInput | null;
|
||||
|
||||
constructor({
|
||||
prisma,
|
||||
options,
|
||||
filters,
|
||||
}: {
|
||||
prisma: typeof readonlyPrisma;
|
||||
options: InsightsRoutingServiceOptions;
|
||||
filters?: InsightsRoutingServiceFilterOptions;
|
||||
}) {
|
||||
this.prisma = prisma;
|
||||
|
||||
const validation = insightsRoutingServiceOptionsSchema.safeParse(options);
|
||||
this.options = validation.success ? validation.data : null;
|
||||
|
||||
this.filters = filters;
|
||||
}
|
||||
|
||||
async findMany(findManyArgs: Prisma.RoutingFormResponseDenormalizedFindManyArgs) {
|
||||
const authConditions = await this.getAuthorizationConditions();
|
||||
const filterConditions = await this.getFilterConditions();
|
||||
|
||||
return this.prisma.routingFormResponseDenormalized.findMany({
|
||||
...findManyArgs,
|
||||
where: {
|
||||
...findManyArgs.where,
|
||||
AND: [authConditions, filterConditions].filter(
|
||||
(c): c is Prisma.RoutingFormResponseDenormalizedWhereInput => c !== null && c !== undefined
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async getAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
if (this.cachedAuthConditions === undefined) {
|
||||
this.cachedAuthConditions = await this.buildAuthorizationConditions();
|
||||
}
|
||||
return this.cachedAuthConditions;
|
||||
}
|
||||
|
||||
async getFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
|
||||
if (this.cachedFilterConditions === undefined) {
|
||||
this.cachedFilterConditions = await this.buildFilterConditions();
|
||||
}
|
||||
return this.cachedFilterConditions;
|
||||
}
|
||||
|
||||
async buildFilterConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput | null> {
|
||||
// Empty for now
|
||||
return null;
|
||||
}
|
||||
|
||||
async buildAuthorizationConditions(): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
if (!this.options) {
|
||||
return NOTHING;
|
||||
}
|
||||
const isOwnerOrAdmin = await this.isOrgOwnerOrAdmin(this.options.userId, this.options.orgId);
|
||||
if (!isOwnerOrAdmin) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
const conditions: Prisma.RoutingFormResponseDenormalizedWhereInput[] = [];
|
||||
|
||||
if (this.options.scope === "user") {
|
||||
conditions.push({
|
||||
formUserId: this.options.userId,
|
||||
});
|
||||
} else if (this.options.scope === "org") {
|
||||
conditions.push(await this.buildOrgAuthorizationCondition(this.options));
|
||||
} else if (this.options.scope === "team") {
|
||||
conditions.push(await this.buildTeamAuthorizationCondition(this.options));
|
||||
} else {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
return {
|
||||
AND: conditions,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildOrgAuthorizationCondition(
|
||||
options: Extract<InsightsRoutingServiceOptions, { scope: "org" }>
|
||||
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
// Get all teams from the organization
|
||||
const teamsFromOrg = await TeamRepository.findAllByParentId({
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return {
|
||||
formTeamId: {
|
||||
in: [options.orgId, ...teamsFromOrg.map((t) => t.id)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async buildTeamAuthorizationCondition(
|
||||
options: Extract<InsightsRoutingServiceOptions, { scope: "team" }>
|
||||
): Promise<Prisma.RoutingFormResponseDenormalizedWhereInput> {
|
||||
const childTeamOfOrg = await TeamRepository.findByIdAndParentId({
|
||||
id: options.teamId,
|
||||
parentId: options.orgId,
|
||||
select: { id: true },
|
||||
});
|
||||
if (!childTeamOfOrg) {
|
||||
return NOTHING;
|
||||
}
|
||||
|
||||
return {
|
||||
formTeamId: options.teamId,
|
||||
};
|
||||
}
|
||||
|
||||
private async isOrgOwnerOrAdmin(userId: number, orgId: number): Promise<boolean> {
|
||||
// Check if the user is an owner or admin of the organization
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
return Boolean(
|
||||
membership &&
|
||||
membership.accepted &&
|
||||
membership.role &&
|
||||
(membership.role === MembershipRole.OWNER || membership.role === MembershipRole.ADMIN)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -418,7 +418,7 @@ export const assignValueToUserInOrgBulk = async ({
|
||||
attributeLabelToValueMap: AttributeLabelToValueMap;
|
||||
updater: BulkAttributeAssigner;
|
||||
}) => {
|
||||
const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
|
||||
if (!membership) {
|
||||
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
## 1.0.114
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#22148](https://github.com/calcom/cal.com/pull/22148) [`d27490e`](https://github.com/calcom/cal.com/commit/d27490e6f2438d353dfcf3e793c8886e723307f3) Thanks [@supalarry](https://github.com/supalarry)! - test release
|
||||
|
||||
## 1.0.63
|
||||
|
||||
1. 💥 BREAKING - `useGetBooking` hook has been renamed to `useBooking` hook and `useGetBookings` hook to `useBookings` and the data returned has different
|
||||
structure. Here is example response from `useBooking`:
|
||||
structure. Here is example response from `useBooking`:
|
||||
|
||||
```
|
||||
{
|
||||
"id": 73,
|
||||
@@ -47,9 +55,11 @@ structure. Here is example response from `useBooking`:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
It is important to note that `useBooking` can return one booking, one individual recurrence of recurring booking or if id of recurring booking is passed then array
|
||||
of all recurrences within recurring booking. Response schema can be viewed in api docs https://cal.com/docs/api-reference/v2/bookings/get-a-booking and switching
|
||||
between data response objects in the dropdown. This means that in your frontend you need to handle case if its an individual object or an array of bookings:
|
||||
|
||||
```
|
||||
const { isLoading, data: booking, refetch } = useBooking((router.query.bookingUid as string) ?? "");
|
||||
|
||||
@@ -63,5 +73,5 @@ if (!Array.isArray(booking)) {
|
||||
```
|
||||
|
||||
2. FIX - phone booking field not showing correctly https://linear.app/calcom/issue/CAL-4465/platform-fix-phone-booking-field
|
||||
3. FEAT - ability to hide booker event type details sidebar https://linear.app/calcom/issue/CAL-4443/platform-feat-booker-hide-event-details
|
||||
4. FEAT - make booker name and email booker properties read only if specified https://linear.app/calcom/issue/CAL-4441/platform-feat-make-name-and-email-fields-not-editable
|
||||
3. FEAT - ability to hide booker event type details sidebar https://linear.app/calcom/issue/CAL-4443/platform-feat-booker-hide-event-details
|
||||
4. FEAT - make booker name and email booker properties read only if specified https://linear.app/calcom/issue/CAL-4441/platform-feat-make-name-and-email-fields-not-editable
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
Customizable UI components to integrate scheduling into your services.
|
||||
|
||||
### Support
|
||||
Currently supports React 18 and Next 14 while Next 15 is experimental.
|
||||
Currently supports React 18, React 19, Next 14 and Next 15.
|
||||
|
||||
### Changelog
|
||||
### Changelog
|
||||
1. Changelog can be viewed [here](https://github.com/calcom/cal.com/blob/main/packages/platform/atoms/CHANGELOG.md).
|
||||
2. For upcoming changes in the next release click [here](https://github.com/calcom/cal.com/pulls?q=is%3Apr+is%3Aopen+%22chore%3A+version+packages%22+in%3Atitle) to see a pull request titled `chore: version packages` containing next release changes.
|
||||
|
||||
@@ -13,6 +13,6 @@ Currently supports React 18 and Next 14 while Next 15 is experimental.
|
||||
3. Some of the versions are suffixed e.g. `1.0.102-framer` and are intended for isolated use cases, so you most probably
|
||||
want to use version without any suffix e.g. `1.0.103`.
|
||||
|
||||
### Documentation
|
||||
### Documentation
|
||||
Documentation on how to get started with platform solution is [here](https://cal.com/docs/platform/quickstart) and list of atoms can be viewed
|
||||
[here](https://cal.com/docs/platform/atoms/cal-provider)
|
||||
|
||||
@@ -30,6 +30,18 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
formMethods,
|
||||
});
|
||||
|
||||
// Check if team has restriction schedule feature enabled
|
||||
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
|
||||
{
|
||||
teamId: props.eventType.team?.id || 0,
|
||||
feature: "restriction-schedule",
|
||||
},
|
||||
{
|
||||
enabled: !!props.eventType.team?.id,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
const { isPending: isSchedulePending, data: scheduleQueryData } =
|
||||
trpc.viewer.availability.schedule.get.useQuery(
|
||||
{
|
||||
@@ -66,6 +78,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
||||
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
|
||||
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
|
||||
hostSchedulesQuery={hostSchedulesQuery}
|
||||
isRestrictionScheduleEnabled={isRestrictionScheduleEnabled}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
"type": "module",
|
||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||
"authors": "Cal.com, Inc.",
|
||||
"version": "1.0.105",
|
||||
"version": "1.0.114",
|
||||
"scripts": {
|
||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=16384' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
"publish": "yarn build && npm publish --access public",
|
||||
"build": "NODE_OPTIONS='--max_old_space_size=12288' rm -rf dist && yarn vite build && npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify && mkdir ./dist/packages/prisma-client && cp -rf ../../../node_modules/.prisma/client/*.d.ts ./dist/packages/prisma-client",
|
||||
"publish-npm": "yarn build && npm publish --access public",
|
||||
"test": "jest"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -37,11 +37,9 @@
|
||||
"dist",
|
||||
"globals.min.css"
|
||||
],
|
||||
"main": "./dist/cal-atoms.umd.cjs",
|
||||
"module": "./dist/cal-atoms.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/cal-atoms.umd.cjs",
|
||||
"import": "./dist/cal-atoms.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
@@ -83,7 +81,6 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-awesome-query-builder": "^5.1.2",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,16 +31,32 @@
|
||||
"../../types/@wojtekmaj__react-daterange-picker.d.ts",
|
||||
"../../types/business-days-plugin.d.ts",
|
||||
"../../types/window.d.ts",
|
||||
"../../lib",
|
||||
"../../features",
|
||||
"../types",
|
||||
"../constants",
|
||||
"../utils",
|
||||
"../libraries",
|
||||
"../../prisma",
|
||||
"../../dayjs",
|
||||
"../../trpc",
|
||||
"../../app-store"
|
||||
"../../prisma/zod-utils.ts",
|
||||
"../../prisma/zod",
|
||||
"../../trpc/react",
|
||||
"../../trpc/server/routers/viewer/slots",
|
||||
"../../trpc/server/types",
|
||||
"../../features/eventtypes",
|
||||
"../../features/schedules",
|
||||
"../../features/bookings/Booker",
|
||||
"../../features/bookings/Booker",
|
||||
"../../features/instant-meeting",
|
||||
"../../features/bookings/handleMarkNoShow.ts",
|
||||
"../../features/bookings/types.ts",
|
||||
"../../features/bookings/lib",
|
||||
"../../features/ee/workflows",
|
||||
"../../features/ee/payments",
|
||||
"../../features/ee/round-robin",
|
||||
"../../features/ee/billing",
|
||||
"../../features/shell",
|
||||
"../../features/components",
|
||||
"../../features/form-builder",
|
||||
"../../features/data-table"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
|
||||
@@ -3,7 +3,6 @@ import path from "path";
|
||||
import { resolve } from "path";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
import Inspect from "vite-plugin-inspect";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), ""); // .env inside of packages/platform/atoms
|
||||
@@ -20,7 +19,7 @@ export default defineConfig(({ mode }) => {
|
||||
"@calcom/platform-utils",
|
||||
],
|
||||
},
|
||||
plugins: [Inspect(), react(), dts({ insertTypesEntry: true })],
|
||||
plugins: [react(), dts({ insertTypesEntry: true })],
|
||||
define: {
|
||||
"process.env.NEXT_PUBLIC_WEBAPP_URL": `"${webAppUrl}"`,
|
||||
},
|
||||
@@ -28,17 +27,16 @@ export default defineConfig(({ mode }) => {
|
||||
noExternal: ["turndown"], // Example if you want to disable SSR for your library
|
||||
},
|
||||
build: {
|
||||
commonjsOptions: {
|
||||
include: [/@calcom\/lib/, /@calcom\/features/, /node_modules/],
|
||||
},
|
||||
lib: {
|
||||
entry: [resolve(__dirname, "index.ts")],
|
||||
name: "CalAtoms",
|
||||
fileName: "cal-atoms",
|
||||
formats: ["es"],
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["react", "fs", "path", "os", "react-dom", "react-awesome-query-builder"],
|
||||
external: ["react", "fs", "path", "os", "react/jsx-runtime", "react-dom", "react-dom/client"],
|
||||
output: {
|
||||
format: "esm", // Force ESM output
|
||||
globals: {
|
||||
react: "React",
|
||||
"react-dom": "ReactDOM",
|
||||
@@ -58,6 +56,7 @@ export default defineConfig(({ mode }) => {
|
||||
"@radix-ui/react-dialog": path.resolve(__dirname, "./src/components/ui/dialog.tsx"),
|
||||
".prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@calcom/prisma/client": path.resolve(__dirname, "../../prisma-client"),
|
||||
"@calcom/prisma": path.resolve(__dirname, "../../prisma"),
|
||||
"@calcom/dayjs": path.resolve(__dirname, "../../dayjs"),
|
||||
"@calcom/platform-constants": path.resolve(__dirname, "../constants/index.ts"),
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc --build --force tsconfig.json",
|
||||
"build:watch": "tsc --build --force ./tsconfig.json --watch",
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"main": "./dist/index.ts",
|
||||
"types": "./dist/index.ts",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": {
|
||||
"require": "./dist/index.js",
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn clean && tsc --build --force tsconfig.json",
|
||||
"clean": "rm -rf ./dist",
|
||||
|
||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
||||
"version": "0.0.0",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"test": "jest ./tests",
|
||||
"build": "tsc --build --force tsconfig.json",
|
||||
|
||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
export function bookingIdempotencyKeyExtension() {
|
||||
@@ -9,12 +9,12 @@ export function bookingIdempotencyKeyExtension() {
|
||||
booking: {
|
||||
async create({ args, query }) {
|
||||
if (args.data.status === BookingStatus.ACCEPTED) {
|
||||
const idempotencyKey = uuidv5(
|
||||
`${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${
|
||||
args.data?.user?.connect?.id
|
||||
}${args.data?.reassignById ? `.${args.data.reassignById}` : ""}`,
|
||||
uuidv5.URL
|
||||
);
|
||||
const idempotencyKey = IdempotencyKeyService.generate({
|
||||
startTime: args.data.startTime,
|
||||
endTime: args.data.endTime,
|
||||
userId: args.data.user?.connect?.id,
|
||||
reassignedById: args.data.reassignById,
|
||||
});
|
||||
args.data.idempotencyKey = idempotencyKey;
|
||||
}
|
||||
return query(args);
|
||||
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "App_RoutingForms_FormResponse_uuid_key";
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX IF EXISTS "RoutingFormResponseDenormalized_uuid_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "App_RoutingForms_FormResponse" ALTER COLUMN "uuid" DROP NOT NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "RoutingFormResponseDenormalized" ALTER COLUMN "uuid" DROP NOT NULL;
|
||||
@@ -1159,7 +1159,7 @@ model App_RoutingForms_Form {
|
||||
|
||||
model App_RoutingForms_FormResponse {
|
||||
id Int @id @default(autoincrement())
|
||||
uuid String @unique @default(uuid())
|
||||
uuid String? @default(uuid())
|
||||
formFillerId String @default(cuid())
|
||||
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||
formId String
|
||||
@@ -1240,7 +1240,7 @@ view RoutingFormResponse {
|
||||
|
||||
model RoutingFormResponseDenormalized {
|
||||
id Int @id
|
||||
uuid String @unique
|
||||
uuid String?
|
||||
formId String
|
||||
formName String
|
||||
formTeamId Int?
|
||||
|
||||
+58
-26
@@ -14,36 +14,25 @@ const handleSendingSMS = async ({
|
||||
senderID,
|
||||
teamId,
|
||||
bookingUid,
|
||||
organizerUserId,
|
||||
}: {
|
||||
reminderPhone: string;
|
||||
smsMessage: string;
|
||||
senderID: string;
|
||||
teamId: number;
|
||||
teamId?: number;
|
||||
bookingUid?: string | null;
|
||||
organizerUserId?: number;
|
||||
}) => {
|
||||
const team = await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
parent: {
|
||||
select: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
disablePhoneOnlySMSNotifications: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!team?.parent?.isOrganization || team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications) {
|
||||
return; // resolves implicitly (as undefined)
|
||||
}
|
||||
|
||||
try {
|
||||
// If teamId is provided, we check the rate limit for the team.
|
||||
// If organizerUserId is provided, we check the rate limit for the organizer.
|
||||
// If neither is provided(Just in case), we check the rate limit for the reminderPhone.
|
||||
await checkSMSRateLimit({
|
||||
identifier: `handleSendingSMS:team:${teamId}`,
|
||||
identifier: teamId
|
||||
? `handleSendingSMS:team:${teamId}`
|
||||
: organizerUserId
|
||||
? `handleSendingSMS:user:${organizerUserId}`
|
||||
: `handleSendingSMS:user:${reminderPhone}`,
|
||||
rateLimitingType: "sms",
|
||||
});
|
||||
|
||||
@@ -52,7 +41,7 @@ const handleSendingSMS = async ({
|
||||
phoneNumber: reminderPhone,
|
||||
body: smsMessage,
|
||||
sender: senderID,
|
||||
teamId,
|
||||
...(!!teamId ? { teamId } : { userId: organizerUserId }),
|
||||
bookingUid,
|
||||
},
|
||||
});
|
||||
@@ -64,15 +53,50 @@ const handleSendingSMS = async ({
|
||||
}
|
||||
};
|
||||
|
||||
const getTeamWithOrganizationSettings = async (teamId: number) => {
|
||||
return await prisma.team.findUnique({
|
||||
where: { id: teamId },
|
||||
select: {
|
||||
parent: {
|
||||
select: {
|
||||
isOrganization: true,
|
||||
organizationSettings: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export default abstract class SMSManager {
|
||||
calEvent: CalendarEvent;
|
||||
isTeamEvent = false;
|
||||
teamId: number | undefined = undefined;
|
||||
organizerUserId: number | undefined = undefined;
|
||||
private _isSMSNotificationEnabled: boolean | null = null;
|
||||
|
||||
constructor(calEvent: CalendarEvent) {
|
||||
this.calEvent = calEvent;
|
||||
this.teamId = this.calEvent?.team?.id;
|
||||
this.isTeamEvent = !!this.calEvent?.team?.id;
|
||||
this.organizerUserId = this.calEvent?.organizer?.id;
|
||||
}
|
||||
|
||||
private async isSMSNotificationEnabled(): Promise<boolean> {
|
||||
if (this._isSMSNotificationEnabled !== null) {
|
||||
return this._isSMSNotificationEnabled;
|
||||
}
|
||||
|
||||
const teamId = this.teamId;
|
||||
|
||||
if (teamId) {
|
||||
const team = await getTeamWithOrganizationSettings(teamId);
|
||||
|
||||
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
|
||||
return this._isSMSNotificationEnabled;
|
||||
}
|
||||
|
||||
this._isSMSNotificationEnabled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
getFormattedTime(
|
||||
@@ -99,17 +123,25 @@ export default abstract class SMSManager {
|
||||
const attendeePhoneNumber = attendee.phoneNumber;
|
||||
const isPhoneOnlyBooking = attendeePhoneNumber && isSmsCalEmail(attendee.email);
|
||||
|
||||
if (!this.isTeamEvent || !teamId || !attendeePhoneNumber || !isPhoneOnlyBooking) return;
|
||||
if (!attendeePhoneNumber || !isPhoneOnlyBooking || !(await this.isSMSNotificationEnabled())) return;
|
||||
|
||||
const smsMessage = this.getMessage(attendee);
|
||||
const senderID = getSenderId(attendeePhoneNumber, SENDER_ID);
|
||||
return handleSendingSMS({ reminderPhone: attendeePhoneNumber, smsMessage, senderID, teamId, bookingUid });
|
||||
return handleSendingSMS({
|
||||
reminderPhone: attendeePhoneNumber,
|
||||
smsMessage,
|
||||
senderID,
|
||||
teamId,
|
||||
bookingUid,
|
||||
organizerUserId: this.organizerUserId,
|
||||
});
|
||||
}
|
||||
|
||||
async sendSMSToAttendees() {
|
||||
if (!this.isTeamEvent) return;
|
||||
const smsToSend: Promise<unknown>[] = [];
|
||||
|
||||
if (!(await this.isSMSNotificationEnabled())) return;
|
||||
|
||||
for (const attendee of this.calEvent.attendees) {
|
||||
smsToSend.push(this.sendSMSToAttendee(attendee, this.calEvent.uid));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/reminders/messageDispatcher";
|
||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||
|
||||
import SMSManager from "../sms-manager";
|
||||
|
||||
vi.mock("@calcom/lib/checkRateLimitAndThrowError");
|
||||
vi.mock("@calcom/features/ee/workflows/lib/reminders/messageDispatcher");
|
||||
vi.mock("@calcom/prisma", () => ({
|
||||
default: {
|
||||
team: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
interface TestAttendee extends Person {
|
||||
name: string;
|
||||
email: string;
|
||||
phoneNumber?: string;
|
||||
timeZone: string;
|
||||
language: {
|
||||
translate: TFunction;
|
||||
locale: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface TestCalEvent extends CalendarEvent {
|
||||
uid: string;
|
||||
type: string;
|
||||
title: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
attendees: TestAttendee[];
|
||||
organizer: {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
timeZone: string;
|
||||
language: {
|
||||
translate: TFunction;
|
||||
locale: string;
|
||||
};
|
||||
};
|
||||
team?: {
|
||||
id: number;
|
||||
name: string;
|
||||
members: unknown[];
|
||||
};
|
||||
}
|
||||
|
||||
// Create a concrete implementation of SMSManager for testing
|
||||
class TestSMSManager extends SMSManager {
|
||||
getMessage(attendee: TestAttendee): string {
|
||||
return `Test message for ${attendee.name}`;
|
||||
}
|
||||
}
|
||||
|
||||
describe("SMSManager", () => {
|
||||
const mockTranslate = ((key: string) => key) as TFunction;
|
||||
const mockCalEvent: TestCalEvent = {
|
||||
uid: "test-booking-uid",
|
||||
type: "test",
|
||||
title: "Test Event",
|
||||
startTime: "2024-03-20T10:00:00Z",
|
||||
endTime: "2024-03-20T11:00:00Z",
|
||||
attendees: [
|
||||
{
|
||||
name: "John Doe",
|
||||
email: "john@sms.cal.com",
|
||||
phoneNumber: "+1234567890",
|
||||
timeZone: "America/New_York",
|
||||
language: { translate: mockTranslate, locale: "en" },
|
||||
},
|
||||
{
|
||||
name: "Jane Smith",
|
||||
email: "jane@example.com",
|
||||
phoneNumber: "+1987654321",
|
||||
timeZone: "America/New_York",
|
||||
language: { translate: mockTranslate, locale: "en" },
|
||||
},
|
||||
],
|
||||
organizer: {
|
||||
id: 1,
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
timeZone: "America/New_York",
|
||||
language: { translate: mockTranslate, locale: "en" },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("sendSMSToAttendee", () => {
|
||||
test("should not send SMS if phone number is missing", async () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const attendeeWithoutPhone: TestAttendee = {
|
||||
...mockCalEvent.attendees[0],
|
||||
phoneNumber: undefined,
|
||||
timeZone: "America/New_York",
|
||||
language: { translate: mockTranslate, locale: "en" },
|
||||
};
|
||||
|
||||
await smsManager.sendSMSToAttendee(attendeeWithoutPhone);
|
||||
|
||||
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should not send SMS if email is not @sms.cal.com", async () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const attendeeWithRegularEmail: TestAttendee = {
|
||||
...mockCalEvent.attendees[0],
|
||||
email: "john@example.com",
|
||||
phoneNumber: "+1234567890",
|
||||
timeZone: "America/New_York",
|
||||
language: { translate: mockTranslate, locale: "en" },
|
||||
};
|
||||
|
||||
await smsManager.sendSMSToAttendee(attendeeWithRegularEmail);
|
||||
|
||||
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should send SMS only when both phone number and @sms.cal.com email are present", async () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const mockSmsResponse = { success: true };
|
||||
|
||||
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
|
||||
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
const result = await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0], "test-booking-uid");
|
||||
|
||||
expect(checkSMSRateLimit).toHaveBeenCalledWith({
|
||||
identifier: "handleSendingSMS:user:1",
|
||||
rateLimitingType: "sms",
|
||||
});
|
||||
|
||||
expect(sendSmsOrFallbackEmail).toHaveBeenCalledWith({
|
||||
twilioData: {
|
||||
phoneNumber: mockCalEvent.attendees[0].phoneNumber,
|
||||
body: expect.stringContaining(mockCalEvent.attendees[0].name),
|
||||
sender: expect.any(String),
|
||||
teamId: undefined,
|
||||
userId: 1,
|
||||
bookingUid: "test-booking-uid",
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual(mockSmsResponse);
|
||||
});
|
||||
|
||||
test("should not send SMS if SMS notifications are disabled for team", async () => {
|
||||
const mockTeamId = 123;
|
||||
const mockTeam = {
|
||||
id: mockTeamId,
|
||||
name: "Test Team",
|
||||
members: [],
|
||||
};
|
||||
const smsManager = new TestSMSManager({
|
||||
...mockCalEvent,
|
||||
team: mockTeam,
|
||||
});
|
||||
|
||||
// Mock team settings to disable SMS
|
||||
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
|
||||
parent: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
disablePhoneOnlySMSNotifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await smsManager.sendSMSToAttendee(mockCalEvent.attendees[0]);
|
||||
|
||||
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should handle SMS sending errors", async () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const mockError = new Error("SMS sending failed");
|
||||
|
||||
(sendSmsOrFallbackEmail as jest.Mock).mockRejectedValue(mockError);
|
||||
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await expect(smsManager.sendSMSToAttendee(mockCalEvent.attendees[0])).rejects.toThrow(mockError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendSMSToAttendees", () => {
|
||||
test("should send SMS only to attendees with phone number and @sms.cal.com email", async () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const mockSmsResponse = { success: true };
|
||||
|
||||
(sendSmsOrFallbackEmail as jest.Mock).mockResolvedValue(mockSmsResponse);
|
||||
(checkSMSRateLimit as jest.Mock).mockResolvedValue(undefined);
|
||||
|
||||
await smsManager.sendSMSToAttendees();
|
||||
|
||||
// Only one attendee has both phone number and @sms.cal.com email
|
||||
expect(sendSmsOrFallbackEmail).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("should not send SMS if notifications are disabled", async () => {
|
||||
const mockTeamId = 123;
|
||||
const mockTeam = {
|
||||
id: mockTeamId,
|
||||
name: "Test Team",
|
||||
members: [],
|
||||
};
|
||||
const smsManager = new TestSMSManager({
|
||||
...mockCalEvent,
|
||||
team: mockTeam,
|
||||
});
|
||||
|
||||
(prisma.team.findUnique as jest.Mock).mockResolvedValue({
|
||||
parent: {
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
disablePhoneOnlySMSNotifications: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await smsManager.sendSMSToAttendees();
|
||||
|
||||
expect(sendSmsOrFallbackEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFormattedTime and getFormattedDate", () => {
|
||||
test("should format time correctly", () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const formattedTime = smsManager.getFormattedTime("America/New_York", "en", "2024-03-20T10:00:00Z");
|
||||
|
||||
expect(formattedTime).toContain("2024");
|
||||
expect(formattedTime).toContain("6:00am");
|
||||
});
|
||||
|
||||
test("should format date range correctly", () => {
|
||||
const smsManager = new TestSMSManager(mockCalEvent);
|
||||
const formattedDate = smsManager.getFormattedDate("America/New_York", "en");
|
||||
|
||||
expect(formattedDate).toContain("2024");
|
||||
expect(formattedDate).toContain("6:00am");
|
||||
expect(formattedDate).toContain("7:00am");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,7 @@ type GetDataOptions = {
|
||||
};
|
||||
|
||||
export const get = async ({ ctx, input }: GetDataOptions) => {
|
||||
const teamMembership = await MembershipRepository.findFirstByUserIdAndTeamId({
|
||||
const teamMembership = await MembershipRepository.findUniqueByUserIdAndTeamId({
|
||||
userId: ctx.user.id,
|
||||
teamId: input.teamId,
|
||||
});
|
||||
|
||||
@@ -44,6 +44,23 @@ const FormCardActions = ({ deleteField, duplicateField }: FormCardActionsProps)
|
||||
|
||||
if (actions.length === 0) return null;
|
||||
|
||||
// If only one action, show a single icon button
|
||||
if (actions.length === 1) {
|
||||
const action = actions[0];
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="icon"
|
||||
color={action.color || "minimal"}
|
||||
className="ml-2"
|
||||
onClick={action.onClick}
|
||||
StartIcon={action.icon}
|
||||
title={action.label}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// If multiple actions, show dropdown
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -73,6 +73,7 @@
|
||||
"dependencies": {
|
||||
"@calcom/lib": "*",
|
||||
"@formkit/auto-animate": "1.0.0-beta.5",
|
||||
"@radix-ui/react-avatar": "^1.1.3",
|
||||
"@radix-ui/react-checkbox": "^1.0.4",
|
||||
"@radix-ui/react-dialog": "^1.0.4",
|
||||
"@radix-ui/react-popover": "^1.0.2",
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
"cache": false,
|
||||
"dependsOn": []
|
||||
},
|
||||
"@calcom/atoms#build": {
|
||||
"outputs": ["dist/**", "globals.min.css"]
|
||||
},
|
||||
"@calcom/api-v2#build": {
|
||||
"dependsOn": ["^build"],
|
||||
"env": [
|
||||
|
||||
@@ -2519,7 +2519,7 @@ __metadata:
|
||||
"@axiomhq/winston": ^1.2.0
|
||||
"@calcom/platform-constants": "*"
|
||||
"@calcom/platform-enums": "*"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223"
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231"
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2"
|
||||
"@calcom/platform-types": "*"
|
||||
"@calcom/platform-utils": "*"
|
||||
@@ -3577,13 +3577,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.223":
|
||||
version: 0.0.223
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.223"
|
||||
"@calcom/platform-libraries@npm:@calcom/platform-libraries@0.0.231":
|
||||
version: 0.0.231
|
||||
resolution: "@calcom/platform-libraries@npm:0.0.231"
|
||||
dependencies:
|
||||
"@calcom/features": "*"
|
||||
"@calcom/lib": "*"
|
||||
checksum: eec88d521be2ab7c999e3435d1fea3b44c1304bb937c82fa9507028f23455e21c4473f6a73d3f2ef52b8e0b48de0c44834ec2c803f11e6c137bfde1989481fc6
|
||||
checksum: 0abd36b041cc2ed2068c56d36aa94286f381f425b88a0d0d7cdb14498263bbece4928162d8cea00bbf4fa01f4fb60a018933f3f8e4573331d7be2b22aab2eeaa
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
@@ -3938,6 +3938,7 @@ __metadata:
|
||||
"@calcom/lib": "*"
|
||||
"@calcom/tsconfig": "*"
|
||||
"@formkit/auto-animate": 1.0.0-beta.5
|
||||
"@radix-ui/react-avatar": ^1.1.3
|
||||
"@radix-ui/react-checkbox": ^1.0.4
|
||||
"@radix-ui/react-dialog": ^1.0.4
|
||||
"@radix-ui/react-popover": ^1.0.2
|
||||
@@ -11927,6 +11928,28 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-avatar@npm:^1.1.3":
|
||||
version: 1.1.3
|
||||
resolution: "@radix-ui/react-avatar@npm:1.1.3"
|
||||
dependencies:
|
||||
"@radix-ui/react-context": 1.1.1
|
||||
"@radix-ui/react-primitive": 2.0.2
|
||||
"@radix-ui/react-use-callback-ref": 1.1.0
|
||||
"@radix-ui/react-use-layout-effect": 1.1.0
|
||||
peerDependencies:
|
||||
"@types/react": "*"
|
||||
"@types/react-dom": "*"
|
||||
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
|
||||
peerDependenciesMeta:
|
||||
"@types/react":
|
||||
optional: true
|
||||
"@types/react-dom":
|
||||
optional: true
|
||||
checksum: e2bb1e65c19d1224cccb7554cfd7de73c62256c0b13ce695a71c106f2495356984ad40f0d8ad91a4d23efbf4da3e9e58851346aec767c002859fe64ca1a6d20e
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@radix-ui/react-checkbox@npm:^1.0.4":
|
||||
version: 1.0.4
|
||||
resolution: "@radix-ui/react-checkbox@npm:1.0.4"
|
||||
|
||||
Reference in New Issue
Block a user