Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": [],
|
"ignore": ["@calcom/platform-libraries"],
|
||||||
"privatePackages": {
|
"privatePackages": {
|
||||||
"version": false,
|
"version": false,
|
||||||
"tag": 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/server/getLuckyUser.ts @calcom/Foundation
|
||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/**/* @calcom/Platform @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/confirm.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
contents: write # to create release by changesets/action below
|
contents: write # to create release by changesets/action below
|
||||||
pull-requests: write # to create pull request by changesets/action below
|
pull-requests: write # to create pull request by changesets/action below
|
||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
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",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node ./index.js"
|
"dev": "node ./index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "Platform API for Cal.com",
|
"description": "Platform API for Cal.com",
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn dev:build && nest build",
|
"build": "yarn dev:build && nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@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-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
|
|||||||
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
|
|||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer
|
consumer
|
||||||
.apply(RawBodyMiddleware)
|
.apply(RawBodyMiddleware)
|
||||||
.forRoutes({
|
.forRoutes(
|
||||||
path: "/api/v2/billing/webhook",
|
{
|
||||||
method: RequestMethod.POST,
|
path: "/api/v2/billing/webhook",
|
||||||
})
|
method: RequestMethod.POST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/v2/billing/webhook",
|
||||||
|
method: RequestMethod.POST,
|
||||||
|
}
|
||||||
|
)
|
||||||
.apply(JsonBodyMiddleware)
|
.apply(JsonBodyMiddleware)
|
||||||
.forRoutes("*")
|
.forRoutes("*")
|
||||||
.apply(RequestIdMiddleware)
|
.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 IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
const tracker = await this.getTracker(request);
|
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) {
|
if (throttleOptions) {
|
||||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||||
}
|
}
|
||||||
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
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)}
|
${JSON.stringify(rateLimit, null, 2)}
|
||||||
`);
|
`);*/
|
||||||
|
|
||||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||||
if (cachedRateLimits) {
|
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}
|
${cachedRateLimits}
|
||||||
`);
|
`);*/
|
||||||
return rateLimitsSchema.parse(JSON.parse(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 },
|
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) {
|
if (!rateLimits || rateLimits.length === 0) {
|
||||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||||
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||||
${JSON.stringify(rateLimits, null, 2)}`);
|
${JSON.stringify(rateLimits, null, 2)}`);*/
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
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);
|
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}`
|
`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:
|
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||||
);
|
);*/
|
||||||
|
|
||||||
return { isBlocked };
|
return { isBlocked };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
);
|
);
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
Delete,
|
Delete,
|
||||||
|
BadRequestException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiExcludeController } from "@nestjs/swagger";
|
import { ApiExcludeController } from "@nestjs/swagger";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
@@ -116,35 +118,59 @@ export class BillingController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Headers("stripe-signature") stripeSignature: string
|
@Headers("stripe-signature") stripeSignature: string
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
const event = await this.billingService.stripeService
|
try {
|
||||||
.getStripe()
|
if (!stripeSignature) {
|
||||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
this.logger.warn("Missing stripe-signature header in webhook request");
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.type) {
|
if (!this.stripeWhSecret) {
|
||||||
case "checkout.session.completed":
|
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
|
||||||
await this.billingService.handleStripeCheckoutEvents(event);
|
return {
|
||||||
break;
|
status: "success",
|
||||||
case "customer.subscription.updated":
|
};
|
||||||
await this.billingService.handleStripePaymentPastDue(event);
|
}
|
||||||
break;
|
|
||||||
case "customer.subscription.deleted":
|
const event = await this.billingService.stripeService
|
||||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
.getStripe()
|
||||||
break;
|
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||||
case "invoice.created":
|
|
||||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
switch (event.type) {
|
||||||
break;
|
case "checkout.session.completed":
|
||||||
case "invoice.payment_failed":
|
await this.billingService.handleStripeCheckoutEvents(event);
|
||||||
await this.billingService.handleStripePaymentFailed(event);
|
break;
|
||||||
break;
|
case "customer.subscription.updated":
|
||||||
case "invoice.payment_succeeded":
|
await this.billingService.handleStripePaymentPastDue(event);
|
||||||
await this.billingService.handleStripePaymentSuccess(event);
|
break;
|
||||||
break;
|
case "customer.subscription.deleted":
|
||||||
default:
|
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||||
break;
|
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) {
|
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||||
|
if (userEmail.includes(`+${oAuthClientId}@`)) {
|
||||||
|
return userEmail;
|
||||||
|
}
|
||||||
const [username, emailDomain] = userEmail.split("@");
|
const [username, emailDomain] = userEmail.split("@");
|
||||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
|
|||||||
import { TooltipExample } from "./avatar.tooltip"
|
import { TooltipExample } from "./avatar.tooltip"
|
||||||
import { LinkExample } from "./avatar.link"
|
import { LinkExample } from "./avatar.link"
|
||||||
import { IndicatorExample } from "./avatar.indicator"
|
import { IndicatorExample } from "./avatar.indicator"
|
||||||
|
import { ShapeExample } from "./avatar.shape"
|
||||||
|
|
||||||
## Size Variations
|
## Size Variations
|
||||||
|
|
||||||
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
|
|||||||
|
|
||||||
<SizesExample/>
|
<SizesExample/>
|
||||||
|
|
||||||
|
## Shape Variations
|
||||||
|
|
||||||
|
Avatars can be displayed in either circular or square shapes.
|
||||||
|
|
||||||
|
<ShapeExample/>
|
||||||
|
|
||||||
## With and Without Image
|
## With and Without Image
|
||||||
|
|
||||||
Avatars can display either an image or a fallback placeholder.
|
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.
|
Avatars can include status indicators or badges.
|
||||||
|
|
||||||
<IndicatorExample/>
|
<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";
|
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
||||||
|
|
||||||
// Mock GoogleCalendarService
|
// Mock GoogleCalendarService
|
||||||
const fetchPrimaryCalendarMock = vi.fn();
|
const getPrimaryCalendarMock = vi.fn();
|
||||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||||
return {
|
return {
|
||||||
default: vi.fn().mockImplementation(() => ({
|
default: vi.fn().mockImplementation(() => ({
|
||||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
getPrimaryCalendar: getPrimaryCalendarMock,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
prismock.user.deleteMany();
|
prismock.user.deleteMany();
|
||||||
prismock.team.deleteMany();
|
prismock.team.deleteMany();
|
||||||
prismock.workspacePlatform.deleteMany();
|
prismock.workspacePlatform.deleteMany();
|
||||||
fetchPrimaryCalendarMock.mockReset();
|
getPrimaryCalendarMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
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",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
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();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
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();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
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();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
delegationCredentialId: delegationCredential.id,
|
delegationCredentialId: delegationCredential.id,
|
||||||
credentialId: 1,
|
credentialId: 1,
|
||||||
});
|
});
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(0);
|
expect(result.success).toBe(0);
|
||||||
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
error: "some error" as unknown as null,
|
error: "some error" as unknown as null,
|
||||||
credentialId: regularCredential.id,
|
credentialId: regularCredential.id,
|
||||||
});
|
});
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
getPrimaryCalendarMock.mockRejectedValue(
|
||||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
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();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(0);
|
expect(result.success).toBe(0);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
|
|||||||
const userEmail = delegationUserCredential.user.email;
|
const userEmail = delegationUserCredential.user.email;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
|
||||||
primaryCalendarId = primaryCalendar?.id;
|
primaryCalendarId = primaryCalendar?.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.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 { ImageResponse } from "next/og";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import type { SatoriOptions } from "satori";
|
import type { SatoriOptions } from "satori";
|
||||||
import { z } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
|
|
||||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
|
|||||||
const { searchParams } = req.nextUrl;
|
const { searchParams } = req.nextUrl;
|
||||||
const imageType = searchParams.get("type");
|
const imageType = searchParams.get("type");
|
||||||
|
|
||||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
try {
|
||||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
fetch(new URL("/fonts/Inter-Medium.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,
|
const ogConfig = {
|
||||||
height: 630,
|
width: 1200,
|
||||||
fonts: [
|
height: 630,
|
||||||
{ name: "inter", data: interFontData, weight: 400 },
|
fonts: [
|
||||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
{ name: "inter", data: interFontData, weight: 400 },
|
||||||
{ name: "cal", data: calFontData, weight: 400 },
|
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||||
{ name: "cal", data: calFontData, weight: 600 },
|
{ name: "cal", data: calFontData, weight: 400 },
|
||||||
] as SatoriOptions["fonts"],
|
{ name: "cal", data: calFontData, weight: 600 },
|
||||||
};
|
] as SatoriOptions["fonts"],
|
||||||
|
};
|
||||||
|
|
||||||
switch (imageType) {
|
switch (imageType) {
|
||||||
case "meeting": {
|
case "meeting": {
|
||||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
try {
|
||||||
names: searchParams.getAll("names"),
|
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||||
usernames: searchParams.getAll("usernames"),
|
names: searchParams.getAll("names"),
|
||||||
title: searchParams.get("title"),
|
usernames: searchParams.getAll("usernames"),
|
||||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
title: searchParams.get("title"),
|
||||||
meetingImage: searchParams.get("meetingImage"),
|
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||||
imageType,
|
meetingImage: searchParams.get("meetingImage"),
|
||||||
});
|
imageType,
|
||||||
|
});
|
||||||
|
|
||||||
const img = new ImageResponse(
|
const img = new ImageResponse(
|
||||||
(
|
(
|
||||||
<Meeting
|
<Meeting
|
||||||
title={title}
|
title={title}
|
||||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ogConfig
|
ogConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(img.body, {
|
return new Response(img.body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
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": {
|
} catch (error) {
|
||||||
const { name, description, slug } = appSchema.parse({
|
return new Response("Internal server error", { status: 500 });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bookerUrl = useBookerUrl();
|
const bookerUrl = useBookerUrl();
|
||||||
|
const session = useSession();
|
||||||
const teamMemberIdsMatchingAttributeLogic =
|
const teamMemberIdsMatchingAttributeLogic =
|
||||||
teamMembersMatchingAttributeLogic?.data
|
teamMembersMatchingAttributeLogic?.data
|
||||||
?.map((member) => member.id)
|
?.map((member) => member.id)
|
||||||
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
|
|||||||
booking,
|
booking,
|
||||||
currentResponse,
|
currentResponse,
|
||||||
searchParams: new URLSearchParams({
|
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,
|
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||||
createBookingMutation.mutate({
|
createBookingMutation.mutate({
|
||||||
rescheduleUid: booking.uid,
|
rescheduleUid: booking.uid,
|
||||||
|
rescheduledBy: session?.data?.user?.email ?? undefined,
|
||||||
// rescheduleReason,
|
// rescheduleReason,
|
||||||
reroutingFormResponses: reroutingFormResponses,
|
reroutingFormResponses: reroutingFormResponses,
|
||||||
...getTimeslotFields(),
|
...getTimeslotFields(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
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 { vi } from "vitest";
|
||||||
|
|
||||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||||
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
|
|||||||
|
|
||||||
vi.stubGlobal("open", mockOpen);
|
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", () => ({
|
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||||
return (
|
return (
|
||||||
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
|
|||||||
describe("New Routing tests", () => {
|
describe("New Routing tests", () => {
|
||||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<SessionProvider session={mockSession}>
|
||||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByText("verify_new_route"));
|
fireEvent.click(screen.getByText("verify_new_route"));
|
||||||
|
|
||||||
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
|
|||||||
describe("New tab rescheduling", () => {
|
describe("New tab rescheduling", () => {
|
||||||
test("new tab is closed when new booking is rerouted", async () => {
|
test("new tab is closed when new booking is rerouted", async () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<SessionProvider session={mockSession}>
|
||||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
clickVerifyNewRouteButton();
|
clickVerifyNewRouteButton();
|
||||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||||
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
|
|||||||
|
|
||||||
test("Rescheduling with same timeslot works", async () => {
|
test("Rescheduling with same timeslot works", async () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<SessionProvider session={mockSession}>
|
||||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
clickVerifyNewRouteButton();
|
clickVerifyNewRouteButton();
|
||||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||||
@@ -44,34 +45,6 @@ type RecurringInfo = {
|
|||||||
bookings: { [key: string]: Date[] };
|
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> = {
|
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||||
upcoming: "upcoming_bookings",
|
upcoming: "upcoming_bookings",
|
||||||
recurring: "recurring_bookings",
|
recurring: "recurring_bookings",
|
||||||
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const user = useMeQuery().data;
|
const user = useMeQuery().data;
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
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 eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||||
const teamIds = useFilterValue("teamId", 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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc";
|
import type { RouterOutputs } from "@calcom/trpc";
|
||||||
import { trpc } 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 { Alert } from "@calcom/ui/components/alert";
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import { TextField } from "@calcom/ui/components/form";
|
||||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
||||||
|
|
||||||
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
|
|||||||
return <AddNewTeamMembersForm />;
|
return <AddNewTeamMembersForm />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useCheckout = () => {
|
const useOrgCreation = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const session = useSession();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
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) => {
|
onSuccess: (data) => {
|
||||||
if (data.checkoutUrl) {
|
if (data.checkoutUrl) {
|
||||||
window.location.href = 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 {
|
return {
|
||||||
mutation,
|
mutation: mutationToUse,
|
||||||
mutate: mutation.mutate,
|
mutate: mutationToUse.mutate,
|
||||||
isPending: mutation.isPending,
|
isPending: mutationToUse.isPending,
|
||||||
errorMessage: serverErrorMessage,
|
errorMessage: serverErrorMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddNewTeamMembersForm = () => {
|
export const AddNewTeamMembersForm = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { useOnboardingStore } = useOnboarding();
|
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||||
const {
|
const {
|
||||||
addInvitedMember,
|
addInvitedMember,
|
||||||
removeInvitedMember,
|
removeInvitedMember,
|
||||||
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
bio,
|
bio,
|
||||||
onboardingId,
|
onboardingId,
|
||||||
} = useOnboardingStore();
|
} = useOnboardingStore();
|
||||||
const checkout = useCheckout();
|
const orgCreation = useOrgCreation();
|
||||||
|
|
||||||
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
||||||
|
|
||||||
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{checkout.errorMessage && (
|
{orgCreation.errorMessage && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Alert severity="error" message={checkout.errorMessage} />
|
<Alert severity="error" message={orgCreation.errorMessage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
checkout.mutation.mutate({
|
orgCreation.mutation.mutate({
|
||||||
logo,
|
logo,
|
||||||
bio,
|
bio,
|
||||||
teams,
|
teams,
|
||||||
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
onboardingId,
|
onboardingId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
loading={checkout.isPending}>
|
loading={orgCreation.isPending}>
|
||||||
{t("checkout")}
|
{isBillingEnabled ? t("checkout") : t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -691,25 +691,23 @@ export default function Signup({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||||
{FEATURES.map((feature) => (
|
{FEATURES.map((feature, index) => (
|
||||||
<>
|
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
<div className="text-emphasis items-center">
|
||||||
<div className="text-emphasis items-center">
|
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||||
<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 className="text-subtle text-sm">
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
feature.description,
|
||||||
|
feature.i18nOptions && {
|
||||||
|
...feature.i18nOptions,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.4.6",
|
"version": "5.4.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
|
|||||||
@@ -395,7 +395,7 @@ export const createUsersFixture = (
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||||
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
|
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||||
queryValue: {
|
queryValue: {
|
||||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
|
|||||||
await test.step("Basic info", async () => {
|
await test.step("Basic info", async () => {
|
||||||
// Check required fields
|
// Check required fields
|
||||||
await page.locator("button[type=submit]").click();
|
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
|
// Happy path
|
||||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||||
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
|
|||||||
await test.step("Basic info", async () => {
|
await test.step("Basic info", async () => {
|
||||||
// Check required fields
|
// Check required fields
|
||||||
await page.locator("button[type=submit]").click();
|
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
|
// Happy path
|
||||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
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_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_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_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",
|
"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_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",
|
"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",
|
"yes_reassign": "Yes, reassign",
|
||||||
"filter_by_attributes": "Filter by attributes",
|
"filter_by_attributes": "Filter by attributes",
|
||||||
"reassign_reason": "Reason for reassigning",
|
"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",
|
"reassigned_by": "Reassigned by",
|
||||||
"row": "row",
|
"row": "row",
|
||||||
"rows": "rows",
|
"rows": "rows",
|
||||||
@@ -2937,7 +2938,7 @@
|
|||||||
"routing_form_insights_assignment_reason": "Assignment Reason",
|
"routing_form_insights_assignment_reason": "Assignment Reason",
|
||||||
"access_denied": "Access Denied",
|
"access_denied": "Access Denied",
|
||||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
"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_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_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||||
"salesforce_option": "Salesforce Option",
|
"salesforce_option": "Salesforce Option",
|
||||||
@@ -3000,7 +3001,7 @@
|
|||||||
"shortfall": "Shortfall",
|
"shortfall": "Shortfall",
|
||||||
"routing_form": "Routing Form",
|
"routing_form": "Routing Form",
|
||||||
"go_back_and_save": "Go back and save",
|
"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": "Leave without saving",
|
||||||
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
||||||
"something_unexpected_occurred": "Something unexpected occurred",
|
"something_unexpected_occurred": "Something unexpected occurred",
|
||||||
@@ -3035,9 +3036,9 @@
|
|||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"show":"Show",
|
"show": "Show",
|
||||||
"utm_params":"UTM parameters",
|
"utm_params": "UTM parameters",
|
||||||
"no_utm_params":"No UTM parameters available.",
|
"no_utm_params": "No UTM parameters available.",
|
||||||
"asc": "Asc",
|
"asc": "Asc",
|
||||||
"desc": "Desc",
|
"desc": "Desc",
|
||||||
"verify_email_change": "Verify email change",
|
"verify_email_change": "Verify email change",
|
||||||
@@ -3107,7 +3108,7 @@
|
|||||||
"user_has_no_team_yet": "You don't have a team yet",
|
"user_has_no_team_yet": "You don't have a team yet",
|
||||||
"no_team_members": "You don't have team members 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.",
|
"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.",
|
"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.",
|
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||||
"segment": "Segment",
|
"segment": "Segment",
|
||||||
@@ -3185,8 +3186,8 @@
|
|||||||
"resend": "Resend",
|
"resend": "Resend",
|
||||||
"verification_email_sent": "Verification email sent",
|
"verification_email_sent": "Verification email sent",
|
||||||
"select_all_members": "Select all members",
|
"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.",
|
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
|
||||||
"form_settings":"Form Settings",
|
"form_settings": "Form Settings",
|
||||||
"remove_whitelist_status": "Remove whitelist status",
|
"remove_whitelist_status": "Remove whitelist status",
|
||||||
"whitelist_user_workflows": "Whitelist user workflows",
|
"whitelist_user_workflows": "Whitelist user workflows",
|
||||||
"user_workflows_whitelisted": "User workflows whitelisted",
|
"user_workflows_whitelisted": "User workflows whitelisted",
|
||||||
|
|||||||
+2
-1
@@ -80,8 +80,9 @@
|
|||||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||||
|
"changesets-add": "yarn changeset add",
|
||||||
"changesets-version": "yarn changeset version",
|
"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": {
|
"devDependencies": {
|
||||||
"@changesets/changelog-github": "^0.5.1",
|
"@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.
|
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
|
||||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
* What it does
|
||||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
* - 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
|
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||||
*/
|
*/
|
||||||
@@ -89,7 +94,13 @@ export class OAuthManager {
|
|||||||
currentTokenObject,
|
currentTokenObject,
|
||||||
fetchNewTokenObject,
|
fetchNewTokenObject,
|
||||||
updateTokenObject,
|
updateTokenObject,
|
||||||
|
/**
|
||||||
|
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||||
|
*/
|
||||||
isTokenObjectUnusable,
|
isTokenObjectUnusable,
|
||||||
|
/**
|
||||||
|
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||||
|
*/
|
||||||
isAccessTokenUnusable,
|
isAccessTokenUnusable,
|
||||||
invalidateTokenObject,
|
invalidateTokenObject,
|
||||||
expireAccessToken,
|
expireAccessToken,
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ export interface DailyVideoCallData {
|
|||||||
url: string;
|
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 =
|
const isS3StorageEnabled =
|
||||||
process.env.CAL_VIDEO_BUCKET_NAME &&
|
process.env.CAL_VIDEO_BUCKET_NAME &&
|
||||||
process.env.CAL_VIDEO_BUCKET_REGION &&
|
process.env.CAL_VIDEO_BUCKET_REGION &&
|
||||||
@@ -203,11 +232,15 @@ export const setEnableRecordingUIAndUserIdForOrganizer = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
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) {
|
if (!event.uid) {
|
||||||
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
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 dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
||||||
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
||||||
properties: {
|
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
|
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||||
// Adds 14 days from the end of the booking as the expiration date
|
// 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;
|
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
|
||||||
@@ -248,6 +281,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
return {
|
return {
|
||||||
privacy: "public",
|
privacy: "public",
|
||||||
properties: {
|
properties: {
|
||||||
|
...(region ? { geo: region } : {}),
|
||||||
enable_prejoin_ui: true,
|
enable_prejoin_ui: true,
|
||||||
enable_knocking: true,
|
enable_knocking: true,
|
||||||
enable_screenshare: 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
|
// added a 1 hour buffer for room expiration
|
||||||
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
||||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||||
@@ -296,6 +330,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
const body = {
|
const body = {
|
||||||
privacy: "public",
|
privacy: "public",
|
||||||
properties: {
|
properties: {
|
||||||
|
...(region ? { geo: region } : {}),
|
||||||
enable_prejoin_ui: true,
|
enable_prejoin_ui: true,
|
||||||
enable_knocking: true,
|
enable_knocking: true,
|
||||||
enable_screenshare: true,
|
enable_screenshare: true,
|
||||||
@@ -347,20 +382,22 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
url: dailyEvent.url,
|
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 {
|
return {
|
||||||
/** Daily doesn't need to return busy times, so we return empty */
|
/** Daily doesn't need to return busy times, so we return empty */
|
||||||
getAvailability: () => {
|
getAvailability: () => {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
},
|
},
|
||||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
||||||
createOrUpdateMeeting("/rooms", event),
|
createOrUpdateMeeting("/rooms", event, region),
|
||||||
deleteMeeting: async (uid: string): Promise<void> => {
|
deleteMeeting: async (uid: string): Promise<void> => {
|
||||||
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
||||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event, region),
|
||||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
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");
|
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> => {
|
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
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 { OAuth2Client } from "googleapis-common";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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 GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||||
import {
|
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
GOOGLE_CALENDAR_SCOPES,
|
|
||||||
SCOPE_USERINFO_PROFILE,
|
|
||||||
WEBAPP_URL,
|
|
||||||
WEBAPP_URL_FOR_OAUTH,
|
|
||||||
} from "@calcom/lib/constants";
|
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
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
|
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
// TODO: Use the avatarUrl when setting the profile picture
|
||||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||||
}
|
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||||
|
// }
|
||||||
|
|
||||||
const selectedCalendarWhereUnique = {
|
const selectedCalendarWhereUnique = {
|
||||||
userId: req.session.user.id,
|
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[]> {
|
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||||
this.log.debug("Listing calendars");
|
this.log.debug("Listing calendars");
|
||||||
const calendar = await this.authedCalendar();
|
const calendar = await this.authedCalendar();
|
||||||
@@ -1009,39 +1000,16 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrimaryCalendar(
|
async getPrimaryCalendar(_calendar?: calendar_v3.Calendar): Promise<calendar_v3.Schema$Calendar | null> {
|
||||||
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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
do {
|
const calendar = _calendar ?? (await this.authedCalendar());
|
||||||
const response: any = await calendar.calendarList.list({
|
const response = await calendar.calendars.get({
|
||||||
fields: `items(${fields.join(",")}),nextPageToken`,
|
calendarId: "primary",
|
||||||
pageToken,
|
});
|
||||||
maxResults: 250, // 250 is max
|
return response.data;
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
} catch (error) {
|
} 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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { vi } from "vitest";
|
|||||||
const setCredentialsMock = vi.fn();
|
const setCredentialsMock = vi.fn();
|
||||||
const freebusyQueryMock = vi.fn();
|
const freebusyQueryMock = vi.fn();
|
||||||
const calendarListMock = vi.fn();
|
const calendarListMock = vi.fn();
|
||||||
|
const calendarsGetMock = vi.fn();
|
||||||
const calendarMockImplementation = {
|
const calendarMockImplementation = {
|
||||||
channels: {
|
channels: {
|
||||||
stop: vi.fn().mockResolvedValue(undefined),
|
stop: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -10,6 +11,9 @@ const calendarMockImplementation = {
|
|||||||
calendarList: {
|
calendarList: {
|
||||||
list: calendarListMock,
|
list: calendarListMock,
|
||||||
},
|
},
|
||||||
|
calendars: {
|
||||||
|
get: calendarsGetMock,
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
watch: vi.fn().mockResolvedValue({
|
watch: vi.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -885,3 +885,27 @@ describe("getAvailability", () => {
|
|||||||
expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]);
|
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 () => {
|
it("should record form response when not in preview and not queued", async () => {
|
||||||
const dbFormResponse = {
|
const dbFormResponse = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
|
||||||
formId: mockForm.id,
|
formId: mockForm.id,
|
||||||
response: mockResponse,
|
response: mockResponse,
|
||||||
chosenRouteId: null,
|
chosenRouteId: null,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
MultiOptionInput,
|
MultiOptionInput,
|
||||||
} from "@calcom/ui/components/form";
|
} from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ function Field({
|
|||||||
moveUp,
|
moveUp,
|
||||||
moveDown,
|
moveDown,
|
||||||
appUrl,
|
appUrl,
|
||||||
|
disableTypeChange,
|
||||||
}: {
|
}: {
|
||||||
fieldIndex: number;
|
fieldIndex: number;
|
||||||
hookForm: HookForm;
|
hookForm: HookForm;
|
||||||
@@ -53,6 +55,7 @@ function Field({
|
|||||||
fn: () => void;
|
fn: () => void;
|
||||||
};
|
};
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
|
disableTypeChange: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -123,32 +126,53 @@ function Field({
|
|||||||
defaultValue={routerField?.type}
|
defaultValue={routerField?.type}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => {
|
||||||
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||||
return (
|
if (disableTypeChange) {
|
||||||
<SelectField
|
return (
|
||||||
maxMenuHeight={200}
|
<div className="data-testid-field-type">
|
||||||
styles={{
|
<Label htmlFor="field-type-button">{t("type")}</Label>
|
||||||
singleValue: (baseStyles) => ({
|
<Tooltip content={t("field_type_change_suggestion")}>
|
||||||
...baseStyles,
|
<Button
|
||||||
fontSize: "14px",
|
type="button"
|
||||||
}),
|
disabled
|
||||||
option: (baseStyles) => ({
|
color="secondary"
|
||||||
...baseStyles,
|
className={classNames(
|
||||||
fontSize: "14px",
|
"h-8 w-full justify-between text-left text-sm",
|
||||||
}),
|
!!router && "bg-subtle cursor-not-allowed"
|
||||||
}}
|
)}>
|
||||||
label="Type"
|
<span className="text-default">{defaultValue?.label || "Select field type"}</span>
|
||||||
isDisabled={!!router}
|
<Icon name="chevron-down" className="text-default h-4 w-4" />
|
||||||
containerClassName="data-testid-field-type"
|
</Button>
|
||||||
options={FieldTypes}
|
</Tooltip>
|
||||||
onChange={(option) => {
|
</div>
|
||||||
if (!option) {
|
);
|
||||||
return;
|
} else {
|
||||||
}
|
return (
|
||||||
onChange(option.value);
|
<SelectField
|
||||||
}}
|
maxMenuHeight={200}
|
||||||
defaultValue={defaultValue}
|
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>
|
</div>
|
||||||
@@ -213,6 +237,7 @@ const FormEdit = ({
|
|||||||
} = useFieldArray({
|
} = useFieldArray({
|
||||||
control: hookForm.control,
|
control: hookForm.control,
|
||||||
name: fieldsNamespace,
|
name: fieldsNamespace,
|
||||||
|
keyName: "_id",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
@@ -236,12 +261,15 @@ const FormEdit = ({
|
|||||||
<div className="w-full py-4 lg:py-8">
|
<div className="w-full py-4 lg:py-8">
|
||||||
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
||||||
{hookFormFields.map((field, key) => {
|
{hookFormFields.map((field, key) => {
|
||||||
|
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
|
||||||
|
const hasFormResponses = (form._count?.responses ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
appUrl={appUrl}
|
appUrl={appUrl}
|
||||||
fieldIndex={key}
|
fieldIndex={key}
|
||||||
hookForm={hookForm}
|
hookForm={hookForm}
|
||||||
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
||||||
|
disableTypeChange={existingField && hasFormResponses}
|
||||||
deleteField={{
|
deleteField={{
|
||||||
check: () => hookFormFields.length > 1,
|
check: () => hookFormFields.length > 1,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||||
@@ -262,7 +263,7 @@ test.describe("Routing Forms", () => {
|
|||||||
option: 2,
|
option: 2,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||||
await saveCurrentForm(page);
|
await saveCurrentForm(page);
|
||||||
|
|
||||||
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||||
@@ -290,7 +291,7 @@ test.describe("Routing Forms", () => {
|
|||||||
|
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.hostname.includes("cal.com");
|
return url.pathname.endsWith("/pro");
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = new URL(page.url());
|
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.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
|
||||||
await page.waitForURL((url) => {
|
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`);
|
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();
|
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
|
||||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||||
expect(routingType).toBe("External Redirect");
|
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"]');
|
await page.click('[data-testid="close-results-button"]');
|
||||||
|
|
||||||
// Multiselect(Legacy)
|
// Multiselect(Legacy)
|
||||||
@@ -925,7 +926,7 @@ test.describe("Routing Forms", () => {
|
|||||||
option: 2,
|
option: 2,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||||
await saveCurrentForm(page);
|
await saveCurrentForm(page);
|
||||||
return {
|
return {
|
||||||
formId,
|
formId,
|
||||||
@@ -978,7 +979,7 @@ test.describe("Routing Forms", () => {
|
|||||||
await page.fill('[data-testid="form-field-short-text"]', "test");
|
await page.fill('[data-testid="form-field-short-text"]', "test");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
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();
|
await fillAllOptionsBasedFields();
|
||||||
page.click('button[type="submit"]');
|
page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
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) {
|
}: GetAttributesForTeamHandlerOptions) {
|
||||||
const { teamId } = input;
|
const { teamId } = input;
|
||||||
const { user } = ctx;
|
const { user } = ctx;
|
||||||
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
|
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
|
||||||
|
|
||||||
if (!isMemberOfTeam) {
|
if (!isMemberOfTeam) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { stringify } from "querystring";
|
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 { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
const params = {
|
const params = {
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
client_id,
|
client_id,
|
||||||
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`,
|
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
|
||||||
state,
|
state,
|
||||||
};
|
};
|
||||||
const query = stringify(params);
|
const query = stringify(params);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
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 { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
const { code } = req.query;
|
const { code } = req.query;
|
||||||
const { client_id, client_secret } = await getZoomAppKeys();
|
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 authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||||
const result = await fetch(
|
const result = await fetch(
|
||||||
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||||
} from "@calcom/lib/constants";
|
} from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
@@ -65,12 +66,12 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
|
|||||||
export const zoomUserSettingsSchema = z.object({
|
export const zoomUserSettingsSchema = z.object({
|
||||||
recording: z
|
recording: z
|
||||||
.object({
|
.object({
|
||||||
auto_recording: z.string(),
|
auto_recording: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
schedule_meeting: z
|
schedule_meeting: z
|
||||||
.object({
|
.object({
|
||||||
default_password_for_scheduled_meetings: z.string(),
|
default_password_for_scheduled_meetings: z.string().nullish(),
|
||||||
})
|
})
|
||||||
.nullish(),
|
.nullish(),
|
||||||
});
|
});
|
||||||
@@ -185,6 +186,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 fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
|
||||||
const auth = new OAuthManager({
|
const auth = new OAuthManager({
|
||||||
credentialSyncVariables: {
|
credentialSyncVariables: {
|
||||||
@@ -220,9 +247,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
},
|
},
|
||||||
isTokenObjectUnusable: async function (response) {
|
isTokenObjectUnusable: async function (response) {
|
||||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
||||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
if (!response.ok) {
|
||||||
const responseBody = await response.json();
|
let responseBody;
|
||||||
|
const responseToUseInCaseOfError = response.clone();
|
||||||
|
try {
|
||||||
|
responseBody = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
return await handleZoomResponseJsonParseError({
|
||||||
|
error: e,
|
||||||
|
clonedResponse: responseToUseInCaseOfError,
|
||||||
|
});
|
||||||
|
}
|
||||||
myLog.debug(safeStringify({ responseBody }));
|
myLog.debug(safeStringify({ responseBody }));
|
||||||
|
|
||||||
if (responseBody.error === "invalid_grant") {
|
if (responseBody.error === "invalid_grant") {
|
||||||
@@ -233,11 +269,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
},
|
},
|
||||||
isAccessTokenUnusable: async function (response) {
|
isAccessTokenUnusable: async function (response) {
|
||||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
||||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
if (!response.ok) {
|
||||||
const responseBody = await response.json();
|
let responseBody;
|
||||||
|
const responseToUseInCaseOfError = response.clone();
|
||||||
|
try {
|
||||||
|
responseBody = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
return await handleZoomResponseJsonParseError({
|
||||||
|
error: e,
|
||||||
|
clonedResponse: responseToUseInCaseOfError,
|
||||||
|
});
|
||||||
|
}
|
||||||
myLog.debug(safeStringify({ responseBody }));
|
myLog.debug(safeStringify({ responseBody }));
|
||||||
|
// 124 is the error code for invalid access token from Zoom API
|
||||||
if (responseBody.code === 124) {
|
if (responseBody.code === 124) {
|
||||||
return { reason: responseBody.message ?? "" };
|
return { reason: responseBody.message ?? "" };
|
||||||
}
|
}
|
||||||
@@ -284,7 +329,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
log.error("Failed to get availability", safeStringify(err));
|
||||||
/* Prevents booking failure when Zoom Token is expired */
|
/* Prevents booking failure when Zoom Token is expired */
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -311,8 +356,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
}
|
}
|
||||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
log.error(
|
||||||
log.error("Zoom meeting creation failed", safeStringify({ error: err, event }));
|
"Zoom meeting creation failed",
|
||||||
|
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
|
||||||
|
);
|
||||||
/* Prevents meeting creation failure when Zoom Token is expired */
|
/* Prevents meeting creation failure when Zoom Token is expired */
|
||||||
throw new Error("Unexpected error");
|
throw new Error("Unexpected error");
|
||||||
}
|
}
|
||||||
@@ -347,7 +394,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
url: result.join_url,
|
url: result.join_url,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} 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"));
|
return Promise.reject(new Error("Failed to update meeting"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = {
|
|||||||
params?: Record<string, string>;
|
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({
|
function fakeCurrentDocumentUrl({
|
||||||
origin = "https://example.com",
|
origin = "https://example.com",
|
||||||
path = "",
|
path = "",
|
||||||
|
|||||||
@@ -594,6 +594,9 @@ function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true";
|
||||||
|
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
|
||||||
|
|
||||||
window.addEventListener("message", (e) => {
|
window.addEventListener("message", (e) => {
|
||||||
const data: Message = e.data;
|
const data: Message = e.data;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -631,7 +634,7 @@ function main() {
|
|||||||
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
||||||
initializeAndSetupEmbed();
|
initializeAndSetupEmbed();
|
||||||
} else {
|
} 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
|
* 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
|
* url has the config as params
|
||||||
*/
|
*/
|
||||||
async function connectPreloadedEmbed({
|
async function connectPreloadedEmbed({
|
||||||
|
|||||||
@@ -1215,26 +1215,26 @@ describe("Cal", () => {
|
|||||||
expect(result).toBe("noAction");
|
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({
|
const result = calInstance.getNextActionForModal({
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
stateData: {
|
stateData: {
|
||||||
...baseArgs.stateData,
|
...baseArgs.stateData,
|
||||||
prerenderOptions: {
|
prerenderOptions: {
|
||||||
reuseFully: true,
|
backgroundSlotsFetch: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toBe("connect-no-slots-fetch");
|
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({
|
const result = calInstance.getNextActionForModal({
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
stateData: {
|
stateData: {
|
||||||
...baseArgs.stateData,
|
...baseArgs.stateData,
|
||||||
prerenderOptions: {
|
prerenderOptions: {
|
||||||
reuseFully: true,
|
backgroundSlotsFetch: true,
|
||||||
},
|
},
|
||||||
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
||||||
embedRenderStartTime: Date.now(),
|
embedRenderStartTime: Date.now(),
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ type ModalPrerenderOptions = {
|
|||||||
slotsStaleTimeMs?: number;
|
slotsStaleTimeMs?: number;
|
||||||
iframeForceReloadThresholdMs?: number;
|
iframeForceReloadThresholdMs?: number;
|
||||||
reuseFully?: boolean;
|
reuseFully?: boolean;
|
||||||
|
// Default value is true right now but it would change soon
|
||||||
|
backgroundSlotsFetch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModalStateData = {
|
type ModalStateData = {
|
||||||
@@ -598,7 +600,7 @@ export class Cal {
|
|||||||
return "fullReload";
|
return "fullReload";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prerenderOptions?.reuseFully && !areSlotsStale) {
|
if (prerenderOptions?.backgroundSlotsFetch && !areSlotsStale) {
|
||||||
return "connect-no-slots-fetch";
|
return "connect-no-slots-fetch";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,6 +988,7 @@ class CalApi {
|
|||||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||||
|
|
||||||
if (__prerender && this.cal.modalBox) {
|
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
|
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||||
this.cal.modalBox.remove();
|
this.cal.modalBox.remove();
|
||||||
}
|
}
|
||||||
@@ -999,9 +1002,18 @@ class CalApi {
|
|||||||
const containerEl = document.body;
|
const containerEl = document.body;
|
||||||
this.cal.isPrerendering = !!__prerender;
|
this.cal.isPrerendering = !!__prerender;
|
||||||
if (__prerender) {
|
if (__prerender) {
|
||||||
// TODO: Make `reuseFully` a configurable param as well later.
|
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||||
// 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
|
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||||
this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath };
|
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
|
// Add prerender query param
|
||||||
config.prerender = "true";
|
config.prerender = "true";
|
||||||
|
|
||||||
@@ -1010,7 +1022,7 @@ class CalApi {
|
|||||||
config["cal.queueFormResponse"] = "true";
|
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
|
// 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
|
// 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";
|
config["cal.skipSlotsFetch"] = "true";
|
||||||
@@ -1045,9 +1057,11 @@ class CalApi {
|
|||||||
isConnectionInitiated,
|
isConnectionInitiated,
|
||||||
prerenderOptions: this.prerenderOptions ?? null,
|
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
|
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
|
||||||
if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) {
|
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
|
||||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||||
existingModalEl.setAttribute("state", "loading");
|
existingModalEl.setAttribute("state", "loading");
|
||||||
|
|
||||||
|
|||||||
@@ -164,4 +164,354 @@ describe("checkForConflicts", () => {
|
|||||||
expect(result).toBe(false);
|
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())) {
|
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const slotStartDate = time.utc().toDate();
|
const slotStart = time.valueOf();
|
||||||
const slotEndDate = time.add(eventLength, "minutes").utc().toDate();
|
const slotEnd = slotStart + eventLength * 60 * 1000;
|
||||||
// handle busy times.
|
|
||||||
return busy.some((busyTime) => {
|
const sortedBusyTimes = busy
|
||||||
const busyStartDate = dayjs.utc(busyTime.start).toDate();
|
.map((busyTime) => ({
|
||||||
const busyEndDate = dayjs.utc(busyTime.end).toDate();
|
start: dayjs.utc(busyTime.start).valueOf(),
|
||||||
// First check if there's any overlap at all
|
end: dayjs.utc(busyTime.end).valueOf(),
|
||||||
// If busy period ends before slot starts or starts after slot ends, there's no overlap
|
}))
|
||||||
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) {
|
.sort((a, b) => a.start - b.start);
|
||||||
return false;
|
|
||||||
|
for (const busyTime of sortedBusyTimes) {
|
||||||
|
if (busyTime.start >= slotEnd) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
// Now check all possible overlap scenarios:
|
if (busyTime.end <= slotStart) {
|
||||||
// 1. Slot start falls within busy period (inclusive start, exclusive end)
|
continue;
|
||||||
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
// 2. Slot end falls within busy period (exclusive start, inclusive end)
|
return true;
|
||||||
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) {
|
}
|
||||||
return true;
|
|
||||||
}
|
return false;
|
||||||
// 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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ import {
|
|||||||
getFirstDelegationConferencingCredentialAppLocation,
|
getFirstDelegationConferencingCredentialAppLocation,
|
||||||
} from "@calcom/lib/delegationCredential/server";
|
} from "@calcom/lib/delegationCredential/server";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
import type { ErrorWithCode } from "@calcom/lib/errors";
|
|
||||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||||
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
||||||
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||||
@@ -455,22 +454,12 @@ async function handler(
|
|||||||
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
|
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
|
||||||
|
|
||||||
if (!rawBookingData.rescheduleUid) {
|
if (!rawBookingData.rescheduleUid) {
|
||||||
try {
|
await checkActiveBookingsLimitForBooker({
|
||||||
await checkActiveBookingsLimitForBooker({
|
eventTypeId,
|
||||||
eventTypeId,
|
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
bookerEmail,
|
||||||
bookerEmail,
|
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||||
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
});
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const error = err as ErrorWithCode;
|
|
||||||
|
|
||||||
throw new HttpError({
|
|
||||||
statusCode: 400,
|
|
||||||
message: error.message,
|
|
||||||
data: error.data,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
||||||
@@ -1407,6 +1396,8 @@ async function handler(
|
|||||||
routingFormResponseId,
|
routingFormResponseId,
|
||||||
organizerId: organizerUser.id,
|
organizerId: organizerUser.id,
|
||||||
teamId,
|
teamId,
|
||||||
|
isRerouting: !!reroutingFormResponses,
|
||||||
|
reroutedByEmail: reqBody.rescheduledBy,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-8
@@ -23,7 +23,6 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { describe, expect } from "vitest";
|
import { describe, expect } from "vitest";
|
||||||
|
|
||||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||||
@@ -256,7 +255,7 @@ describe("handleNewRecurringBooking", () => {
|
|||||||
timeout
|
timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test.skip(
|
||||||
`should fail recurring booking if second slot is already booked`,
|
`should fail recurring booking if second slot is already booked`,
|
||||||
async ({}) => {
|
async ({}) => {
|
||||||
const booker = getBooker({
|
const booker = getBooker({
|
||||||
@@ -372,11 +371,12 @@ describe("handleNewRecurringBooking", () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(() =>
|
await expect(
|
||||||
handleNewRecurringBooking({
|
async () =>
|
||||||
bookingData: bookingDataArray,
|
await handleNewRecurringBooking({
|
||||||
userId: -1,
|
bookingData: bookingDataArray,
|
||||||
})
|
userId: -1,
|
||||||
|
})
|
||||||
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
||||||
},
|
},
|
||||||
timeout
|
timeout
|
||||||
@@ -384,7 +384,7 @@ describe("handleNewRecurringBooking", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Round robin event type:", () => {
|
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({
|
const booker = getBooker({
|
||||||
email: "booker@example.com",
|
email: "booker@example.com",
|
||||||
name: "Booker",
|
name: "Booker",
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ describe("handleNewBooking", () => {
|
|||||||
timeout
|
timeout
|
||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test.skip(
|
||||||
`should fail recurring booking if second slot is already booked`,
|
`should fail recurring booking if second slot is already booked`,
|
||||||
async ({}) => {
|
async ({}) => {
|
||||||
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
||||||
@@ -822,7 +822,7 @@ describe("handleNewBooking", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
describe("Round robin event type:", () => {
|
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"))
|
const handleRecurringEventBooking = (await import("@calcom/web/pages/api/book/recurring-event"))
|
||||||
.handleRecurringEventBooking;
|
.handleRecurringEventBooking;
|
||||||
const booker = getBooker({
|
const booker = getBooker({
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export const handleNewRecurringBooking = async (input: BookingHandlerInput): Pro
|
|||||||
});
|
});
|
||||||
const appsStatus: AppsStatus[] | undefined = undefined;
|
const appsStatus: AppsStatus[] | undefined = undefined;
|
||||||
|
|
||||||
const numSlotsToCheckForAvailability = 2;
|
const numSlotsToCheckForAvailability = 1;
|
||||||
|
|
||||||
let thirdPartyRecurringEventId = null;
|
let thirdPartyRecurringEventId = null;
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,24 @@ export function TimezoneSelectComponent({
|
|||||||
}}
|
}}
|
||||||
onInputChange={handleInputChange}
|
onInputChange={handleInputChange}
|
||||||
{...props}
|
{...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) => (
|
formatOptionLabel={(option) => (
|
||||||
<p className="truncate">{(option as ITimezoneOption).value.replace(/_/g, " ")}</p>
|
<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 { Controller, useForm } from "react-hook-form";
|
||||||
|
|
||||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
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 { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import slugify from "@calcom/lib/slugify";
|
import slugify from "@calcom/lib/slugify";
|
||||||
import { CreationSource } from "@calcom/prisma/enums";
|
import { CreationSource } from "@calcom/prisma/enums";
|
||||||
@@ -55,8 +55,9 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
const [serverErrorMessage, setServerErrorMessage] = useState<string | null>(null);
|
||||||
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
const isAdmin = session.data.user.role === UserPermissionRole.ADMIN;
|
||||||
const defaultOrgOwnerEmail = session.data.user.email ?? "";
|
// Let self-hosters create an organization with their own email. Hosted's Admin already has an organization for their email
|
||||||
const { useOnboardingStore } = useOnboarding({ step: "start" });
|
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 } =
|
const { slug, name, orgOwnerEmail, billingPeriod, pricePerSeat, seats, onboardingId, reset } =
|
||||||
useOnboardingStore();
|
useOnboardingStore();
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
billingPeriod: billingPeriod ?? BillingPeriod.MONTHLY,
|
billingPeriod: billingPeriod ?? BillingPeriod.MONTHLY,
|
||||||
slug: slug ?? (!isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined),
|
slug: slug ?? (!isAdmin ? deriveSlugFromEmail(defaultOrgOwnerEmail) : undefined),
|
||||||
orgOwnerEmail: orgOwnerEmail || (!isAdmin ? defaultOrgOwnerEmail : undefined),
|
orgOwnerEmail: orgOwnerEmail || defaultOrgOwnerEmail,
|
||||||
name: name ?? (!isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined),
|
name: name ?? (!isAdmin ? deriveOrgNameFromEmail(defaultOrgOwnerEmail) : undefined),
|
||||||
seats: seats ?? null,
|
seats: seats ?? null,
|
||||||
pricePerSeat: pricePerSeat ?? null,
|
pricePerSeat: pricePerSeat ?? null,
|
||||||
@@ -92,7 +93,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
|||||||
slug: data.slug,
|
slug: data.slug,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isAdmin) {
|
if (isAdmin && data.userId !== session.data.user.id) {
|
||||||
router.push("/settings/organizations/new/handover");
|
router.push("/settings/organizations/new/handover");
|
||||||
} else {
|
} else {
|
||||||
router.push("/settings/organizations/new/about");
|
router.push("/settings/organizations/new/about");
|
||||||
@@ -133,7 +134,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
|||||||
<Alert severity="error" message={serverErrorMessage} />
|
<Alert severity="error" message={serverErrorMessage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{isAdmin && (
|
{isBillingEnabled && isAdmin && (
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
<Controller
|
<Controller
|
||||||
name="billingPeriod"
|
name="billingPeriod"
|
||||||
@@ -251,7 +252,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isAdmin && (
|
{isBillingEnabled && isAdmin && (
|
||||||
<>
|
<>
|
||||||
<section className="grid grid-cols-2 gap-2">
|
<section className="grid grid-cols-2 gap-2">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
@@ -305,7 +306,7 @@ const CreateANewOrganizationFormChild = ({ session }: { session: Ensure<SessionC
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* This radio group does nothing - its just for visual purposes */}
|
{/* This radio group does nothing - its just for visual purposes */}
|
||||||
{!isAdmin && (
|
{isBillingEnabled && !isAdmin && (
|
||||||
<>
|
<>
|
||||||
<div className="bg-subtle space-y-5 rounded-lg p-5">
|
<div className="bg-subtle space-y-5 rounded-lg p-5">
|
||||||
<h3 className="font-cal text-default text-lg font-semibold leading-4">
|
<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 { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
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 { UserPermissionRole } from "@calcom/prisma/enums";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
|
|
||||||
@@ -102,6 +102,10 @@ export const useOnboardingStore = create<OnboardingStoreState>()(
|
|||||||
if (state) {
|
if (state) {
|
||||||
set(state);
|
set(state);
|
||||||
} else {
|
} else {
|
||||||
|
// Clear the localStorage entry for this store
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
localStorage.removeItem("org-creation-onboarding");
|
||||||
|
}
|
||||||
set(initialState);
|
set(initialState);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -117,6 +121,7 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const path = usePathname();
|
const path = usePathname();
|
||||||
const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN;
|
const isAdmin = session.data?.user?.role === UserPermissionRole.ADMIN;
|
||||||
|
const isBillingEnabled = !(IS_SELF_HOSTED && isAdmin) || process.env.NEXT_PUBLIC_IS_E2E;
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } =
|
const { data: organizationOnboarding, isPending: isLoadingOrgOnboarding } =
|
||||||
trpc.viewer.organizations.getOrganizationOnboarding.useQuery();
|
trpc.viewer.organizations.getOrganizationOnboarding.useQuery();
|
||||||
@@ -127,6 +132,10 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (organizationOnboarding?.isComplete) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
if (organizationOnboarding) {
|
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
|
// 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) {
|
if (!window.isOrgOnboardingSynced) {
|
||||||
@@ -143,6 +152,9 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
|||||||
bio: organizationOnboarding.bio,
|
bio: organizationOnboarding.bio,
|
||||||
logo: organizationOnboarding.logo,
|
logo: organizationOnboarding.logo,
|
||||||
});
|
});
|
||||||
|
if (isAdmin && organizationOnboarding?.orgOwnerEmail !== session.data?.user.email) {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// First step doesn't require onboardingId
|
// First step doesn't require onboardingId
|
||||||
@@ -154,11 +166,6 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
|||||||
router.push("/settings/organizations/new");
|
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]);
|
}, [organizationOnboarding, isLoadingOrgOnboarding, isAdmin, onboardingId, reset, step, router]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -170,5 +177,10 @@ export const useOnboarding = (params?: { step?: "start" | "status" | null }) =>
|
|||||||
router.push(`/auth/login?callbackUrl=${WEBAPP_URL}${path}${searchString ? `?${searchString}` : ""}`);
|
router.push(`/auth/login?callbackUrl=${WEBAPP_URL}${path}${searchString ? `?${searchString}` : ""}`);
|
||||||
}
|
}
|
||||||
}, [session, router, path, searchParams]);
|
}, [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 { 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 { createDomain } from "@calcom/lib/domainManager/organization";
|
||||||
|
import type { OrganizationOnboarding } from "@calcom/prisma/client";
|
||||||
import { MembershipRole } from "@calcom/prisma/enums";
|
import { MembershipRole } from "@calcom/prisma/enums";
|
||||||
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
import { createTeamsHandler } from "@calcom/trpc/server/routers/viewer/organizations/createTeams.handler";
|
||||||
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
import { inviteMembersWithNoInviterPermissionCheck } from "@calcom/trpc/server/routers/viewer/teams/inviteMember/inviteMember.handler";
|
||||||
|
|
||||||
import { createOrganizationFromOnboarding } from "./createOrganizationFromOnboarding";
|
import { createOrganizationFromOnboarding } from "./createOrganizationFromOnboarding";
|
||||||
|
|
||||||
|
vi.mock("@calcom/ee/common/server/LicenseKeyService", () => ({
|
||||||
|
LicenseKeySingleton: {
|
||||||
|
getInstance: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
// Mock the external functions
|
// Mock the external functions
|
||||||
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
|
vi.mock("@calcom/features/auth/lib/verifyEmail", () => ({
|
||||||
sendEmailVerification: vi.fn(),
|
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", () => {
|
describe("createOrganizationFromOnboarding", () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
@@ -154,260 +176,326 @@ describe("createOrganizationFromOnboarding", () => {
|
|||||||
await prismock.reset();
|
await prismock.reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Skipped because non-existend user creation support isn't there through onboarding now.
|
describe("hosted", () => {
|
||||||
it.skip("should create an organization with a non-existent user as owner", async () => {
|
beforeEach(() => {
|
||||||
const organizationOnboarding = await createTestOrganizationOnboarding();
|
vi.spyOn(constants, "IS_SELF_HOSTED", "get").mockReturnValue(false);
|
||||||
|
|
||||||
const { organization, owner } = await createOrganizationFromOnboarding({
|
|
||||||
organizationOnboarding,
|
|
||||||
paymentSubscriptionId: "mock_subscription_id",
|
|
||||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Verify organization creation
|
// Skipped because non-existend user creation support isn't there through onboarding now.
|
||||||
expect(organization).toBeDefined();
|
it.skip("should create an organization with a non-existent user as owner", async () => {
|
||||||
expect(organization.name).toBe(organizationOnboarding.name);
|
const organizationOnboarding = await createTestOrganizationOnboarding();
|
||||||
expect(organization.slug).toBe(organizationOnboarding.slug);
|
|
||||||
expect(organization.metadata).toEqual({
|
const { organization, owner } = await createOrganizationFromOnboarding({
|
||||||
orgSeats: 5,
|
organizationOnboarding,
|
||||||
orgPricePerSeat: 20,
|
paymentSubscriptionId: "mock_subscription_id",
|
||||||
billingPeriod: "MONTHLY",
|
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||||
subscriptionId: "mock_subscription_id",
|
});
|
||||||
subscriptionItemId: "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
|
it("should create an organization with an existing user as owner", async () => {
|
||||||
expect(owner).toBeDefined();
|
const orgOwnerEmail = "test@example.com";
|
||||||
expect(owner.email).toBe(organizationOnboarding.orgOwnerEmail);
|
const existingUser = await createTestUser({
|
||||||
});
|
email: 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: {
|
|
||||||
name: "Existing User",
|
name: "Existing User",
|
||||||
username: "existinguser",
|
username: "existinguser",
|
||||||
email: "test@example.com",
|
onboardingCompleted: true,
|
||||||
},
|
emailVerified: new Date(),
|
||||||
organizationOnboarding: {
|
});
|
||||||
organizationId: existingOrg.id,
|
|
||||||
|
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,
|
name: orgName,
|
||||||
slug: orgSlug,
|
slug: orgSlug,
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const { organization } = await createOrganizationFromOnboarding({
|
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||||
organizationOnboarding,
|
user: {
|
||||||
paymentSubscriptionId: "mock_subscription_id",
|
name: "Existing User",
|
||||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
username: "existinguser",
|
||||||
});
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
organizationOnboarding: {
|
||||||
|
organizationId: existingOrg.id,
|
||||||
|
name: orgName,
|
||||||
|
slug: orgSlug,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Verify the existing organization was reused
|
const { organization } = await createOrganizationFromOnboarding({
|
||||||
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({
|
|
||||||
organizationOnboarding,
|
organizationOnboarding,
|
||||||
paymentSubscriptionId: "mock_subscription_id",
|
paymentSubscriptionId: "mock_subscription_id",
|
||||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
paymentSubscriptionItemId: "mock_subscription_item_id",
|
||||||
})
|
});
|
||||||
).rejects.toThrow("organization_url_taken");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should create team with slugified slug", async () => {
|
// Verify the existing organization was reused
|
||||||
const teamToMove = await createTestTeam({
|
expect(organization.id).toBe(existingOrg.id);
|
||||||
name: "Sales",
|
|
||||||
slug: "Company Sales",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
it("should throw error if organization with same slug exists", async () => {
|
||||||
user: {
|
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||||
email: "test@example.com",
|
user: {
|
||||||
},
|
email: "test@example.com",
|
||||||
organizationOnboarding: {
|
},
|
||||||
slug: "UPPERCASE-SLUG",
|
organizationOnboarding: {
|
||||||
teams: [{ id: teamToMove.id, name: "Sales", isBeingMigrated: true, slug: "sales-team" }],
|
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({
|
it("should create team with slugified slug", async () => {
|
||||||
organizationOnboarding,
|
const teamToMove = await createTestTeam({
|
||||||
paymentSubscriptionId: "mock_subscription_id",
|
name: "Sales",
|
||||||
paymentSubscriptionItemId: "mock_subscription_item_id",
|
slug: "Company Sales",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createTeamsHandler).toHaveBeenCalledWith(
|
const { organizationOnboarding } = await createOnboardingEligibleUserAndOnboarding({
|
||||||
expect.objectContaining({
|
user: {
|
||||||
input: expect.objectContaining({
|
email: "test@example.com",
|
||||||
orgId: organization.id,
|
},
|
||||||
teamNames: [],
|
organizationOnboarding: {
|
||||||
moveTeams: expect.arrayContaining([expect.objectContaining({ id: 1, newSlug: "sales-team" })]),
|
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 { organization } = await createOrganizationFromOnboarding({
|
||||||
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,
|
organizationOnboarding,
|
||||||
paymentSubscriptionId: "mock_subscription_id",
|
paymentSubscriptionId: "mock_subscription_id",
|
||||||
paymentSubscriptionItemId: "mock_subscription_item_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 type { TFunction } from "i18next";
|
||||||
|
|
||||||
|
import { LicenseKeySingleton } from "@calcom/ee/common/server/LicenseKeyService";
|
||||||
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
|
import { sendOrganizationCreationEmail } from "@calcom/emails/email-manager";
|
||||||
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
import { sendEmailVerification } from "@calcom/features/auth/lib/verifyEmail";
|
||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
@@ -10,9 +11,11 @@ import {
|
|||||||
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
} from "@calcom/features/ee/organizations/lib/server/orgCreationUtils";
|
||||||
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
import { DEFAULT_SCHEDULE } from "@calcom/lib/availability";
|
||||||
import { getAvailabilityFromSchedule } 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 logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
|
import { DeploymentRepository } from "@calcom/lib/server/repository/deployment";
|
||||||
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
import { OrganizationRepository } from "@calcom/lib/server/repository/organization";
|
||||||
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||||
import { UserRepository } from "@calcom/lib/server/repository/user";
|
import { UserRepository } from "@calcom/lib/server/repository/user";
|
||||||
@@ -333,9 +336,13 @@ async function backwardCompatibilityForSubscriptionDetails({
|
|||||||
id: number;
|
id: number;
|
||||||
metadata: Prisma.JsonValue;
|
metadata: Prisma.JsonValue;
|
||||||
};
|
};
|
||||||
paymentSubscriptionId: string;
|
paymentSubscriptionId?: string;
|
||||||
paymentSubscriptionItemId: string;
|
paymentSubscriptionItemId?: string;
|
||||||
}) {
|
}) {
|
||||||
|
if (!paymentSubscriptionId || !paymentSubscriptionItemId) {
|
||||||
|
return organization;
|
||||||
|
}
|
||||||
|
|
||||||
const existingMetadata = teamMetadataSchema.parse(organization.metadata);
|
const existingMetadata = teamMetadataSchema.parse(organization.metadata);
|
||||||
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
|
const updatedOrganization = await OrganizationRepository.updateStripeSubscriptionDetails({
|
||||||
id: organization.id,
|
id: organization.id,
|
||||||
@@ -387,9 +394,19 @@ async function handleOrganizationCreation({
|
|||||||
}: {
|
}: {
|
||||||
organizationOnboarding: OrganizationOnboardingArg;
|
organizationOnboarding: OrganizationOnboardingArg;
|
||||||
owner: OrgOwner;
|
owner: OrgOwner;
|
||||||
paymentSubscriptionId: string;
|
paymentSubscriptionId?: string;
|
||||||
paymentSubscriptionItemId: 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;
|
let organization;
|
||||||
const orgData = {
|
const orgData = {
|
||||||
id: organizationOnboarding.organizationId,
|
id: organizationOnboarding.organizationId,
|
||||||
@@ -468,8 +485,8 @@ export const createOrganizationFromOnboarding = async ({
|
|||||||
paymentSubscriptionItemId,
|
paymentSubscriptionItemId,
|
||||||
}: {
|
}: {
|
||||||
organizationOnboarding: OrganizationOnboardingArg;
|
organizationOnboarding: OrganizationOnboardingArg;
|
||||||
paymentSubscriptionId: string;
|
paymentSubscriptionId?: string;
|
||||||
paymentSubscriptionItemId: string;
|
paymentSubscriptionItemId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
log.info(
|
log.info(
|
||||||
"createOrganizationFromOnboarding",
|
"createOrganizationFromOnboarding",
|
||||||
@@ -478,6 +495,11 @@ export const createOrganizationFromOnboarding = async ({
|
|||||||
orgSlug: organizationOnboarding.slug,
|
orgSlug: organizationOnboarding.slug,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!IS_SELF_HOSTED && (!paymentSubscriptionId || !paymentSubscriptionItemId)) {
|
||||||
|
throw new Error("payment_subscription_id_and_payment_subscription_item_id_are_required");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
await hasConflictingOrganization({
|
await hasConflictingOrganization({
|
||||||
slug: organizationOnboarding.slug,
|
slug: organizationOnboarding.slug,
|
||||||
@@ -491,10 +513,13 @@ export const createOrganizationFromOnboarding = async ({
|
|||||||
const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
|
const userFromEmail = await findUserToBeOrgOwner(organizationOnboarding.orgOwnerEmail);
|
||||||
const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common");
|
const orgOwnerTranslation = await getTranslation(userFromEmail?.locale || "en", "common");
|
||||||
|
|
||||||
await handleDomainSetup({
|
// TODO: We need to send emails to admin in the case of SELF_HOSTING where NEXT_PUBLIC_SINGLE_ORG_SLUG isn't set
|
||||||
organizationOnboarding,
|
if (!process.env.NEXT_PUBLIC_SINGLE_ORG_SLUG) {
|
||||||
orgOwnerTranslation,
|
await handleDomainSetup({
|
||||||
});
|
organizationOnboarding,
|
||||||
|
orgOwnerTranslation,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const { organization, owner } = await handleOrganizationCreation({
|
const { organization, owner } = await handleOrganizationCreation({
|
||||||
organizationOnboarding,
|
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,
|
routingFormResponseId,
|
||||||
organizerId,
|
organizerId,
|
||||||
teamId,
|
teamId,
|
||||||
|
isRerouting,
|
||||||
|
reroutedByEmail,
|
||||||
}: {
|
}: {
|
||||||
bookingId: number;
|
bookingId: number;
|
||||||
routingFormResponseId: number;
|
routingFormResponseId: number;
|
||||||
organizerId: number;
|
organizerId: number;
|
||||||
teamId: number;
|
teamId: number;
|
||||||
|
isRerouting: boolean;
|
||||||
|
reroutedByEmail?: string | null;
|
||||||
}) {
|
}) {
|
||||||
// Get the routing form data
|
// Get the routing form data
|
||||||
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
const routingFormResponse = await prisma.app_RoutingForms_FormResponse.findUnique({
|
||||||
@@ -113,8 +117,12 @@ export default class AssignmentReasonRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const reasonEnum = AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
const reasonEnum = isRerouting
|
||||||
const reasonString = attributeValues.join(", ");
|
? AssignmentReasonEnum.REROUTED
|
||||||
|
: AssignmentReasonEnum.ROUTING_FORM_ROUTING;
|
||||||
|
const reasonString = `${
|
||||||
|
isRerouting && reroutedByEmail ? `Rerouted by ${reroutedByEmail}` : ""
|
||||||
|
} ${attributeValues.join(", ")}`;
|
||||||
|
|
||||||
await prisma.assignmentReason.create({
|
await prisma.assignmentReason.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { SENDER_NAME } from "@calcom/lib/constants";
|
|||||||
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
|
import { enrichUserWithDelegationCredentialsIncludeServiceAccountKey } from "@calcom/lib/delegationCredential/server";
|
||||||
import { getEventName } from "@calcom/lib/event";
|
import { getEventName } from "@calcom/lib/event";
|
||||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||||
|
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
@@ -181,6 +182,12 @@ export const roundRobinManualReassignment = async ({
|
|||||||
userPrimaryEmail: newUser.email,
|
userPrimaryEmail: newUser.email,
|
||||||
reassignReason,
|
reassignReason,
|
||||||
reassignById: reassignedById,
|
reassignById: reassignedById,
|
||||||
|
idempotencyKey: IdempotencyKeyService.generate({
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
userId: newUser.id,
|
||||||
|
reassignedById,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
select: bookingSelect,
|
select: bookingSelect,
|
||||||
});
|
});
|
||||||
@@ -379,7 +386,7 @@ export const roundRobinManualReassignment = async ({
|
|||||||
return booking;
|
return booking;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function handleWorkflowsUpdate({
|
export async function handleWorkflowsUpdate({
|
||||||
booking,
|
booking,
|
||||||
newUser,
|
newUser,
|
||||||
evt,
|
evt,
|
||||||
@@ -403,6 +410,7 @@ async function handleWorkflowsUpdate({
|
|||||||
scheduled: true,
|
scheduled: true,
|
||||||
OR: [{ cancelled: false }, { cancelled: null }],
|
OR: [{ cancelled: false }, { cancelled: null }],
|
||||||
workflowStep: {
|
workflowStep: {
|
||||||
|
action: WorkflowActions.EMAIL_HOST,
|
||||||
workflow: {
|
workflow: {
|
||||||
trigger: {
|
trigger: {
|
||||||
in: [
|
in: [
|
||||||
|
|||||||
@@ -16,31 +16,25 @@ import type { IsFixedAwareUser } from "@calcom/features/bookings/lib/handleNewBo
|
|||||||
import AssignmentReasonRecorder, {
|
import AssignmentReasonRecorder, {
|
||||||
RRReassignmentType,
|
RRReassignmentType,
|
||||||
} from "@calcom/features/ee/round-robin/assignmentReason/AssignmentReasonRecorder";
|
} 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 {
|
import {
|
||||||
enrichHostsWithDelegationCredentials,
|
enrichHostsWithDelegationCredentials,
|
||||||
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
|
enrichUserWithDelegationCredentialsIncludeServiceAccountKey,
|
||||||
} from "@calcom/lib/delegationCredential/server";
|
} from "@calcom/lib/delegationCredential/server";
|
||||||
import { getEventName } from "@calcom/lib/event";
|
import { getEventName } from "@calcom/lib/event";
|
||||||
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
import { getBookerBaseUrl } from "@calcom/lib/getBookerUrl/server";
|
||||||
|
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||||
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
import { isPrismaObjOrUndefined } from "@calcom/lib/isPrismaObj";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
import { getLuckyUser } from "@calcom/lib/server/getLuckyUser";
|
||||||
import { getTranslation } from "@calcom/lib/server/i18n";
|
import { getTranslation } from "@calcom/lib/server/i18n";
|
||||||
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
import { getTimeFormatStringFromUserTimeFormat } from "@calcom/lib/timeFormat";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { WorkflowActions, WorkflowMethods, WorkflowTriggerEvents } from "@calcom/prisma/enums";
|
|
||||||
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
import { userMetadata as userMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||||
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
import type { EventTypeMetadata, PlatformClientParams } from "@calcom/prisma/zod-utils";
|
||||||
import type { CalendarEvent } from "@calcom/types/Calendar";
|
import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
|
import { handleRescheduleEventManager } from "./handleRescheduleEventManager";
|
||||||
|
import { handleWorkflowsUpdate } from "./roundRobinManualReassignment";
|
||||||
import { bookingSelect } from "./utils/bookingSelect";
|
import { bookingSelect } from "./utils/bookingSelect";
|
||||||
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
|
import { getDestinationCalendar } from "./utils/getDestinationCalendar";
|
||||||
import { getTeamMembers } from "./utils/getTeamMembers";
|
import { getTeamMembers } from "./utils/getTeamMembers";
|
||||||
@@ -246,6 +240,12 @@ export const roundRobinReassignment = async ({
|
|||||||
userId: reassignedRRHost.id,
|
userId: reassignedRRHost.id,
|
||||||
userPrimaryEmail: reassignedRRHost.email,
|
userPrimaryEmail: reassignedRRHost.email,
|
||||||
title: newBookingTitle,
|
title: newBookingTitle,
|
||||||
|
idempotencyKey: IdempotencyKeyService.generate({
|
||||||
|
startTime: booking.startTime,
|
||||||
|
endTime: booking.endTime,
|
||||||
|
userId: reassignedRRHost.id,
|
||||||
|
reassignedById,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
select: bookingSelect,
|
select: bookingSelect,
|
||||||
});
|
});
|
||||||
@@ -434,144 +434,12 @@ export const roundRobinReassignment = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scheduledWorkflowReminders = await prisma.workflowReminder.findMany({
|
await handleWorkflowsUpdate({
|
||||||
where: {
|
booking,
|
||||||
bookingUid: booking.uid,
|
newUser: reassignedRRHost,
|
||||||
method: WorkflowMethods.EMAIL,
|
evt: evtWithAdditionalInfo,
|
||||||
scheduled: true,
|
eventType,
|
||||||
OR: [{ cancelled: false }, { cancelled: null }],
|
orgId,
|
||||||
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,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
namespace: string;
|
||||||
}) => {
|
}) => {
|
||||||
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
|
return code`${getApiNameForVanillaJsSnippet({ namespace, mainApiName: "Cal" })}("inline", {
|
||||||
elementOrSelector:"#my-cal-inline",
|
elementOrSelector:"#my-cal-inline-${namespace}",
|
||||||
config: ${JSON.stringify(previewState.config)},
|
config: ${JSON.stringify(previewState.config)},
|
||||||
calLink: "${calLink}",
|
calLink: "${calLink}",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export const tabs = [
|
|||||||
embedType === "inline"
|
embedType === "inline"
|
||||||
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
? `<div style="width:${getDimension(previewState.inline.width)};height:${getDimension(
|
||||||
previewState.inline.height
|
previewState.inline.height
|
||||||
)};overflow:scroll" id="my-cal-inline"></div>\n`
|
)};overflow:scroll" id="my-cal-inline-${namespace}"></div>\n`
|
||||||
: ""
|
: ""
|
||||||
}<script type="text/javascript">
|
}<script type="text/javascript">
|
||||||
${embedSnippetString}
|
${embedSnippetString}
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { weekdayNames } from "@calcom/lib/weekday";
|
|||||||
import { weekStartNum } from "@calcom/lib/weekstart";
|
import { weekStartNum } from "@calcom/lib/weekstart";
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
import { SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
@@ -121,6 +120,7 @@ type EventTypeScheduleProps = {
|
|||||||
isSchedulePending?: boolean;
|
isSchedulePending?: boolean;
|
||||||
isRestrictionSchedulePending?: boolean;
|
isRestrictionSchedulePending?: boolean;
|
||||||
restrictionScheduleRedirectUrl?: string;
|
restrictionScheduleRedirectUrl?: string;
|
||||||
|
isRestrictionScheduleEnabled?: boolean;
|
||||||
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
|
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
|
||||||
Omit<EventTypeTeamScheduleProps, "customClassNames">;
|
Omit<EventTypeTeamScheduleProps, "customClassNames">;
|
||||||
|
|
||||||
@@ -810,6 +810,7 @@ const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null
|
|||||||
const UseTeamEventScheduleSettingsToggle = ({
|
const UseTeamEventScheduleSettingsToggle = ({
|
||||||
eventType,
|
eventType,
|
||||||
customClassNames,
|
customClassNames,
|
||||||
|
isRestrictionScheduleEnabled,
|
||||||
...rest
|
...rest
|
||||||
}: UseTeamEventScheduleSettingsToggle) => {
|
}: UseTeamEventScheduleSettingsToggle) => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -819,18 +820,6 @@ const UseTeamEventScheduleSettingsToggle = ({
|
|||||||
eventType.restrictionScheduleId
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
<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,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 { HttpError } from "../http-error";
|
||||||
import { getServerErrorFromUnknown } from "./getServerErrorFromUnknown";
|
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", () => {
|
describe("getServerErrorFromUnknown", () => {
|
||||||
it("should handle a StripeInvalidRequestError", () => {
|
test("should handle a StripeInvalidRequestError", () => {
|
||||||
const stripeError = {
|
const stripeError = {
|
||||||
name: "StripeInvalidRequestError",
|
name: "StripeInvalidRequestError",
|
||||||
type: "StripeInvalidRequestError",
|
type: "StripeInvalidRequestError",
|
||||||
@@ -31,4 +63,75 @@ describe("getServerErrorFromUnknown", () => {
|
|||||||
expect(result.cause).toEqual(stripeError);
|
expect(result.cause).toEqual(stripeError);
|
||||||
expect(result.name).toBe("HttpError");
|
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 { stripeInvalidRequestErrorSchema } from "@calcom/app-store/_utils/stripe.types";
|
||||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||||
|
import { ErrorWithCode } from "@calcom/lib/errors";
|
||||||
|
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||||
@@ -60,6 +61,15 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
|||||||
if (parsedStripeError.success) {
|
if (parsedStripeError.success) {
|
||||||
return getHttpError({ statusCode: 400, cause: parsedStripeError.data });
|
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) {
|
if (cause instanceof HttpError) {
|
||||||
const redactedCause = redactError(cause);
|
const redactedCause = redactError(cause);
|
||||||
return {
|
return {
|
||||||
@@ -87,25 +97,35 @@ export function getServerErrorFromUnknown(cause: unknown): HttpError {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStatusCode(cause: Error): number {
|
function getStatusCode(cause: Error | ErrorWithCode): number {
|
||||||
switch (cause.message) {
|
const errorCode = cause instanceof ErrorWithCode ? cause.code : cause.message;
|
||||||
|
|
||||||
|
switch (errorCode) {
|
||||||
|
// 400 Bad Request
|
||||||
case ErrorCode.RequestBodyWithouEnd:
|
case ErrorCode.RequestBodyWithouEnd:
|
||||||
case ErrorCode.MissingPaymentCredential:
|
case ErrorCode.MissingPaymentCredential:
|
||||||
case ErrorCode.MissingPaymentAppId:
|
case ErrorCode.MissingPaymentAppId:
|
||||||
case ErrorCode.AvailabilityNotFoundInSchedule:
|
case ErrorCode.AvailabilityNotFoundInSchedule:
|
||||||
return 400;
|
|
||||||
case ErrorCode.CancelledBookingsCannotBeRescheduled:
|
case ErrorCode.CancelledBookingsCannotBeRescheduled:
|
||||||
|
case ErrorCode.BookingTimeOutOfBounds:
|
||||||
|
case ErrorCode.BookingNotAllowedByRestrictionSchedule:
|
||||||
|
case ErrorCode.BookerLimitExceeded:
|
||||||
|
case ErrorCode.BookerLimitExceededReschedule:
|
||||||
return 400;
|
return 400;
|
||||||
|
// 409 Conflict
|
||||||
case ErrorCode.NoAvailableUsersFound:
|
case ErrorCode.NoAvailableUsersFound:
|
||||||
case ErrorCode.HostsUnavailableForBooking:
|
case ErrorCode.HostsUnavailableForBooking:
|
||||||
case ErrorCode.PaymentCreationFailure:
|
|
||||||
case ErrorCode.ChargeCardFailure:
|
|
||||||
case ErrorCode.AlreadySignedUpForBooking:
|
case ErrorCode.AlreadySignedUpForBooking:
|
||||||
case ErrorCode.BookingSeatsFull:
|
case ErrorCode.BookingSeatsFull:
|
||||||
case ErrorCode.NotEnoughAvailableSeats:
|
case ErrorCode.NotEnoughAvailableSeats:
|
||||||
|
case ErrorCode.BookingConflict:
|
||||||
|
case ErrorCode.PaymentCreationFailure:
|
||||||
|
case ErrorCode.ChargeCardFailure:
|
||||||
return 409;
|
return 409;
|
||||||
|
// 404 Not Found
|
||||||
case ErrorCode.EventTypeNotFound:
|
case ErrorCode.EventTypeNotFound:
|
||||||
case ErrorCode.BookingNotFound:
|
case ErrorCode.BookingNotFound:
|
||||||
|
case ErrorCode.RestrictionScheduleNotFound:
|
||||||
return 404;
|
return 404;
|
||||||
case ErrorCode.UnableToSubscribeToThePlatform:
|
case ErrorCode.UnableToSubscribeToThePlatform:
|
||||||
case ErrorCode.UpdatingOauthClientError:
|
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({
|
return await prisma.membership.findUnique({
|
||||||
where: {
|
where: {
|
||||||
userId_teamId: {
|
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 }[];
|
teams?: { id: number; name: string; isBeingMigrated: boolean; slug: string | null }[];
|
||||||
error?: string | null;
|
error?: string | null;
|
||||||
isDomainConfigured?: boolean;
|
isDomainConfigured?: boolean;
|
||||||
|
isComplete?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class OrganizationOnboardingRepository {
|
export class OrganizationOnboardingRepository {
|
||||||
@@ -95,13 +96,16 @@ export class OrganizationOnboardingRepository {
|
|||||||
|
|
||||||
static async update(id: OnboardingId, data: Partial<CreateOrganizationOnboardingInput>) {
|
static async update(id: OnboardingId, data: Partial<CreateOrganizationOnboardingInput>) {
|
||||||
logger.debug("Updating organization onboarding", safeStringify({ id, data }));
|
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({
|
return await prisma.organizationOnboarding.update({
|
||||||
where: {
|
where: {
|
||||||
id,
|
id,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
...data,
|
...rest,
|
||||||
|
...(organizationId ? { organization: { connect: { id: organizationId } } } : {}),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -186,6 +186,39 @@ export class TeamRepository {
|
|||||||
return getParsedTeam(team);
|
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 }) {
|
static async deleteById({ id }: { id: number }) {
|
||||||
try {
|
try {
|
||||||
await WorkflowService.deleteWorkflowRemindersOfRemovedTeam(id);
|
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;
|
attributeLabelToValueMap: AttributeLabelToValueMap;
|
||||||
updater: BulkAttributeAssigner;
|
updater: BulkAttributeAssigner;
|
||||||
}) => {
|
}) => {
|
||||||
const membership = await MembershipRepository.findFirstByUserIdAndTeamId({ userId, teamId: orgId });
|
const membership = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId, teamId: orgId });
|
||||||
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
|
const defaultReturn = { numOfAttributeOptionsSet: 0, numOfAttributeOptionsDeleted: 0 };
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
|
console.error(`User ${userId} not a member of org ${orgId}, not assigning attribute options`);
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
|
## 1.0.108
|
||||||
|
|
||||||
|
## 1.0.110
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#22107](https://github.com/calcom/cal.com/pull/22107) [`88a2bd3`](https://github.com/calcom/cal.com/commit/88a2bd3317a2cfaf6b367524e2736445544dbd63) Thanks [@supalarry](https://github.com/supalarry)! - test log can ignore
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#22103](https://github.com/calcom/cal.com/pull/22103) [`61274bc`](https://github.com/calcom/cal.com/commit/61274bc7efc67b162d46b59cd75bd376ad515c51) Thanks [@supalarry](https://github.com/supalarry)! - testing changesets - can ignore this
|
||||||
|
|
||||||
|
## 1.0.107
|
||||||
|
|
||||||
|
### Patch Changes
|
||||||
|
|
||||||
|
- [#22095](https://github.com/calcom/cal.com/pull/22095) [`3c43ce4`](https://github.com/calcom/cal.com/commit/3c43ce4165bce1da29b203d5f2eb62090c5fddd2) Thanks [@supalarry](https://github.com/supalarry)! - test by updating changelog
|
||||||
|
|
||||||
|
- [#21864](https://github.com/calcom/cal.com/pull/21864) [`540bf86`](https://github.com/calcom/cal.com/commit/540bf868b5b60a98d9d3aeb565e2089f15c3dfd3) Thanks [@supalarry](https://github.com/supalarry)! - fix saving event type settings
|
||||||
|
|
||||||
## 1.0.63
|
## 1.0.63
|
||||||
|
|
||||||
1. 💥 BREAKING - `useGetBooking` hook has been renamed to `useBooking` hook and `useGetBookings` hook to `useBookings` and the data returned has different
|
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,
|
"id": 73,
|
||||||
@@ -47,9 +69,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
|
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
|
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:
|
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) ?? "");
|
const { isLoading, data: booking, refetch } = useBooking((router.query.bookingUid as string) ?? "");
|
||||||
|
|
||||||
@@ -63,5 +87,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
|
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
|
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
|
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.
|
Customizable UI components to integrate scheduling into your services.
|
||||||
|
|
||||||
### Support
|
### 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).
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
|||||||
formMethods,
|
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 } =
|
const { isPending: isSchedulePending, data: scheduleQueryData } =
|
||||||
trpc.viewer.availability.schedule.get.useQuery(
|
trpc.viewer.availability.schedule.get.useQuery(
|
||||||
{
|
{
|
||||||
@@ -66,6 +78,7 @@ const EventAvailabilityTabWebWrapper = (props: EventAvailabilityTabWebWrapperPro
|
|||||||
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
|
editAvailabilityRedirectUrl={`/availability/${scheduleQueryData?.id}`}
|
||||||
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
|
restrictionScheduleRedirectUrl={`/availability/${restrictionScheduleQueryData?.id}`}
|
||||||
hostSchedulesQuery={hostSchedulesQuery}
|
hostSchedulesQuery={hostSchedulesQuery}
|
||||||
|
isRestrictionScheduleEnabled={isRestrictionScheduleEnabled}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,11 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Customizable UI components to integrate scheduling into your product.",
|
"description": "Customizable UI components to integrate scheduling into your product.",
|
||||||
"authors": "Cal.com, Inc.",
|
"authors": "Cal.com, Inc.",
|
||||||
"version": "1.0.105",
|
"version": "1.0.110",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "yarn vite build --watch & npx tailwindcss -i ./globals.css -o ./globals.min.css --postcss --minify --watch",
|
"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",
|
"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": "yarn build && npm publish --access public",
|
"publish-npm": "yarn build && npm publish --access public",
|
||||||
"test": "jest"
|
"test": "jest"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --build --force tsconfig.json",
|
"build": "tsc --build --force tsconfig.json",
|
||||||
"build:watch": "tsc --build --force ./tsconfig.json --watch",
|
"build:watch": "tsc --build --force ./tsconfig.json --watch",
|
||||||
|
|||||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "./dist/index.ts",
|
"main": "./dist/index.ts",
|
||||||
"types": "./dist/index.ts",
|
"types": "./dist/index.ts",
|
||||||
|
"private": true,
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"require": "./dist/index.js",
|
"require": "./dist/index.js",
|
||||||
|
|||||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.js",
|
"types": "./dist/index.js",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn clean && tsc --build --force tsconfig.json",
|
"build": "yarn clean && tsc --build --force tsconfig.json",
|
||||||
"clean": "rm -rf ./dist",
|
"clean": "rm -rf ./dist",
|
||||||
|
|||||||
Binary file not shown.
@@ -3,6 +3,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "jest ./tests",
|
"test": "jest ./tests",
|
||||||
"build": "tsc --build --force tsconfig.json",
|
"build": "tsc --build --force tsconfig.json",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
import { Prisma } from "@prisma/client";
|
import { Prisma } from "@prisma/client";
|
||||||
import { v5 as uuidv5 } from "uuid";
|
|
||||||
|
|
||||||
|
import { IdempotencyKeyService } from "@calcom/lib/idempotencyKey/idempotencyKeyService";
|
||||||
import { BookingStatus } from "@calcom/prisma/enums";
|
import { BookingStatus } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
export function bookingIdempotencyKeyExtension() {
|
export function bookingIdempotencyKeyExtension() {
|
||||||
@@ -9,12 +9,12 @@ export function bookingIdempotencyKeyExtension() {
|
|||||||
booking: {
|
booking: {
|
||||||
async create({ args, query }) {
|
async create({ args, query }) {
|
||||||
if (args.data.status === BookingStatus.ACCEPTED) {
|
if (args.data.status === BookingStatus.ACCEPTED) {
|
||||||
const idempotencyKey = uuidv5(
|
const idempotencyKey = IdempotencyKeyService.generate({
|
||||||
`${args.data.startTime.valueOf()}.${args.data.endTime.valueOf()}.${
|
startTime: args.data.startTime,
|
||||||
args.data?.user?.connect?.id
|
endTime: args.data.endTime,
|
||||||
}${args.data?.reassignById ? `.${args.data.reassignById}` : ""}`,
|
userId: args.data.user?.connect?.id,
|
||||||
uuidv5.URL
|
reassignedById: args.data.reassignById,
|
||||||
);
|
});
|
||||||
args.data.idempotencyKey = idempotencyKey;
|
args.data.idempotencyKey = idempotencyKey;
|
||||||
}
|
}
|
||||||
return query(args);
|
return query(args);
|
||||||
|
|||||||
+109
@@ -0,0 +1,109 @@
|
|||||||
|
-- Step 1: Add column as nullable
|
||||||
|
ALTER TABLE "App_RoutingForms_FormResponse" ADD COLUMN "uuid" TEXT;
|
||||||
|
|
||||||
|
-- Step 2: Populate existing rows with UUIDs
|
||||||
|
UPDATE "App_RoutingForms_FormResponse" SET "uuid" = gen_random_uuid()::text WHERE "uuid" IS NULL;
|
||||||
|
|
||||||
|
-- Step 3: Make column NOT NULL
|
||||||
|
ALTER TABLE "App_RoutingForms_FormResponse" ALTER COLUMN "uuid" SET NOT NULL;
|
||||||
|
|
||||||
|
-- Step 4: Add unique constraint
|
||||||
|
CREATE UNIQUE INDEX "App_RoutingForms_FormResponse_uuid_key" ON "App_RoutingForms_FormResponse"("uuid");
|
||||||
|
|
||||||
|
-- Step 5: Add UUID to RoutingFormResponseDenormalized
|
||||||
|
ALTER TABLE "RoutingFormResponseDenormalized" ADD COLUMN "uuid" TEXT;
|
||||||
|
|
||||||
|
-- Step 6: Populate existing rows with UUIDs
|
||||||
|
UPDATE "RoutingFormResponseDenormalized" d
|
||||||
|
SET "uuid" = f."uuid"
|
||||||
|
FROM "App_RoutingForms_FormResponse" f
|
||||||
|
WHERE d."id" = f."id";
|
||||||
|
|
||||||
|
-- Step 7: Make column NOT NULL
|
||||||
|
ALTER TABLE "RoutingFormResponseDenormalized" ALTER COLUMN "uuid" SET NOT NULL;
|
||||||
|
|
||||||
|
-- Step 8: Add unique constraint
|
||||||
|
CREATE UNIQUE INDEX "RoutingFormResponseDenormalized_uuid_key" ON "RoutingFormResponseDenormalized"("uuid");
|
||||||
|
|
||||||
|
-- Step 9: Add UUID to RoutingFormResponseDenormalized
|
||||||
|
CREATE OR REPLACE FUNCTION refresh_routing_form_response_denormalized(response_id INTEGER)
|
||||||
|
RETURNS VOID AS $$
|
||||||
|
BEGIN
|
||||||
|
-- Delete existing entry if any
|
||||||
|
DELETE FROM "RoutingFormResponseDenormalized" WHERE id = response_id;
|
||||||
|
|
||||||
|
-- Insert form response with all related data
|
||||||
|
INSERT INTO "RoutingFormResponseDenormalized" (
|
||||||
|
id,
|
||||||
|
uuid,
|
||||||
|
"formId",
|
||||||
|
"formName",
|
||||||
|
"formTeamId",
|
||||||
|
"formUserId",
|
||||||
|
"bookingUid",
|
||||||
|
"bookingId",
|
||||||
|
"bookingStatus",
|
||||||
|
"bookingStatusOrder",
|
||||||
|
"bookingCreatedAt",
|
||||||
|
"bookingStartTime",
|
||||||
|
"bookingEndTime",
|
||||||
|
"bookingUserId",
|
||||||
|
"bookingUserName",
|
||||||
|
"bookingUserEmail",
|
||||||
|
"bookingUserAvatarUrl",
|
||||||
|
"bookingAssignmentReason",
|
||||||
|
"eventTypeId",
|
||||||
|
"eventTypeParentId",
|
||||||
|
"eventTypeSchedulingType",
|
||||||
|
"createdAt",
|
||||||
|
"utm_source",
|
||||||
|
"utm_medium",
|
||||||
|
"utm_campaign",
|
||||||
|
"utm_term",
|
||||||
|
"utm_content"
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
r.id,
|
||||||
|
r."uuid",
|
||||||
|
r."formId",
|
||||||
|
f.name as "formName",
|
||||||
|
f."teamId" as "formTeamId",
|
||||||
|
f."userId" as "formUserId",
|
||||||
|
b.uid as "bookingUid",
|
||||||
|
b.id as "bookingId",
|
||||||
|
b.status as "bookingStatus",
|
||||||
|
calculate_booking_status_order(b.status::text) as "bookingStatusOrder",
|
||||||
|
b."createdAt" as "bookingCreatedAt",
|
||||||
|
b."startTime" as "bookingStartTime",
|
||||||
|
b."endTime" as "bookingEndTime",
|
||||||
|
b."userId" as "bookingUserId",
|
||||||
|
u.name as "bookingUserName",
|
||||||
|
u.email as "bookingUserEmail",
|
||||||
|
u."avatarUrl" as "bookingUserAvatarUrl",
|
||||||
|
COALESCE(
|
||||||
|
(
|
||||||
|
SELECT ar."reasonString"
|
||||||
|
FROM "AssignmentReason" ar
|
||||||
|
WHERE ar."bookingId" = b.id
|
||||||
|
LIMIT 1
|
||||||
|
),
|
||||||
|
''
|
||||||
|
) as "bookingAssignmentReason",
|
||||||
|
et.id as "eventTypeId",
|
||||||
|
et."parentId" as "eventTypeParentId",
|
||||||
|
et."schedulingType" as "eventTypeSchedulingType",
|
||||||
|
r."createdAt",
|
||||||
|
t.utm_source,
|
||||||
|
t.utm_medium,
|
||||||
|
t.utm_campaign,
|
||||||
|
t.utm_term,
|
||||||
|
t.utm_content
|
||||||
|
FROM "App_RoutingForms_FormResponse" r
|
||||||
|
INNER JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||||
|
INNER JOIN "users" u ON f."userId" = u.id
|
||||||
|
LEFT JOIN "Booking" b ON b.uid = r."routedToBookingUid"
|
||||||
|
LEFT JOIN "EventType" et ON b."eventTypeId" = et.id
|
||||||
|
LEFT JOIN "Tracking" t ON t."bookingId" = b.id
|
||||||
|
WHERE r.id = response_id;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
+11
@@ -0,0 +1,11 @@
|
|||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "App_RoutingForms_FormResponse_uuid_key";
|
||||||
|
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX IF EXISTS "RoutingFormResponseDenormalized_uuid_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "App_RoutingForms_FormResponse" ALTER COLUMN "uuid" DROP NOT NULL;
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "RoutingFormResponseDenormalized" ALTER COLUMN "uuid" DROP NOT NULL;
|
||||||
@@ -1159,6 +1159,7 @@ model App_RoutingForms_Form {
|
|||||||
|
|
||||||
model App_RoutingForms_FormResponse {
|
model App_RoutingForms_FormResponse {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
|
uuid String? @default(uuid())
|
||||||
formFillerId String @default(cuid())
|
formFillerId String @default(cuid())
|
||||||
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
form App_RoutingForms_Form @relation(fields: [formId], references: [id], onDelete: Cascade)
|
||||||
formId String
|
formId String
|
||||||
@@ -1239,6 +1240,7 @@ view RoutingFormResponse {
|
|||||||
|
|
||||||
model RoutingFormResponseDenormalized {
|
model RoutingFormResponseDenormalized {
|
||||||
id Int @id
|
id Int @id
|
||||||
|
uuid String?
|
||||||
formId String
|
formId String
|
||||||
formName String
|
formName String
|
||||||
formTeamId Int?
|
formTeamId Int?
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { sendSmsOrFallbackEmail } from "@calcom/features/ee/workflows/lib/remind
|
|||||||
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
import { checkSMSRateLimit } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||||
import { SENDER_ID } from "@calcom/lib/constants";
|
import { SENDER_ID } from "@calcom/lib/constants";
|
||||||
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
import isSmsCalEmail from "@calcom/lib/isSmsCalEmail";
|
||||||
import { TeamRepository } from "@calcom/lib/server/repository/team";
|
|
||||||
import { TimeFormat } from "@calcom/lib/timeFormat";
|
import { TimeFormat } from "@calcom/lib/timeFormat";
|
||||||
|
import prisma from "@calcom/prisma";
|
||||||
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
|
||||||
|
|
||||||
const handleSendingSMS = async ({
|
const handleSendingSMS = async ({
|
||||||
@@ -53,6 +53,20 @@ const handleSendingSMS = async ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getTeamWithOrganizationSettings = async (teamId: number) => {
|
||||||
|
return await prisma.team.findUnique({
|
||||||
|
where: { id: teamId },
|
||||||
|
select: {
|
||||||
|
parent: {
|
||||||
|
select: {
|
||||||
|
isOrganization: true,
|
||||||
|
organizationSettings: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export default abstract class SMSManager {
|
export default abstract class SMSManager {
|
||||||
calEvent: CalendarEvent;
|
calEvent: CalendarEvent;
|
||||||
isTeamEvent = false;
|
isTeamEvent = false;
|
||||||
@@ -75,7 +89,7 @@ export default abstract class SMSManager {
|
|||||||
const teamId = this.teamId;
|
const teamId = this.teamId;
|
||||||
|
|
||||||
if (teamId) {
|
if (teamId) {
|
||||||
const team = await TeamRepository.findTeamWithOrganizationSettings(teamId);
|
const team = await getTeamWithOrganizationSettings(teamId);
|
||||||
|
|
||||||
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
|
this._isSMSNotificationEnabled = !team?.parent?.organizationSettings?.disablePhoneOnlySMSNotifications;
|
||||||
return this._isSMSNotificationEnabled;
|
return this._isSMSNotificationEnabled;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ZAdminUpdate } from "./adminUpdate.schema";
|
|||||||
import { ZAdminVerifyInput } from "./adminVerify.schema";
|
import { ZAdminVerifyInput } from "./adminVerify.schema";
|
||||||
import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
|
import { ZBulkUsersDelete } from "./bulkDeleteUsers.schema.";
|
||||||
import { ZCreateInputSchema } from "./create.schema";
|
import { ZCreateInputSchema } from "./create.schema";
|
||||||
|
import { ZCreateSelfHostedInputSchema } from "./createSelfHosted.schema";
|
||||||
import { ZCreateTeamsSchema } from "./createTeams.schema";
|
import { ZCreateTeamsSchema } from "./createTeams.schema";
|
||||||
import { ZCreateWithPaymentIntentInputSchema } from "./createWithPaymentIntent.schema";
|
import { ZCreateWithPaymentIntentInputSchema } from "./createWithPaymentIntent.schema";
|
||||||
import { ZDeleteTeamInputSchema } from "./deleteTeam.schema";
|
import { ZDeleteTeamInputSchema } from "./deleteTeam.schema";
|
||||||
@@ -158,4 +159,8 @@ export const viewerOrganizationsRouter = router({
|
|||||||
const { default: handler } = await import("./createPhoneCall.handler");
|
const { default: handler } = await import("./createPhoneCall.handler");
|
||||||
return handler(opts);
|
return handler(opts);
|
||||||
}),
|
}),
|
||||||
|
createSelfHosted: authedProcedure.input(ZCreateSelfHostedInputSchema).mutation(async (opts) => {
|
||||||
|
const { default: handler } = await import("./createSelfHosted.handler");
|
||||||
|
return handler(opts);
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { createOrganizationFromOnboarding } from "@calcom/features/ee/organizations/lib/server/createOrganizationFromOnboarding";
|
||||||
|
import { IS_SELF_HOSTED } from "@calcom/lib/constants";
|
||||||
|
import { OrganizationOnboardingRepository } from "@calcom/lib/server/repository/organizationOnboarding";
|
||||||
|
|
||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
|
||||||
|
import type { TrpcSessionUser } from "../../../types";
|
||||||
|
import type { TCreateSelfHostedInputSchema } from "./createSelfHosted.schema";
|
||||||
|
|
||||||
|
type CreateSelfHostedOptions = {
|
||||||
|
ctx: {
|
||||||
|
user: NonNullable<TrpcSessionUser>;
|
||||||
|
};
|
||||||
|
input: TCreateSelfHostedInputSchema;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createSelfHostedHandler = async ({ input, ctx }: CreateSelfHostedOptions) => {
|
||||||
|
if (!IS_SELF_HOSTED) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: "Self-hosted is not enabled",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!input.onboardingId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "onboardingId is required",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const organizationOnboarding = await OrganizationOnboardingRepository.findById(input.onboardingId);
|
||||||
|
|
||||||
|
if (!organizationOnboarding) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Organization onboarding not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organizationOnboarding.orgOwnerEmail) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Organization owner email not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { organization } = await createOrganizationFromOnboarding({
|
||||||
|
organizationOnboarding: {
|
||||||
|
id: input.onboardingId,
|
||||||
|
logo: input.logo ?? null,
|
||||||
|
bio: input.bio ?? null,
|
||||||
|
invitedMembers: input.invitedMembers ?? [],
|
||||||
|
teams: input.teams ?? [],
|
||||||
|
orgOwnerEmail: organizationOnboarding.orgOwnerEmail,
|
||||||
|
slug: organizationOnboarding.slug,
|
||||||
|
name: organizationOnboarding.name,
|
||||||
|
billingPeriod: organizationOnboarding.billingPeriod,
|
||||||
|
seats: organizationOnboarding.seats,
|
||||||
|
pricePerSeat: organizationOnboarding.pricePerSeat,
|
||||||
|
stripeCustomerId: organizationOnboarding.stripeCustomerId,
|
||||||
|
isPlatform: organizationOnboarding.isPlatform,
|
||||||
|
isDomainConfigured: organizationOnboarding.isDomainConfigured,
|
||||||
|
organizationId: organizationOnboarding.organizationId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await OrganizationOnboardingRepository.markAsComplete(organizationOnboarding.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
organization,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export default createSelfHostedHandler;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas";
|
||||||
|
|
||||||
|
export const ZCreateSelfHostedInputSchema = createOrganizationSchema;
|
||||||
|
|
||||||
|
export type TCreateSelfHostedInputSchema = z.infer<typeof ZCreateSelfHostedInputSchema>;
|
||||||
+3
-11
@@ -1,20 +1,12 @@
|
|||||||
import { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
import { orgOnboardingInvitedMembersSchema, orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
|
import { createOrganizationSchema } from "@calcom/features/ee/organizations/types/schemas";
|
||||||
|
|
||||||
export enum BillingPeriod {
|
export enum BillingPeriod {
|
||||||
MONTHLY = "MONTHLY",
|
MONTHLY = "MONTHLY",
|
||||||
ANNUALLY = "ANNUALLY",
|
ANNUALLY = "ANNUALLY",
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base user schema - fields that any user can set
|
export const ZCreateWithPaymentIntentInputSchema = createOrganizationSchema;
|
||||||
export const ZCreateWithPaymentIntentInputSchema = z.object({
|
|
||||||
language: z.string().optional(),
|
|
||||||
logo: z.string().nullish(),
|
|
||||||
bio: z.string().nullish(),
|
|
||||||
onboardingId: z.string(),
|
|
||||||
invitedMembers: orgOnboardingInvitedMembersSchema.optional(),
|
|
||||||
teams: orgOnboardingTeamsSchema.optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type TCreateWithPaymentIntentInputSchema = z.infer<typeof ZCreateWithPaymentIntentInputSchema>;
|
export type TCreateWithPaymentIntentInputSchema = z.infer<typeof ZCreateWithPaymentIntentInputSchema>;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user