Compare commits
85
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 | ||
|
|
ed5086df6b | ||
|
|
218ec9140c | ||
|
|
6dad0e7679 | ||
|
|
cb96a56960 | ||
|
|
b35eb77edb | ||
|
|
89408ce12b | ||
|
|
e22d9a551c | ||
|
|
c215dc5864 | ||
|
|
a37f1d9798 | ||
|
|
07c8bbc562 | ||
|
|
b381cfe207 | ||
|
|
a560b9c8f4 | ||
|
|
e5b8cc6912 | ||
|
|
18b5612c0d | ||
|
|
fad488623f | ||
|
|
c490f084dc | ||
|
|
b1caacd643 | ||
|
|
76c8876923 | ||
|
|
a911c9cb8d | ||
|
|
07d5594085 | ||
|
|
a745d8a60a | ||
|
|
8b83d39ac3 | ||
|
|
4edb39616d | ||
|
|
7a2060fe08 | ||
|
|
d009682d4a | ||
|
|
8adb720a80 | ||
|
|
4d6f338f0c | ||
|
|
ffe9d43d09 | ||
|
|
2227b4ff91 | ||
|
|
5dd4937a1a | ||
|
|
866a0e2596 | ||
|
|
64bfda9dbe | ||
|
|
aacbbc1785 | ||
|
|
3714d5faab | ||
|
|
12cb94950a | ||
|
|
795fd788e2 | ||
|
|
956fd6ae2f | ||
|
|
04e60611ed |
@@ -7,7 +7,7 @@
|
||||
"access": "public",
|
||||
"baseBranch": "main",
|
||||
"updateInternalDependencies": "patch",
|
||||
"ignore": [],
|
||||
"ignore": ["@calcom/platform-libraries"],
|
||||
"privatePackages": {
|
||||
"version": false,
|
||||
"tag": false
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@calcom/atoms": patch
|
||||
---
|
||||
|
||||
fix saving event type settings
|
||||
+1
-1
@@ -24,7 +24,7 @@
|
||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||
/packages/lib/slots.ts @calcom/Foundation
|
||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/Foundation
|
||||
/packages/prisma/**/* @calcom/DBA
|
||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
contents: write # to create release by changesets/action below
|
||||
pull-requests: write # to create pull request by changesets/action below
|
||||
name: Release
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- name: Checkout Repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
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
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
undecided:
|
||||
- calcom-monorepo
|
||||
- "@calcom/prisma"
|
||||
@@ -3,6 +3,7 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node ./index.js"
|
||||
},
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"description": "Platform API for Cal.com",
|
||||
"author": "Cal.com Inc.",
|
||||
"license": "UNLICENSED",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "yarn dev:build && nest build",
|
||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||
@@ -37,7 +38,7 @@
|
||||
"@axiomhq/winston": "^1.2.0",
|
||||
"@calcom/platform-constants": "*",
|
||||
"@calcom/platform-enums": "*",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
|
||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||
"@calcom/platform-types": "*",
|
||||
"@calcom/platform-utils": "*",
|
||||
@@ -108,7 +109,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer): void {
|
||||
consumer
|
||||
.apply(RawBodyMiddleware)
|
||||
.forRoutes({
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
})
|
||||
.forRoutes(
|
||||
{
|
||||
path: "/api/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
},
|
||||
{
|
||||
path: "/v2/billing/webhook",
|
||||
method: RequestMethod.POST,
|
||||
}
|
||||
)
|
||||
.apply(JsonBodyMiddleware)
|
||||
.forRoutes("*")
|
||||
.apply(RequestIdMiddleware)
|
||||
|
||||
@@ -6,6 +6,100 @@ import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
export type TRPCErrorCode = TRPCError["code"];
|
||||
|
||||
export interface ErrorDefinition {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Define the specific TRPC error codes
|
||||
export const TRPC_ERROR_CODE = {
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
CONFLICT: "CONFLICT",
|
||||
TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
|
||||
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
||||
CLIENT_CLOSED_REQUEST: "CLIENT_CLOSED_REQUEST",
|
||||
METHOD_NOT_SUPPORTED: "METHOD_NOT_SUPPORTED",
|
||||
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
|
||||
PRECONDITION_FAILED: "PRECONDITION_FAILED",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
UNPROCESSABLE_CONTENT: "UNPROCESSABLE_CONTENT",
|
||||
PARSE_ERROR: "PARSE_ERROR",
|
||||
} as const; // `as const` ensures literal types, improving type inference
|
||||
|
||||
// Map TRPC error codes to their HTTP status codes and default messages
|
||||
export const TRPC_ERROR_MAP: Record<(typeof TRPC_ERROR_CODE)[keyof typeof TRPC_ERROR_CODE], ErrorDefinition> =
|
||||
{
|
||||
[TRPC_ERROR_CODE.UNAUTHORIZED]: {
|
||||
statusCode: 401,
|
||||
message: "You are not authorized to access this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.FORBIDDEN]: {
|
||||
statusCode: 403,
|
||||
message: "You don't have necessary permissions to access this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.NOT_FOUND]: {
|
||||
statusCode: 404,
|
||||
message: "The requested resource was not found",
|
||||
},
|
||||
[TRPC_ERROR_CODE.INTERNAL_SERVER_ERROR]: {
|
||||
statusCode: 500,
|
||||
message: "An unexpected error occurred on the server. Please try again later",
|
||||
},
|
||||
[TRPC_ERROR_CODE.BAD_REQUEST]: {
|
||||
statusCode: 400,
|
||||
message: "Bad request: Please check your request data and try again",
|
||||
},
|
||||
[TRPC_ERROR_CODE.CONFLICT]: {
|
||||
statusCode: 409,
|
||||
message: "Could not process the request due to a resource conflict with the current state",
|
||||
},
|
||||
[TRPC_ERROR_CODE.TOO_MANY_REQUESTS]: {
|
||||
statusCode: 429,
|
||||
message: "You have exceeded the allowed number of requests",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PAYLOAD_TOO_LARGE]: {
|
||||
statusCode: 413,
|
||||
message: "The request payload is too large",
|
||||
},
|
||||
[TRPC_ERROR_CODE.CLIENT_CLOSED_REQUEST]: {
|
||||
statusCode: 499,
|
||||
message: "The client closed the connection before the server could finish processing the request",
|
||||
},
|
||||
[TRPC_ERROR_CODE.METHOD_NOT_SUPPORTED]: {
|
||||
statusCode: 405,
|
||||
message: "The requested method is not supported for this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.NOT_IMPLEMENTED]: {
|
||||
statusCode: 501,
|
||||
message: "The requested method is not implemented on the server",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PRECONDITION_FAILED]: {
|
||||
statusCode: 412,
|
||||
message: "The server does not meet one of the preconditions that the requester put on the request",
|
||||
},
|
||||
[TRPC_ERROR_CODE.TIMEOUT]: {
|
||||
statusCode: 408,
|
||||
message: "The request took too long to complete. Please try again later",
|
||||
},
|
||||
[TRPC_ERROR_CODE.UNPROCESSABLE_CONTENT]: {
|
||||
statusCode: 422,
|
||||
message:
|
||||
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PARSE_ERROR]: {
|
||||
statusCode: 400,
|
||||
message: "The request could not be parsed due to invalid syntax.",
|
||||
},
|
||||
};
|
||||
|
||||
// --- TRPCExceptionFilter (updated) ---
|
||||
|
||||
@Catch(TRPCError)
|
||||
export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("TRPCExceptionFilter");
|
||||
@@ -15,77 +109,13 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode = 500;
|
||||
let errorMessage = exception.message;
|
||||
const errorDefinition = TRPC_ERROR_MAP[exception.code as keyof typeof TRPC_ERROR_CODE] || {
|
||||
statusCode: 500,
|
||||
message: exception.message,
|
||||
};
|
||||
|
||||
switch (exception.code) {
|
||||
case "UNAUTHORIZED":
|
||||
statusCode = 401;
|
||||
errorMessage = "You are not authorized to access this resource";
|
||||
break;
|
||||
case "FORBIDDEN":
|
||||
statusCode = 403;
|
||||
errorMessage = "You don't have necessary permissions to access this resource";
|
||||
break;
|
||||
case "NOT_FOUND":
|
||||
statusCode = 404;
|
||||
errorMessage = "The requested resource was not found";
|
||||
break;
|
||||
case "INTERNAL_SERVER_ERROR":
|
||||
statusCode = 500;
|
||||
errorMessage = "An unexpected error occurred on the server. Please try again later";
|
||||
break;
|
||||
case "BAD_REQUEST":
|
||||
statusCode = 400;
|
||||
errorMessage = "Bad request: Please check your request data and try again";
|
||||
break;
|
||||
case "CONFLICT":
|
||||
statusCode = 409;
|
||||
errorMessage = "Could not process the request due to a resource conflict with the current state";
|
||||
break;
|
||||
case "TOO_MANY_REQUESTS":
|
||||
statusCode = 429;
|
||||
errorMessage = "You have exceeded the allowed number of requests";
|
||||
break;
|
||||
case "PAYLOAD_TOO_LARGE":
|
||||
statusCode = 413;
|
||||
errorMessage = "The request payload is too large";
|
||||
break;
|
||||
case "CLIENT_CLOSED_REQUEST":
|
||||
statusCode = 499;
|
||||
errorMessage =
|
||||
"The client closed the connection before the server could finish processing the request";
|
||||
break;
|
||||
case "METHOD_NOT_SUPPORTED":
|
||||
statusCode = 405;
|
||||
errorMessage = "The requested method is not supported for this resource";
|
||||
case "NOT_IMPLEMENTED":
|
||||
statusCode = 501;
|
||||
errorMessage = "The requested method is not implemented on the server";
|
||||
break;
|
||||
case "PRECONDITION_FAILED":
|
||||
statusCode = 412;
|
||||
errorMessage =
|
||||
"The server does not meet one of the preconditions that the requester put on the request";
|
||||
break;
|
||||
case "TIMEOUT":
|
||||
statusCode = 408;
|
||||
errorMessage = "The request took too long to complete. Please try again later";
|
||||
break;
|
||||
case "UNPROCESSABLE_CONTENT":
|
||||
statusCode = 422;
|
||||
errorMessage =
|
||||
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect";
|
||||
break;
|
||||
case "PARSE_ERROR":
|
||||
statusCode = 400;
|
||||
errorMessage = "The request could not be parsed due to invalid syntax.";
|
||||
break;
|
||||
default:
|
||||
statusCode = 500;
|
||||
errorMessage = exception.message;
|
||||
break;
|
||||
}
|
||||
const statusCode = errorDefinition.statusCode;
|
||||
const errorMessage = errorDefinition.message;
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
@@ -103,7 +133,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
status: ERROR_STATUS,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
error: { code: exception.name, message: errorMessage },
|
||||
error: { code: exception.name, message: errorMessage }, // exception.name is usually "TRPCError"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,12 +58,6 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
const IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||
const response = context.switchToHttp().getResponse<Response>();
|
||||
const tracker = await this.getTracker(request);
|
||||
this.logger.verbose(
|
||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
||||
"Authorization"
|
||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
||||
);
|
||||
|
||||
if (throttleOptions) {
|
||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||
}
|
||||
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||
${JSON.stringify(rateLimit, null, 2)}
|
||||
`);
|
||||
`);*/
|
||||
|
||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||
if (isBlocked) {
|
||||
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
|
||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||
if (cachedRateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||
${cachedRateLimits}
|
||||
`);
|
||||
`);*/
|
||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||
}
|
||||
|
||||
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||
});
|
||||
|
||||
if (rateLimits) {
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
}
|
||||
|
||||
if (!rateLimits || rateLimits.length === 0) {
|
||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);
|
||||
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||
${JSON.stringify(rateLimits, null, 2)}`);*/
|
||||
}
|
||||
|
||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
||||
);
|
||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||
|
||||
this.logger.verbose(
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||
);
|
||||
this.logger.verbose(
|
||||
);*/
|
||||
/*this.logger.verbose(
|
||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||
);
|
||||
);*/
|
||||
|
||||
return { isBlocked };
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
);
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
||||
|
||||
return request(app.getHttpServer())
|
||||
.post("/v2/billing/webhook")
|
||||
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||
.expect(200)
|
||||
.then(async (res) => {
|
||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||
|
||||
@@ -21,10 +21,12 @@ import {
|
||||
HttpStatus,
|
||||
Logger,
|
||||
Delete,
|
||||
BadRequestException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { ApiExcludeController } from "@nestjs/swagger";
|
||||
import { Request } from "express";
|
||||
import Stripe from "stripe";
|
||||
|
||||
import { ApiResponse } from "@calcom/platform-types";
|
||||
|
||||
@@ -116,35 +118,59 @@ export class BillingController {
|
||||
@Req() request: Request,
|
||||
@Headers("stripe-signature") stripeSignature: string
|
||||
): Promise<ApiResponse> {
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
try {
|
||||
if (!stripeSignature) {
|
||||
this.logger.warn("Missing stripe-signature header in webhook request");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
if (!this.stripeWhSecret) {
|
||||
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
|
||||
const event = await this.billingService.stripeService
|
||||
.getStripe()
|
||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||
|
||||
switch (event.type) {
|
||||
case "checkout.session.completed":
|
||||
await this.billingService.handleStripeCheckoutEvents(event);
|
||||
break;
|
||||
case "customer.subscription.updated":
|
||||
await this.billingService.handleStripePaymentPastDue(event);
|
||||
break;
|
||||
case "customer.subscription.deleted":
|
||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||
break;
|
||||
case "invoice.created":
|
||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||
break;
|
||||
case "invoice.payment_failed":
|
||||
await this.billingService.handleStripePaymentFailed(event);
|
||||
break;
|
||||
case "invoice.payment_succeeded":
|
||||
await this.billingService.handleStripePaymentSuccess(event);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
|
||||
this.logger.error("Webhook signature validation failed", error);
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: "success",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,18 @@ export class CalendarEventHost {
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
nullable: true,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
description: "Host's response to the invitation",
|
||||
example: CalendarEventResponseStatus.ACCEPTED,
|
||||
})
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
|
||||
+20
-20
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
|
||||
}
|
||||
}
|
||||
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
|
||||
if (googleEvent.organizer) {
|
||||
return [
|
||||
{
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
},
|
||||
];
|
||||
} else if (googleEvent.attendees) {
|
||||
// If no explicit organizer, find the first attendee with organizer flag
|
||||
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
|
||||
if (organizerAttendee) {
|
||||
return [
|
||||
{
|
||||
email: organizerAttendee.email,
|
||||
name: organizerAttendee.displayName,
|
||||
},
|
||||
];
|
||||
}
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse) {
|
||||
const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
|
||||
[];
|
||||
|
||||
const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
|
||||
if (organizerAttendees?.length) {
|
||||
organizerAttendees.forEach((organizer) => {
|
||||
hosts.push({
|
||||
email: organizer.email,
|
||||
name: organizer.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
hosts.push({
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
responseStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
return hosts;
|
||||
}
|
||||
|
||||
private transformLocations(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@@ -8,12 +9,26 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
export class ConferencingRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
subscriptionId: true,
|
||||
paymentStatus: true,
|
||||
billingCycleStart: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
|
||||
async findConferencingApps(userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,24 +38,28 @@ export class ConferencingRepository {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findConferencingApp(userId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, appId: app },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +144,9 @@ export class OAuthClientUsersService {
|
||||
}
|
||||
|
||||
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||
if (userEmail.includes(`+${oAuthClientId}@`)) {
|
||||
return userEmail;
|
||||
}
|
||||
const [username, emailDomain] = userEmail.split("@");
|
||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
|
||||
@@ -209,7 +211,12 @@ export class SlotsController_2024_04_15 {
|
||||
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
|
||||
);
|
||||
}
|
||||
|
||||
if (TRPC_ERROR_MAP[error.message as keyof typeof TRPC_ERROR_CODE]) {
|
||||
throw new TRPCError({ code: error.message as TRPCErrorCode });
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26400,7 +26400,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -26460,6 +26460,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
|
||||
import { TooltipExample } from "./avatar.tooltip"
|
||||
import { LinkExample } from "./avatar.link"
|
||||
import { IndicatorExample } from "./avatar.indicator"
|
||||
import { ShapeExample } from "./avatar.shape"
|
||||
|
||||
## Size Variations
|
||||
|
||||
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
|
||||
|
||||
<SizesExample/>
|
||||
|
||||
## Shape Variations
|
||||
|
||||
Avatars can be displayed in either circular or square shapes.
|
||||
|
||||
<ShapeExample/>
|
||||
|
||||
## With and Without Image
|
||||
|
||||
Avatars can display either an image or a fallback placeholder.
|
||||
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
|
||||
Avatars can include status indicators or badges.
|
||||
|
||||
<IndicatorExample/>
|
||||
|
||||
## Usage Example
|
||||
|
||||
Here's a complete example showing how to use the Avatar component with all its props:
|
||||
|
||||
```tsx
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
function Example() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Basic Avatar */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
/>
|
||||
|
||||
{/* Avatar with Link */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
href="/profile"
|
||||
/>
|
||||
|
||||
{/* Avatar with Tooltip and Indicator */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
imageSrc="https://example.com/avatar.jpg"
|
||||
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
|
||||
/>
|
||||
|
||||
{/* Avatar with Fallback */}
|
||||
<Avatar
|
||||
alt="User Name"
|
||||
title="User Name"
|
||||
size="md"
|
||||
shape="circle"
|
||||
fallback={<span>UN</span>}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
|
||||
export function ShapeExample() {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Avatar
|
||||
alt="Circle Avatar"
|
||||
title="Circle Avatar"
|
||||
size="lg"
|
||||
shape="circle"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
<Avatar
|
||||
alt="Square Avatar"
|
||||
title="Square Avatar"
|
||||
size="lg"
|
||||
shape="square"
|
||||
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -21,11 +23,18 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
const stepSchema = z.enum(["1", "2", "3", "4"]);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
const ServerPage = async ({ params, searchParams: _searchParams }: ServerPageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const stepResult = stepSchema.safeParse(searchParams?.step);
|
||||
|
||||
// If step parameter is invalid, redirect to step 1
|
||||
if (!stepResult.success) {
|
||||
return redirect(`/auth/setup?step=1`);
|
||||
}
|
||||
|
||||
const props = await getData(buildLegacyCtx(await headers(), await cookies(), await params, searchParams));
|
||||
return <Setup {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ async function postHandler(req: NextRequest) {
|
||||
if (!log.smsSid) return;
|
||||
|
||||
try {
|
||||
const price = await twilio.getPriceForSMS(log.smsSid);
|
||||
const { price, numSegments } = await twilio.getMessageInfo(log.smsSid);
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
if (!credits) return;
|
||||
|
||||
const updatedLog = await prisma.creditExpenseLog.update({
|
||||
where: { id: log.id },
|
||||
data: { credits },
|
||||
data: { credits, smsSegments: numSegments },
|
||||
select: {
|
||||
creditBalance: {
|
||||
select: {
|
||||
|
||||
@@ -8,11 +8,11 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
|
||||
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
||||
|
||||
// Mock GoogleCalendarService
|
||||
const fetchPrimaryCalendarMock = vi.fn();
|
||||
const getPrimaryCalendarMock = vi.fn();
|
||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
||||
getPrimaryCalendar: getPrimaryCalendarMock,
|
||||
})),
|
||||
};
|
||||
});
|
||||
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
prismock.user.deleteMany();
|
||||
prismock.team.deleteMany();
|
||||
prismock.workspacePlatform.deleteMany();
|
||||
fetchPrimaryCalendarMock.mockReset();
|
||||
getPrimaryCalendarMock.mockReset();
|
||||
});
|
||||
|
||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
||||
@@ -138,7 +138,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
delegationCredentialId: delegationCredential.id,
|
||||
credentialId: 1,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
error: "some error" as unknown as null,
|
||||
credentialId: regularCredential.id,
|
||||
});
|
||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(1);
|
||||
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
||||
getPrimaryCalendarMock.mockRejectedValue(
|
||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||
);
|
||||
|
||||
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
||||
domain: "example.com",
|
||||
});
|
||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
getPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||
|
||||
const result = await handleCreateSelectedCalendars();
|
||||
expect(result.success).toBe(0);
|
||||
|
||||
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
|
||||
const userEmail = delegationUserCredential.user.email;
|
||||
|
||||
try {
|
||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
||||
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
|
||||
primaryCalendarId = primaryCalendar?.id;
|
||||
} catch (error) {
|
||||
log.error(
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||
|
||||
import { GET } from "../route";
|
||||
|
||||
vi.mock("next/og", () => ({
|
||||
ImageResponse: vi.fn().mockImplementation(() => ({
|
||||
body: new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/OgImages", () => ({
|
||||
Meeting: vi.fn(() => null),
|
||||
App: vi.fn(() => null),
|
||||
Generic: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/lib/constants", () => ({
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
}));
|
||||
|
||||
global.fetch = vi.fn();
|
||||
|
||||
function createNextRequest(url: string): NextRequest {
|
||||
const request = new Request(url, { method: "GET" });
|
||||
return new NextRequest(request);
|
||||
}
|
||||
|
||||
describe("GET /api/social/og/image", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
vi.mocked(global.fetch).mockResolvedValue({
|
||||
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("Validation errors (400 Bad Request)", () => {
|
||||
test("meeting type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for meeting image");
|
||||
expect(errorData.message).toBe(
|
||||
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
|
||||
);
|
||||
});
|
||||
|
||||
test("app type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for app image");
|
||||
expect(errorData.message).toBe("Required parameters: name, description, slug");
|
||||
});
|
||||
|
||||
test("generic type: returns 400 when required parameters are missing", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
const errorData = await response.json();
|
||||
expect(errorData.error).toBe("Invalid parameters for generic image");
|
||||
expect(errorData.message).toBe("Required parameters: title, description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Not found errors (404 Not Found)", () => {
|
||||
test("returns 404 when no type parameter is provided", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
});
|
||||
|
||||
test("returns 404 when invalid type parameter is provided", async () => {
|
||||
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Server errors (500 Internal Server Error)", () => {
|
||||
test("returns 500 when font loading fails", async () => {
|
||||
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
|
||||
|
||||
const request = createNextRequest(
|
||||
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
|
||||
);
|
||||
const response = await GET(request);
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(await response.text()).toBe("Internal server error");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import type { NextRequest } from "next/server";
|
||||
import type { SatoriOptions } from "satori";
|
||||
import { z } from "zod";
|
||||
import { z, ZodError } from "zod";
|
||||
|
||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
|
||||
const { searchParams } = req.nextUrl;
|
||||
const imageType = searchParams.get("type");
|
||||
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
};
|
||||
try {
|
||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||
]);
|
||||
const ogConfig = {
|
||||
width: 1200,
|
||||
height: 630,
|
||||
fonts: [
|
||||
{ name: "inter", data: interFontData, weight: 400 },
|
||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||
{ name: "cal", data: calFontData, weight: 400 },
|
||||
{ name: "cal", data: calFontData, weight: 600 },
|
||||
] as SatoriOptions["fonts"],
|
||||
};
|
||||
|
||||
switch (imageType) {
|
||||
case "meeting": {
|
||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||
names: searchParams.getAll("names"),
|
||||
usernames: searchParams.getAll("usernames"),
|
||||
title: searchParams.get("title"),
|
||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
switch (imageType) {
|
||||
case "meeting": {
|
||||
try {
|
||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||
names: searchParams.getAll("names"),
|
||||
usernames: searchParams.getAll("usernames"),
|
||||
title: searchParams.get("title"),
|
||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||
meetingImage: searchParams.get("meetingImage"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
const img = new ImageResponse(
|
||||
(
|
||||
<Meeting
|
||||
title={title}
|
||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||
/>
|
||||
),
|
||||
ogConfig
|
||||
);
|
||||
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
});
|
||||
return new Response(img.body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for meeting image",
|
||||
message:
|
||||
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
case "app": {
|
||||
try {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for app image",
|
||||
message: "Required parameters: name, description, slug",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
try {
|
||||
const { title, description } = genericSchema.parse({
|
||||
title: searchParams.get("title"),
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "Invalid parameters for generic image",
|
||||
message: "Required parameters: title, description",
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
}
|
||||
case "app": {
|
||||
const { name, description, slug } = appSchema.parse({
|
||||
name: searchParams.get("name"),
|
||||
description: searchParams.get("description"),
|
||||
slug: searchParams.get("slug"),
|
||||
imageType,
|
||||
});
|
||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
}
|
||||
|
||||
case "generic": {
|
||||
const { title, description } = genericSchema.parse({
|
||||
title: searchParams.get("title"),
|
||||
description: searchParams.get("description"),
|
||||
imageType,
|
||||
});
|
||||
|
||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||
|
||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||
}
|
||||
|
||||
default:
|
||||
return new Response("What you're looking for is not here..", { status: 404 });
|
||||
} catch (error) {
|
||||
return new Response("Internal server error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useSession } from "next-auth/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const bookerUrl = useBookerUrl();
|
||||
const session = useSession();
|
||||
const teamMemberIdsMatchingAttributeLogic =
|
||||
teamMembersMatchingAttributeLogic?.data
|
||||
?.map((member) => member.id)
|
||||
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
|
||||
booking,
|
||||
currentResponse,
|
||||
searchParams: new URLSearchParams({
|
||||
// rescheduleReason
|
||||
rescheduledBy: session?.data?.user?.email ?? "",
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
|
||||
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||
createBookingMutation.mutate({
|
||||
rescheduleUid: booking.uid,
|
||||
rescheduledBy: session?.data?.user?.email ?? undefined,
|
||||
// rescheduleReason,
|
||||
reroutingFormResponses: reroutingFormResponses,
|
||||
...getTimeslotFields(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
import { vi } from "vitest";
|
||||
|
||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
|
||||
|
||||
vi.stubGlobal("open", mockOpen);
|
||||
|
||||
const mockSession = {
|
||||
expires: new Date(Date.now() + 2 * 86400).toISOString(),
|
||||
user: {
|
||||
id: 1,
|
||||
name: "Test User",
|
||||
email: "user@example.com",
|
||||
},
|
||||
} as Session;
|
||||
|
||||
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||
return (
|
||||
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New Routing tests", () => {
|
||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
fireEvent.click(screen.getByText("verify_new_route"));
|
||||
|
||||
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
|
||||
describe("New tab rescheduling", () => {
|
||||
test("new tab is closed when new booking is rerouted", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
|
||||
|
||||
test("Rescheduling with same timeslot works", async () => {
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
<SessionProvider session={mockSession}>
|
||||
<TooltipProvider>
|
||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||
</TooltipProvider>
|
||||
</SessionProvider>
|
||||
);
|
||||
clickVerifyNewRouteButton();
|
||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||
|
||||
@@ -3,7 +3,6 @@ import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||
@@ -12,15 +11,13 @@ export const getTemporaryOrgRedirect = async ({
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
prismaClient,
|
||||
}: {
|
||||
slugs: string[] | string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
prismaClient?: PrismaClient;
|
||||
}) => {
|
||||
const prisma = prismaClient ?? (await import("@calcom/prisma")).default;
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
slugs = slugs instanceof Array ? slugs : [slugs];
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
|
||||
@@ -2,11 +2,9 @@ import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import type { GetBookingType } from "@calcom/features/bookings/lib/get-booking";
|
||||
import { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||
import {
|
||||
orgDomainConfig,
|
||||
whereClauseForOrgWithSlugOrRequestedSlug,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
@@ -32,7 +30,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { req, params, query } = context;
|
||||
const session = await getServerSession({ req });
|
||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting } = query;
|
||||
const { rescheduleUid, isInstantMeeting: queryIsInstantMeeting, email } = query;
|
||||
const allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
@@ -43,7 +41,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
prismaClient: prisma,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
@@ -51,49 +48,43 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
}
|
||||
}
|
||||
|
||||
const [orgId, booking] = await Promise.all([
|
||||
isOrgContext ? getOrgId(currentOrgDomain) : Promise.resolve(null),
|
||||
rescheduleUid ? getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : Promise.resolve(null),
|
||||
]);
|
||||
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||
|
||||
const team = await getTeamData(teamSlug, orgId);
|
||||
|
||||
if (!team) {
|
||||
if (!team || !team.eventTypes?.[0]) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
|
||||
const eventData = await getEventTypeData(meetingSlug, team.id);
|
||||
|
||||
if (!eventData) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
const eventData = team.eventTypes[0];
|
||||
|
||||
if (rescheduleUid && eventData.disableRescheduling) {
|
||||
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
|
||||
}
|
||||
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const eventTypeId = eventData.id;
|
||||
const eventHostsUserData = await getUsersData(
|
||||
team.isPrivate,
|
||||
eventTypeId,
|
||||
eventData.hosts.map((h) => h.user)
|
||||
);
|
||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||
const name = team.parent?.name ?? team.name ?? null;
|
||||
|
||||
const eventHostsUserData = getEventHosts(
|
||||
team.isPrivate,
|
||||
eventData.hosts.map((h) => h.user),
|
||||
eventData.users ?? []
|
||||
);
|
||||
let booking: GetBookingType | null = null;
|
||||
if (rescheduleUid) {
|
||||
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||
if (
|
||||
booking?.status === BookingStatus.CANCELLED &&
|
||||
!allowRescheduleForCancelledBooking &&
|
||||
!eventData.allowReschedulingCancelledBookings
|
||||
) {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
||||
@@ -104,9 +95,11 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
// Handle string[] type from query params
|
||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||
|
||||
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
||||
? crmContactOwnerRecordType[0]
|
||||
: crmContactOwnerRecordType;
|
||||
|
||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||
|
||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||
@@ -129,6 +122,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const organizationSettings = getOrganizationSEOSettings(team);
|
||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||
|
||||
const featureRepo = new FeaturesRepository();
|
||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||
@@ -178,149 +172,116 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
};
|
||||
};
|
||||
|
||||
const getOrgId = async (orgSlug: string): Promise<number | null> => {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
return org?.id ?? null;
|
||||
};
|
||||
|
||||
const getTeamData = async (teamSlug: string, orgId: number | null) => {
|
||||
const teamSelectFields = {
|
||||
id: true,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
isOrganization: true,
|
||||
parent: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (orgId !== null) {
|
||||
const publishedTeam = await prisma.team.findUnique({
|
||||
where: {
|
||||
slug_parentId: {
|
||||
slug: teamSlug,
|
||||
parentId: orgId,
|
||||
},
|
||||
isOrganization: false,
|
||||
},
|
||||
select: teamSelectFields,
|
||||
});
|
||||
if (publishedTeam) return publishedTeam;
|
||||
}
|
||||
|
||||
const getTeamWithEventsData = async (
|
||||
teamSlug: string,
|
||||
meetingSlug: string,
|
||||
isValidOrgDomain: boolean,
|
||||
currentOrgDomain: string | null
|
||||
) => {
|
||||
return await prisma.team.findFirst({
|
||||
where: {
|
||||
parentId: orgId ?? null,
|
||||
isOrganization: false,
|
||||
OR: [
|
||||
{ slug: teamSlug },
|
||||
{
|
||||
metadata: {
|
||||
path: ["requestedSlug"],
|
||||
equals: teamSlug,
|
||||
},
|
||||
},
|
||||
],
|
||||
...getSlugOrRequestedSlug(teamSlug),
|
||||
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: teamSelectFields,
|
||||
orderBy: {
|
||||
slug: { sort: "asc", nulls: "last" },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getEventTypeData = async (meetingSlug: string, teamId: number) => {
|
||||
return await prisma.eventType.findUnique({
|
||||
where: {
|
||||
// Use the EventType_teamId_slug_key unique index
|
||||
teamId_slug: {
|
||||
teamId: teamId,
|
||||
slug: meetingSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
isPrivate: true,
|
||||
hideBranding: true,
|
||||
parent: {
|
||||
select: {
|
||||
user: {
|
||||
slug: true,
|
||||
name: true,
|
||||
bannerUrl: true,
|
||||
logoUrl: true,
|
||||
hideBranding: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// Include users for when hosts is empty
|
||||
users: {
|
||||
take: 1,
|
||||
logoUrl: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
eventTypes: {
|
||||
where: {
|
||||
slug: meetingSlug,
|
||||
},
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
id: true,
|
||||
title: true,
|
||||
isInstantEvent: true,
|
||||
schedulingType: true,
|
||||
metadata: true,
|
||||
length: true,
|
||||
hidden: true,
|
||||
disableCancelling: true,
|
||||
disableRescheduling: true,
|
||||
allowReschedulingCancelledBookings: true,
|
||||
interfaceLanguage: true,
|
||||
hosts: {
|
||||
take: 3,
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
name: true,
|
||||
username: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isOrganization: true,
|
||||
organizationSettings: {
|
||||
select: {
|
||||
allowSEOIndexing: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const getEventHosts = (
|
||||
const getUsersData = async (
|
||||
isPrivateTeam: boolean,
|
||||
hosts: Pick<User, "username" | "name">[],
|
||||
eventTypeId: number,
|
||||
users: Pick<User, "username" | "name">[]
|
||||
) => {
|
||||
if (isPrivateTeam) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (hosts.length > 0) {
|
||||
return hosts
|
||||
if (!isPrivateTeam && users.length > 0) {
|
||||
return users
|
||||
.filter((user) => user.username)
|
||||
.map((user) => ({
|
||||
username: user.username ?? "",
|
||||
name: user.name ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
if (users.length > 0) {
|
||||
return [
|
||||
{
|
||||
username: users[0].username ?? "",
|
||||
name: users[0].name ?? "",
|
||||
if (!isPrivateTeam && users.length === 0) {
|
||||
const { users: data } = await prisma.eventType.findUniqueOrThrow({
|
||||
where: { id: eventTypeId },
|
||||
select: {
|
||||
users: {
|
||||
take: 1,
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return data.length > 0
|
||||
? [
|
||||
{
|
||||
username: data[0].username ?? "",
|
||||
name: data[0].name ?? "",
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useMemo, useRef } from "react";
|
||||
|
||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||
@@ -44,34 +45,6 @@ type RecurringInfo = {
|
||||
bookings: { [key: string]: Date[] };
|
||||
};
|
||||
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
||||
{
|
||||
name: "upcoming",
|
||||
href: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
href: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
href: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
href: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
href: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||
upcoming: "upcoming_bookings",
|
||||
recurring: "recurring_bookings",
|
||||
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
|
||||
const { t } = useLocale();
|
||||
const user = useMeQuery().data;
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// Generate dynamic tabs that preserve query parameters
|
||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
|
||||
const queryString = searchParams?.toString() || "";
|
||||
|
||||
const baseTabConfigs = [
|
||||
{
|
||||
name: "upcoming",
|
||||
path: "/bookings/upcoming",
|
||||
"data-testid": "upcoming",
|
||||
},
|
||||
{
|
||||
name: "unconfirmed",
|
||||
path: "/bookings/unconfirmed",
|
||||
"data-testid": "unconfirmed",
|
||||
},
|
||||
{
|
||||
name: "recurring",
|
||||
path: "/bookings/recurring",
|
||||
"data-testid": "recurring",
|
||||
},
|
||||
{
|
||||
name: "past",
|
||||
path: "/bookings/past",
|
||||
"data-testid": "past",
|
||||
},
|
||||
{
|
||||
name: "cancelled",
|
||||
path: "/bookings/cancelled",
|
||||
"data-testid": "cancelled",
|
||||
},
|
||||
];
|
||||
|
||||
return baseTabConfigs.map((tabConfig) => ({
|
||||
name: tabConfig.name,
|
||||
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||
"data-testid": tabConfig["data-testid"],
|
||||
}));
|
||||
}, [searchParams?.toString()]);
|
||||
|
||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||
|
||||
@@ -315,17 +315,20 @@ export default function Success(props: PageProps) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
if (isAttendee) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
return t(`emailed_host_and_attendee${titleSuffix}`);
|
||||
@@ -546,7 +549,7 @@ export default function Success(props: PageProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
|
||||
@@ -3,22 +3,60 @@
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/toast";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
type MonthOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
// returns the last 12 months starting from May 2025 (when credits were introduced)
|
||||
const getMonthOptions = (): MonthOption[] => {
|
||||
const options: MonthOption[] = [];
|
||||
const minDate = dayjs.utc("2025-05-01");
|
||||
|
||||
let date = dayjs.utc();
|
||||
let count = 0;
|
||||
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
|
||||
const startDate = date.startOf("month");
|
||||
const endDate = date.endOf("month");
|
||||
options.push({
|
||||
value: date.format("MMMM YYYY"),
|
||||
label: date.format("MMMM YYYY"),
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
date = date.subtract(1, "month");
|
||||
count++;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export default function BillingCredits() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -28,8 +66,8 @@ export default function BillingCredits() {
|
||||
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
||||
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const teamId = params.id ? Number(params.id) : undefined;
|
||||
|
||||
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
|
||||
|
||||
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
||||
@@ -43,6 +81,27 @@ export default function BillingCredits() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const result = await utils.viewer.credits.downloadExpenseLog.fetch({
|
||||
teamId,
|
||||
startDate: selectedMonth.startDate,
|
||||
endDate: selectedMonth.endDate,
|
||||
});
|
||||
if (result?.csvData) {
|
||||
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
|
||||
downloadAsCsv(result.csvData, filename);
|
||||
} else {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!IS_SMS_CREDITS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
@@ -112,8 +171,8 @@ export default function BillingCredits() {
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
||||
<div className="mr-auto">
|
||||
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div>
|
||||
<div className="-mb-1 mr-auto">
|
||||
<Label>{t("buy_additional_credits")}</Label>
|
||||
<div className="flex flex-col">
|
||||
<TextField
|
||||
required
|
||||
@@ -132,12 +191,32 @@ export default function BillingCredits() {
|
||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 mt-auto">
|
||||
<div className="mt-auto">
|
||||
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
|
||||
{t("buy_credits")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-auto ">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mt-2 flex flex-col">
|
||||
<Select
|
||||
options={monthOptions}
|
||||
value={selectedMonth}
|
||||
onChange={(option) => option && setSelectedMonth(option)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function BillingCreditsSkeleton() {
|
||||
<div className="flex">
|
||||
<div className="mr-auto">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 mt-1 h-5 w-[268px]" /> {/* Buy credits label */}
|
||||
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
||||
@@ -56,6 +56,24 @@ export function BillingCreditsSkeleton() {
|
||||
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Download expense log */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto mt-1">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
|
||||
@@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { TextField } from "@calcom/ui/components/form";
|
||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { showToast } from "@calcom/ui/components/toast";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
||||
|
||||
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
|
||||
return <AddNewTeamMembersForm />;
|
||||
};
|
||||
|
||||
const useCheckout = () => {
|
||||
const useOrgCreation = () => {
|
||||
const { t } = useLocale();
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||
const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
@@ -43,17 +48,34 @@ const useCheckout = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data.organization) {
|
||||
// Invalidate the organizations query to ensure fresh data on the next page
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setServerErrorMessage(t(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||
|
||||
return {
|
||||
mutation,
|
||||
mutate: mutation.mutate,
|
||||
isPending: mutation.isPending,
|
||||
mutation: mutationToUse,
|
||||
mutate: mutationToUse.mutate,
|
||||
isPending: mutationToUse.isPending,
|
||||
errorMessage: serverErrorMessage,
|
||||
};
|
||||
};
|
||||
|
||||
export const AddNewTeamMembersForm = () => {
|
||||
const { t } = useLocale();
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const {
|
||||
addInvitedMember,
|
||||
removeInvitedMember,
|
||||
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
bio,
|
||||
onboardingId,
|
||||
} = useOnboardingStore();
|
||||
const checkout = useCheckout();
|
||||
const orgCreation = useOrgCreation();
|
||||
|
||||
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
||||
|
||||
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{checkout.errorMessage && (
|
||||
{orgCreation.errorMessage && (
|
||||
<div className="mb-4">
|
||||
<Alert severity="error" message={checkout.errorMessage} />
|
||||
<Alert severity="error" message={orgCreation.errorMessage} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-6">
|
||||
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||
<Button
|
||||
onClick={() => {
|
||||
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
checkout.mutation.mutate({
|
||||
orgCreation.mutation.mutate({
|
||||
logo,
|
||||
bio,
|
||||
teams,
|
||||
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
|
||||
onboardingId,
|
||||
});
|
||||
}}
|
||||
loading={checkout.isPending}>
|
||||
{t("checkout")}
|
||||
loading={orgCreation.isPending}>
|
||||
{isBillingEnabled ? t("checkout") : t("create")}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -691,25 +691,23 @@ export default function Signup({
|
||||
/>
|
||||
</div>
|
||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||
{FEATURES.map((feature) => (
|
||||
<>
|
||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{FEATURES.map((feature, index) => (
|
||||
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||
<div className="text-emphasis items-center">
|
||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||
</div>
|
||||
</>
|
||||
<div className="text-subtle text-sm">
|
||||
<p>
|
||||
{t(
|
||||
feature.description,
|
||||
feature.i18nOptions && {
|
||||
...feature.i18nOptions,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.4",
|
||||
"version": "5.4.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -194,7 +194,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
|
||||
@@ -121,7 +121,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
if (!orgId) {
|
||||
orgId = await getOrgIdFromMemberOrTeamId({
|
||||
memberId: parsedUserId,
|
||||
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
|
||||
teamId: parsedTeamId,
|
||||
});
|
||||
}
|
||||
@@ -137,7 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
|
||||
}
|
||||
|
||||
const price = await twilio.getPriceForSMS(smsSid);
|
||||
const { price, numSegments } = await twilio.getMessageInfo(smsSid);
|
||||
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
|
||||
@@ -147,6 +147,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
userId: parsedUserId,
|
||||
smsSid,
|
||||
bookingUid: parsedBookingUid,
|
||||
smsSegments: numSegments ?? undefined,
|
||||
});
|
||||
|
||||
if (chargedUserOrTeamId) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { randomString } from "@calcom/lib/random";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewEventType,
|
||||
createNewUserEventType,
|
||||
gotoBookingPage,
|
||||
gotoFirstEventType,
|
||||
saveEventType,
|
||||
@@ -55,7 +55,7 @@ test.describe("Event Types tests", () => {
|
||||
test("can add new event type", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
@@ -63,7 +63,7 @@ test.describe("Event Types tests", () => {
|
||||
test("new event type appears first in the list", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
|
||||
const firstEventTitle = await firstEvent.getAttribute("title");
|
||||
@@ -73,7 +73,7 @@ test.describe("Event Types tests", () => {
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `my recurring event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
|
||||
// fix the race condition
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
@@ -393,7 +393,7 @@ test.describe("Event Types tests", () => {
|
||||
}) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `Conflict event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await page.click(`text=${eventTitle}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { loginUser } from "playwright/fixtures/regularBookings";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("AI Translation - Event Type", () => {
|
||||
test("Toggle should not be clickable for free plan members", async ({ page, users, bookingPage }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("Toggle should not be clickable for team plan members", async ({ page, users, bookingPage }) => {
|
||||
const user = await users.create(
|
||||
{ name: "testuser" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teamRole: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("Toggle should be clickable for org members", async ({ page, orgs, users, bookingPage }) => {
|
||||
const org = await orgs.create({
|
||||
name: "acme",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -198,5 +198,19 @@ export function createBookingPageFixture(page: Page) {
|
||||
checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
|
||||
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
|
||||
},
|
||||
getAITranslationToggleDisabled: async () => {
|
||||
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
|
||||
timeout: 5000,
|
||||
state: "attached",
|
||||
});
|
||||
const toggle = page.getByTestId("ai_translation_toggle");
|
||||
return (await toggle.getAttribute("disabled")) !== null;
|
||||
},
|
||||
toggleAITranslation: async () => {
|
||||
await page.getByTestId("ai_translation_toggle").click();
|
||||
},
|
||||
updateEventTypeDescription: async (description: string) => {
|
||||
await page.getByTestId("editor-input").fill(description);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -395,7 +395,7 @@ export const createUsersFixture = (
|
||||
},
|
||||
{
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
|
||||
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||
queryValue: {
|
||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||
type: "group",
|
||||
|
||||
@@ -7,12 +7,14 @@ import { createServer } from "http";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { Messages } from "mailhog";
|
||||
import { totp } from "otplib";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import type { CreateUsersFixture } from "../fixtures/users";
|
||||
import type { Fixtures } from "./fixtures";
|
||||
|
||||
type Request = IncomingMessage & { body?: unknown };
|
||||
@@ -177,10 +179,12 @@ export async function expectSlotNotAllowedToBook(page: Page) {
|
||||
await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible();
|
||||
}
|
||||
|
||||
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
export const createNewUserEventType = async (page: Page, args: { eventTitle: string; username?: string }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const eventTitle = args.eventTitle;
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
if (args.username) {
|
||||
await page.getByRole("button", { name: args.username }).click();
|
||||
}
|
||||
await page.fill("[name=title]", args.eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
@@ -216,7 +220,7 @@ export async function setupManagedEvent({
|
||||
|
||||
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
const eventTitle = args.eventTitle;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -563,3 +567,26 @@ export async function expectPageToBeNotFound({ page, url }: { page: Page; url: s
|
||||
await page.goto(`${url}`);
|
||||
await expect(page.getByTestId(`404-page`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function setupOrgMember(users: CreateUsersFixture) {
|
||||
const orgRequestedSlug = `example-${uuid()}`;
|
||||
|
||||
const orgMember = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
orgRequestedSlug,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team: org } = await orgMember.getOrgMembership();
|
||||
const { team } = await orgMember.getFirstTeamMembership();
|
||||
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
|
||||
const userEvent = orgMember.eventTypes[0];
|
||||
|
||||
await orgMember.apiLogin();
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
createHttpServer,
|
||||
createNewEventType,
|
||||
createNewUserEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
submitAndWaitForResponse,
|
||||
} from "./lib/testUtils";
|
||||
@@ -752,7 +752,7 @@ test.describe("Text area min and max characters text", () => {
|
||||
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
|
||||
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { doOnOrgDomain } from "playwright/lib/testUtils";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { doOnOrgDomain, setupOrgMember } from "playwright/lib/testUtils";
|
||||
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { CreateUsersFixture } from "../fixtures/users";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
async function setupOrgMember(users: CreateUsersFixture) {
|
||||
const orgRequestedSlug = `example-${uuid()}`;
|
||||
|
||||
const orgMember = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
orgRequestedSlug,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team: org } = await orgMember.getOrgMembership();
|
||||
const { team } = await orgMember.getFirstTeamMembership();
|
||||
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
|
||||
const userEvent = orgMember.eventTypes[0];
|
||||
|
||||
await orgMember.apiLogin();
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
type TestContext = Awaited<ReturnType<typeof setupOrgMember>>;
|
||||
|
||||
interface ToggleSeoSwitchParams {
|
||||
|
||||
@@ -267,6 +267,8 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
"is_still_available": "is still available.",
|
||||
@@ -1710,6 +1712,8 @@
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"questions": "Questions",
|
||||
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||
"platform_members": "Platform members",
|
||||
@@ -2835,7 +2839,7 @@
|
||||
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
|
||||
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
|
||||
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
|
||||
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
|
||||
"salesforce_change_record_owner_on_booking": "Change record owner on booking",
|
||||
"mass_assign_attributes": "Mass assign attributes",
|
||||
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
|
||||
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
|
||||
@@ -2888,6 +2892,7 @@
|
||||
"yes_reassign": "Yes, reassign",
|
||||
"filter_by_attributes": "Filter by attributes",
|
||||
"reassign_reason": "Reason for reassigning",
|
||||
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
|
||||
"reassigned_by": "Reassigned by",
|
||||
"row": "row",
|
||||
"rows": "rows",
|
||||
@@ -2933,7 +2938,7 @@
|
||||
"routing_form_insights_assignment_reason": "Assignment Reason",
|
||||
"access_denied": "Access Denied",
|
||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||
"salesforce_add_attendees_as":"Add new attendees as",
|
||||
"salesforce_add_attendees_as": "Add new attendees as",
|
||||
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
||||
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||
"salesforce_option": "Salesforce Option",
|
||||
@@ -2996,7 +3001,7 @@
|
||||
"shortfall": "Shortfall",
|
||||
"routing_form": "Routing Form",
|
||||
"go_back_and_save": "Go back and save",
|
||||
"save_changes":"Save changes",
|
||||
"save_changes": "Save changes",
|
||||
"leave_without_saving": "Leave without saving",
|
||||
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
||||
"something_unexpected_occurred": "Something unexpected occurred",
|
||||
@@ -3031,9 +3036,9 @@
|
||||
"authorize": "Authorize",
|
||||
"next": "Next",
|
||||
"hide": "Hide",
|
||||
"show":"Show",
|
||||
"utm_params":"UTM parameters",
|
||||
"no_utm_params":"No UTM parameters available.",
|
||||
"show": "Show",
|
||||
"utm_params": "UTM parameters",
|
||||
"no_utm_params": "No UTM parameters available.",
|
||||
"asc": "Asc",
|
||||
"desc": "Desc",
|
||||
"verify_email_change": "Verify email change",
|
||||
@@ -3103,7 +3108,7 @@
|
||||
"user_has_no_team_yet": "You don't have a team yet",
|
||||
"no_team_members": "You don't have team members yet",
|
||||
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||
"segment": "Segment",
|
||||
@@ -3181,8 +3186,8 @@
|
||||
"resend": "Resend",
|
||||
"verification_email_sent": "Verification email sent",
|
||||
"select_all_members": "Select all members",
|
||||
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.",
|
||||
"form_settings":"Form Settings",
|
||||
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
|
||||
"form_settings": "Form Settings",
|
||||
"remove_whitelist_status": "Remove whitelist status",
|
||||
"whitelist_user_workflows": "Whitelist user workflows",
|
||||
"user_workflows_whitelisted": "User workflows whitelisted",
|
||||
@@ -3252,6 +3257,8 @@
|
||||
"meeting_start_time": "Meeting Start Time",
|
||||
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||
"download_expense_log": "Download Expense Log",
|
||||
"error_downloading_expense_log": "Error downloading expense log",
|
||||
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
|
||||
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
|
||||
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
|
||||
"notes": "Jegyzetek",
|
||||
"manage_my_bookings": "Foglalásaim kezelése",
|
||||
"need_to_make_a_change": "Változtatnál valamit?",
|
||||
"need_to_make_a_change": "Változtatna valamin?",
|
||||
"new_event_scheduled": "Új eseményt foglaltak.",
|
||||
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
|
||||
"invitee_email": "Meghívó e-mail",
|
||||
@@ -240,7 +240,7 @@
|
||||
"copy_public_page_link": "Nyilvános oldal linkjének másolása",
|
||||
"copy_referral_link": "Ajánlói link másolása",
|
||||
"sign_out": "Kijelentkezés",
|
||||
"add_another": "Adj hozzá egy másikat",
|
||||
"add_another": "Másvalaki hozzáadása",
|
||||
"install_another": "Telepítsen másikat",
|
||||
"until": "amíg",
|
||||
"powered_by": "működteti",
|
||||
@@ -354,10 +354,10 @@
|
||||
"connect_caldav": "Csatlakozás a CalDav szerverhez",
|
||||
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
|
||||
"connect": "Csatlakozás",
|
||||
"try_for_free": "Próbáld ki ingyen",
|
||||
"try_for_free": "Próbálja ki ingyen",
|
||||
"create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével",
|
||||
"who": "Ki",
|
||||
"what": "Mit",
|
||||
"what": "Mi",
|
||||
"when": "Mikor",
|
||||
"where": "Hol",
|
||||
"add_to_calendar": "Hozzáadás naptárhoz",
|
||||
@@ -373,32 +373,32 @@
|
||||
"round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
|
||||
"round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
|
||||
"emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.",
|
||||
"needs_to_be_confirmed_or_rejected": "Foglalását még meg kell erősíteni vagy el kell utasítani.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozót még meg kell erősíteni vagy el kell utasítani.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges a foglalásod véglegesítéséhez.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még mindig vissza kell igazolnia vagy elutasítania a foglalást az ismétlődő találkozóra.",
|
||||
"needs_to_be_confirmed_or_rejected": "Foglalását még megerősítésre vagy lemondásra vár.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozó még megerősítésre vagy lemondásra vár.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges foglalása véglegesítéséhez.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még vissza kell igazolnia vagy el kell utasítania a foglalást az ismétlődő találkozóra.",
|
||||
"meeting_is_scheduled": "Találkozó sikeresen lefoglalva",
|
||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontod le lett foglalva",
|
||||
"booking_submitted": "A foglalásod be lett küldve",
|
||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldtük",
|
||||
"booking_confirmed": "A foglalásod vissza lett igazolva",
|
||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontja le lett foglalva",
|
||||
"booking_submitted": "Foglalását elküldte",
|
||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldte",
|
||||
"booking_confirmed": "Foglalását visszaigazolták",
|
||||
"booking_confirmed_recurring": "Ismétlődő találkozóját megerősítették",
|
||||
"warning_recurring_event_payment": "A fizetések még nem támogatottak ismétlődő események esetén",
|
||||
"warning_payment_recurring_event": "Az ismétlődő eseményeket a fizetés még nincs támogatva",
|
||||
"enter_new_password": "Adja meg a fiókjához kívánt új jelszót.",
|
||||
"reset_password": "Jelszó visszaállítása",
|
||||
"change_your_password": "Változtasd meg a jelszavadat",
|
||||
"show_password": "Mutasd a jelszót",
|
||||
"change_your_password": "Változtassa meg a jelszavát",
|
||||
"show_password": "Jelszó megjelenítése",
|
||||
"hide_password": "Jelszó elrejtése",
|
||||
"try_again": "Próbáld újra",
|
||||
"try_again": "Próbálja újra",
|
||||
"request_is_expired": "Ez a kérelem lejárt.",
|
||||
"reset_instructions": "Adja meg a fiókjához társított e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
|
||||
"reset_instructions": "Adja meg a fiókjához tartozó e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
|
||||
"request_is_expired_instructions": "Ez a kérelem lejárt. Menjen vissza, és adja meg a fiókjához tartozó e-mail-címet, és küldünk egy újabb linket a jelszó visszaállításához.",
|
||||
"whoops": "Hoppá",
|
||||
"login": "Bejelentkezés",
|
||||
"success": "Siker",
|
||||
"failed": "Nem sikerült",
|
||||
"password_has_been_reset_login": "A jelszavad vissza lett állítva. Most már bejelentkezhet az újonnan létrehozott jelszavával.",
|
||||
"password_has_been_reset_login": "Jelszavad visszaállítottuk. Most már bejelentkezhetsz az újonnan létrehozott jelszavaddal.",
|
||||
"layout": "Elrendezés",
|
||||
"bookerlayout_default_title": "Alapértelmezett nézet",
|
||||
"bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.",
|
||||
@@ -641,41 +641,41 @@
|
||||
"additional_notes": "Egyéb megjegyzés",
|
||||
"booking_fail": "Találkozó nem foglalható.",
|
||||
"reschedule_fail": "Nem sikerült időpontot módisítani.",
|
||||
"share_additional_notes": "Kérlek ossz meg velünk bármit, ami segíthet felkészülni a találkozóra.",
|
||||
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét a következővel: {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Módosítsd {{eventTypeTitle}} időpontod nála: {{profileName}}",
|
||||
"share_additional_notes": "Kérjük osszon meg velünk bármit, ami segíthet felkészülni a találkozóra.",
|
||||
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét vele: {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Módosítsa {{eventTypeTitle}} időpontját nála: {{profileName}}",
|
||||
"in_person_meeting": "Személyes találkozó",
|
||||
"in_person": "Személyesen (Szervező címén)",
|
||||
"link_meeting": "Találkozó összekapcsolása",
|
||||
"phone_number": "Telefonszám",
|
||||
"attendee_phone_number": "A résztvevő telefonszáma",
|
||||
"attendee_phone_number": "Résztvevő telefonszáma",
|
||||
"organizer_phone_number": "Szervező telefonszáma",
|
||||
"enter_phone_number": "Adja meg a telefonszámot",
|
||||
"reschedule": "átütmezés",
|
||||
"reschedule_this": "Inkább időpontmódosítás",
|
||||
"book_a_team_member": "Foglaljon helyette egy csapattagot",
|
||||
"or": "VAGY",
|
||||
"book_a_team_member": "Foglaljon inkáb egy másik csapattagnál",
|
||||
"or": "vagy",
|
||||
"go_back": "Vissza",
|
||||
"email_or_username": "E-mail cím vagy felhasználónév",
|
||||
"send_invite_email": "Küldj meghívó e-mailt",
|
||||
"send_invite_email": "Meghívó e-mail küldése",
|
||||
"role": "Szerepkör",
|
||||
"edit_role": "Szerepkör szerkesztése",
|
||||
"edit_team": "Csapat szerkesztése",
|
||||
"editing_user": "Felhasználó szerkesztése",
|
||||
"editing_org": "Szervezet szerkesztése",
|
||||
"edit_or_manage_webhooks": "Ha szeretnéd szerkeszteni vagy kezelni a webhookjaidat, látogass el a <0>webhook beállításokhoz</0>",
|
||||
"reject": "Elutasít",
|
||||
"reject": "Elutasítás",
|
||||
"reject_all": "Az összes elutasítása",
|
||||
"accept": "Elfogad",
|
||||
"leave": "Elhagyás",
|
||||
"accept": "Elfogadás",
|
||||
"leave": "Kilépés",
|
||||
"profile": "Profil",
|
||||
"my_team_url": "Csapatom URL-je",
|
||||
"my_teams": "A csapataim",
|
||||
"team_name": "Csapat neve",
|
||||
"your_team_name": "A csapatod neve",
|
||||
"team_updated_successfully": "A csapat sikeresen frissítve",
|
||||
"your_team_updated_successfully": "Csapatát sikeresen frissítette.",
|
||||
"your_org_updated_successfully": "Szervezete sikeresen frissítve.",
|
||||
"your_team_updated_successfully": "Csapatod sikeresen frissítetted.",
|
||||
"your_org_updated_successfully": "Szervezet sikeresen frissítve.",
|
||||
"error_copying_to_clipboard": "Hiba a vágólapra másolás során",
|
||||
"about": "Rólunk",
|
||||
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
|
||||
|
||||
@@ -24102,7 +24102,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": ["accepted", "pending", "declined", "needsAction"]
|
||||
},
|
||||
"CalendarEventAttendee": {
|
||||
@@ -24150,6 +24150,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
|
||||
@@ -156,7 +156,7 @@ To create a new webhook subscription, visit `/settings/developer/webhooks` and p
|
||||
Use the **secret key** to create an HMAC, and update it with the webhook payload received to generate an SHA256 hash.
|
||||
</Step>
|
||||
<Step title="Verify the payload authenticity">
|
||||
Compare the hash received in the header of the webhook `(X-Cal-Signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
|
||||
Compare the hash received in the header of the webhook `(x-cal-signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
|
||||
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ Below is a list of props that can be passed to the Conferencing Apps Atom
|
||||
| :------------------ | :------- | :-------------------------------------------------------------------------- |
|
||||
| returnTo | No | The URL of the page to redirect to after a successful installation. |
|
||||
| onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. |
|
||||
| disableToasts | No | boolean value to disable toast notifications in the atom. |
|
||||
| disableToasts | No | Boolean value to disable toast notifications in the atom. |
|
||||
| apps | No | Array of conferencing app slugs to display in the dropdown. If provided, only these apps will be shown (if not already installed). Valid values are `'google-meet'`, `'zoom'`, and `'msteams'`. |
|
||||
| disableBulkUpdateEventTypes | No | A Boolean flag that prevents the bulk update of event types modal from appearing when the default conferencing app is changed. Defaults to false |
|
||||
|
||||
|
||||
## Google Meet
|
||||
|
||||
+3
-2
@@ -80,8 +80,9 @@
|
||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||
"changesets-add": "yarn changeset add",
|
||||
"changesets-version": "yarn changeset version",
|
||||
"changesets-release": "turbo run build && yarn changeset publish"
|
||||
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@changesets/changelog-github": "^0.5.1",
|
||||
@@ -108,7 +109,7 @@
|
||||
"prismock": "^1.33.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.1",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
|
||||
Binary file not shown.
@@ -31,6 +31,6 @@
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
/**
|
||||
* Manages OAuth2.0 tokens for an app and resourceOwner. It automatically refreshes the token when needed.
|
||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
|
||||
* What it does
|
||||
* - It automatically refreshes the token if needed when making a request.
|
||||
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||
*
|
||||
* What it doesn't do yet
|
||||
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
|
||||
*
|
||||
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||
*/
|
||||
@@ -89,7 +94,13 @@ export class OAuthManager {
|
||||
currentTokenObject,
|
||||
fetchNewTokenObject,
|
||||
updateTokenObject,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isTokenObjectUnusable,
|
||||
/**
|
||||
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||
*/
|
||||
isAccessTokenUnusable,
|
||||
invalidateTokenObject,
|
||||
expireAccessToken,
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { InstallAppButtonMap } from "./apps.browser.generated";
|
||||
import type { InstallAppButtonProps } from "./types";
|
||||
@@ -59,44 +55,8 @@ export const InstallAppButton = (
|
||||
disableInstall?: boolean;
|
||||
} & InstallAppButtonProps
|
||||
) => {
|
||||
const { isPending: isUserLoading, data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const proProtectionElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { isPending: isTeamPlanStatusLoading, hasTeamPlan } = useHasTeamPlan();
|
||||
|
||||
useEffect(() => {
|
||||
const el = proProtectionElementRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!user) {
|
||||
router.push(
|
||||
`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`
|
||||
);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.teamsPlanRequired && !hasTeamPlan) {
|
||||
// TODO: I think we should show the UpgradeTip in a Dialog here. This would solve the problem of no way to go back to the App page from the UpgradeTip page(except browser's back button)
|
||||
router.push(props.teamsPlanRequired.upgradeUrl);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}, [isUserLoading, user, router, hasTeamPlan, props.teamsPlanRequired]);
|
||||
|
||||
if (isUserLoading || isTeamPlanStatusLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={proProtectionElementRef} className={props.wrapperClassName}>
|
||||
<div className={props.wrapperClassName}>
|
||||
<InstallAppButtonWithoutPlanCheck {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -42,6 +42,35 @@ export interface DailyVideoCallData {
|
||||
url: string;
|
||||
}
|
||||
|
||||
// Regions available to create DailyVideo Rooms in.
|
||||
const REGION_CODES = [
|
||||
"af-south-1",
|
||||
"ap-northeast-2",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-south-1",
|
||||
"eu-central-1",
|
||||
"eu-west-2",
|
||||
"sa-east-1",
|
||||
"us-east-1",
|
||||
"us-west-2",
|
||||
] as const;
|
||||
|
||||
type RoomGeo = (typeof REGION_CODES)[number];
|
||||
|
||||
function getDailyVideoRegionFromEnv(): RoomGeo | undefined {
|
||||
if (!process?.env?.DAILY_VIDEO_REGION) return;
|
||||
const isRoomGeo = (value: string): value is RoomGeo => REGION_CODES.includes(value as RoomGeo);
|
||||
function assertIsDailyVideoRegion(value: string): asserts value is RoomGeo {
|
||||
if (!isRoomGeo(value)) {
|
||||
throw new Error(`Invalid region code: ${value}. Must be one of: ${REGION_CODES.join(", ")}`);
|
||||
}
|
||||
}
|
||||
const region = process.env.DAILY_VIDEO_REGION;
|
||||
assertIsDailyVideoRegion(region);
|
||||
return region;
|
||||
}
|
||||
|
||||
const isS3StorageEnabled =
|
||||
process.env.CAL_VIDEO_BUCKET_NAME &&
|
||||
process.env.CAL_VIDEO_BUCKET_REGION &&
|
||||
@@ -203,11 +232,15 @@ export const setEnableRecordingUIAndUserIdForOrganizer = async (
|
||||
};
|
||||
|
||||
const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
|
||||
async function createOrUpdateMeeting(
|
||||
endpoint: string,
|
||||
event: CalendarEvent,
|
||||
region?: RoomGeo
|
||||
): Promise<VideoCallData> {
|
||||
if (!event.uid) {
|
||||
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
||||
}
|
||||
const body = await translateEvent(event);
|
||||
const body = await translateEvent(event, region);
|
||||
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
||||
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
||||
properties: {
|
||||
@@ -226,7 +259,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
});
|
||||
}
|
||||
|
||||
const translateEvent = async (event: CalendarEvent) => {
|
||||
const translateEvent = async (event: CalendarEvent, region?: RoomGeo) => {
|
||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||
// Adds 14 days from the end of the booking as the expiration date
|
||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
|
||||
@@ -248,6 +281,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
return {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
...(region ? { geo: region } : {}),
|
||||
enable_prejoin_ui: true,
|
||||
enable_knocking: true,
|
||||
enable_screenshare: true,
|
||||
@@ -284,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
};
|
||||
};
|
||||
|
||||
async function createInstantMeeting(endTime: string) {
|
||||
async function createInstantMeeting(endTime: string, region?: RoomGeo) {
|
||||
// added a 1 hour buffer for room expiration
|
||||
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||
@@ -296,6 +330,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
const body = {
|
||||
privacy: "public",
|
||||
properties: {
|
||||
...(region ? { geo: region } : {}),
|
||||
enable_prejoin_ui: true,
|
||||
enable_knocking: true,
|
||||
enable_screenshare: true,
|
||||
@@ -347,20 +382,22 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
url: dailyEvent.url,
|
||||
});
|
||||
}
|
||||
|
||||
// Region on which the DailyVideo room is created can be controlled by ENV var
|
||||
// undefined region leaves the choice to DailyVideo
|
||||
const region = getDailyVideoRegionFromEnv();
|
||||
return {
|
||||
/** Daily doesn't need to return busy times, so we return empty */
|
||||
getAvailability: () => {
|
||||
return Promise.resolve([]);
|
||||
},
|
||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting("/rooms", event),
|
||||
createOrUpdateMeeting("/rooms", event, region),
|
||||
deleteMeeting: async (uid: string): Promise<void> => {
|
||||
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
||||
return Promise.resolve();
|
||||
},
|
||||
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event, region),
|
||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
||||
@@ -371,7 +408,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||
throw new Error("Something went wrong! Unable to get recording");
|
||||
}
|
||||
},
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
|
||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime, region),
|
||||
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
||||
try {
|
||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
||||
|
||||
@@ -2,15 +2,9 @@ import { calendar_v3 } from "@googleapis/calendar";
|
||||
import { OAuth2Client } from "googleapis-common";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
|
||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||
import {
|
||||
GOOGLE_CALENDAR_SCOPES,
|
||||
SCOPE_USERINFO_PROFILE,
|
||||
WEBAPP_URL,
|
||||
WEBAPP_URL_FOR_OAUTH,
|
||||
} from "@calcom/lib/constants";
|
||||
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
@@ -101,9 +95,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
||||
}
|
||||
|
||||
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
}
|
||||
// TODO: Use the avatarUrl when setting the profile picture
|
||||
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||
// }
|
||||
|
||||
const selectedCalendarWhereUnique = {
|
||||
userId: req.session.user.id,
|
||||
|
||||
@@ -29,7 +29,14 @@ const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/Calenda
|
||||
|
||||
class MyGoogleOAuth2Client extends OAuth2Client {
|
||||
constructor(client_id: string, client_secret: string, redirect_uri: string) {
|
||||
super(client_id, client_secret, redirect_uri);
|
||||
super({
|
||||
clientId: client_id,
|
||||
clientSecret: client_secret,
|
||||
redirectUri: redirect_uri,
|
||||
// default: 5 * 60 * 1000, 5 minutes
|
||||
// tho, fn will never run in excess of 60 seconds
|
||||
eagerRefreshThresholdMillis: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
isTokenExpiring() {
|
||||
|
||||
@@ -678,15 +678,6 @@ export default class GoogleCalendarService implements Calendar {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Better alternative to `getPrimaryCalendar` that doesn't require any argument
|
||||
*/
|
||||
async fetchPrimaryCalendar() {
|
||||
const calendar = await this.authedCalendar();
|
||||
const primaryCalendar = await this.getPrimaryCalendar(calendar);
|
||||
return primaryCalendar;
|
||||
}
|
||||
|
||||
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||
this.log.debug("Listing calendars");
|
||||
const calendar = await this.authedCalendar();
|
||||
@@ -1009,39 +1000,16 @@ export default class GoogleCalendarService implements Calendar {
|
||||
}
|
||||
}
|
||||
|
||||
async getPrimaryCalendar(
|
||||
calendar: calendar_v3.Calendar,
|
||||
fields: string[] = ["id", "summary", "primary", "accessRole"]
|
||||
): Promise<calendar_v3.Schema$CalendarListEntry | null> {
|
||||
let pageToken: string | undefined;
|
||||
let firstCalendar: calendar_v3.Schema$CalendarListEntry | undefined;
|
||||
|
||||
async getPrimaryCalendar(_calendar?: calendar_v3.Calendar): Promise<calendar_v3.Schema$Calendar | null> {
|
||||
try {
|
||||
do {
|
||||
const response: any = await calendar.calendarList.list({
|
||||
fields: `items(${fields.join(",")}),nextPageToken`,
|
||||
pageToken,
|
||||
maxResults: 250, // 250 is max
|
||||
});
|
||||
|
||||
const cals = response.data.items ?? [];
|
||||
const primaryCal = cals.find((cal: calendar_v3.Schema$CalendarListEntry) => cal.primary);
|
||||
if (primaryCal) {
|
||||
return primaryCal;
|
||||
}
|
||||
|
||||
// Store the first calendar in case no primary is found
|
||||
if (cals.length > 0 && !firstCalendar) {
|
||||
firstCalendar = cals[0];
|
||||
}
|
||||
|
||||
pageToken = response.data.nextPageToken;
|
||||
} while (pageToken);
|
||||
|
||||
// should not be reached because Google Cal always has a primary cal
|
||||
return firstCalendar ?? null;
|
||||
const calendar = _calendar ?? (await this.authedCalendar());
|
||||
const response = await calendar.calendars.get({
|
||||
calendarId: "primary",
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
logger.error("Error in `getPrimaryCalendar`", { error });
|
||||
// should not be reached because Google Cal always has a primary cal
|
||||
logger.error("Error getting primary calendar", { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { vi } from "vitest";
|
||||
const setCredentialsMock = vi.fn();
|
||||
const freebusyQueryMock = vi.fn();
|
||||
const calendarListMock = vi.fn();
|
||||
const calendarsGetMock = vi.fn();
|
||||
const calendarMockImplementation = {
|
||||
channels: {
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -10,6 +11,9 @@ const calendarMockImplementation = {
|
||||
calendarList: {
|
||||
list: calendarListMock,
|
||||
},
|
||||
calendars: {
|
||||
get: calendarsGetMock,
|
||||
},
|
||||
events: {
|
||||
watch: vi.fn().mockResolvedValue({
|
||||
data: {
|
||||
|
||||
@@ -885,3 +885,27 @@ describe("getAvailability", () => {
|
||||
expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPrimaryCalendar", () => {
|
||||
test("should fetch primary calendar using 'primary' keyword", async () => {
|
||||
const credential = await createCredentialForCalendarService();
|
||||
const calendarService = new CalendarService(credential);
|
||||
setFullMockOAuthManagerRequest();
|
||||
const mockPrimaryCalendar = {
|
||||
id: "user@example.com",
|
||||
summary: "Primary Calendar",
|
||||
description: "Primary calendar for user@example.com",
|
||||
timeZone: "America/New_York",
|
||||
};
|
||||
const calendarsGetMock = vi.fn().mockResolvedValue({
|
||||
data: mockPrimaryCalendar,
|
||||
});
|
||||
calendarMock.calendar_v3.Calendar().calendars.get = calendarsGetMock;
|
||||
const result = await calendarService.getPrimaryCalendar();
|
||||
expect(calendarsGetMock).toHaveBeenCalledTimes(1);
|
||||
expect(calendarsGetMock).toHaveBeenCalledWith({
|
||||
calendarId: "primary",
|
||||
});
|
||||
expect(result).toEqual(mockPrimaryCalendar);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||
|
||||
@@ -20,13 +20,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
isEmbed: false,
|
||||
};
|
||||
|
||||
it("should return CAL_URL for non-migrated user", () => {
|
||||
it("should return WEBAPP_URL for non-migrated user", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "user/event",
|
||||
form: { ...defaultForm, nonOrgUsername: "user" },
|
||||
});
|
||||
expect(result).toBe(`${CAL_URL}/user/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/user/event?`);
|
||||
});
|
||||
|
||||
it("should return user origin for migrated user", () => {
|
||||
@@ -34,13 +34,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
expect(result).toBe("https://user.cal.com/user/event?");
|
||||
});
|
||||
|
||||
it("should return CAL_URL for non-migrated team", () => {
|
||||
it("should return WEBAPP_URL for non-migrated team", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "team/team1/event",
|
||||
form: { ...defaultForm, nonOrgTeamslug: "team1" },
|
||||
});
|
||||
expect(result).toBe(`${CAL_URL}/team/team1/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/team/team1/event?`);
|
||||
});
|
||||
|
||||
it("should return team origin for migrated team", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const enrichFormWithMigrationData = <
|
||||
@@ -45,12 +45,12 @@ export const enrichFormWithMigrationData = <
|
||||
? getOrgFullOrigin(formOwnerOrgSlug, {
|
||||
protocol: true,
|
||||
})
|
||||
: CAL_URL,
|
||||
: WEBAPP_URL,
|
||||
teamOrigin: form.team?.parent?.slug
|
||||
? getOrgFullOrigin(form.team.parent.slug, {
|
||||
protocol: true,
|
||||
})
|
||||
: CAL_URL,
|
||||
: WEBAPP_URL,
|
||||
nonOrgUsername,
|
||||
nonOrgTeamslug,
|
||||
};
|
||||
|
||||
@@ -139,6 +139,7 @@ describe("handleResponse", () => {
|
||||
it("should record form response when not in preview and not queued", async () => {
|
||||
const dbFormResponse = {
|
||||
id: 1,
|
||||
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
|
||||
formId: mockForm.id,
|
||||
response: mockResponse,
|
||||
chosenRouteId: null,
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
MultiOptionInput,
|
||||
} from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
@@ -36,6 +37,7 @@ function Field({
|
||||
moveUp,
|
||||
moveDown,
|
||||
appUrl,
|
||||
disableTypeChange,
|
||||
}: {
|
||||
fieldIndex: number;
|
||||
hookForm: HookForm;
|
||||
@@ -53,6 +55,7 @@ function Field({
|
||||
fn: () => void;
|
||||
};
|
||||
appUrl: string;
|
||||
disableTypeChange: boolean;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
|
||||
@@ -123,32 +126,53 @@ function Field({
|
||||
defaultValue={routerField?.type}
|
||||
render={({ field: { value, onChange } }) => {
|
||||
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||
return (
|
||||
<SelectField
|
||||
maxMenuHeight={200}
|
||||
styles={{
|
||||
singleValue: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
option: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
}}
|
||||
label="Type"
|
||||
isDisabled={!!router}
|
||||
containerClassName="data-testid-field-type"
|
||||
options={FieldTypes}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onChange(option.value);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
if (disableTypeChange) {
|
||||
return (
|
||||
<div className="data-testid-field-type">
|
||||
<Label htmlFor="field-type-button">{t("type")}</Label>
|
||||
<Tooltip content={t("field_type_change_suggestion")}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled
|
||||
color="secondary"
|
||||
className={classNames(
|
||||
"h-8 w-full justify-between text-left text-sm",
|
||||
!!router && "bg-subtle cursor-not-allowed"
|
||||
)}>
|
||||
<span className="text-default">{defaultValue?.label || "Select field type"}</span>
|
||||
<Icon name="chevron-down" className="text-default h-4 w-4" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<SelectField
|
||||
maxMenuHeight={200}
|
||||
styles={{
|
||||
singleValue: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
option: (baseStyles) => ({
|
||||
...baseStyles,
|
||||
fontSize: "14px",
|
||||
}),
|
||||
}}
|
||||
label="Type"
|
||||
isDisabled={!!router}
|
||||
containerClassName="data-testid-field-type"
|
||||
options={FieldTypes}
|
||||
onChange={(option) => {
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
onChange(option.value);
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -213,6 +237,7 @@ const FormEdit = ({
|
||||
} = useFieldArray({
|
||||
control: hookForm.control,
|
||||
name: fieldsNamespace,
|
||||
keyName: "_id",
|
||||
});
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||
@@ -236,12 +261,15 @@ const FormEdit = ({
|
||||
<div className="w-full py-4 lg:py-8">
|
||||
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
||||
{hookFormFields.map((field, key) => {
|
||||
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
|
||||
const hasFormResponses = (form._count?.responses ?? 0) > 0;
|
||||
return (
|
||||
<Field
|
||||
appUrl={appUrl}
|
||||
fieldIndex={key}
|
||||
hookForm={hookForm}
|
||||
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
||||
disableTypeChange={existingField && hasFormResponses}
|
||||
deleteField={{
|
||||
check: () => hookFormFields.length > 1,
|
||||
fn: () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||
@@ -262,7 +263,7 @@ test.describe("Routing Forms", () => {
|
||||
option: 2,
|
||||
page,
|
||||
});
|
||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
||||
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||
await saveCurrentForm(page);
|
||||
|
||||
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||
@@ -290,7 +291,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com");
|
||||
return url.pathname.endsWith("/pro");
|
||||
});
|
||||
|
||||
const url = new URL(page.url());
|
||||
@@ -509,7 +510,7 @@ test.describe("Routing Forms", () => {
|
||||
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com") && url.searchParams.get("Test field") === "external-redirect";
|
||||
return url.pathname.endsWith("/pro") && url.searchParams.get("Test field") === "external-redirect";
|
||||
});
|
||||
|
||||
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
||||
@@ -576,7 +577,7 @@ test.describe("Routing Forms", () => {
|
||||
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
|
||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||
expect(routingType).toBe("External Redirect");
|
||||
expect(route).toBe("https://cal.com/peer");
|
||||
expect(route).toBe(`${WEBAPP_URL}/pro`);
|
||||
await page.click('[data-testid="close-results-button"]');
|
||||
|
||||
// Multiselect(Legacy)
|
||||
@@ -925,7 +926,7 @@ test.describe("Routing Forms", () => {
|
||||
option: 2,
|
||||
page,
|
||||
});
|
||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
||||
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||
await saveCurrentForm(page);
|
||||
return {
|
||||
formId,
|
||||
@@ -978,7 +979,7 @@ test.describe("Routing Forms", () => {
|
||||
await page.fill('[data-testid="form-field-short-text"]', "test");
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com");
|
||||
return url.pathname.endsWith("/pro");
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1059,7 +1060,7 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
||||
await fillAllOptionsBasedFields();
|
||||
page.click('button[type="submit"]');
|
||||
await page.waitForURL((url) => {
|
||||
return url.hostname.includes("cal.com");
|
||||
return url.pathname.endsWith("/pro");
|
||||
});
|
||||
})();
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
|
||||
}: GetAttributesForTeamHandlerOptions) {
|
||||
const { teamId } = input;
|
||||
const { user } = ctx;
|
||||
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
|
||||
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
|
||||
|
||||
if (!isMemberOfTeam) {
|
||||
throw new TRPCError({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
||||
const params = {
|
||||
response_type: "code",
|
||||
client_id,
|
||||
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`,
|
||||
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
|
||||
state,
|
||||
};
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
||||
const { code } = req.query;
|
||||
const { client_id, client_secret } = await getZoomAppKeys();
|
||||
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`);
|
||||
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`);
|
||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||
const result = await fetch(
|
||||
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||
@@ -63,12 +64,16 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
|
||||
|
||||
/** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */
|
||||
export const zoomUserSettingsSchema = z.object({
|
||||
recording: z.object({
|
||||
auto_recording: z.string(),
|
||||
}),
|
||||
schedule_meeting: z.object({
|
||||
default_password_for_scheduled_meetings: z.string(),
|
||||
}),
|
||||
recording: z
|
||||
.object({
|
||||
auto_recording: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
schedule_meeting: z
|
||||
.object({
|
||||
default_password_for_scheduled_meetings: z.string().nullish(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
|
||||
@@ -181,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 auth = new OAuthManager({
|
||||
credentialSyncVariables: {
|
||||
@@ -216,9 +247,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
isTokenObjectUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (e) {
|
||||
return await handleZoomResponseJsonParseError({
|
||||
error: e,
|
||||
clonedResponse: responseToUseInCaseOfError,
|
||||
});
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
if (responseBody.error === "invalid_grant") {
|
||||
@@ -229,11 +269,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
},
|
||||
isAccessTokenUnusable: async function (response) {
|
||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
||||
const responseBody = await response.json();
|
||||
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||
if (!response.ok) {
|
||||
let responseBody;
|
||||
const responseToUseInCaseOfError = response.clone();
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (e) {
|
||||
return await handleZoomResponseJsonParseError({
|
||||
error: e,
|
||||
clonedResponse: responseToUseInCaseOfError,
|
||||
});
|
||||
}
|
||||
myLog.debug(safeStringify({ responseBody }));
|
||||
|
||||
// 124 is the error code for invalid access token from Zoom API
|
||||
if (responseBody.code === 124) {
|
||||
return { reason: responseBody.message ?? "" };
|
||||
}
|
||||
@@ -280,7 +329,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error("Failed to get availability", safeStringify(err));
|
||||
/* Prevents booking failure when Zoom Token is expired */
|
||||
return [];
|
||||
}
|
||||
@@ -307,7 +356,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
}
|
||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error(
|
||||
"Zoom meeting creation failed",
|
||||
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
|
||||
);
|
||||
/* Prevents meeting creation failure when Zoom Token is expired */
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
@@ -342,7 +394,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
url: result.join_url,
|
||||
};
|
||||
} catch (err) {
|
||||
log.error("Failed to update meeting", safeStringify(err));
|
||||
log.error(
|
||||
"Failed to update meeting",
|
||||
safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) })
|
||||
);
|
||||
return Promise.reject(new Error("Failed to update meeting"));
|
||||
}
|
||||
},
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^4.1.2",
|
||||
"vite-plugin-environment": "^1.1.3"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Mock } from "vitest";
|
||||
|
||||
import { EmbedElement } from "./EmbedElement";
|
||||
import inlineHTML from "./Inline/inlineHtml";
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import { getColorSchemeDarkQuery } from "./ui-utils";
|
||||
|
||||
(function defineEmbedTestElement() {
|
||||
@@ -269,8 +270,8 @@ describe("EmbedElement", () => {
|
||||
},
|
||||
getSkeletonData: vi.fn(),
|
||||
});
|
||||
expect(element.classList.contains("dark")).toBe(true);
|
||||
expect(element.classList.contains("light")).toBe(false);
|
||||
expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
|
||||
expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should update theme class when system preference changes as long as embed theme is not set", () => {
|
||||
@@ -279,8 +280,8 @@ describe("EmbedElement", () => {
|
||||
});
|
||||
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
|
||||
|
||||
expect(element.classList.contains("dark")).toBe(true);
|
||||
expect(element.classList.contains("light")).toBe(false);
|
||||
expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
|
||||
expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not update theme on system color scheme change when embed theme is set", () => {
|
||||
@@ -291,7 +292,7 @@ describe("EmbedElement", () => {
|
||||
getSkeletonData: vi.fn(),
|
||||
});
|
||||
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
|
||||
expect(element.classList.contains("light")).toBe(true);
|
||||
expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types";
|
||||
import {
|
||||
getThemeClassForEmbed,
|
||||
@@ -157,9 +158,7 @@ export class EmbedElement extends HTMLElement {
|
||||
this.isModal = data.isModal;
|
||||
this.layout = this.getLayout();
|
||||
this.theme = this.dataset.theme as EmbedThemeConfig | null;
|
||||
this.themeClass = getThemeClassForEmbed({ theme: this.theme });
|
||||
this.classList.add(this.themeClass);
|
||||
|
||||
this.setTheme(this.theme);
|
||||
this.getSkeletonData = data.getSkeletonData;
|
||||
this.boundResizeHandler = this.resizeHandler.bind(this);
|
||||
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
|
||||
@@ -197,20 +196,26 @@ export class EmbedElement extends HTMLElement {
|
||||
skeletonEl.innerHTML = skeletonContent;
|
||||
}
|
||||
|
||||
public setTheme(theme: EmbedThemeConfig | null) {
|
||||
const allPossibleThemeClasses = [EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS];
|
||||
|
||||
const newThemeClass = getThemeClassForEmbed({ theme });
|
||||
|
||||
if (newThemeClass === this.themeClass) {
|
||||
return;
|
||||
}
|
||||
this.themeClass = newThemeClass;
|
||||
this.classList.remove(...allPossibleThemeClasses);
|
||||
this.classList.add(this.themeClass);
|
||||
}
|
||||
|
||||
public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) {
|
||||
const isDarkPreferred = e.matches;
|
||||
const allPossibleThemeClasses = ["dark", "light"];
|
||||
if (isThemePreferenceProvided(this.theme)) {
|
||||
// User has provided a theme preference, so we stick to that and don't react to system theme change
|
||||
return;
|
||||
}
|
||||
const newThemeClass = getThemeClassForEmbed({
|
||||
theme: isDarkPreferred ? "dark" : "light",
|
||||
});
|
||||
if (newThemeClass !== this.themeClass) {
|
||||
this.classList.remove(...allPossibleThemeClasses);
|
||||
this.classList.add(newThemeClass);
|
||||
}
|
||||
this.setTheme(isDarkPreferred ? "dark" : "light");
|
||||
}
|
||||
connectedCallback() {
|
||||
// Make sure to show the loader initially.
|
||||
|
||||
@@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = {
|
||||
params?: Record<string, string>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
function fakeCurrentDocumentUrl({
|
||||
origin = "https://example.com",
|
||||
path = "",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EMBED_DARK_THEME_CLASS } from "./constants";
|
||||
|
||||
export const addAppCssVars = () => {
|
||||
const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"];
|
||||
try {
|
||||
@@ -148,7 +150,7 @@ export const addAppCssVars = () => {
|
||||
--cal-brand-text: hsla(0, 0%, 100%, 1); /* white */
|
||||
}
|
||||
|
||||
${calElements.map((element) => `${element}.dark`).join(",")} {
|
||||
${calElements.map((element) => `${element}.${EMBED_DARK_THEME_CLASS}`).join(",")} {
|
||||
/* Background Standard */
|
||||
--cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */
|
||||
--cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */
|
||||
|
||||
@@ -10,3 +10,8 @@ if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHO
|
||||
"EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS"
|
||||
);
|
||||
}
|
||||
|
||||
// These classes are applied to Embed Elements and thus are in the same scope as the embedding webpage.
|
||||
// So, these classes need to be unique to us, to avoid accidental override
|
||||
export const EMBED_LIGHT_THEME_CLASS = "cal-element-embed-light";
|
||||
export const EMBED_DARK_THEME_CLASS = "cal-element-embed-dark";
|
||||
|
||||
@@ -594,6 +594,9 @@ function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true";
|
||||
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
|
||||
|
||||
window.addEventListener("message", (e) => {
|
||||
const data: Message = e.data;
|
||||
if (!data) {
|
||||
@@ -631,7 +634,7 @@ function main() {
|
||||
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
||||
initializeAndSetupEmbed();
|
||||
} else {
|
||||
log(`Preloaded scenario - Skipping initialization and setup`);
|
||||
log(`Preloaded scenario - Skipping initialization and setup as only assets need to be loaded`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +682,7 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
|
||||
/**
|
||||
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||
* If there is a need to fetch the slots, then the slots would be fetched and then only this function call would complete
|
||||
* url has the config as params
|
||||
*/
|
||||
async function connectPreloadedEmbed({
|
||||
|
||||
@@ -1215,26 +1215,26 @@ describe("Cal", () => {
|
||||
expect(result).toBe("noAction");
|
||||
});
|
||||
|
||||
it("should return connect-no-slots-fetch when reuseFully is true and slots are not stale", () => {
|
||||
it("should return connect-no-slots-fetch when backgroundSlotsFetch is true and slots are not stale", () => {
|
||||
const result = calInstance.getNextActionForModal({
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
prerenderOptions: {
|
||||
reuseFully: true,
|
||||
backgroundSlotsFetch: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result).toBe("connect-no-slots-fetch");
|
||||
});
|
||||
|
||||
it("should return connect when reuseFully is true but slots are stale", () => {
|
||||
it("should return connect when backgroundSlotsFetch is true but slots are stale", () => {
|
||||
const result = calInstance.getNextActionForModal({
|
||||
...baseArgs,
|
||||
stateData: {
|
||||
...baseArgs.stateData,
|
||||
prerenderOptions: {
|
||||
reuseFully: true,
|
||||
backgroundSlotsFetch: true,
|
||||
},
|
||||
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
||||
embedRenderStartTime: Date.now(),
|
||||
|
||||
@@ -55,6 +55,8 @@ type ModalPrerenderOptions = {
|
||||
slotsStaleTimeMs?: number;
|
||||
iframeForceReloadThresholdMs?: number;
|
||||
reuseFully?: boolean;
|
||||
// Default value is true right now but it would change soon
|
||||
backgroundSlotsFetch?: boolean;
|
||||
};
|
||||
|
||||
type ModalStateData = {
|
||||
@@ -598,7 +600,7 @@ export class Cal {
|
||||
return "fullReload";
|
||||
}
|
||||
|
||||
if (prerenderOptions?.reuseFully && !areSlotsStale) {
|
||||
if (prerenderOptions?.backgroundSlotsFetch && !areSlotsStale) {
|
||||
return "connect-no-slots-fetch";
|
||||
}
|
||||
|
||||
@@ -986,6 +988,7 @@ class CalApi {
|
||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||
|
||||
if (__prerender && this.cal.modalBox) {
|
||||
log("Destroying previous prerendered modalbox");
|
||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||
this.cal.modalBox.remove();
|
||||
}
|
||||
@@ -999,9 +1002,18 @@ class CalApi {
|
||||
const containerEl = document.body;
|
||||
this.cal.isPrerendering = !!__prerender;
|
||||
if (__prerender) {
|
||||
// TODO: Make `reuseFully` a configurable param as well later.
|
||||
// If someone's preloading the headless router path, we must reuse the iframe fully as is as Router would redirect to a Booking Page and that should be shown as is
|
||||
this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath };
|
||||
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||
const backgroundSlotsFetch =
|
||||
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||
: prerenderOptions.backgroundSlotsFetch;
|
||||
|
||||
this.prerenderOptions = {
|
||||
...prerenderOptions,
|
||||
backgroundSlotsFetch,
|
||||
};
|
||||
// Add prerender query param
|
||||
config.prerender = "true";
|
||||
|
||||
@@ -1010,7 +1022,7 @@ class CalApi {
|
||||
config["cal.queueFormResponse"] = "true";
|
||||
}
|
||||
|
||||
if (!this.prerenderOptions?.reuseFully) {
|
||||
if (!backgroundSlotsFetch) {
|
||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||
config["cal.skipSlotsFetch"] = "true";
|
||||
@@ -1045,9 +1057,11 @@ class CalApi {
|
||||
isConnectionInitiated,
|
||||
prerenderOptions: this.prerenderOptions ?? null,
|
||||
};
|
||||
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
|
||||
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
|
||||
|
||||
// If we want to reuse fully then we shouldn't submit response again
|
||||
if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) {
|
||||
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
|
||||
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
|
||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||
existingModalEl.setAttribute("state", "loading");
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import type { AllPossibleLayouts, EmbedThemeConfig } from "./types";
|
||||
|
||||
export function getMaxHeightForModal() {
|
||||
@@ -32,7 +33,7 @@ function detectColorScheme() {
|
||||
}
|
||||
|
||||
function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) {
|
||||
return theme ?? "light";
|
||||
return theme === "dark" ? EMBED_DARK_THEME_CLASS : EMBED_LIGHT_THEME_CLASS;
|
||||
}
|
||||
|
||||
export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"eslint": "^8.34.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^4.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"@typescript-eslint/parser": "^5.52.0",
|
||||
"@typescript-eslint/utils": "^5.52.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.4.5"
|
||||
|
||||
@@ -438,7 +438,7 @@ export const getOptions = ({
|
||||
encode: async ({ token, maxAge, secret }) => {
|
||||
log.debug("jwt:encode", safeStringify({ token, maxAge }));
|
||||
if (token?.sub && isNumber(token.sub)) {
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: Number(token.sub) },
|
||||
select: { metadata: true },
|
||||
});
|
||||
@@ -489,7 +489,7 @@ export const getOptions = ({
|
||||
} as JWT;
|
||||
}
|
||||
const autoMergeIdentities = async () => {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
where: { email: token.email! },
|
||||
select: {
|
||||
@@ -855,7 +855,7 @@ export const getOptions = ({
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
// with the new email address. If it does, for now we return an error. If
|
||||
// not, update the email of their account and log them in.
|
||||
const userWithNewEmail = await prisma.user.findUnique({
|
||||
const userWithNewEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
@@ -875,9 +875,12 @@ export const getOptions = ({
|
||||
// a new account. If an account already exists with the incoming email
|
||||
// address return an error for now.
|
||||
|
||||
const existingUserWithEmail = await prisma.user.findUnique({
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
email: {
|
||||
equals: user.email,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
password: true,
|
||||
|
||||
@@ -168,7 +168,9 @@ export const EventMeta = ({
|
||||
{translatedTitle ?? event?.title}
|
||||
</EventTitle>
|
||||
{(event.description || translatedDescription) && (
|
||||
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
|
||||
<EventMetaBlock
|
||||
data-testid="event-meta-description"
|
||||
contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
|
||||
<div
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -43,16 +43,14 @@ type EventDetailCustomBlock = {
|
||||
|
||||
type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock);
|
||||
|
||||
interface EventMetaProps {
|
||||
interface EventMetaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
customIcon?: React.ReactNode;
|
||||
icon?: IconName;
|
||||
iconUrl?: string;
|
||||
children: React.ReactNode;
|
||||
// Emphasises the text in the block. For now only
|
||||
// applying in dark mode.
|
||||
highlight?: boolean;
|
||||
contentClassName?: string;
|
||||
className?: string;
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
@@ -80,6 +78,7 @@ export const EventMetaBlock = ({
|
||||
contentClassName,
|
||||
className,
|
||||
isDark,
|
||||
...rest
|
||||
}: EventMetaProps) => {
|
||||
if (!React.Children.count(children)) return null;
|
||||
|
||||
@@ -89,7 +88,8 @@ export const EventMetaBlock = ({
|
||||
"flex items-start justify-start text-sm",
|
||||
highlight ? "text-emphasis" : "text-text",
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
{...rest}>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
|
||||
@@ -164,4 +164,354 @@ describe("checkForConflicts", () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("comprehensive edge cases and boundary conditions", () => {
|
||||
it("should return false for empty busy array", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot that starts exactly when busy period ends", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot that ends exactly when busy period starts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T08:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle slot start exactly matching busy period start (inclusive)", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle slot end exactly matching busy period end (inclusive)", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:15:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very short event length (1 minute)", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00Z"),
|
||||
eventLength: 1,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:00:30Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:01:30Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle very long event length (8 hours)", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00Z"),
|
||||
eventLength: 480, // 8 hours
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T13:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiple busy periods scenarios", () => {
|
||||
it("should return true when first busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when last busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T10:30:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T11:15:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true when middle busy period conflicts", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T10:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:00:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T10:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T11:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T12:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle many non-overlapping busy periods", () => {
|
||||
const busyPeriods = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const hour = (8 + i * 2) % 24; // Wrap around to prevent invalid hours
|
||||
busyPeriods.push({
|
||||
start: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:00:00Z`).toDate(),
|
||||
end: dayjs.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:30:00Z`).toDate(),
|
||||
});
|
||||
}
|
||||
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T07:00:00Z"),
|
||||
busy: busyPeriods,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle overlapping busy periods", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:30:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T10:00:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("currentSeats comprehensive scenarios", () => {
|
||||
it("should return false when currentSeats has exact time match", () => {
|
||||
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: testTime.toDate(),
|
||||
_count: { attendees: 2 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: testTime,
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true when currentSeats has no matching time but busy period conflicts", () => {
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T09:00:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle multiple currentSeats with one matching", () => {
|
||||
const testTime = dayjs.utc("2023-01-01T09:00:00Z");
|
||||
const currentSeats: CurrentSeats = [
|
||||
{
|
||||
uid: "booking-1",
|
||||
startTime: dayjs.utc("2023-01-01T08:00:00Z").toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
{
|
||||
uid: "booking-2",
|
||||
startTime: testTime.toDate(),
|
||||
_count: { attendees: 1 },
|
||||
},
|
||||
];
|
||||
|
||||
const result = checkForConflicts({
|
||||
time: testTime,
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty currentSeats array", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T09:00:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
currentSeats: [],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("timezone and date handling", () => {
|
||||
it("should handle different timezone inputs correctly", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs("2023-01-01T09:00:00-05:00"), // EST
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T14:15:00Z").toDate(), // UTC equivalent of 9:15 EST
|
||||
end: dayjs.utc("2023-01-01T14:45:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle cross-day boundaries", () => {
|
||||
const result = checkForConflicts({
|
||||
time: dayjs.utc("2023-01-01T23:45:00Z"),
|
||||
eventLength: 30,
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-02T00:00:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-02T00:30:00Z").toDate(),
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("performance and stress scenarios", () => {
|
||||
it("should handle large number of busy periods efficiently", () => {
|
||||
const busyPeriods = [];
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
const hour = (8 + (i % 12)) % 24; // Wrap around to prevent invalid hours
|
||||
const minute = i % 60;
|
||||
const endMinute = ((i % 60) + 15) % 60;
|
||||
const endHour = endMinute < minute ? (hour + 1) % 24 : hour; // Handle minute overflow
|
||||
|
||||
busyPeriods.push({
|
||||
start: dayjs
|
||||
.utc(`2023-01-01T${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")}:00Z`)
|
||||
.toDate(),
|
||||
end: dayjs
|
||||
.utc(
|
||||
`2023-01-01T${endHour.toString().padStart(2, "0")}:${endMinute.toString().padStart(2, "0")}:00Z`
|
||||
)
|
||||
.toDate(),
|
||||
});
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T07:00:00Z"),
|
||||
busy: busyPeriods,
|
||||
});
|
||||
const endTime = performance.now();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(endTime - startTime).toBeLessThan(100); // Should complete in under 100ms
|
||||
});
|
||||
|
||||
it("should handle zero-duration busy periods", () => {
|
||||
const result = checkForConflicts({
|
||||
...createTestData("2023-01-01T09:00:00Z"),
|
||||
busy: [
|
||||
{
|
||||
start: dayjs.utc("2023-01-01T09:15:00Z").toDate(),
|
||||
end: dayjs.utc("2023-01-01T09:15:00Z").toDate(), // Zero duration
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,34 +26,25 @@ export function checkForConflicts({
|
||||
if (currentSeats?.some((booking) => booking.startTime.toISOString() === time.toISOString())) {
|
||||
return false;
|
||||
}
|
||||
const slotStartDate = time.utc().toDate();
|
||||
const slotEndDate = time.add(eventLength, "minutes").utc().toDate();
|
||||
// handle busy times.
|
||||
return busy.some((busyTime) => {
|
||||
const busyStartDate = dayjs.utc(busyTime.start).toDate();
|
||||
const busyEndDate = dayjs.utc(busyTime.end).toDate();
|
||||
// First check if there's any overlap at all
|
||||
// If busy period ends before slot starts or starts after slot ends, there's no overlap
|
||||
if (busyEndDate <= slotStartDate || busyStartDate >= slotEndDate) {
|
||||
return false;
|
||||
const slotStart = time.valueOf();
|
||||
const slotEnd = slotStart + eventLength * 60 * 1000;
|
||||
|
||||
const sortedBusyTimes = busy
|
||||
.map((busyTime) => ({
|
||||
start: dayjs.utc(busyTime.start).valueOf(),
|
||||
end: dayjs.utc(busyTime.end).valueOf(),
|
||||
}))
|
||||
.sort((a, b) => a.start - b.start);
|
||||
|
||||
for (const busyTime of sortedBusyTimes) {
|
||||
if (busyTime.start >= slotEnd) {
|
||||
break;
|
||||
}
|
||||
// Now check all possible overlap scenarios:
|
||||
// 1. Slot start falls within busy period (inclusive start, exclusive end)
|
||||
if (slotStartDate >= busyStartDate && slotStartDate < busyEndDate) {
|
||||
return true;
|
||||
if (busyTime.end <= slotStart) {
|
||||
continue;
|
||||
}
|
||||
// 2. Slot end falls within busy period (exclusive start, inclusive end)
|
||||
if (slotEndDate > busyStartDate && slotEndDate <= busyEndDate) {
|
||||
return true;
|
||||
}
|
||||
// 3. Busy period completely contained within slot
|
||||
if (busyStartDate >= slotStartDate && busyEndDate <= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
// 4. Slot completely contained within busy period
|
||||
if (busyStartDate <= slotStartDate && busyEndDate >= slotEndDate) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ export const getBookingForReschedule = async (uid: string, userId?: number) => {
|
||||
let attendeeEmail: string | null = null;
|
||||
let bookingSeatData: { description?: string; responses: Prisma.JsonValue } | null = null;
|
||||
if (!theBooking) {
|
||||
const bookingSeat = await prisma.bookingSeat.findUnique({
|
||||
const bookingSeat = await prisma.bookingSeat.findFirst({
|
||||
where: {
|
||||
referenceUid: uid,
|
||||
},
|
||||
|
||||
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: !isEmailFieldOptional,
|
||||
editable: isOrgTeamEvent ? "system-but-optional" : "system",
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
defaultLabel: "location",
|
||||
type: "radioInput",
|
||||
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
],
|
||||
},
|
||||
];
|
||||
if (isOrgTeamEvent) {
|
||||
systemBeforeFields.splice(2, 0, {
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// These fields should be added after other user fields
|
||||
const systemAfterFields: typeof bookingFields = [
|
||||
|
||||
@@ -52,7 +52,6 @@ import {
|
||||
getFirstDelegationConferencingCredentialAppLocation,
|
||||
} from "@calcom/lib/delegationCredential/server";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import type { ErrorWithCode } from "@calcom/lib/errors";
|
||||
import { getErrorFromUnknown } from "@calcom/lib/errors";
|
||||
import { getEventName, updateHostInEventName } from "@calcom/lib/event";
|
||||
import { extractBaseEmail } from "@calcom/lib/extract-base-email";
|
||||
@@ -455,22 +454,12 @@ async function handler(
|
||||
await checkIfBookerEmailIsBlocked({ loggedInUserId: userId, bookerEmail });
|
||||
|
||||
if (!rawBookingData.rescheduleUid) {
|
||||
try {
|
||||
await checkActiveBookingsLimitForBooker({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err as ErrorWithCode;
|
||||
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: error.message,
|
||||
data: error.data,
|
||||
});
|
||||
}
|
||||
await checkActiveBookingsLimitForBooker({
|
||||
eventTypeId,
|
||||
maxActiveBookingsPerBooker: eventType.maxActiveBookingsPerBooker,
|
||||
bookerEmail,
|
||||
offerToRescheduleLastBooking: eventType.maxActiveBookingPerBookerOfferReschedule,
|
||||
});
|
||||
}
|
||||
|
||||
if (isEventTypeLoggingEnabled({ eventTypeId, usernameOrTeamName: reqBody.user })) {
|
||||
@@ -1407,6 +1396,8 @@ async function handler(
|
||||
routingFormResponseId,
|
||||
organizerId: organizerUser.id,
|
||||
teamId,
|
||||
isRerouting: !!reroutingFormResponses,
|
||||
reroutedByEmail: reqBody.rescheduledBy,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -23,7 +23,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { describe, expect } from "vitest";
|
||||
|
||||
import { WEBAPP_URL, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { test } from "@calcom/web/test/fixtures/fixtures";
|
||||
@@ -256,7 +255,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
timeout
|
||||
);
|
||||
|
||||
test(
|
||||
test.skip(
|
||||
`should fail recurring booking if second slot is already booked`,
|
||||
async ({}) => {
|
||||
const booker = getBooker({
|
||||
@@ -372,11 +371,12 @@ describe("handleNewRecurringBooking", () => {
|
||||
};
|
||||
});
|
||||
|
||||
await expect(() =>
|
||||
handleNewRecurringBooking({
|
||||
bookingData: bookingDataArray,
|
||||
userId: -1,
|
||||
})
|
||||
await expect(
|
||||
async () =>
|
||||
await handleNewRecurringBooking({
|
||||
bookingData: bookingDataArray,
|
||||
userId: -1,
|
||||
})
|
||||
).rejects.toThrow(ErrorCode.NoAvailableUsersFound);
|
||||
},
|
||||
timeout
|
||||
@@ -384,7 +384,7 @@ describe("handleNewRecurringBooking", () => {
|
||||
});
|
||||
|
||||
describe("Round robin event type:", () => {
|
||||
test("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
||||
test.skip("should fail recurring booking if a fixed host is not available on the second slot", async () => {
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user