Compare commits
56
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 | ||
|
|
cf79a3f580 | ||
|
|
c5da78593a | ||
|
|
801b0be414 | ||
|
|
29aad2c535 | ||
|
|
82d3b9d29d | ||
|
|
a542ba486c | ||
|
|
d50e1a0e15 | ||
|
|
79a0071d65 | ||
|
|
2f1f989266 | ||
|
|
2303eb7908 | ||
|
|
c3666dd064 | ||
|
|
6ed197d35c | ||
|
|
164c9e37d7 | ||
|
|
fa0a18b31d | ||
|
|
80e2118e68 |
@@ -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
+6
@@ -0,0 +1,6 @@
|
||||
undecided:
|
||||
- "@calcom/app-store-cli"
|
||||
- "@calcom/platform-constants"
|
||||
- "@calcom/platform-enums"
|
||||
- "@calcom/platform-types"
|
||||
- "@calcom/platform-utils"
|
||||
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": "*",
|
||||
|
||||
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer
|
||||
.apply(RawBodyMiddleware)
|
||||
.forRoutes({
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
})
|
||||
.forRoutes(
|
||||
{
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
},
|
||||
{
|
||||
path: "/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
}
|
||||
)
|
||||
.apply(JsonBodyMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RequestIdMiddleware)
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
);
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@@ -116,35 +118,59 @@ export class BillingController {
|
||||
@Req() request: Request,
|
||||
@Headers("stripe-signature") stripeSignature: string
|
||||
): Promise<ApiResponse> {
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
try {
|
||||
if (!stripeSignature) {
|
||||
this.logger.warn("Missing stripe-signature header in webhook request");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (!this.stripeWhSecret) {
|
||||
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
|
||||
this.logger.error("Webhook signature validation failed", error);
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,11 +8,11 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
|
||||
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
||||
|
||||
// Mock GoogleCalendarService
|
||||
const fetchPrimaryCalendarMock = vi.fn();
|
||||
const getPrimaryCalendarMock = vi.fn();
|
||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
||||
getPrimaryCalendar: getPrimaryCalendarMock,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
fetchPrimaryCalendarMock.mockReset();
|
||||
getPrimaryCalendarMock.mockReset();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
||||
@@ -138,7 +138,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
credentialId: 1,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
error: "some error" as unknown as null,
|
||||
credentialId: regularCredential.id,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
||||
getPrimaryCalendarMock.mockRejectedValue(
|
||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||
);
|
||||
|
||||
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
getPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
|
||||
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
|
||||
try {
|
||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
|
||||
primaryCalendarId = primaryCalendar?.id;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("next/og", () => ({
|
||||
ImageResponse: vi.fn().mockImplementation(() => ({
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/OgImages", () => ({
|
||||
Meeting: vi.fn(() => null),
|
||||
App: vi.fn(() => null),
|
||||
Generic: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
function createNextRequest(url: string): NextRequest {
|
||||
const request = new Request(url, { method: "GET" });
|
||||
return new NextRequest(request);
|
||||
}
|
||||
|
||||
describe("GET /api/social/og/image", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("Validation errors (400 Bad Request)", () => {
|
||||
test("meeting type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for meeting image");
|
||||
expect(errorData.message).toBe(
|
||||
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
|
||||
);
|
||||
});
|
||||
|
||||
test("app type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for app image");
|
||||
expect(errorData.message).toBe("Required parameters: name, description, slug");
|
||||
});
|
||||
|
||||
test("generic type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for generic image");
|
||||
expect(errorData.message).toBe("Required parameters: title, description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not found errors (404 Not Found)", () => {
|
||||
test("returns 404 when no type parameter is provided", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
});
|
||||
|
||||
test("returns 404 when invalid type parameter is provided", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server errors (500 Internal Server Error)", () => {
|
||||
test("returns 500 when font loading fails", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
|
||||
|
||||
const request = createNextRequest(
|
||||
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { SatoriOptions } from "satori";
|
||||
import { z } from "zod";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const imageType = searchParams.get("type");
|
||||
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
};
|
||||
try {
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
};
|
||||
|
||||
switch (imageType) {
|
||||
case "meeting": {
|
||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||
names: searchParams.getAll("names"),
|
||||
usernames: searchParams.getAll("usernames"),
|
||||
title: searchParams.get("title"),
|
||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
switch (imageType) {
|
||||
case "meeting": {
|
||||
try {
|
||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||
names: searchParams.getAll("names"),
|
||||
usernames: searchParams.getAll("usernames"),
|
||||
title: searchParams.get("title"),
|
||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
});
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for meeting image",
|
||||
message:
|
||||
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case "app": {
|
||||
try {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for app image",
|
||||
message: "Required parameters: name, description, slug",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
try {
|
||||
const { title, description } = genericSchema.parse({
|
||||
title: searchParams.get("title"),
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for generic image",
|
||||
message: "Required parameters: title, description",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
}
|
||||
case "app": {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
const { title, description } = genericSchema.parse({
|
||||
title: searchParams.get("title"),
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
} catch (error) {
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
||||
|
||||
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
|
||||
return <AddNewTeamMembersForm />;
|
||||
};
|
||||
|
||||
const useCheckout = () => {
|
||||
const useOrgCreation = () => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||
const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
@@ -43,17 +48,34 @@ const useCheckout = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data.organization) {
|
||||
// Invalidate the organizations query to ensure fresh data on the next page
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setServerErrorMessage(t(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||
|
||||
return {
|
||||
mutation,
|
||||
mutate: mutation.mutate,
|
||||
isPending: mutation.isPending,
|
||||
mutation: mutationToUse,
|
||||
mutate: mutationToUse.mutate,
|
||||
isPending: mutationToUse.isPending,
|
||||
errorMessage: serverErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export const AddNewTeamMembersForm = () => {
|
||||
const { t } = useLocale();
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const {
|
||||
addInvitedMember,
|
||||
removeInvitedMember,
|
||||
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
bio,
|
||||
onboardingId,
|
||||
} = useOnboardingStore();
|
||||
const checkout = useCheckout();
|
||||
const orgCreation = useOrgCreation();
|
||||
|
||||
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
||||
|
||||
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{checkout.errorMessage && (
|
||||
{orgCreation.errorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert severity="error" message={checkout.errorMessage} />
|
||||
<Alert severity="error" message={orgCreation.errorMessage} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
checkout.mutation.mutate({
|
||||
orgCreation.mutation.mutate({
|
||||
logo,
|
||||
bio,
|
||||
teams,
|
||||
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
|
||||
onboardingId,
|
||||
});
|
||||
}}
|
||||
loading={checkout.isPending}>
|
||||
{t("checkout")}
|
||||
loading={orgCreation.isPending}>
|
||||
{isBillingEnabled ? t("checkout") : t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -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.6",
|
||||
"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">
|
||||
|
||||
@@ -395,7 +395,7 @@ export const createUsersFixture = (
|
||||
},
|
||||
{
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
|
||||
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||
queryValue: {
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
type: "group",
|
||||
|
||||
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
|
||||
@@ -2839,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",
|
||||
@@ -2892,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",
|
||||
@@ -2937,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",
|
||||
@@ -3000,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",
|
||||
@@ -3035,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",
|
||||
@@ -3107,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",
|
||||
@@ -3185,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",
|
||||
|
||||
Binary file not shown.
@@ -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,
|
||||
|
||||
@@ -42,6 +42,35 @@ export interface DailyVideoCallData {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Regions available to create DailyVideo Rooms in.
|
||||
const REGION_CODES = [
|
||||
"af-south-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-south-1",
|
||||
"eu-central-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
] as const;
|
||||
|
||||
type RoomGeo = (typeof REGION_CODES)[number];
|
||||
|
||||
function getDailyVideoRegionFromEnv(): RoomGeo | undefined {
|
||||
if (!process?.env?.DAILY_VIDEO_REGION) return;
|
||||
const isRoomGeo = (value: string): value is RoomGeo => REGION_CODES.includes(value as RoomGeo);
|
||||
function assertIsDailyVideoRegion(value: string): asserts value is RoomGeo {
|
||||
if (!isRoomGeo(value)) {
|
||||
throw new Error(`Invalid region code: ${value}. Must be one of: ${REGION_CODES.join(", ")}`);
|
||||
}
|
||||
}
|
||||
const region = process.env.DAILY_VIDEO_REGION;
|
||||
assertIsDailyVideoRegion(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
const isS3StorageEnabled =
|
||||
process.env.CAL_VIDEO_BUCKET_NAME &&
|
||||
process.env.CAL_VIDEO_BUCKET_REGION &&
|
||||
@@ -203,11 +232,15 @@ export const setEnableRecordingUIAndUserIdForOrganizer = async (
|
||||
};
|
||||
|
||||
const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
|
||||
async function createOrUpdateMeeting(
|
||||
endpoint: string,
|
||||
event: CalendarEvent,
|
||||
region?: RoomGeo
|
||||
): Promise<VideoCallData> {
|
||||
if (!event.uid) {
|
||||
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
||||
}
|
||||
const body = await translateEvent(event);
|
||||
const body = await translateEvent(event, region);
|
||||
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
||||
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
||||
properties: {
|
||||
@@ -226,7 +259,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
});
|
||||
}
|
||||
|
||||
const translateEvent = async (event: CalendarEvent) => {
|
||||
const translateEvent = async (event: CalendarEvent, region?: RoomGeo) => {
|
||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||
// Adds 14 days from the end of the booking as the expiration date
|
||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
|
||||
@@ -248,6 +281,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
return {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
...(region ? { geo: region } : {}),
|
||||
enable_prejoin_ui: true,
|
||||
enable_knocking: true,
|
||||
enable_screenshare: true,
|
||||
@@ -284,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
};
|
||||
};
|
||||
|
||||
async function createInstantMeeting(endTime: string) {
|
||||
async function createInstantMeeting(endTime: string, region?: RoomGeo) {
|
||||
// added a 1 hour buffer for room expiration
|
||||
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||
@@ -296,6 +330,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
const body = {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
...(region ? { geo: region } : {}),
|
||||
enable_prejoin_ui: true,
|
||||
enable_knocking: true,
|
||||
enable_screenshare: true,
|
||||
@@ -347,20 +382,22 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
url: dailyEvent.url,
|
||||
});
|
||||
}
|
||||
|
||||
// Region on which the DailyVideo room is created can be controlled by ENV var
|
||||
// undefined region leaves the choice to DailyVideo
|
||||
const region = getDailyVideoRegionFromEnv();
|
||||
return {
|
||||
/** Daily doesn't need to return busy times, so we return empty */
|
||||
getAvailability: () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting("/rooms", event),
|
||||
createOrUpdateMeeting("/rooms", event, region),
|
||||
deleteMeeting: async (uid: string): Promise<void> => {
|
||||
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
||||
return Promise.resolve();
|
||||
},
|
||||
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event, region),
|
||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
||||
@@ -371,7 +408,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
throw new Error("Something went wrong! Unable to get recording");
|
||||
}
|
||||
},
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime, region),
|
||||
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -678,15 +678,6 @@ export default class GoogleCalendarService implements Calendar {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Better alternative to `getPrimaryCalendar` that doesn't require any argument
|
||||
*/
|
||||
async fetchPrimaryCalendar() {
|
||||
const calendar = await this.authedCalendar();
|
||||
const primaryCalendar = await this.getPrimaryCalendar(calendar);
|
||||
return primaryCalendar;
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
this.log.debug("Listing calendars");
|
||||
const calendar = await this.authedCalendar();
|
||||
@@ -1009,39 +1000,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
}
|
||||
}
|
||||
|
||||
async getPrimaryCalendar(
|
||||
calendar: calendar_v3.Calendar,
|
||||
fields: string[] = ["id", "summary", "primary", "accessRole"]
|
||||
): Promise<calendar_v3.Schema$CalendarListEntry | null> {
|
||||
let pageToken: string | undefined;
|
||||
let firstCalendar: calendar_v3.Schema$CalendarListEntry | undefined;
|
||||
|
||||
async getPrimaryCalendar(_calendar?: calendar_v3.Calendar): Promise<calendar_v3.Schema$Calendar | null> {
|
||||
try {
|
||||
do {
|
||||
const response: any = await calendar.calendarList.list({
|
||||
fields: `items(${fields.join(",")}),nextPageToken`,
|
||||
pageToken,
|
||||
maxResults: 250, // 250 is max
|
||||
});
|
||||
|
||||
const cals = response.data.items ?? [];
|
||||
const primaryCal = cals.find((cal: calendar_v3.Schema$CalendarListEntry) => cal.primary);
|
||||
if (primaryCal) {
|
||||
return primaryCal;
|
||||
}
|
||||
|
||||
// Store the first calendar in case no primary is found
|
||||
if (cals.length > 0 && !firstCalendar) {
|
||||
firstCalendar = cals[0];
|
||||
}
|
||||
|
||||
pageToken = response.data.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
// should not be reached because Google Cal always has a primary cal
|
||||
return firstCalendar ?? null;
|
||||
const calendar = _calendar ?? (await this.authedCalendar());
|
||||
const response = await calendar.calendars.get({
|
||||
calendarId: "primary",
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error("Error in `getPrimaryCalendar`", { error });
|
||||
// should not be reached because Google Cal always has a primary cal
|
||||
logger.error("Error getting primary calendar", { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { vi } from "vitest";
|
||||
const setCredentialsMock = vi.fn();
|
||||
const freebusyQueryMock = vi.fn();
|
||||
const calendarListMock = vi.fn();
|
||||
const calendarsGetMock = vi.fn();
|
||||
const calendarMockImplementation = {
|
||||
channels: {
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -10,6 +11,9 @@ const calendarMockImplementation = {
|
||||
calendarList: {
|
||||
list: calendarListMock,
|
||||
},
|
||||
calendars: {
|
||||
get: calendarsGetMock,
|
||||
},
|
||||
events: {
|
||||
watch: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@@ -885,3 +885,27 @@ describe("getAvailability", () => {
|
||||
expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrimaryCalendar", () => {
|
||||
test("should fetch primary calendar using 'primary' keyword", async () => {
|
||||
const credential = await createCredentialForCalendarService();
|
||||
const calendarService = new CalendarService(credential);
|
||||
setFullMockOAuthManagerRequest();
|
||||
const mockPrimaryCalendar = {
|
||||
id: "user@example.com",
|
||||
summary: "Primary Calendar",
|
||||
description: "Primary calendar for user@example.com",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
const calendarsGetMock = vi.fn().mockResolvedValue({
|
||||
data: mockPrimaryCalendar,
|
||||
});
|
||||
calendarMock.calendar_v3.Calendar().calendars.get = calendarsGetMock;
|
||||
const result = await calendarService.getPrimaryCalendar();
|
||||
expect(calendarsGetMock).toHaveBeenCalledTimes(1);
|
||||
expect(calendarsGetMock).toHaveBeenCalledWith({
|
||||
calendarId: "primary",
|
||||
});
|
||||
expect(result).toEqual(mockPrimaryCalendar);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,6 +139,7 @@ describe("handleResponse", () => {
|
||||
it("should record form response when not in preview and not queued", async () => {
|
||||
const dbFormResponse = {
|
||||
id: 1,
|
||||
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
|
||||
formId: mockForm.id,
|
||||
response: mockResponse,
|
||||
chosenRouteId: null,
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
@@ -262,7 +263,7 @@ test.describe("Routing Forms", () => {
|
||||
option: 2,
|
||||
page,
|
||||
});
|
||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
||||
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||
await saveCurrentForm(page);
|
||||
|
||||
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||
@@ -290,7 +291,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com");
|
||||
return url.pathname.endsWith("/pro");
|
||||
});
|
||||
|
||||
const url = new URL(page.url());
|
||||
@@ -509,7 +510,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com") && url.searchParams.get("Test field") === "external-redirect";
|
||||
return url.pathname.endsWith("/pro") && url.searchParams.get("Test field") === "external-redirect";
|
||||
});
|
||||
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
||||
@@ -576,7 +577,7 @@ test.describe("Routing Forms", () => {
|
||||
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("External Redirect");
|
||||
expect(route).toBe("https://cal.com/peer");
|
||||
expect(route).toBe(`${WEBAPP_URL}/pro`);
|
||||
await page.click('[data-testid="close-results-button"]');
|
||||
|
||||
// Multiselect(Legacy)
|
||||
@@ -925,7 +926,7 @@ test.describe("Routing Forms", () => {
|
||||
option: 2,
|
||||
page,
|
||||
});
|
||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
||||
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
formId,
|
||||
@@ -978,7 +979,7 @@ test.describe("Routing Forms", () => {
|
||||
await page.fill('[data-testid="form-field-short-text"]', "test");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com");
|
||||
return url.pathname.endsWith("/pro");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1059,7 +1060,7 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await fillAllOptionsBasedFields();
|
||||
page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com");
|
||||
return url.pathname.endsWith("/pro");
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = {
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
function fakeCurrentDocumentUrl({
|
||||
origin = "https://example.com",
|
||||
path = "",
|
||||
|
||||
@@ -594,6 +594,9 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true";
|
||||
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
const data: Message = e.data;
|
||||
if (!data) {
|
||||
@@ -631,7 +634,7 @@ function main() {
|
||||
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
||||
initializeAndSetupEmbed();
|
||||
} else {
|
||||
log(`Preloaded scenario - Skipping initialization and setup`);
|
||||
log(`Preloaded scenario - Skipping initialization and setup as only assets need to be loaded`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +682,7 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
|
||||
/**
|
||||
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||
* If there is a need to fetch the slots, then the slots would be fetched and then only this function call would complete
|
||||
* url has the config as params
|
||||
*/
|
||||
async function connectPreloadedEmbed({
|
||||
|
||||
@@ -1215,26 +1215,26 @@ describe("Cal", () => {
|
||||
expect(result).toBe("noAction");
|
||||
});
|
||||
|
||||
it("should return connect-no-slots-fetch when reuseFully is true and slots are not stale", () => {
|
||||
it("should return connect-no-slots-fetch when backgroundSlotsFetch is true and slots are not stale", () => {
|
||||
const result = calInstance.getNextActionForModal({
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
prerenderOptions: {
|
||||
reuseFully: true,
|
||||
backgroundSlotsFetch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toBe("connect-no-slots-fetch");
|
||||
});
|
||||
|
||||
it("should return connect when reuseFully is true but slots are stale", () => {
|
||||
it("should return connect when backgroundSlotsFetch is true but slots are stale", () => {
|
||||
const result = calInstance.getNextActionForModal({
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
prerenderOptions: {
|
||||
reuseFully: true,
|
||||
backgroundSlotsFetch: true,
|
||||
},
|
||||
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
||||
embedRenderStartTime: Date.now(),
|
||||
|
||||
@@ -55,6 +55,8 @@ type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
reuseFully?: boolean;
|
||||
// Default value is true right now but it would change soon
|
||||
backgroundSlotsFetch?: boolean;
|
||||
};
|
||||
|
||||
type ModalStateData = {
|
||||
@@ -598,7 +600,7 @@ export class Cal {
|
||||
return "fullReload";
|
||||
}
|
||||
|
||||
if (prerenderOptions?.reuseFully && !areSlotsStale) {
|
||||
if (prerenderOptions?.backgroundSlotsFetch && !areSlotsStale) {
|
||||
return "connect-no-slots-fetch";
|
||||
}
|
||||
|
||||
@@ -986,6 +988,7 @@ class CalApi {
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
if (__prerender && this.cal.modalBox) {
|
||||
log("Destroying previous prerendered modalbox");
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.cal.modalBox.remove();
|
||||
}
|
||||
@@ -999,9 +1002,18 @@ class CalApi {
|
||||
const containerEl = document.body;
|
||||
this.cal.isPrerendering = !!__prerender;
|
||||
if (__prerender) {
|
||||
// TODO: Make `reuseFully` a configurable param as well later.
|
||||
// If someone's preloading the headless router path, we must reuse the iframe fully as is as Router would redirect to a Booking Page and that should be shown as is
|
||||
this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath };
|
||||
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||
const backgroundSlotsFetch =
|
||||
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||
: prerenderOptions.backgroundSlotsFetch;
|
||||
|
||||
this.prerenderOptions = {
|
||||
...prerenderOptions,
|
||||
backgroundSlotsFetch,
|
||||
};
|
||||
// Add prerender query param
|
||||
config.prerender = "true";
|
||||
|
||||
@@ -1010,7 +1022,7 @@ class CalApi {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!this.prerenderOptions?.reuseFully) {
|
||||
if (!backgroundSlotsFetch) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
@@ -1045,9 +1057,11 @@ class CalApi {
|
||||
isConnectionInitiated,
|
||||
prerenderOptions: this.prerenderOptions ?? null,
|
||||
};
|
||||
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
|
||||
|
||||
// If we want to reuse fully then we shouldn't submit response again
|
||||
if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) {
|
||||
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
|
||||
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
getFirstDelegationConferencingCredentialAppLocation,
|
||||
} from "@calcom/lib/delegationCredential/server";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import type { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
||||
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||
@@ -455,22 +454,12 @@ async function handler(
|
||||
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
|
||||
|
||||
if (!rawBookingData.rescheduleUid) {
|
||||
try {
|
||||
await checkActiveBookingsLimitForBooker({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as ErrorWithCode;
|
||||
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
});
|
||||
}
|
||||
await checkActiveBookingsLimitForBooker({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
||||
@@ -1407,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>
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useState } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { MINIMUM_NUMBER_OF_ORG_SEATS } from "@calcom/lib/constants";
|
||||
import { MINIMUM_NUMBER_OF_ORG_SEATS, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
@@ -55,8 +55,9 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
const router = useRouter();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
||||
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
||||
const defaultOrgOwnerEmail = session.data.user.email ?? "";
|
||||
const { useOnboardingStore } = useOnboarding({ step: "start" });
|
||||
// Let self-hosters create an organization with their own email. Hosted's Admin already has an organization for their email
|
||||
const defaultOrgOwnerEmail = (!isAdmin || IS_SELF_HOSTED ? session.data.user.email : null) ?? "";
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding({ step: "start" });
|
||||
const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } =
|
||||
useOnboardingStore();
|
||||
|
||||
@@ -71,7 +72,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
defaultValues: {
|
||||
billingPeriod: billingPeriod ?? BillingPeriod.MONTHLY,
|
||||
slug: slug ?? (!isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined),
|
||||
orgOwnerEmail: orgOwnerEmail || (!isAdmin ? defaultOrgOwnerEmail : undefined),
|
||||
orgOwnerEmail: orgOwnerEmail || defaultOrgOwnerEmail,
|
||||
name: name ?? (!isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined),
|
||||
seats: seats ?? null,
|
||||
pricePerSeat: pricePerSeat ?? null,
|
||||
@@ -92,7 +93,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
slug: data.slug,
|
||||
});
|
||||
|
||||
if (isAdmin) {
|
||||
if (isAdmin && data.userId !== session.data.user.id) {
|
||||
router.push("/settings/organizations/new/handover");
|
||||
} else {
|
||||
router.push("/settings/organizations/new/about");
|
||||
@@ -133,7 +134,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
<Alert severity="error" message={serverErrorMessage} />
|
||||
</div>
|
||||
)}
|
||||
{isAdmin && (
|
||||
{isBillingEnabled && isAdmin && (
|
||||
<div className="mb-5">
|
||||
<Controller
|
||||
name="billingPeriod"
|
||||
@@ -251,7 +252,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
{isBillingEnabled && isAdmin && (
|
||||
<>
|
||||
<section className="grid grid-cols-2 gap-2">
|
||||
<div className="w-full">
|
||||
@@ -305,7 +306,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
||||
)}
|
||||
|
||||
{/* This radio group does nothing - its just for visual purposes */}
|
||||
{!isAdmin && (
|
||||
{isBillingEnabled && !isAdmin && (
|
||||
<>
|
||||
<div className="bg-subtle space-y-5 rounded-lg p-5">
|
||||
<h3 className="font-cal text-default text-lg font-semibold leading-4">
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useEffect } from "react";
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL, IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import { UserPermissionRole } from "@calcom/prisma/enums";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
@@ -102,6 +102,10 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
|
||||
if (state) {
|
||||
set(state);
|
||||
} else {
|
||||
// Clear the localStorage entry for this store
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.removeItem("org-creation-onboarding");
|
||||
}
|
||||
set(initialState);
|
||||
}
|
||||
},
|
||||
@@ -117,6 +121,7 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
||||
const router = useRouter();
|
||||
const path = usePathname();
|
||||
const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN;
|
||||
const isBillingEnabled = !(IS_SELF_HOSTED && isAdmin) || process.env.NEXT_PUBLIC_IS_E2E;
|
||||
const searchParams = useSearchParams();
|
||||
const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } =
|
||||
trpc.viewer.organizations.getOrganizationOnboarding.useQuery();
|
||||
@@ -127,6 +132,10 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
||||
return;
|
||||
}
|
||||
|
||||
if (organizationOnboarding?.isComplete) {
|
||||
reset();
|
||||
}
|
||||
|
||||
if (organizationOnboarding) {
|
||||
// Must not keep resetting state on each step change as every step doesn't save at the moment and this would reset user's changes
|
||||
if (!window.isOrgOnboardingSynced) {
|
||||
@@ -143,6 +152,9 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
||||
bio: organizationOnboarding.bio,
|
||||
logo: organizationOnboarding.logo,
|
||||
});
|
||||
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
|
||||
reset();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// First step doesn't require onboardingId
|
||||
@@ -154,11 +166,6 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
||||
router.push("/settings/organizations/new");
|
||||
}
|
||||
}
|
||||
|
||||
// Admin must start fresh, so that they can create organization with any email and data doesn't mix from different org creations
|
||||
if (isAdmin) {
|
||||
reset();
|
||||
}
|
||||
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -170,5 +177,10 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
||||
router.push(`/auth/login?callbackUrl=${WEBAPP_URL}${path}${searchString ? `?${searchString}` : ""}`);
|
||||
}
|
||||
}, [session, router, path, searchParams]);
|
||||
return { useOnboardingStore, isLoadingOrgOnboarding, dbOnboarding: organizationOnboarding };
|
||||
return {
|
||||
useOnboardingStore,
|
||||
isLoadingOrgOnboarding,
|
||||
dbOnboarding: organizationOnboarding,
|
||||
isBillingEnabled,
|
||||
};
|
||||
};
|
||||
|
||||
+320
-232
@@ -6,13 +6,22 @@ import prismock from "../../../../../../tests/libs/__mocks__/prisma";
|
||||
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import * as constants from "@calcom/lib/constants";
|
||||
import { createDomain } from "@calcom/lib/domainManager/organization";
|
||||
import type { OrganizationOnboarding } from "@calcom/prisma/client";
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||
|
||||
import { createOrganizationFromOnboarding } from "./createOrganizationFromOnboarding";
|
||||
|
||||
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
|
||||
LicenseKeySingleton: {
|
||||
getInstance: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock the external functions
|
||||
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
|
||||
sendEmailVerification: vi.fn(),
|
||||
@@ -147,6 +156,19 @@ async function createTestMembership(data: { userId: number; teamId: number; role
|
||||
});
|
||||
}
|
||||
|
||||
async function expectOrganizationOnboardingToHaveDataContaining({
|
||||
onboardingId,
|
||||
expectedData,
|
||||
}: {
|
||||
onboardingId: string;
|
||||
expectedData: Partial<OrganizationOnboarding>;
|
||||
}) {
|
||||
const updatedOrganizationOnboarding = await prismock.organizationOnboarding.findUnique({
|
||||
where: { id: onboardingId },
|
||||
});
|
||||
expect(updatedOrganizationOnboarding).toEqual(expect.objectContaining(expectedData));
|
||||
}
|
||||
|
||||
describe("createOrganizationFromOnboarding", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetAllMocks();
|
||||
@@ -154,260 +176,326 @@ describe("createOrganizationFromOnboarding", () => {
|
||||
await prismock.reset();
|
||||
});
|
||||
|
||||
// Skipped because non-existend user creation support isn't there through onboarding now.
|
||||
it.skip("should create an organization with a non-existent user as owner", async () => {
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding();
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
describe("hosted", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(organizationOnboarding.name);
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
expect(organization.metadata).toEqual({
|
||||
orgSeats: 5,
|
||||
orgPricePerSeat: 20,
|
||||
billingPeriod: "MONTHLY",
|
||||
subscriptionId: "mock_subscription_id",
|
||||
subscriptionItemId: "mock_subscription_item_id",
|
||||
// Skipped because non-existend user creation support isn't there through onboarding now.
|
||||
it.skip("should create an organization with a non-existent user as owner", async () => {
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding();
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(organizationOnboarding.name);
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
expect(organization.metadata).toEqual({
|
||||
orgSeats: 5,
|
||||
orgPricePerSeat: 20,
|
||||
billingPeriod: "MONTHLY",
|
||||
subscriptionId: "mock_subscription_id",
|
||||
subscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify owner creation
|
||||
expect(owner).toBeDefined();
|
||||
expect(owner.email).toBe(organizationOnboarding.orgOwnerEmail);
|
||||
});
|
||||
|
||||
// Verify owner creation
|
||||
expect(owner).toBeDefined();
|
||||
expect(owner.email).toBe(organizationOnboarding.orgOwnerEmail);
|
||||
});
|
||||
|
||||
it("should create an organization with an existing user as owner", async () => {
|
||||
const orgOwnerEmail = "test@example.com";
|
||||
const existingUser = await createTestUser({
|
||||
email: orgOwnerEmail,
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding({
|
||||
orgOwnerEmail,
|
||||
});
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(organizationOnboarding.name);
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
|
||||
// Verify owner is the existing user
|
||||
expect(owner.id).toBe(existingUser.id);
|
||||
expect(owner.email).toBe(existingUser.email);
|
||||
|
||||
expect(createDomain).toHaveBeenCalledWith(organization.slug);
|
||||
// Verify team creation and member invites still work
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orgSlug: organization.slug,
|
||||
teamId: organization.id,
|
||||
invitations: organizationOnboarding.invitedMembers?.map((member) => ({
|
||||
usernameOrEmail: member.email,
|
||||
role: MembershipRole.MEMBER,
|
||||
})),
|
||||
})
|
||||
);
|
||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
orgId: organization.id,
|
||||
teamNames: ["New Team"],
|
||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "new-team-slug" })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should reuse existing organization if organizationId is already set in onboarding. Covers the retry scenario where org got created but then error occurred", async () => {
|
||||
const orgName = "Existing Org";
|
||||
const orgSlug = "existing-org";
|
||||
const existingOrg = await createTestOrganization({
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
});
|
||||
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
it("should create an organization with an existing user as owner", async () => {
|
||||
const orgOwnerEmail = "test@example.com";
|
||||
const existingUser = await createTestUser({
|
||||
email: orgOwnerEmail,
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
organizationId: existingOrg.id,
|
||||
onboardingCompleted: true,
|
||||
emailVerified: new Date(),
|
||||
});
|
||||
|
||||
const organizationOnboarding = await createTestOrganizationOnboarding({
|
||||
orgOwnerEmail,
|
||||
});
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify organization creation
|
||||
expect(organization).toBeDefined();
|
||||
expect(organization.name).toBe(organizationOnboarding.name);
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
|
||||
// Verify owner is the existing user
|
||||
expect(owner.id).toBe(existingUser.id);
|
||||
expect(owner.email).toBe(existingUser.email);
|
||||
|
||||
expect(createDomain).toHaveBeenCalledWith(organization.slug);
|
||||
// Verify team creation and member invites still work
|
||||
expect(inviteMembersWithNoInviterPermissionCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
orgSlug: organization.slug,
|
||||
teamId: organization.id,
|
||||
invitations: organizationOnboarding.invitedMembers?.map((member) => ({
|
||||
usernameOrEmail: member.email,
|
||||
role: MembershipRole.MEMBER,
|
||||
})),
|
||||
})
|
||||
);
|
||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
orgId: organization.id,
|
||||
teamNames: ["New Team"],
|
||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "new-team-slug" })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should reuse existing organization if organizationId is already set in onboarding. Covers the retry scenario where org got created but then error occurred", async () => {
|
||||
const orgName = "Existing Org";
|
||||
const orgSlug = "existing-org";
|
||||
const existingOrg = await createTestOrganization({
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
name: "Existing User",
|
||||
username: "existinguser",
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
organizationId: existingOrg.id,
|
||||
name: orgName,
|
||||
slug: orgSlug,
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the existing organization was reused
|
||||
expect(organization.id).toBe(existingOrg.id);
|
||||
});
|
||||
|
||||
it("should throw error if organization with same slug exists", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createTestOrganization({
|
||||
name: "Conflicting Org with same slug",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
});
|
||||
|
||||
it("should create team with slugified slug", async () => {
|
||||
const teamToMove = await createTestTeam({
|
||||
name: "Sales",
|
||||
slug: "Company Sales",
|
||||
// Verify the existing organization was reused
|
||||
expect(organization.id).toBe(existingOrg.id);
|
||||
});
|
||||
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "UPPERCASE-SLUG",
|
||||
teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }],
|
||||
},
|
||||
it("should throw error if organization with same slug exists", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createTestOrganization({
|
||||
name: "Conflicting Org with same slug",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
it("should create team with slugified slug", async () => {
|
||||
const teamToMove = await createTestTeam({
|
||||
name: "Sales",
|
||||
slug: "Company Sales",
|
||||
});
|
||||
|
||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
orgId: organization.id,
|
||||
teamNames: [],
|
||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "UPPERCASE-SLUG",
|
||||
teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }],
|
||||
},
|
||||
});
|
||||
|
||||
it("should update stripe customer ID for existing user", async () => {
|
||||
const { user: existingUser, organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify user's stripe customer ID was updated
|
||||
const updatedUser = await prismock.user.findUnique({
|
||||
where: { id: existingUser.id },
|
||||
});
|
||||
|
||||
expect(updatedUser?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization in with slug set even if there is a team with same slug of which orgOwner(to-be) is a member", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
const teamWithConflictingSlug = await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlug",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Make the orgOwner a member of the team
|
||||
await createTestMembership({
|
||||
userId: organizationOnboarding.createdById,
|
||||
teamId: teamWithConflictingSlug.id,
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
});
|
||||
|
||||
it("should not create organization if there is a team with same slug of which orgOwner(to-be) is not a member", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Don't make the orgOwner a member of the team
|
||||
// await createTestMembership({
|
||||
// userId: organizationOnboarding.createdById,
|
||||
// teamId: teamWithConflictingSlug.id,
|
||||
// role: MembershipRole.ADMIN,
|
||||
// });
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: expect.objectContaining({
|
||||
orgId: organization.id,
|
||||
teamNames: [],
|
||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should update stripe customer ID for existing user", async () => {
|
||||
const { user: existingUser, organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
// Verify user's stripe customer ID was updated
|
||||
const updatedUser = await prismock.user.findUnique({
|
||||
where: { id: existingUser.id },
|
||||
});
|
||||
|
||||
expect(updatedUser?.metadata).toEqual(
|
||||
expect.objectContaining({
|
||||
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should create organization in with slug set even if there is a team with same slug of which orgOwner(to-be) is a member", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
const teamWithConflictingSlug = await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlug",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Make the orgOwner a member of the team
|
||||
await createTestMembership({
|
||||
userId: organizationOnboarding.createdById,
|
||||
teamId: teamWithConflictingSlug.id,
|
||||
role: MembershipRole.ADMIN,
|
||||
});
|
||||
|
||||
const { organization } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
||||
});
|
||||
|
||||
it("should not create organization if there is a team with same slug of which orgOwner(to-be) is not a member", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
organizationOnboarding: {
|
||||
slug: "conflicting-org-with-same-slug",
|
||||
},
|
||||
});
|
||||
|
||||
await createTestTeam({
|
||||
name: "TestTeamWithConflictingSlugNotOwnedByOrgOwner",
|
||||
slug: organizationOnboarding.slug,
|
||||
});
|
||||
|
||||
// Don't make the orgOwner a member of the team
|
||||
// await createTestMembership({
|
||||
// userId: organizationOnboarding.createdById,
|
||||
// teamId: teamWithConflictingSlug.id,
|
||||
// role: MembershipRole.ADMIN,
|
||||
// });
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("organization_url_taken");
|
||||
});
|
||||
|
||||
it("should not mark the Onboarding as completed(isComplete=true)", async () => {
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
await expectOrganizationOnboardingToHaveDataContaining({
|
||||
onboardingId: organizationOnboarding.id,
|
||||
expectedData: {
|
||||
isComplete: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("self-hosted", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should fail if the license is invalid", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(false),
|
||||
});
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
})
|
||||
).rejects.toThrow("Self hosted license not valid");
|
||||
});
|
||||
|
||||
it("should create an organization with a valid license", async () => {
|
||||
vi.mocked(LicenseKeySingleton.getInstance).mockResolvedValue({
|
||||
checkLicense: vi.fn().mockResolvedValue(true),
|
||||
});
|
||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||
user: {
|
||||
email: "test@example.com",
|
||||
},
|
||||
});
|
||||
|
||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||
organizationOnboarding,
|
||||
paymentSubscriptionId: "mock_subscription_id",
|
||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||
});
|
||||
|
||||
expect(organization).toBeDefined();
|
||||
expect(owner).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
|
||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
@@ -10,9 +11,11 @@ import {
|
||||
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||
import { getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||
@@ -333,9 +336,13 @@ async function backwardCompatibilityForSubscriptionDetails({
|
||||
id: number;
|
||||
metadata: Prisma.JsonValue;
|
||||
};
|
||||
paymentSubscriptionId: string;
|
||||
paymentSubscriptionItemId: string;
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) {
|
||||
if (!paymentSubscriptionId || !paymentSubscriptionItemId) {
|
||||
return organization;
|
||||
}
|
||||
|
||||
const existingMetadata = teamMetadataSchema.parse(organization.metadata);
|
||||
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
|
||||
id: organization.id,
|
||||
@@ -387,9 +394,19 @@ async function handleOrganizationCreation({
|
||||
}: {
|
||||
organizationOnboarding: OrganizationOnboardingArg;
|
||||
owner: OrgOwner;
|
||||
paymentSubscriptionId: string;
|
||||
paymentSubscriptionItemId: string;
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) {
|
||||
if (IS_SELF_HOSTED) {
|
||||
const deploymentRepo = new DeploymentRepository(prisma);
|
||||
const licenseKeyService = await LicenseKeySingleton.getInstance(deploymentRepo);
|
||||
const hasValidLicense = await licenseKeyService.checkLicense();
|
||||
|
||||
if (!hasValidLicense) {
|
||||
throw new Error("Self hosted license not valid");
|
||||
}
|
||||
}
|
||||
|
||||
let organization;
|
||||
const orgData = {
|
||||
id: organizationOnboarding.organizationId,
|
||||
@@ -468,8 +485,8 @@ export const createOrganizationFromOnboarding = async ({
|
||||
paymentSubscriptionItemId,
|
||||
}: {
|
||||
organizationOnboarding: OrganizationOnboardingArg;
|
||||
paymentSubscriptionId: string;
|
||||
paymentSubscriptionItemId: string;
|
||||
paymentSubscriptionId?: string;
|
||||
paymentSubscriptionItemId?: string;
|
||||
}) => {
|
||||
log.info(
|
||||
"createOrganizationFromOnboarding",
|
||||
@@ -478,6 +495,11 @@ export const createOrganizationFromOnboarding = async ({
|
||||
orgSlug: organizationOnboarding.slug,
|
||||
})
|
||||
);
|
||||
|
||||
if (!IS_SELF_HOSTED && (!paymentSubscriptionId || !paymentSubscriptionItemId)) {
|
||||
throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required");
|
||||
}
|
||||
|
||||
if (
|
||||
await hasConflictingOrganization({
|
||||
slug: organizationOnboarding.slug,
|
||||
@@ -491,10 +513,13 @@ export const createOrganizationFromOnboarding = async ({
|
||||
const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
|
||||
const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common");
|
||||
|
||||
await handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
// TODO: We need to send emails to admin in the case of SELF_HOSTING where NEXT_PUBLIC_SINGLE_ORG_SLUG isn't set
|
||||
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
|
||||
await handleDomainSetup({
|
||||
organizationOnboarding,
|
||||
orgOwnerTranslation,
|
||||
});
|
||||
}
|
||||
|
||||
const { organization, owner } = await handleOrganizationCreation({
|
||||
organizationOnboarding,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import z from "zod";
|
||||
|
||||
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
// Base user schema - fields that any user can set
|
||||
export const createOrganizationSchema = z.object({
|
||||
language: z.string().optional(),
|
||||
logo: z.string().nullish(),
|
||||
bio: z.string().nullish(),
|
||||
onboardingId: z.string(),
|
||||
invitedMembers: orgOnboardingInvitedMembersSchema.optional(),
|
||||
teams: orgOnboardingTeamsSchema.optional(),
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
@@ -379,7 +386,7 @@ export const roundRobinManualReassignment = async ({
|
||||
return booking;
|
||||
};
|
||||
|
||||
async function handleWorkflowsUpdate({
|
||||
export async function handleWorkflowsUpdate({
|
||||
booking,
|
||||
newUser,
|
||||
evt,
|
||||
@@ -403,6 +410,7 @@ async function handleWorkflowsUpdate({
|
||||
scheduled: true,
|
||||
OR: [{ cancelled: false }, { cancelled: null }],
|
||||
workflowStep: {
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
workflow: {
|
||||
trigger: {
|
||||
in: [
|
||||
|
||||
@@ -16,31 +16,25 @@ import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBo
|
||||
import AssignmentReasonRecorder, {
|
||||
RRReassignmentType,
|
||||
} from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
||||
import {
|
||||
scheduleEmailReminder,
|
||||
deleteScheduledEmailReminder,
|
||||
} from "@calcom/features/ee/workflows/lib/reminders/emailReminderManager";
|
||||
import { scheduleWorkflowReminders } from "@calcom/features/ee/workflows/lib/reminders/reminderScheduler";
|
||||
import { getVideoCallUrlFromCalEvent } from "@calcom/lib/CalEventParser";
|
||||
import { SENDER_NAME } from "@calcom/lib/constants";
|
||||
import {
|
||||
enrichHostsWithDelegationCredentials,
|
||||
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";
|
||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
|
||||
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
|
||||
import { handleWorkflowsUpdate } from "./roundRobinManualReassignment";
|
||||
import { bookingSelect } from "./utils/bookingSelect";
|
||||
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
|
||||
import { getTeamMembers } from "./utils/getTeamMembers";
|
||||
@@ -246,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,
|
||||
});
|
||||
@@ -434,144 +434,12 @@ export const roundRobinReassignment = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const scheduledWorkflowReminders = await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
bookingUid: booking.uid,
|
||||
method: WorkflowMethods.EMAIL,
|
||||
scheduled: true,
|
||||
OR: [{ cancelled: false }, { cancelled: null }],
|
||||
workflowStep: {
|
||||
workflow: {
|
||||
trigger: {
|
||||
in: [
|
||||
WorkflowTriggerEvents.BEFORE_EVENT,
|
||||
WorkflowTriggerEvents.NEW_EVENT,
|
||||
WorkflowTriggerEvents.AFTER_EVENT,
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
referenceId: true,
|
||||
workflowStep: {
|
||||
select: {
|
||||
id: true,
|
||||
template: true,
|
||||
workflow: {
|
||||
select: {
|
||||
userId: true,
|
||||
teamId: true,
|
||||
trigger: true,
|
||||
time: true,
|
||||
timeUnit: true,
|
||||
},
|
||||
},
|
||||
emailSubject: true,
|
||||
reminderBody: true,
|
||||
sender: true,
|
||||
includeCalendarEvent: true,
|
||||
verifiedAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const workflowEventMetadata = { videoCallUrl: getVideoCallUrlFromCalEvent(evtWithAdditionalInfo) };
|
||||
|
||||
const bookerUrl = await getBookerBaseUrl(orgId);
|
||||
|
||||
for (const workflowReminder of scheduledWorkflowReminders) {
|
||||
const workflowStep = workflowReminder?.workflowStep;
|
||||
const workflow = workflowStep?.workflow;
|
||||
|
||||
if (workflowStep && workflow) {
|
||||
await scheduleEmailReminder({
|
||||
evt: {
|
||||
...evtWithAdditionalInfo,
|
||||
metadata: workflowEventMetadata,
|
||||
eventType,
|
||||
bookerUrl,
|
||||
},
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
triggerEvent: workflow.trigger,
|
||||
timeSpan: {
|
||||
time: workflow.time,
|
||||
timeUnit: workflow.timeUnit,
|
||||
},
|
||||
sendTo: [reassignedRRHost.email],
|
||||
template: workflowStep.template,
|
||||
emailSubject: workflowStep.emailSubject || undefined,
|
||||
emailBody: workflowStep.reminderBody || undefined,
|
||||
sender: workflowStep.sender || SENDER_NAME,
|
||||
hideBranding: true,
|
||||
includeCalendarEvent: workflowStep.includeCalendarEvent,
|
||||
workflowStepId: workflowStep.id,
|
||||
verifiedAt: workflowStep.verifiedAt,
|
||||
userId: workflow.userId,
|
||||
teamId: workflow.teamId,
|
||||
});
|
||||
}
|
||||
|
||||
await deleteScheduledEmailReminder(workflowReminder.id);
|
||||
}
|
||||
// Send new event workflows to new organizer
|
||||
const newEventWorkflows = await prisma.workflow.findMany({
|
||||
where: {
|
||||
trigger: WorkflowTriggerEvents.NEW_EVENT,
|
||||
OR: [
|
||||
{
|
||||
isActiveOnAll: true,
|
||||
teamId: eventType?.teamId,
|
||||
},
|
||||
{
|
||||
activeOn: {
|
||||
some: {
|
||||
eventTypeId: eventTypeId,
|
||||
},
|
||||
},
|
||||
},
|
||||
...(eventType?.teamId
|
||||
? [
|
||||
{
|
||||
activeOnTeams: {
|
||||
some: {
|
||||
teamId: eventType.teamId,
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(eventType?.team?.parentId
|
||||
? [
|
||||
{
|
||||
isActiveOnAll: true,
|
||||
teamId: eventType.team.parentId,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
include: {
|
||||
steps: {
|
||||
where: {
|
||||
action: WorkflowActions.EMAIL_HOST,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await scheduleWorkflowReminders({
|
||||
workflows: newEventWorkflows,
|
||||
smsReminderNumber: null,
|
||||
calendarEvent: {
|
||||
...evtWithAdditionalInfo,
|
||||
metadata: workflowEventMetadata,
|
||||
eventType: { slug: eventType.slug },
|
||||
bookerUrl,
|
||||
},
|
||||
hideBranding: !!eventType?.owner?.hideBranding,
|
||||
await handleWorkflowsUpdate({
|
||||
booking,
|
||||
newUser: reassignedRRHost,
|
||||
evt: evtWithAdditionalInfo,
|
||||
eventType,
|
||||
orgId,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -269,4 +269,6 @@ export const scheduleWhatsappReminder = async (args: ScheduleTextReminderArgs) =
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteScheduledWhatsappReminder = deleteScheduledSMSReminder;
|
||||
export const deleteScheduledWhatsappReminder = async (reminderId: number, referenceId: string | null) => {
|
||||
return await deleteScheduledSMSReminder(reminderId, referenceId);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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/");
|
||||
}
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
import { expect, it, describe } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
|
||||
import { HttpError } from "../http-error";
|
||||
import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
|
||||
|
||||
const test400Codes = [
|
||||
ErrorCode.RequestBodyWithouEnd,
|
||||
ErrorCode.MissingPaymentCredential,
|
||||
ErrorCode.MissingPaymentAppId,
|
||||
ErrorCode.AvailabilityNotFoundInSchedule,
|
||||
ErrorCode.CancelledBookingsCannotBeRescheduled,
|
||||
ErrorCode.BookingTimeOutOfBounds,
|
||||
ErrorCode.BookingNotAllowedByRestrictionSchedule,
|
||||
ErrorCode.BookerLimitExceeded,
|
||||
ErrorCode.BookerLimitExceededReschedule,
|
||||
];
|
||||
|
||||
const test404Codes = [
|
||||
ErrorCode.EventTypeNotFound,
|
||||
ErrorCode.BookingNotFound,
|
||||
ErrorCode.RestrictionScheduleNotFound,
|
||||
];
|
||||
|
||||
const test409Codes = [
|
||||
ErrorCode.NoAvailableUsersFound,
|
||||
ErrorCode.HostsUnavailableForBooking,
|
||||
ErrorCode.AlreadySignedUpForBooking,
|
||||
ErrorCode.BookingSeatsFull,
|
||||
ErrorCode.NotEnoughAvailableSeats,
|
||||
ErrorCode.BookingConflict,
|
||||
ErrorCode.PaymentCreationFailure,
|
||||
ErrorCode.ChargeCardFailure,
|
||||
];
|
||||
|
||||
describe("getServerErrorFromUnknown", () => {
|
||||
it("should handle a StripeInvalidRequestError", () => {
|
||||
test("should handle a StripeInvalidRequestError", () => {
|
||||
const stripeError = {
|
||||
name: "StripeInvalidRequestError",
|
||||
type: "StripeInvalidRequestError",
|
||||
@@ -31,4 +63,75 @@ describe("getServerErrorFromUnknown", () => {
|
||||
expect(result.cause).toEqual(stripeError);
|
||||
expect(result.name).toBe("HttpError");
|
||||
});
|
||||
|
||||
test("ErrorWithCode should preserve data property", () => {
|
||||
const rescheduleData = {
|
||||
rescheduleUid: "abc123",
|
||||
startTime: new Date("2024-01-15T10:00:00Z"),
|
||||
attendees: [{ name: "John Doe", email: "john@example.com" }],
|
||||
};
|
||||
|
||||
const error = new ErrorWithCode(
|
||||
ErrorCode.BookerLimitExceededReschedule,
|
||||
"Maximum booking limit reached, please reschedule an existing booking",
|
||||
rescheduleData
|
||||
);
|
||||
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
|
||||
expect(result.statusCode).toBe(400);
|
||||
expect(result.message).toBe("Maximum booking limit reached, please reschedule an existing booking");
|
||||
expect(result.data).toEqual(rescheduleData);
|
||||
});
|
||||
|
||||
test("Plain Error with ErrorCode message should work the same as ErrorWithCode", () => {
|
||||
const error = new Error(ErrorCode.NoAvailableUsersFound);
|
||||
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
|
||||
expect(result.statusCode).toBe(409);
|
||||
expect(result.message).toBe(ErrorCode.NoAvailableUsersFound);
|
||||
});
|
||||
|
||||
test("Generic Error without ErrorCode should return 500", () => {
|
||||
const error = new Error("Some unexpected server error");
|
||||
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.message).toBe("Some unexpected server error");
|
||||
});
|
||||
|
||||
test("Server-side ErrorCode should return 500", () => {
|
||||
const error = new ErrorWithCode(ErrorCode.CreatingOauthClientError, "OAuth client creation failed");
|
||||
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
|
||||
expect(result.statusCode).toBe(500);
|
||||
expect(result.message).toBe("OAuth client creation failed");
|
||||
});
|
||||
});
|
||||
|
||||
test400Codes.forEach((errorCode) => {
|
||||
test(`${errorCode} should return 400 Bad Request`, () => {
|
||||
const error = new ErrorWithCode(errorCode, `Test message for ${errorCode}`);
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
expect(result.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
test404Codes.forEach((errorCode) => {
|
||||
test(`${errorCode} should return 404 Not Found`, () => {
|
||||
const error = new ErrorWithCode(errorCode, `Test message for ${errorCode}`);
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
expect(result.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
test409Codes.forEach((errorCode) => {
|
||||
test(`${errorCode} should return 409 Conflict`, () => {
|
||||
const error = new ErrorWithCode(errorCode, `Test message for ${errorCode}`);
|
||||
const result = getServerErrorFromUnknown(error);
|
||||
expect(result.statusCode).toBe(409);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ZodError } from "zod";
|
||||
|
||||
import { stripeInvalidRequestErrorSchema } from "@calcom/app-store/_utils/stripe.types";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
@@ -60,6 +61,15 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
||||
if (parsedStripeError.success) {
|
||||
return getHttpError({ statusCode: 400, cause: parsedStripeError.data });
|
||||
}
|
||||
if (cause instanceof ErrorWithCode) {
|
||||
const statusCode = getStatusCode(cause);
|
||||
return new HttpError({
|
||||
statusCode,
|
||||
message: cause.message ?? "",
|
||||
data: cause.data,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
if (cause instanceof HttpError) {
|
||||
const redactedCause = redactError(cause);
|
||||
return {
|
||||
@@ -87,25 +97,35 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusCode(cause: Error): number {
|
||||
switch (cause.message) {
|
||||
function getStatusCode(cause: Error | ErrorWithCode): number {
|
||||
const errorCode = cause instanceof ErrorWithCode ? cause.code : cause.message;
|
||||
|
||||
switch (errorCode) {
|
||||
// 400 Bad Request
|
||||
case ErrorCode.RequestBodyWithouEnd:
|
||||
case ErrorCode.MissingPaymentCredential:
|
||||
case ErrorCode.MissingPaymentAppId:
|
||||
case ErrorCode.AvailabilityNotFoundInSchedule:
|
||||
return 400;
|
||||
case ErrorCode.CancelledBookingsCannotBeRescheduled:
|
||||
case ErrorCode.BookingTimeOutOfBounds:
|
||||
case ErrorCode.BookingNotAllowedByRestrictionSchedule:
|
||||
case ErrorCode.BookerLimitExceeded:
|
||||
case ErrorCode.BookerLimitExceededReschedule:
|
||||
return 400;
|
||||
// 409 Conflict
|
||||
case ErrorCode.NoAvailableUsersFound:
|
||||
case ErrorCode.HostsUnavailableForBooking:
|
||||
case ErrorCode.PaymentCreationFailure:
|
||||
case ErrorCode.ChargeCardFailure:
|
||||
case ErrorCode.AlreadySignedUpForBooking:
|
||||
case ErrorCode.BookingSeatsFull:
|
||||
case ErrorCode.NotEnoughAvailableSeats:
|
||||
case ErrorCode.BookingConflict:
|
||||
case ErrorCode.PaymentCreationFailure:
|
||||
case ErrorCode.ChargeCardFailure:
|
||||
return 409;
|
||||
// 404 Not Found
|
||||
case ErrorCode.EventTypeNotFound:
|
||||
case ErrorCode.BookingNotFound:
|
||||
case ErrorCode.RestrictionScheduleNotFound:
|
||||
return 404;
|
||||
case ErrorCode.UnableToSubscribeToThePlatform:
|
||||
case ErrorCode.UpdatingOauthClientError:
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export type CreateOrganizationOnboardingInput = {
|
||||
teams?: { id: number; name: string; isBeingMigrated: boolean; slug: string | null }[];
|
||||
error?: string | null;
|
||||
isDomainConfigured?: boolean;
|
||||
isComplete?: boolean;
|
||||
};
|
||||
|
||||
export class OrganizationOnboardingRepository {
|
||||
@@ -95,13 +96,16 @@ export class OrganizationOnboardingRepository {
|
||||
|
||||
static async update(id: OnboardingId, data: Partial<CreateOrganizationOnboardingInput>) {
|
||||
logger.debug("Updating organization onboarding", safeStringify({ id, data }));
|
||||
// We don't want to update the createdById field in update
|
||||
const { organizationId, createdById: _, ...rest } = data;
|
||||
|
||||
return await prisma.organizationOnboarding.update({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
data: {
|
||||
...data,
|
||||
...rest,
|
||||
...(organizationId ? { organization: { connect: { id: organizationId } } } : {}),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user