Compare commits
92
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 | ||
|
|
bc99e9e504 | ||
|
|
2f35428232 | ||
|
|
00aac89ed7 | ||
|
|
f390e91bd7 | ||
|
|
e413359c8c | ||
|
|
601df7f808 | ||
|
|
e160a4add0 |
@@ -7,7 +7,7 @@
|
|||||||
"access": "public",
|
"access": "public",
|
||||||
"baseBranch": "main",
|
"baseBranch": "main",
|
||||||
"updateInternalDependencies": "patch",
|
"updateInternalDependencies": "patch",
|
||||||
"ignore": [],
|
"ignore": ["@calcom/platform-libraries"],
|
||||||
"privatePackages": {
|
"privatePackages": {
|
||||||
"version": false,
|
"version": false,
|
||||||
"tag": false
|
"tag": false
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
"@calcom/atoms": patch
|
|
||||||
---
|
|
||||||
|
|
||||||
fix saving event type settings
|
|
||||||
+1
-1
@@ -24,7 +24,7 @@
|
|||||||
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
/packages/lib/server/getLuckyUser.ts @calcom/Foundation
|
||||||
/packages/lib/slots.ts @calcom/Foundation
|
/packages/lib/slots.ts @calcom/Foundation
|
||||||
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
/packages/platform/**/* @calcom/Platform @calcom/Foundation
|
||||||
/packages/prisma/**/* @calcom/Foundation
|
/packages/prisma/**/* @calcom/DBA
|
||||||
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/confirm.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
/packages/trpc/server/routers/viewer/bookings/get.handler.ts @calcom/Foundation
|
||||||
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
/packages/trpc/server/routers/viewer/slots/**/* @calcom/Foundation
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ jobs:
|
|||||||
contents: write # to create release by changesets/action below
|
contents: write # to create release by changesets/action below
|
||||||
pull-requests: write # to create pull request by changesets/action below
|
pull-requests: write # to create pull request by changesets/action below
|
||||||
name: Release
|
name: Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: buildjet-4vcpu-ubuntu-2204
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repo
|
- name: Checkout Repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|||||||
Vendored
+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
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",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "node ./index.js"
|
"dev": "node ./index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"next-axiom": "^0.17.0",
|
"next-axiom": "^0.17.0",
|
||||||
"next-swagger-doc": "^0.3.6",
|
"next-swagger-doc": "^0.3.6",
|
||||||
"next-validations": "^0.2.0",
|
"next-validations": "^0.2.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.7.2",
|
||||||
"tzdata": "^1.0.30",
|
"tzdata": "^1.0.30",
|
||||||
"uuid": "^8.3.2",
|
"uuid": "^8.3.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"description": "Platform API for Cal.com",
|
"description": "Platform API for Cal.com",
|
||||||
"author": "Cal.com Inc.",
|
"author": "Cal.com Inc.",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "yarn dev:build && nest build",
|
"build": "yarn dev:build && nest build",
|
||||||
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
"@axiomhq/winston": "^1.2.0",
|
"@axiomhq/winston": "^1.2.0",
|
||||||
"@calcom/platform-constants": "*",
|
"@calcom/platform-constants": "*",
|
||||||
"@calcom/platform-enums": "*",
|
"@calcom/platform-enums": "*",
|
||||||
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.223",
|
"@calcom/platform-libraries": "npm:@calcom/platform-libraries@0.0.231",
|
||||||
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
"@calcom/platform-libraries-0.0.2": "npm:@calcom/platform-libraries@0.0.2",
|
||||||
"@calcom/platform-types": "*",
|
"@calcom/platform-types": "*",
|
||||||
"@calcom/platform-utils": "*",
|
"@calcom/platform-utils": "*",
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
"ts-loader": "^9.4.3",
|
"ts-loader": "^9.4.3",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"tsconfig-paths": "^4.1.0",
|
"tsconfig-paths": "^4.1.0",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"schema": "../../../packages/prisma/schema.prisma"
|
"schema": "../../../packages/prisma/schema.prisma"
|
||||||
|
|||||||
@@ -86,10 +86,16 @@ export class AppModule implements NestModule {
|
|||||||
configure(consumer: MiddlewareConsumer): void {
|
configure(consumer: MiddlewareConsumer): void {
|
||||||
consumer
|
consumer
|
||||||
.apply(RawBodyMiddleware)
|
.apply(RawBodyMiddleware)
|
||||||
.forRoutes({
|
.forRoutes(
|
||||||
path: "/api/v2/billing/webhook",
|
{
|
||||||
method: RequestMethod.POST,
|
path: "/api/v2/billing/webhook",
|
||||||
})
|
method: RequestMethod.POST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/v2/billing/webhook",
|
||||||
|
method: RequestMethod.POST,
|
||||||
|
}
|
||||||
|
)
|
||||||
.apply(JsonBodyMiddleware)
|
.apply(JsonBodyMiddleware)
|
||||||
.forRoutes("*")
|
.forRoutes("*")
|
||||||
.apply(RequestIdMiddleware)
|
.apply(RequestIdMiddleware)
|
||||||
|
|||||||
@@ -6,6 +6,100 @@ import { ERROR_STATUS } from "@calcom/platform-constants";
|
|||||||
import { TRPCError } from "@calcom/platform-libraries";
|
import { TRPCError } from "@calcom/platform-libraries";
|
||||||
import { Response } from "@calcom/platform-types";
|
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)
|
@Catch(TRPCError)
|
||||||
export class TRPCExceptionFilter implements ExceptionFilter {
|
export class TRPCExceptionFilter implements ExceptionFilter {
|
||||||
private readonly logger = new Logger("TRPCExceptionFilter");
|
private readonly logger = new Logger("TRPCExceptionFilter");
|
||||||
@@ -15,77 +109,13 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
|||||||
const response = ctx.getResponse<Response>();
|
const response = ctx.getResponse<Response>();
|
||||||
const request = ctx.getRequest<Request>();
|
const request = ctx.getRequest<Request>();
|
||||||
|
|
||||||
let statusCode = 500;
|
const errorDefinition = TRPC_ERROR_MAP[exception.code as keyof typeof TRPC_ERROR_CODE] || {
|
||||||
let errorMessage = exception.message;
|
statusCode: 500,
|
||||||
|
message: exception.message,
|
||||||
|
};
|
||||||
|
|
||||||
switch (exception.code) {
|
const statusCode = errorDefinition.statusCode;
|
||||||
case "UNAUTHORIZED":
|
const errorMessage = errorDefinition.message;
|
||||||
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 requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||||
response.setHeader("X-Request-Id", requestId.toString());
|
response.setHeader("X-Request-Id", requestId.toString());
|
||||||
@@ -103,7 +133,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
|||||||
status: ERROR_STATUS,
|
status: ERROR_STATUS,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
path: request.url,
|
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 IP = request?.headers?.["cf-connecting-ip"] ?? request?.headers?.["CF-Connecting-IP"] ?? request.ip;
|
||||||
const response = context.switchToHttp().getResponse<Response>();
|
const response = context.switchToHttp().getResponse<Response>();
|
||||||
const tracker = await this.getTracker(request);
|
const tracker = await this.getTracker(request);
|
||||||
this.logger.verbose(
|
|
||||||
`Tracker "${tracker}" generated based on: Bearer token "${request.get(
|
|
||||||
"Authorization"
|
|
||||||
)}", OAuth client ID "${request.get(X_CAL_CLIENT_ID)}" and IP "${IP}"`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (throttleOptions) {
|
if (throttleOptions) {
|
||||||
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
return this.handleApiEndpointThrottle(tracker, throttleOptions, response);
|
||||||
}
|
}
|
||||||
@@ -104,9 +98,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
private async handleNonApiKeyRequest(tracker: string, response: Response): Promise<boolean> {
|
||||||
const rateLimit = this.getDefaultRateLimit(tracker);
|
const rateLimit = this.getDefaultRateLimit(tracker);
|
||||||
this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
/*this.logger.verbose(`Tracker "${tracker}" uses default rate limits because it is not tracking api key:
|
||||||
${JSON.stringify(rateLimit, null, 2)}
|
${JSON.stringify(rateLimit, null, 2)}
|
||||||
`);
|
`);*/
|
||||||
|
|
||||||
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
const { isBlocked } = await this.incrementRateLimit(tracker, rateLimit, response);
|
||||||
if (isBlocked) {
|
if (isBlocked) {
|
||||||
@@ -150,9 +144,9 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
|
|
||||||
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
const cachedRateLimits = await this.storageService.redis.get(cacheKey);
|
||||||
if (cachedRateLimits) {
|
if (cachedRateLimits) {
|
||||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
/*this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from redis cache:
|
||||||
${cachedRateLimits}
|
${cachedRateLimits}
|
||||||
`);
|
`);*/
|
||||||
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
return rateLimitsSchema.parse(JSON.parse(cachedRateLimits));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,15 +166,10 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
select: { name: true, limit: true, ttl: true, blockDuration: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (rateLimits) {
|
|
||||||
this.logger.verbose(`Tracker "${tracker}" rate limits retrieved from database:
|
|
||||||
${JSON.stringify(rateLimits, null, 2)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rateLimits || rateLimits.length === 0) {
|
if (!rateLimits || rateLimits.length === 0) {
|
||||||
rateLimits = [this.getDefaultRateLimit(tracker)];
|
rateLimits = [this.getDefaultRateLimit(tracker)];
|
||||||
this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
/*this.logger.verbose(`Tracker "${tracker}" rate limits not found in database. Using default rate limits:
|
||||||
${JSON.stringify(rateLimits, null, 2)}`);
|
${JSON.stringify(rateLimits, null, 2)}`);*/
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
await this.storageService.redis.set(cacheKey, JSON.stringify(rateLimits), "EX", 3600);
|
||||||
@@ -209,15 +198,15 @@ export class CustomThrottlerGuard extends ThrottlerGuard {
|
|||||||
);
|
);
|
||||||
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
response.setHeader(`X-RateLimit-Reset-${nameFirstUpper}`, timeToBlockExpire || timeToExpire);
|
||||||
|
|
||||||
this.logger.verbose(
|
/*this.logger.verbose(
|
||||||
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
`Tracker "${tracker}" rate limit "${name}" incremented. isBlocked ${isBlocked}, totalHits ${totalHits}, timeToExpire ${timeToExpire}, timeToBlockExpire ${timeToBlockExpire}`
|
||||||
);
|
);*/
|
||||||
this.logger.verbose(
|
/*this.logger.verbose(
|
||||||
`Tracker "${tracker}" rate limit "${name}" response headers:
|
`Tracker "${tracker}" rate limit "${name}" response headers:
|
||||||
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
X-RateLimit-Limit-${nameFirstUpper}: ${limit},
|
||||||
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
X-RateLimit-Remaining-${nameFirstUpper}: ${timeToBlockExpire ? 0 : Math.max(0, limit - totalHits)},
|
||||||
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
X-RateLimit-Reset-${nameFirstUpper}: ${timeToBlockExpire || timeToExpire}`
|
||||||
);
|
);*/
|
||||||
|
|
||||||
return { isBlocked };
|
return { isBlocked };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
@@ -143,6 +144,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
);
|
);
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
@@ -172,6 +174,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
@@ -204,6 +207,7 @@ describe("Platform Billing Controller (e2e)", () => {
|
|||||||
|
|
||||||
return request(app.getHttpServer())
|
return request(app.getHttpServer())
|
||||||
.post("/v2/billing/webhook")
|
.post("/v2/billing/webhook")
|
||||||
|
.set("stripe-signature", "t=1234567890,v1=random_signature_for_e2e_test")
|
||||||
.expect(200)
|
.expect(200)
|
||||||
.then(async (res) => {
|
.then(async (res) => {
|
||||||
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
const billing = await platformBillingRepositoryFixture.get(organization.id);
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ import {
|
|||||||
HttpStatus,
|
HttpStatus,
|
||||||
Logger,
|
Logger,
|
||||||
Delete,
|
Delete,
|
||||||
|
BadRequestException,
|
||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { ConfigService } from "@nestjs/config";
|
import { ConfigService } from "@nestjs/config";
|
||||||
import { ApiExcludeController } from "@nestjs/swagger";
|
import { ApiExcludeController } from "@nestjs/swagger";
|
||||||
import { Request } from "express";
|
import { Request } from "express";
|
||||||
|
import Stripe from "stripe";
|
||||||
|
|
||||||
import { ApiResponse } from "@calcom/platform-types";
|
import { ApiResponse } from "@calcom/platform-types";
|
||||||
|
|
||||||
@@ -116,35 +118,59 @@ export class BillingController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Headers("stripe-signature") stripeSignature: string
|
@Headers("stripe-signature") stripeSignature: string
|
||||||
): Promise<ApiResponse> {
|
): Promise<ApiResponse> {
|
||||||
const event = await this.billingService.stripeService
|
try {
|
||||||
.getStripe()
|
if (!stripeSignature) {
|
||||||
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
this.logger.warn("Missing stripe-signature header in webhook request");
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
switch (event.type) {
|
if (!this.stripeWhSecret) {
|
||||||
case "checkout.session.completed":
|
this.logger.error("Missing STRIPE_WEBHOOK_SECRET configuration");
|
||||||
await this.billingService.handleStripeCheckoutEvents(event);
|
return {
|
||||||
break;
|
status: "success",
|
||||||
case "customer.subscription.updated":
|
};
|
||||||
await this.billingService.handleStripePaymentPastDue(event);
|
}
|
||||||
break;
|
|
||||||
case "customer.subscription.deleted":
|
const event = await this.billingService.stripeService
|
||||||
await this.billingService.handleStripeSubscriptionDeleted(event);
|
.getStripe()
|
||||||
break;
|
.webhooks.constructEventAsync(request.body, stripeSignature, this.stripeWhSecret);
|
||||||
case "invoice.created":
|
|
||||||
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
switch (event.type) {
|
||||||
break;
|
case "checkout.session.completed":
|
||||||
case "invoice.payment_failed":
|
await this.billingService.handleStripeCheckoutEvents(event);
|
||||||
await this.billingService.handleStripePaymentFailed(event);
|
break;
|
||||||
break;
|
case "customer.subscription.updated":
|
||||||
case "invoice.payment_succeeded":
|
await this.billingService.handleStripePaymentPastDue(event);
|
||||||
await this.billingService.handleStripePaymentSuccess(event);
|
break;
|
||||||
break;
|
case "customer.subscription.deleted":
|
||||||
default:
|
await this.billingService.handleStripeSubscriptionDeleted(event);
|
||||||
break;
|
break;
|
||||||
|
case "invoice.created":
|
||||||
|
await this.billingService.handleStripeSubscriptionForActiveManagedUsers(event);
|
||||||
|
break;
|
||||||
|
case "invoice.payment_failed":
|
||||||
|
await this.billingService.handleStripePaymentFailed(event);
|
||||||
|
break;
|
||||||
|
case "invoice.payment_succeeded":
|
||||||
|
await this.billingService.handleStripePaymentSuccess(event);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Stripe.errors.StripeSignatureVerificationError) {
|
||||||
|
this.logger.error("Webhook signature validation failed", error);
|
||||||
|
return {
|
||||||
|
status: "success",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
|
||||||
status: "success",
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,6 +211,18 @@ export class CalendarEventHost {
|
|||||||
description: "Display name of the event host",
|
description: "Display name of the event host",
|
||||||
})
|
})
|
||||||
name?: string;
|
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 {
|
export class CalendarEventAttendee {
|
||||||
|
|||||||
+20
-20
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
|
private transformHosts(googleEvent: GoogleCalendarEventResponse) {
|
||||||
if (googleEvent.organizer) {
|
const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
|
||||||
return [
|
[];
|
||||||
{
|
|
||||||
email: googleEvent.organizer.email,
|
const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
|
||||||
name: googleEvent.organizer.displayName,
|
if (organizerAttendees?.length) {
|
||||||
},
|
organizerAttendees.forEach((organizer) => {
|
||||||
];
|
hosts.push({
|
||||||
} else if (googleEvent.attendees) {
|
email: organizer.email,
|
||||||
// If no explicit organizer, find the first attendee with organizer flag
|
name: organizer.displayName,
|
||||||
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
|
responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
|
||||||
if (organizerAttendee) {
|
});
|
||||||
return [
|
});
|
||||||
{
|
} else {
|
||||||
email: organizerAttendee.email,
|
hosts.push({
|
||||||
name: organizerAttendee.displayName,
|
email: googleEvent.organizer.email,
|
||||||
},
|
name: googleEvent.organizer.displayName,
|
||||||
];
|
responseStatus: null,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return hosts;
|
||||||
}
|
}
|
||||||
|
|
||||||
private transformLocations(
|
private transformLocations(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
|
import { Prisma } from "@prisma/client";
|
||||||
|
|
||||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||||
|
|
||||||
@@ -8,12 +9,26 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
|||||||
export class ConferencingRepository {
|
export class ConferencingRepository {
|
||||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
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) {
|
async findConferencingApps(userId: number) {
|
||||||
return this.dbRead.prisma.credential.findMany({
|
return this.dbRead.prisma.credential.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
type: { endsWith: "_video" },
|
type: { endsWith: "_video" },
|
||||||
},
|
},
|
||||||
|
select: this.credentialSelect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,24 +38,28 @@ export class ConferencingRepository {
|
|||||||
teamId,
|
teamId,
|
||||||
type: { endsWith: "_video" },
|
type: { endsWith: "_video" },
|
||||||
},
|
},
|
||||||
|
select: this.credentialSelect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findGoogleMeet(userId: number) {
|
async findGoogleMeet(userId: number) {
|
||||||
return this.dbRead.prisma.credential.findFirst({
|
return this.dbRead.prisma.credential.findFirst({
|
||||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||||
|
select: this.credentialSelect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findConferencingApp(userId: number, app: string) {
|
async findConferencingApp(userId: number, app: string) {
|
||||||
return this.dbRead.prisma.credential.findFirst({
|
return this.dbRead.prisma.credential.findFirst({
|
||||||
where: { userId, appId: app },
|
where: { userId, appId: app },
|
||||||
|
select: this.credentialSelect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async findTeamConferencingApp(teamId: number, app: string) {
|
async findTeamConferencingApp(teamId: number, app: string) {
|
||||||
return this.dbRead.prisma.credential.findFirst({
|
return this.dbRead.prisma.credential.findFirst({
|
||||||
where: { teamId, appId: app },
|
where: { teamId, appId: app },
|
||||||
|
select: this.credentialSelect,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,6 +144,9 @@ export class OAuthClientUsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
static getOAuthUserEmail(oAuthClientId: string, userEmail: string) {
|
||||||
|
if (userEmail.includes(`+${oAuthClientId}@`)) {
|
||||||
|
return userEmail;
|
||||||
|
}
|
||||||
const [username, emailDomain] = userEmail.split("@");
|
const [username, emailDomain] = userEmail.split("@");
|
||||||
return `${username}+${oAuthClientId}@${emailDomain}`;
|
return `${username}+${oAuthClientId}@${emailDomain}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { 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 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";
|
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_06_11,
|
||||||
VERSION_2024_08_13,
|
VERSION_2024_08_13,
|
||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
|
import { TRPCError } from "@calcom/platform-libraries";
|
||||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||||
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||||
import { ApiResponse, GetAvailableSlotsInput_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."
|
"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;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26400,7 +26400,7 @@
|
|||||||
},
|
},
|
||||||
"CalendarEventResponseStatus": {
|
"CalendarEventResponseStatus": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Attendee's response to the invitation",
|
"description": "Host's response to the invitation",
|
||||||
"enum": [
|
"enum": [
|
||||||
"accepted",
|
"accepted",
|
||||||
"pending",
|
"pending",
|
||||||
@@ -26460,6 +26460,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"description": "Display name of the event host"
|
"description": "Display name of the event host"
|
||||||
|
},
|
||||||
|
"responseStatus": {
|
||||||
|
"nullable": true,
|
||||||
|
"example": "accepted",
|
||||||
|
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { ImageExample } from "./avatar.image"
|
|||||||
import { TooltipExample } from "./avatar.tooltip"
|
import { TooltipExample } from "./avatar.tooltip"
|
||||||
import { LinkExample } from "./avatar.link"
|
import { LinkExample } from "./avatar.link"
|
||||||
import { IndicatorExample } from "./avatar.indicator"
|
import { IndicatorExample } from "./avatar.indicator"
|
||||||
|
import { ShapeExample } from "./avatar.shape"
|
||||||
|
|
||||||
## Size Variations
|
## Size Variations
|
||||||
|
|
||||||
@@ -17,6 +18,12 @@ Avatars come in different sizes to suit various use cases.
|
|||||||
|
|
||||||
<SizesExample/>
|
<SizesExample/>
|
||||||
|
|
||||||
|
## Shape Variations
|
||||||
|
|
||||||
|
Avatars can be displayed in either circular or square shapes.
|
||||||
|
|
||||||
|
<ShapeExample/>
|
||||||
|
|
||||||
## With and Without Image
|
## With and Without Image
|
||||||
|
|
||||||
Avatars can display either an image or a fallback placeholder.
|
Avatars can display either an image or a fallback placeholder.
|
||||||
@@ -40,3 +47,57 @@ Avatars can be made clickable by providing an href.
|
|||||||
Avatars can include status indicators or badges.
|
Avatars can include status indicators or badges.
|
||||||
|
|
||||||
<IndicatorExample/>
|
<IndicatorExample/>
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
Here's a complete example showing how to use the Avatar component with all its props:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
|
|
||||||
|
function Example() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{/* Basic Avatar */}
|
||||||
|
<Avatar
|
||||||
|
alt="User Name"
|
||||||
|
title="User Name"
|
||||||
|
size="md"
|
||||||
|
shape="circle"
|
||||||
|
imageSrc="https://example.com/avatar.jpg"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Avatar with Link */}
|
||||||
|
<Avatar
|
||||||
|
alt="User Name"
|
||||||
|
title="User Name"
|
||||||
|
size="lg"
|
||||||
|
shape="square"
|
||||||
|
imageSrc="https://example.com/avatar.jpg"
|
||||||
|
href="/profile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Avatar with Tooltip and Indicator */}
|
||||||
|
<Avatar
|
||||||
|
alt="User Name"
|
||||||
|
title="User Name"
|
||||||
|
size="md"
|
||||||
|
shape="circle"
|
||||||
|
imageSrc="https://example.com/avatar.jpg"
|
||||||
|
indicator={<div className="absolute bottom-0 right-0 h-3 w-3 rounded-full bg-green-500" />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Avatar with Fallback */}
|
||||||
|
<Avatar
|
||||||
|
alt="User Name"
|
||||||
|
title="User Name"
|
||||||
|
size="md"
|
||||||
|
shape="circle"
|
||||||
|
fallback={<span>UN</span>}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
|
|
||||||
|
export function ShapeExample() {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Avatar
|
||||||
|
alt="Circle Avatar"
|
||||||
|
title="Circle Avatar"
|
||||||
|
size="lg"
|
||||||
|
shape="circle"
|
||||||
|
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||||
|
/>
|
||||||
|
<Avatar
|
||||||
|
alt="Square Avatar"
|
||||||
|
title="Square Avatar"
|
||||||
|
size="lg"
|
||||||
|
shape="square"
|
||||||
|
imageSrc="https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=facearea&facepad=2&w=256&h=256&q=80"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
|||||||
import type { PageProps as ServerPageProps } from "app/_types";
|
import type { PageProps as ServerPageProps } from "app/_types";
|
||||||
import { _generateMetadata } from "app/_utils";
|
import { _generateMetadata } from "app/_utils";
|
||||||
import { cookies, headers } from "next/headers";
|
import { cookies, headers } from "next/headers";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||||
|
|
||||||
@@ -21,11 +23,18 @@ export const generateMetadata = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||||
|
const stepSchema = z.enum(["1", "2", "3", "4"]);
|
||||||
|
|
||||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
const ServerPage = async ({ params, searchParams: _searchParams }: ServerPageProps) => {
|
||||||
const props = await getData(
|
const searchParams = await _searchParams;
|
||||||
buildLegacyCtx(await headers(), await cookies(), await params, 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} />;
|
return <Setup {...props} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -46,13 +46,13 @@ async function postHandler(req: NextRequest) {
|
|||||||
if (!log.smsSid) return;
|
if (!log.smsSid) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const price = await twilio.getPriceForSMS(log.smsSid);
|
const { price, numSegments } = await twilio.getMessageInfo(log.smsSid);
|
||||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||||
if (!credits) return;
|
if (!credits) return;
|
||||||
|
|
||||||
const updatedLog = await prisma.creditExpenseLog.update({
|
const updatedLog = await prisma.creditExpenseLog.update({
|
||||||
where: { id: log.id },
|
where: { id: log.id },
|
||||||
data: { credits },
|
data: { credits, smsSegments: numSegments },
|
||||||
select: {
|
select: {
|
||||||
creditBalance: {
|
creditBalance: {
|
||||||
select: {
|
select: {
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ import { CalendarAppDelegationCredentialInvalidGrantError } from "@calcom/lib/Ca
|
|||||||
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
import { handleCreateSelectedCalendars, isSameEmail } from "../route";
|
||||||
|
|
||||||
// Mock GoogleCalendarService
|
// Mock GoogleCalendarService
|
||||||
const fetchPrimaryCalendarMock = vi.fn();
|
const getPrimaryCalendarMock = vi.fn();
|
||||||
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
vi.mock("@calcom/app-store/googlecalendar/lib/CalendarService", () => {
|
||||||
return {
|
return {
|
||||||
default: vi.fn().mockImplementation(() => ({
|
default: vi.fn().mockImplementation(() => ({
|
||||||
fetchPrimaryCalendar: fetchPrimaryCalendarMock,
|
getPrimaryCalendar: getPrimaryCalendarMock,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -116,7 +116,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
prismock.user.deleteMany();
|
prismock.user.deleteMany();
|
||||||
prismock.team.deleteMany();
|
prismock.team.deleteMany();
|
||||||
prismock.workspacePlatform.deleteMany();
|
prismock.workspacePlatform.deleteMany();
|
||||||
fetchPrimaryCalendarMock.mockReset();
|
getPrimaryCalendarMock.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
it("shows a helpful message when no Delegation Credentials are set up in the system", async () => {
|
||||||
@@ -138,7 +138,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -159,7 +159,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "notuser@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -183,7 +183,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -210,7 +210,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
delegationCredentialId: delegationCredential.id,
|
delegationCredentialId: delegationCredential.id,
|
||||||
credentialId: 1,
|
credentialId: 1,
|
||||||
});
|
});
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(0);
|
expect(result.success).toBe(0);
|
||||||
@@ -247,7 +247,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
error: "some error" as unknown as null,
|
error: "some error" as unknown as null,
|
||||||
credentialId: regularCredential.id,
|
credentialId: regularCredential.id,
|
||||||
});
|
});
|
||||||
fetchPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
getPrimaryCalendarMock.mockResolvedValue({ id: "user1@example.com" });
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(1);
|
expect(result.success).toBe(1);
|
||||||
@@ -272,7 +272,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||||
fetchPrimaryCalendarMock.mockRejectedValue(
|
getPrimaryCalendarMock.mockRejectedValue(
|
||||||
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
new CalendarAppDelegationCredentialInvalidGrantError("some error")
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -293,7 +293,7 @@ describe("handleCreateSelectedCalendars integration", () => {
|
|||||||
domain: "example.com",
|
domain: "example.com",
|
||||||
});
|
});
|
||||||
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
await createCredential({ id: 1, userId: user.id, delegationCredentialId: delegationCredential.id });
|
||||||
fetchPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
getPrimaryCalendarMock.mockRejectedValue(new Error("some error"));
|
||||||
|
|
||||||
const result = await handleCreateSelectedCalendars();
|
const result = await handleCreateSelectedCalendars();
|
||||||
expect(result.success).toBe(0);
|
expect(result.success).toBe(0);
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ async function fetchPrimaryCalendarId({
|
|||||||
const userEmail = delegationUserCredential.user.email;
|
const userEmail = delegationUserCredential.user.email;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const primaryCalendar = await googleCalendarService.fetchPrimaryCalendar();
|
const primaryCalendar = await googleCalendarService.getPrimaryCalendar();
|
||||||
primaryCalendarId = primaryCalendar?.id;
|
primaryCalendarId = primaryCalendar?.id;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { describe, expect, test, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
import { GET } from "../route";
|
||||||
|
|
||||||
|
vi.mock("next/og", () => ({
|
||||||
|
ImageResponse: vi.fn().mockImplementation(() => ({
|
||||||
|
body: new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(new Uint8Array([1, 2, 3, 4]));
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/OgImages", () => ({
|
||||||
|
Meeting: vi.fn(() => null),
|
||||||
|
App: vi.fn(() => null),
|
||||||
|
Generic: vi.fn(() => null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/constants", () => ({
|
||||||
|
WEBAPP_URL: "http://localhost:3000",
|
||||||
|
}));
|
||||||
|
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
|
||||||
|
function createNextRequest(url: string): NextRequest {
|
||||||
|
const request = new Request(url, { method: "GET" });
|
||||||
|
return new NextRequest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("GET /api/social/og/image", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
|
||||||
|
vi.mocked(global.fetch).mockResolvedValue({
|
||||||
|
arrayBuffer: vi.fn().mockResolvedValue(new ArrayBuffer(8)),
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Validation errors (400 Bad Request)", () => {
|
||||||
|
test("meeting type: returns 400 when required parameters are missing", async () => {
|
||||||
|
const request = createNextRequest("http://example.com/api/social/og/image?type=meeting");
|
||||||
|
const response = await GET(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("application/json");
|
||||||
|
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.error).toBe("Invalid parameters for meeting image");
|
||||||
|
expect(errorData.message).toBe(
|
||||||
|
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage"
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("app type: returns 400 when required parameters are missing", async () => {
|
||||||
|
const request = createNextRequest("http://example.com/api/social/og/image?type=app");
|
||||||
|
const response = await GET(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.error).toBe("Invalid parameters for app image");
|
||||||
|
expect(errorData.message).toBe("Required parameters: name, description, slug");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("generic type: returns 400 when required parameters are missing", async () => {
|
||||||
|
const request = createNextRequest("http://example.com/api/social/og/image?type=generic");
|
||||||
|
const response = await GET(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const errorData = await response.json();
|
||||||
|
expect(errorData.error).toBe("Invalid parameters for generic image");
|
||||||
|
expect(errorData.message).toBe("Required parameters: title, description");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Not found errors (404 Not Found)", () => {
|
||||||
|
test("returns 404 when no type parameter is provided", async () => {
|
||||||
|
const request = createNextRequest("http://example.com/api/social/og/image");
|
||||||
|
const response = await GET(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("returns 404 when invalid type parameter is provided", async () => {
|
||||||
|
const request = createNextRequest("http://example.com/api/social/og/image?type=invalid");
|
||||||
|
const response = await GET(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.text()).toBe("What you're looking for is not here..");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Server errors (500 Internal Server Error)", () => {
|
||||||
|
test("returns 500 when font loading fails", async () => {
|
||||||
|
vi.mocked(global.fetch).mockRejectedValue(new Error("Font loading failed"));
|
||||||
|
|
||||||
|
const request = createNextRequest(
|
||||||
|
"http://example.com/api/social/og/image?type=meeting&title=Test&meetingProfileName=John"
|
||||||
|
);
|
||||||
|
const response = await GET(request);
|
||||||
|
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(await response.text()).toBe("Internal server error");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ImageResponse } from "next/og";
|
import { ImageResponse } from "next/og";
|
||||||
import type { NextRequest } from "next/server";
|
import type { NextRequest } from "next/server";
|
||||||
import type { SatoriOptions } from "satori";
|
import type { SatoriOptions } from "satori";
|
||||||
import { z } from "zod";
|
import { z, ZodError } from "zod";
|
||||||
|
|
||||||
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
import { Meeting, App, Generic } from "@calcom/lib/OgImages";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
@@ -34,75 +34,128 @@ async function handler(req: NextRequest) {
|
|||||||
const { searchParams } = req.nextUrl;
|
const { searchParams } = req.nextUrl;
|
||||||
const imageType = searchParams.get("type");
|
const imageType = searchParams.get("type");
|
||||||
|
|
||||||
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
try {
|
||||||
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
const [calFontData, interFontData, interFontMediumData] = await Promise.all([
|
||||||
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
fetch(new URL("/fonts/cal.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
fetch(new URL("/fonts/Inter-Regular.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
]);
|
fetch(new URL("/fonts/Inter-Medium.ttf", WEBAPP_URL)).then((res) => res.arrayBuffer()),
|
||||||
const ogConfig = {
|
]);
|
||||||
width: 1200,
|
const ogConfig = {
|
||||||
height: 630,
|
width: 1200,
|
||||||
fonts: [
|
height: 630,
|
||||||
{ name: "inter", data: interFontData, weight: 400 },
|
fonts: [
|
||||||
{ name: "inter", data: interFontMediumData, weight: 500 },
|
{ name: "inter", data: interFontData, weight: 400 },
|
||||||
{ name: "cal", data: calFontData, weight: 400 },
|
{ name: "inter", data: interFontMediumData, weight: 500 },
|
||||||
{ name: "cal", data: calFontData, weight: 600 },
|
{ name: "cal", data: calFontData, weight: 400 },
|
||||||
] as SatoriOptions["fonts"],
|
{ name: "cal", data: calFontData, weight: 600 },
|
||||||
};
|
] as SatoriOptions["fonts"],
|
||||||
|
};
|
||||||
|
|
||||||
switch (imageType) {
|
switch (imageType) {
|
||||||
case "meeting": {
|
case "meeting": {
|
||||||
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
try {
|
||||||
names: searchParams.getAll("names"),
|
const { names, usernames, title, meetingProfileName, meetingImage } = meetingSchema.parse({
|
||||||
usernames: searchParams.getAll("usernames"),
|
names: searchParams.getAll("names"),
|
||||||
title: searchParams.get("title"),
|
usernames: searchParams.getAll("usernames"),
|
||||||
meetingProfileName: searchParams.get("meetingProfileName"),
|
title: searchParams.get("title"),
|
||||||
meetingImage: searchParams.get("meetingImage"),
|
meetingProfileName: searchParams.get("meetingProfileName"),
|
||||||
imageType,
|
meetingImage: searchParams.get("meetingImage"),
|
||||||
});
|
imageType,
|
||||||
|
});
|
||||||
|
|
||||||
const img = new ImageResponse(
|
const img = new ImageResponse(
|
||||||
(
|
(
|
||||||
<Meeting
|
<Meeting
|
||||||
title={title}
|
title={title}
|
||||||
profile={{ name: meetingProfileName, image: meetingImage }}
|
profile={{ name: meetingProfileName, image: meetingImage }}
|
||||||
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
users={names.map((name, index) => ({ name, username: usernames[index] }))}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
ogConfig
|
ogConfig
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(img.body, {
|
return new Response(img.body, {
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
headers: { "Content-Type": "image/png", "cache-control": "max-age=0" },
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid parameters for meeting image",
|
||||||
|
message:
|
||||||
|
"Required parameters: title, meetingProfileName. Optional: names, usernames, meetingImage",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "app": {
|
||||||
|
try {
|
||||||
|
const { name, description, slug } = appSchema.parse({
|
||||||
|
name: searchParams.get("name"),
|
||||||
|
description: searchParams.get("description"),
|
||||||
|
slug: searchParams.get("slug"),
|
||||||
|
imageType,
|
||||||
|
});
|
||||||
|
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
||||||
|
|
||||||
|
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid parameters for app image",
|
||||||
|
message: "Required parameters: name, description, slug",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case "generic": {
|
||||||
|
try {
|
||||||
|
const { title, description } = genericSchema.parse({
|
||||||
|
title: searchParams.get("title"),
|
||||||
|
description: searchParams.get("description"),
|
||||||
|
imageType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
||||||
|
|
||||||
|
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ZodError) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "Invalid parameters for generic image",
|
||||||
|
message: "Required parameters: title, description",
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
status: 400,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
return new Response("What you're looking for is not here..", { status: 404 });
|
||||||
}
|
}
|
||||||
case "app": {
|
} catch (error) {
|
||||||
const { name, description, slug } = appSchema.parse({
|
return new Response("Internal server error", { status: 500 });
|
||||||
name: searchParams.get("name"),
|
|
||||||
description: searchParams.get("description"),
|
|
||||||
slug: searchParams.get("slug"),
|
|
||||||
imageType,
|
|
||||||
});
|
|
||||||
const img = new ImageResponse(<App name={name} description={description} slug={slug} />, ogConfig);
|
|
||||||
|
|
||||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
case "generic": {
|
|
||||||
const { title, description } = genericSchema.parse({
|
|
||||||
title: searchParams.get("title"),
|
|
||||||
description: searchParams.get("description"),
|
|
||||||
imageType,
|
|
||||||
});
|
|
||||||
|
|
||||||
const img = new ImageResponse(<Generic title={title} description={description} />, ogConfig);
|
|
||||||
|
|
||||||
return new Response(img.body, { status: 200, headers: { "Content-Type": "image/png" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return new Response("What you're looking for is not here..", { status: 404 });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
@@ -279,6 +280,7 @@ const NewRoutingManager = ({
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bookerUrl = useBookerUrl();
|
const bookerUrl = useBookerUrl();
|
||||||
|
const session = useSession();
|
||||||
const teamMemberIdsMatchingAttributeLogic =
|
const teamMemberIdsMatchingAttributeLogic =
|
||||||
teamMembersMatchingAttributeLogic?.data
|
teamMembersMatchingAttributeLogic?.data
|
||||||
?.map((member) => member.id)
|
?.map((member) => member.id)
|
||||||
@@ -305,7 +307,7 @@ const NewRoutingManager = ({
|
|||||||
booking,
|
booking,
|
||||||
currentResponse,
|
currentResponse,
|
||||||
searchParams: new URLSearchParams({
|
searchParams: new URLSearchParams({
|
||||||
// rescheduleReason
|
rescheduledBy: session?.data?.user?.email ?? "",
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -469,6 +471,7 @@ const NewRoutingManager = ({
|
|||||||
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
// TODO: Long term, we should refactor handleNewBooking and use a different route specific for this purpose,
|
||||||
createBookingMutation.mutate({
|
createBookingMutation.mutate({
|
||||||
rescheduleUid: booking.uid,
|
rescheduleUid: booking.uid,
|
||||||
|
rescheduledBy: session?.data?.user?.email ?? undefined,
|
||||||
// rescheduleReason,
|
// rescheduleReason,
|
||||||
reroutingFormResponses: reroutingFormResponses,
|
reroutingFormResponses: reroutingFormResponses,
|
||||||
...getTimeslotFields(),
|
...getTimeslotFields(),
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { vi } from "vitest";
|
import { vi } from "vitest";
|
||||||
|
|
||||||
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
import { RouteActionType } from "@calcom/app-store/routing-forms/zod";
|
||||||
@@ -37,6 +39,15 @@ const mockOpen = vi.fn((_url: string) => {
|
|||||||
|
|
||||||
vi.stubGlobal("open", mockOpen);
|
vi.stubGlobal("open", mockOpen);
|
||||||
|
|
||||||
|
const mockSession = {
|
||||||
|
expires: new Date(Date.now() + 2 * 86400).toISOString(),
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
name: "Test User",
|
||||||
|
email: "user@example.com",
|
||||||
|
},
|
||||||
|
} as Session;
|
||||||
|
|
||||||
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
vi.mock("@calcom/app-store/routing-forms/components/FormInputFields", () => ({
|
||||||
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
default: vi.fn(({ response, form, setResponse, disabledFields }) => {
|
||||||
return (
|
return (
|
||||||
@@ -404,9 +415,11 @@ describe("RerouteDialog", () => {
|
|||||||
describe("New Routing tests", () => {
|
describe("New Routing tests", () => {
|
||||||
test("when verify_new_route is clicked, the form is submitted", async () => {
|
test("when verify_new_route is clicked, the form is submitted", async () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<SessionProvider session={mockSession}>
|
||||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
fireEvent.click(screen.getByText("verify_new_route"));
|
fireEvent.click(screen.getByText("verify_new_route"));
|
||||||
|
|
||||||
@@ -425,9 +438,11 @@ describe("RerouteDialog", () => {
|
|||||||
describe("New tab rescheduling", () => {
|
describe("New tab rescheduling", () => {
|
||||||
test("new tab is closed when new booking is rerouted", async () => {
|
test("new tab is closed when new booking is rerouted", async () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<SessionProvider session={mockSession}>
|
||||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
clickVerifyNewRouteButton();
|
clickVerifyNewRouteButton();
|
||||||
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
clickRescheduleToTheNewEventWithDifferentTimeslotButton();
|
||||||
@@ -466,9 +481,11 @@ describe("RerouteDialog", () => {
|
|||||||
|
|
||||||
test("Rescheduling with same timeslot works", async () => {
|
test("Rescheduling with same timeslot works", async () => {
|
||||||
render(
|
render(
|
||||||
<TooltipProvider>
|
<SessionProvider session={mockSession}>
|
||||||
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
<TooltipProvider>
|
||||||
</TooltipProvider>
|
<RerouteDialog isOpenDialog={true} setIsOpenDialog={mockSetIsOpenDialog} booking={mockBooking} />
|
||||||
|
</TooltipProvider>
|
||||||
|
</SessionProvider>
|
||||||
);
|
);
|
||||||
clickVerifyNewRouteButton();
|
clickVerifyNewRouteButton();
|
||||||
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
clickRescheduleWithSameTimeslotOfChosenEventButton();
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ const CustomEmailTextField = ({
|
|||||||
<DropdownItem
|
<DropdownItem
|
||||||
StartIcon="trash"
|
StartIcon="trash"
|
||||||
color="destructive"
|
color="destructive"
|
||||||
className="disabled:opacity-40"
|
className="rounded-t-none disabled:opacity-40"
|
||||||
onClick={handleItemDelete}
|
onClick={handleItemDelete}
|
||||||
disabled={emailPrimary}
|
disabled={emailPrimary}
|
||||||
data-testid="secondary-email-delete-button">
|
data-testid="secondary-email-delete-button">
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { stringify } from "querystring";
|
|||||||
|
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import type { PrismaClient } from "@calcom/prisma";
|
|
||||||
import type { RedirectType } from "@calcom/prisma/client";
|
import type { RedirectType } from "@calcom/prisma/client";
|
||||||
|
|
||||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||||
@@ -12,15 +11,13 @@ export const getTemporaryOrgRedirect = async ({
|
|||||||
redirectType,
|
redirectType,
|
||||||
eventTypeSlug,
|
eventTypeSlug,
|
||||||
currentQuery,
|
currentQuery,
|
||||||
prismaClient,
|
|
||||||
}: {
|
}: {
|
||||||
slugs: string[] | string;
|
slugs: string[] | string;
|
||||||
redirectType: RedirectType;
|
redirectType: RedirectType;
|
||||||
eventTypeSlug: string | null;
|
eventTypeSlug: string | null;
|
||||||
currentQuery: ParsedUrlQuery;
|
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];
|
slugs = slugs instanceof Array ? slugs : [slugs];
|
||||||
log.debug(
|
log.debug(
|
||||||
`Looking for redirect for`,
|
`Looking for redirect for`,
|
||||||
|
|||||||
@@ -2,11 +2,9 @@ import type { GetServerSidePropsContext } from "next";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
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 { getBookingForReschedule } from "@calcom/features/bookings/lib/get-booking";
|
||||||
import {
|
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||||
orgDomainConfig,
|
|
||||||
whereClauseForOrgWithSlugOrRequestedSlug,
|
|
||||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
|
||||||
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
import { getOrganizationSEOSettings } from "@calcom/features/ee/organizations/lib/orgSettings";
|
||||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||||
@@ -32,7 +30,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
const { req, params, query } = context;
|
const { req, params, query } = context;
|
||||||
const session = await getServerSession({ req });
|
const session = await getServerSession({ req });
|
||||||
const { slug: teamSlug, type: meetingSlug } = paramsSchema.parse(params);
|
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 allowRescheduleForCancelledBooking = query.allowRescheduleForCancelledBooking === "true";
|
||||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req, params?.orgSlug);
|
||||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||||
@@ -43,7 +41,6 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
redirectType: RedirectType.Team,
|
redirectType: RedirectType.Team,
|
||||||
eventTypeSlug: meetingSlug,
|
eventTypeSlug: meetingSlug,
|
||||||
currentQuery: context.query,
|
currentQuery: context.query,
|
||||||
prismaClient: prisma,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (redirect) {
|
if (redirect) {
|
||||||
@@ -51,49 +48,43 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [orgId, booking] = await Promise.all([
|
const team = await getTeamWithEventsData(teamSlug, meetingSlug, isValidOrgDomain, currentOrgDomain);
|
||||||
isOrgContext ? getOrgId(currentOrgDomain) : Promise.resolve(null),
|
|
||||||
rescheduleUid ? getBookingForReschedule(`${rescheduleUid}`, session?.user?.id) : Promise.resolve(null),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const team = await getTeamData(teamSlug, orgId);
|
if (!team || !team.eventTypes?.[0]) {
|
||||||
|
|
||||||
if (!team) {
|
|
||||||
return { notFound: true } as const;
|
return { notFound: true } as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventData = await getEventTypeData(meetingSlug, team.id);
|
const eventData = team.eventTypes[0];
|
||||||
|
|
||||||
if (!eventData) {
|
|
||||||
return { notFound: true } as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rescheduleUid && eventData.disableRescheduling) {
|
if (rescheduleUid && eventData.disableRescheduling) {
|
||||||
return { redirect: { destination: `/booking/${rescheduleUid}`, permanent: false } };
|
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 eventTypeId = eventData.id;
|
||||||
|
const eventHostsUserData = await getUsersData(
|
||||||
|
team.isPrivate,
|
||||||
|
eventTypeId,
|
||||||
|
eventData.hosts.map((h) => h.user)
|
||||||
|
);
|
||||||
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
const orgSlug = isValidOrgDomain ? currentOrgDomain : null;
|
||||||
const name = team.parent?.name ?? team.name ?? null;
|
const name = team.parent?.name ?? team.name ?? null;
|
||||||
|
|
||||||
const eventHostsUserData = getEventHosts(
|
let booking: GetBookingType | null = null;
|
||||||
team.isPrivate,
|
if (rescheduleUid) {
|
||||||
eventData.hosts.map((h) => h.user),
|
booking = await getBookingForReschedule(`${rescheduleUid}`, session?.user?.id);
|
||||||
eventData.users ?? []
|
if (
|
||||||
);
|
booking?.status === BookingStatus.CANCELLED &&
|
||||||
|
!allowRescheduleForCancelledBooking &&
|
||||||
|
!eventData.allowReschedulingCancelledBookings
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
permanent: false,
|
||||||
|
destination: `/team/${teamSlug}/${meetingSlug}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
const fromRedirectOfNonOrgLink = context.query.orgRedirection === "true";
|
||||||
const isUnpublished = team.parent ? !team.parent.slug : !team.slug;
|
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
|
// Handle string[] type from query params
|
||||||
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
let teamMemberEmail = Array.isArray(crmContactOwnerEmail) ? crmContactOwnerEmail[0] : crmContactOwnerEmail;
|
||||||
|
|
||||||
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
let crmOwnerRecordType = Array.isArray(crmContactOwnerRecordType)
|
||||||
? crmContactOwnerRecordType[0]
|
? crmContactOwnerRecordType[0]
|
||||||
: crmContactOwnerRecordType;
|
: crmContactOwnerRecordType;
|
||||||
|
|
||||||
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
let crmAppSlug = Array.isArray(crmAppSlugParam) ? crmAppSlugParam[0] : crmAppSlugParam;
|
||||||
|
|
||||||
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
if (!teamMemberEmail || !crmOwnerRecordType || !crmAppSlug) {
|
||||||
@@ -129,6 +122,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
|
|
||||||
const organizationSettings = getOrganizationSEOSettings(team);
|
const organizationSettings = getOrganizationSEOSettings(team);
|
||||||
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
const allowSEOIndexing = organizationSettings?.allowSEOIndexing ?? false;
|
||||||
|
|
||||||
const featureRepo = new FeaturesRepository();
|
const featureRepo = new FeaturesRepository();
|
||||||
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
const teamHasApiV2Route = await featureRepo.checkIfTeamHasFeature(team.id, "use-api-v2-for-team-slots");
|
||||||
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
const useApiV2 = teamHasApiV2Route && hasApiV2RouteInEnv();
|
||||||
@@ -178,149 +172,116 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const getOrgId = async (orgSlug: string): Promise<number | null> => {
|
const getTeamWithEventsData = async (
|
||||||
const org = await prisma.team.findFirst({
|
teamSlug: string,
|
||||||
where: whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
|
meetingSlug: string,
|
||||||
select: { id: true },
|
isValidOrgDomain: boolean,
|
||||||
});
|
currentOrgDomain: string | null
|
||||||
|
) => {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await prisma.team.findFirst({
|
return await prisma.team.findFirst({
|
||||||
where: {
|
where: {
|
||||||
parentId: orgId ?? null,
|
...getSlugOrRequestedSlug(teamSlug),
|
||||||
isOrganization: false,
|
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||||
OR: [
|
|
||||||
{ slug: teamSlug },
|
|
||||||
{
|
|
||||||
metadata: {
|
|
||||||
path: ["requestedSlug"],
|
|
||||||
equals: teamSlug,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
select: teamSelectFields,
|
|
||||||
orderBy: {
|
orderBy: {
|
||||||
slug: { sort: "asc", nulls: "last" },
|
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: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
title: true,
|
isPrivate: true,
|
||||||
isInstantEvent: true,
|
hideBranding: true,
|
||||||
schedulingType: true,
|
parent: {
|
||||||
metadata: true,
|
|
||||||
length: true,
|
|
||||||
hidden: true,
|
|
||||||
disableCancelling: true,
|
|
||||||
disableRescheduling: true,
|
|
||||||
allowReschedulingCancelledBookings: true,
|
|
||||||
interfaceLanguage: true,
|
|
||||||
hosts: {
|
|
||||||
take: 3,
|
|
||||||
select: {
|
select: {
|
||||||
user: {
|
slug: true,
|
||||||
|
name: true,
|
||||||
|
bannerUrl: true,
|
||||||
|
logoUrl: true,
|
||||||
|
hideBranding: true,
|
||||||
|
organizationSettings: {
|
||||||
select: {
|
select: {
|
||||||
name: true,
|
allowSEOIndexing: true,
|
||||||
username: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Include users for when hosts is empty
|
logoUrl: true,
|
||||||
users: {
|
name: true,
|
||||||
take: 1,
|
slug: true,
|
||||||
|
eventTypes: {
|
||||||
|
where: {
|
||||||
|
slug: meetingSlug,
|
||||||
|
},
|
||||||
select: {
|
select: {
|
||||||
username: true,
|
id: true,
|
||||||
name: 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,
|
isPrivateTeam: boolean,
|
||||||
hosts: Pick<User, "username" | "name">[],
|
eventTypeId: number,
|
||||||
users: Pick<User, "username" | "name">[]
|
users: Pick<User, "username" | "name">[]
|
||||||
) => {
|
) => {
|
||||||
if (isPrivateTeam) {
|
if (!isPrivateTeam && users.length > 0) {
|
||||||
return [];
|
return users
|
||||||
}
|
|
||||||
|
|
||||||
if (hosts.length > 0) {
|
|
||||||
return hosts
|
|
||||||
.filter((user) => user.username)
|
.filter((user) => user.username)
|
||||||
.map((user) => ({
|
.map((user) => ({
|
||||||
username: user.username ?? "",
|
username: user.username ?? "",
|
||||||
name: user.name ?? "",
|
name: user.name ?? "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
if (!isPrivateTeam && users.length === 0) {
|
||||||
if (users.length > 0) {
|
const { users: data } = await prisma.eventType.findUniqueOrThrow({
|
||||||
return [
|
where: { id: eventTypeId },
|
||||||
{
|
select: {
|
||||||
username: users[0].username ?? "",
|
users: {
|
||||||
name: users[0].name ?? "",
|
take: 1,
|
||||||
|
select: {
|
||||||
|
username: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
});
|
||||||
|
|
||||||
|
return data.length > 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
username: data[0].username ?? "",
|
||||||
|
name: data[0].name ?? "",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
import { useReactTable, getCoreRowModel, getSortedRowModel, createColumnHelper } from "@tanstack/react-table";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useMemo, useRef } from "react";
|
import { useMemo, useRef } from "react";
|
||||||
|
|
||||||
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
import { WipeMyCalActionButton } from "@calcom/app-store/wipemycalother/components";
|
||||||
@@ -44,34 +45,6 @@ type RecurringInfo = {
|
|||||||
bookings: { [key: string]: Date[] };
|
bookings: { [key: string]: Date[] };
|
||||||
};
|
};
|
||||||
|
|
||||||
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = [
|
|
||||||
{
|
|
||||||
name: "upcoming",
|
|
||||||
href: "/bookings/upcoming",
|
|
||||||
"data-testid": "upcoming",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unconfirmed",
|
|
||||||
href: "/bookings/unconfirmed",
|
|
||||||
"data-testid": "unconfirmed",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "recurring",
|
|
||||||
href: "/bookings/recurring",
|
|
||||||
"data-testid": "recurring",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "past",
|
|
||||||
href: "/bookings/past",
|
|
||||||
"data-testid": "past",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "cancelled",
|
|
||||||
href: "/bookings/cancelled",
|
|
||||||
"data-testid": "cancelled",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
const descriptionByStatus: Record<BookingListingStatus, string> = {
|
||||||
upcoming: "upcoming_bookings",
|
upcoming: "upcoming_bookings",
|
||||||
recurring: "recurring_bookings",
|
recurring: "recurring_bookings",
|
||||||
@@ -107,6 +80,46 @@ function BookingsContent({ status }: BookingsProps) {
|
|||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const user = useMeQuery().data;
|
const user = useMeQuery().data;
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Generate dynamic tabs that preserve query parameters
|
||||||
|
const tabs: (VerticalTabItemProps | HorizontalTabItemProps)[] = useMemo(() => {
|
||||||
|
const queryString = searchParams?.toString() || "";
|
||||||
|
|
||||||
|
const baseTabConfigs = [
|
||||||
|
{
|
||||||
|
name: "upcoming",
|
||||||
|
path: "/bookings/upcoming",
|
||||||
|
"data-testid": "upcoming",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unconfirmed",
|
||||||
|
path: "/bookings/unconfirmed",
|
||||||
|
"data-testid": "unconfirmed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "recurring",
|
||||||
|
path: "/bookings/recurring",
|
||||||
|
"data-testid": "recurring",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "past",
|
||||||
|
path: "/bookings/past",
|
||||||
|
"data-testid": "past",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "cancelled",
|
||||||
|
path: "/bookings/cancelled",
|
||||||
|
"data-testid": "cancelled",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return baseTabConfigs.map((tabConfig) => ({
|
||||||
|
name: tabConfig.name,
|
||||||
|
href: queryString ? `${tabConfig.path}?${queryString}` : tabConfig.path,
|
||||||
|
"data-testid": tabConfig["data-testid"],
|
||||||
|
}));
|
||||||
|
}, [searchParams?.toString()]);
|
||||||
|
|
||||||
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
const eventTypeIds = useFilterValue("eventTypeId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||||
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
const teamIds = useFilterValue("teamId", ZMultiSelectFilterValue)?.data as number[] | undefined;
|
||||||
|
|||||||
@@ -315,17 +315,20 @@ export default function Success(props: PageProps) {
|
|||||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||||
host,
|
host,
|
||||||
attendee,
|
attendee,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (isAttendee) {
|
if (isAttendee) {
|
||||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||||
host,
|
host,
|
||||||
attendee,
|
attendee,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||||
host,
|
host,
|
||||||
attendee,
|
attendee,
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return t(`emailed_host_and_attendee${titleSuffix}`);
|
return t(`emailed_host_and_attendee${titleSuffix}`);
|
||||||
@@ -546,7 +549,7 @@ export default function Success(props: PageProps) {
|
|||||||
</h4>
|
</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 && (
|
{(isCancelled || reschedule) && cancellationReason && (
|
||||||
<>
|
<>
|
||||||
<div className="font-medium">
|
<div className="font-medium">
|
||||||
|
|||||||
@@ -750,7 +750,7 @@ export const InfiniteEventTypeList = ({
|
|||||||
setDeleteDialogSchedulingType(type.schedulingType);
|
setDeleteDialogSchedulingType(type.schedulingType);
|
||||||
}}
|
}}
|
||||||
StartIcon="trash"
|
StartIcon="trash"
|
||||||
className="w-full rounded-none">
|
className="w-full rounded-t-none">
|
||||||
{t("delete")}
|
{t("delete")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -3,22 +3,60 @@
|
|||||||
import { ProgressBar } from "@tremor/react";
|
import { ProgressBar } from "@tremor/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useState, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import dayjs from "@calcom/dayjs";
|
||||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||||
|
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
import { trpc } from "@calcom/trpc/react";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import { Select } from "@calcom/ui/components/form";
|
||||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||||
import { showToast } from "@calcom/ui/toast";
|
import { showToast } from "@calcom/ui/toast";
|
||||||
|
|
||||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
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() {
|
export default function BillingCredits() {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||||
|
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||||
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
|
const utils = trpc.useUtils();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -28,8 +66,8 @@ export default function BillingCredits() {
|
|||||||
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
||||||
|
|
||||||
const params = useParamsWithFallback();
|
const params = useParamsWithFallback();
|
||||||
|
|
||||||
const teamId = params.id ? Number(params.id) : undefined;
|
const teamId = params.id ? Number(params.id) : undefined;
|
||||||
|
|
||||||
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
|
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
|
||||||
|
|
||||||
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
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) {
|
if (!IS_SMS_CREDITS_ENABLED) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -112,8 +171,8 @@ export default function BillingCredits() {
|
|||||||
<hr className="border-subtle mb-3 mt-3" />
|
<hr className="border-subtle mb-3 mt-3" />
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
||||||
<div className="mr-auto">
|
<div className="-mb-1 mr-auto">
|
||||||
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div>
|
<Label>{t("buy_additional_credits")}</Label>
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<TextField
|
<TextField
|
||||||
required
|
required
|
||||||
@@ -132,12 +191,32 @@ export default function BillingCredits() {
|
|||||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-1 mt-auto">
|
<div className="mt-auto">
|
||||||
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
|
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
|
||||||
{t("buy_credits")}
|
{t("buy_credits")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export function BillingCreditsSkeleton() {
|
|||||||
<div className="flex">
|
<div className="flex">
|
||||||
<div className="mr-auto">
|
<div className="mr-auto">
|
||||||
<div>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
<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 */}
|
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</SkeletonContainer>
|
</SkeletonContainer>
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingS
|
|||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import type { RouterOutputs } from "@calcom/trpc";
|
import type { RouterOutputs } from "@calcom/trpc";
|
||||||
import { trpc } from "@calcom/trpc";
|
import { trpc } from "@calcom/trpc";
|
||||||
import { Badge } from "@calcom/ui/components/badge";
|
|
||||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
|
||||||
import { TextField } from "@calcom/ui/components/form";
|
|
||||||
import { Alert } from "@calcom/ui/components/alert";
|
import { Alert } from "@calcom/ui/components/alert";
|
||||||
import { Avatar } from "@calcom/ui/components/avatar";
|
import { Avatar } from "@calcom/ui/components/avatar";
|
||||||
|
import { Badge } from "@calcom/ui/components/badge";
|
||||||
import { Button } from "@calcom/ui/components/button";
|
import { Button } from "@calcom/ui/components/button";
|
||||||
|
import { TextField } from "@calcom/ui/components/form";
|
||||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||||
import { showToast } from "@calcom/ui/components/toast";
|
import { showToast } from "@calcom/ui/components/toast";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
||||||
|
|
||||||
@@ -29,10 +29,15 @@ const AddNewTeamMembers = () => {
|
|||||||
return <AddNewTeamMembersForm />;
|
return <AddNewTeamMembersForm />;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useCheckout = () => {
|
const useOrgCreation = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
const session = useSession();
|
||||||
|
const utils = trpc.useUtils();
|
||||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||||
const mutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||||
|
const { reset } = useOnboardingStore();
|
||||||
|
|
||||||
|
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data.checkoutUrl) {
|
if (data.checkoutUrl) {
|
||||||
window.location.href = data.checkoutUrl;
|
window.location.href = data.checkoutUrl;
|
||||||
@@ -43,17 +48,34 @@ const useCheckout = () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||||
|
onSuccess: async (data) => {
|
||||||
|
if (data.organization) {
|
||||||
|
// Invalidate the organizations query to ensure fresh data on the next page
|
||||||
|
await utils.viewer.organizations.listCurrent.invalidate();
|
||||||
|
await session.update();
|
||||||
|
reset();
|
||||||
|
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setServerErrorMessage(t(error.message));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mutation,
|
mutation: mutationToUse,
|
||||||
mutate: mutation.mutate,
|
mutate: mutationToUse.mutate,
|
||||||
isPending: mutation.isPending,
|
isPending: mutationToUse.isPending,
|
||||||
errorMessage: serverErrorMessage,
|
errorMessage: serverErrorMessage,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddNewTeamMembersForm = () => {
|
export const AddNewTeamMembersForm = () => {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const { useOnboardingStore } = useOnboarding();
|
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||||
const {
|
const {
|
||||||
addInvitedMember,
|
addInvitedMember,
|
||||||
removeInvitedMember,
|
removeInvitedMember,
|
||||||
@@ -64,7 +86,7 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
bio,
|
bio,
|
||||||
onboardingId,
|
onboardingId,
|
||||||
} = useOnboardingStore();
|
} = useOnboardingStore();
|
||||||
const checkout = useCheckout();
|
const orgCreation = useOrgCreation();
|
||||||
|
|
||||||
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
const teamIds = teams.filter((team) => team.isBeingMigrated && team.id > 0).map((team) => team.id);
|
||||||
|
|
||||||
@@ -117,9 +139,9 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{checkout.errorMessage && (
|
{orgCreation.errorMessage && (
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<Alert severity="error" message={checkout.errorMessage} />
|
<Alert severity="error" message={orgCreation.errorMessage} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -180,6 +202,7 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -190,7 +213,7 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
checkout.mutation.mutate({
|
orgCreation.mutation.mutate({
|
||||||
logo,
|
logo,
|
||||||
bio,
|
bio,
|
||||||
teams,
|
teams,
|
||||||
@@ -198,8 +221,8 @@ export const AddNewTeamMembersForm = () => {
|
|||||||
onboardingId,
|
onboardingId,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
loading={checkout.isPending}>
|
loading={orgCreation.isPending}>
|
||||||
{t("checkout")}
|
{isBillingEnabled ? t("checkout") : t("create")}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -691,25 +691,23 @@ export default function Signup({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
<div className="mr-12 mt-8 hidden h-full w-full grid-cols-3 gap-4 overflow-hidden lg:grid">
|
||||||
{FEATURES.map((feature) => (
|
{FEATURES.map((feature, index) => (
|
||||||
<>
|
<div key={index} className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
||||||
<div className="max-w-52 mb-8 flex flex-col leading-none sm:mb-0">
|
<div className="text-emphasis items-center">
|
||||||
<div className="text-emphasis items-center">
|
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
||||||
<Icon name={feature.icon} className="mb-1 h-4 w-4" />
|
<span className="text-sm font-medium">{t(feature.title)}</span>
|
||||||
<span className="text-sm font-medium">{t(feature.title)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-subtle text-sm">
|
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
feature.description,
|
|
||||||
feature.i18nOptions && {
|
|
||||||
...feature.i18nOptions,
|
|
||||||
}
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="text-subtle text-sm">
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
feature.description,
|
||||||
|
feature.i18nOptions && {
|
||||||
|
...feature.i18nOptions,
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.4.3",
|
"version": "5.4.9",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"analyze": "ANALYZE=true next build",
|
"analyze": "ANALYZE=true next build",
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"tailwindcss-animate": "^1.0.6",
|
"tailwindcss-animate": "^1.0.6",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"nextBundleAnalysis": {
|
"nextBundleAnalysis": {
|
||||||
"budget": 358400,
|
"budget": 358400,
|
||||||
|
|||||||
@@ -121,7 +121,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
|
|
||||||
if (!orgId) {
|
if (!orgId) {
|
||||||
orgId = await getOrgIdFromMemberOrTeamId({
|
orgId = await getOrgIdFromMemberOrTeamId({
|
||||||
memberId: parsedUserId,
|
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
|
||||||
teamId: parsedTeamId,
|
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`);
|
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;
|
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||||
|
|
||||||
@@ -147,6 +147,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
userId: parsedUserId,
|
userId: parsedUserId,
|
||||||
smsSid,
|
smsSid,
|
||||||
bookingUid: parsedBookingUid,
|
bookingUid: parsedBookingUid,
|
||||||
|
smsSegments: numSegments ?? undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (chargedUserOrTeamId) {
|
if (chargedUserOrTeamId) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { randomString } from "@calcom/lib/random";
|
|||||||
import { test } from "./lib/fixtures";
|
import { test } from "./lib/fixtures";
|
||||||
import {
|
import {
|
||||||
bookTimeSlot,
|
bookTimeSlot,
|
||||||
createNewEventType,
|
createNewUserEventType,
|
||||||
gotoBookingPage,
|
gotoBookingPage,
|
||||||
gotoFirstEventType,
|
gotoFirstEventType,
|
||||||
saveEventType,
|
saveEventType,
|
||||||
@@ -55,7 +55,7 @@ test.describe("Event Types tests", () => {
|
|||||||
test("can add new event type", async ({ page }) => {
|
test("can add new event type", async ({ page }) => {
|
||||||
const nonce = randomString(3);
|
const nonce = randomString(3);
|
||||||
const eventTitle = `hello ${nonce}`;
|
const eventTitle = `hello ${nonce}`;
|
||||||
await createNewEventType(page, { eventTitle });
|
await createNewUserEventType(page, { eventTitle });
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
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 }) => {
|
test("new event type appears first in the list", async ({ page }) => {
|
||||||
const nonce = randomString(3);
|
const nonce = randomString(3);
|
||||||
const eventTitle = `hello ${nonce}`;
|
const eventTitle = `hello ${nonce}`;
|
||||||
await createNewEventType(page, { eventTitle });
|
await createNewUserEventType(page, { eventTitle });
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
|
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
|
||||||
const firstEventTitle = await firstEvent.getAttribute("title");
|
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 }) => {
|
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||||
const nonce = randomString(3);
|
const nonce = randomString(3);
|
||||||
const eventTitle = `my recurring event ${nonce}`;
|
const eventTitle = `my recurring event ${nonce}`;
|
||||||
await createNewEventType(page, { eventTitle });
|
await createNewUserEventType(page, { eventTitle });
|
||||||
|
|
||||||
// fix the race condition
|
// fix the race condition
|
||||||
await page.waitForSelector('[data-testid="event-title"]');
|
await page.waitForSelector('[data-testid="event-title"]');
|
||||||
@@ -393,7 +393,7 @@ test.describe("Event Types tests", () => {
|
|||||||
}) => {
|
}) => {
|
||||||
const nonce = randomString(3);
|
const nonce = randomString(3);
|
||||||
const eventTitle = `Conflict event ${nonce}`;
|
const eventTitle = `Conflict event ${nonce}`;
|
||||||
await createNewEventType(page, { eventTitle });
|
await createNewUserEventType(page, { eventTitle });
|
||||||
await page.goto("/event-types");
|
await page.goto("/event-types");
|
||||||
await page.click(`text=${eventTitle}`);
|
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) => {
|
checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
|
||||||
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
|
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",
|
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||||
action: { type: "externalRedirectUrl", value: "https://cal.com/peer" },
|
action: { type: "externalRedirectUrl", value: `${WEBAPP_URL}/pro` },
|
||||||
queryValue: {
|
queryValue: {
|
||||||
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
id: "a8ba9aab-4567-489a-bcde-f1823f71b4ad",
|
||||||
type: "group",
|
type: "group",
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import { createServer } from "http";
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import type { Messages } from "mailhog";
|
import type { Messages } from "mailhog";
|
||||||
import { totp } from "otplib";
|
import { totp } from "otplib";
|
||||||
|
import { v4 as uuid } from "uuid";
|
||||||
|
|
||||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||||
import type { Prisma } from "@calcom/prisma/client";
|
import type { Prisma } from "@calcom/prisma/client";
|
||||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||||
|
|
||||||
import type { createEmailsFixture } from "../fixtures/emails";
|
import type { createEmailsFixture } from "../fixtures/emails";
|
||||||
|
import type { CreateUsersFixture } from "../fixtures/users";
|
||||||
import type { Fixtures } from "./fixtures";
|
import type { Fixtures } from "./fixtures";
|
||||||
|
|
||||||
type Request = IncomingMessage & { body?: unknown };
|
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();
|
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]");
|
await page.click("[data-testid=new-event-type]");
|
||||||
const eventTitle = args.eventTitle;
|
if (args.username) {
|
||||||
await page.fill("[name=title]", eventTitle);
|
await page.getByRole("button", { name: args.username }).click();
|
||||||
|
}
|
||||||
|
await page.fill("[name=title]", args.eventTitle);
|
||||||
await page.fill("[name=length]", "10");
|
await page.fill("[name=length]", "10");
|
||||||
await page.click("[type=submit]");
|
await page.click("[type=submit]");
|
||||||
|
|
||||||
@@ -216,7 +220,7 @@ export async function setupManagedEvent({
|
|||||||
|
|
||||||
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||||
const eventTitle = args.eventTitle;
|
const eventTitle = args.eventTitle;
|
||||||
await createNewEventType(page, { eventTitle });
|
await createNewUserEventType(page, { eventTitle });
|
||||||
await page.waitForSelector('[data-testid="event-title"]');
|
await page.waitForSelector('[data-testid="event-title"]');
|
||||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||||
"aria-current",
|
"aria-current",
|
||||||
@@ -563,3 +567,26 @@ export async function expectPageToBeNotFound({ page, url }: { page: Page; url: s
|
|||||||
await page.goto(`${url}`);
|
await page.goto(`${url}`);
|
||||||
await expect(page.getByTestId(`404-page`)).toBeVisible();
|
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 { test } from "./lib/fixtures";
|
||||||
import {
|
import {
|
||||||
createHttpServer,
|
createHttpServer,
|
||||||
createNewEventType,
|
createNewUserEventType,
|
||||||
selectFirstAvailableTimeSlotNextMonth,
|
selectFirstAvailableTimeSlotNextMonth,
|
||||||
submitAndWaitForResponse,
|
submitAndWaitForResponse,
|
||||||
} from "./lib/testUtils";
|
} from "./lib/testUtils";
|
||||||
@@ -752,7 +752,7 @@ test.describe("Text area min and max characters text", () => {
|
|||||||
|
|
||||||
// We wait until loading is finished
|
// We wait until loading is finished
|
||||||
await page.waitForSelector('[data-testid="event-types"]');
|
await page.waitForSelector('[data-testid="event-types"]');
|
||||||
await createNewEventType(page, { eventTitle });
|
await createNewUserEventType(page, { eventTitle });
|
||||||
await page.waitForSelector('[data-testid="event-title"]');
|
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")).toContainText("Event Setup"); //fix the race condition
|
||||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ test.describe("Organization", () => {
|
|||||||
await test.step("Basic info", async () => {
|
await test.step("Basic info", async () => {
|
||||||
// Check required fields
|
// Check required fields
|
||||||
await page.locator("button[type=submit]").click();
|
await page.locator("button[type=submit]").click();
|
||||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||||
|
|
||||||
// Happy path
|
// Happy path
|
||||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||||
@@ -168,7 +168,7 @@ test.describe("Organization", () => {
|
|||||||
await test.step("Basic info", async () => {
|
await test.step("Basic info", async () => {
|
||||||
// Check required fields
|
// Check required fields
|
||||||
await page.locator("button[type=submit]").click();
|
await page.locator("button[type=submit]").click();
|
||||||
await expect(page.getByTestId("field-error")).toHaveCount(3);
|
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||||
|
|
||||||
// Happy path
|
// Happy path
|
||||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||||
|
|||||||
@@ -1,36 +1,9 @@
|
|||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
import type { Page } from "@playwright/test";
|
import type { Page } from "@playwright/test";
|
||||||
import { doOnOrgDomain } from "playwright/lib/testUtils";
|
import { doOnOrgDomain, setupOrgMember } from "playwright/lib/testUtils";
|
||||||
import { v4 as uuid } from "uuid";
|
|
||||||
|
|
||||||
import { SchedulingType } from "@calcom/prisma/enums";
|
|
||||||
|
|
||||||
import type { CreateUsersFixture } from "../fixtures/users";
|
|
||||||
import { test } from "../lib/fixtures";
|
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>>;
|
type TestContext = Awaited<ReturnType<typeof setupOrgMember>>;
|
||||||
|
|
||||||
interface ToggleSeoSwitchParams {
|
interface ToggleSeoSwitchParams {
|
||||||
|
|||||||
@@ -267,6 +267,8 @@
|
|||||||
"guests": "Guests",
|
"guests": "Guests",
|
||||||
"guest": "Guest",
|
"guest": "Guest",
|
||||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
"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",
|
"404_the_user": "The username",
|
||||||
"username": "Username",
|
"username": "Username",
|
||||||
"is_still_available": "is still available.",
|
"is_still_available": "is still available.",
|
||||||
@@ -1710,6 +1712,8 @@
|
|||||||
"show_available_seats_count": "Show the number of available seats",
|
"show_available_seats_count": "Show the number of available seats",
|
||||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||||
"format": "Format",
|
"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",
|
"uppercase_for_letters": "Use uppercase for all letters",
|
||||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||||
"platform_members": "Platform members",
|
"platform_members": "Platform members",
|
||||||
@@ -2835,7 +2839,7 @@
|
|||||||
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
|
"salesforce_skip_entry_creation": "Skip creating {{entry}} record if they do not exist in Salesforce",
|
||||||
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
|
"salesforce_if_account_does_not_exist": "If the contact does not exist under an account, create new lead from attendee",
|
||||||
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
|
"salesforce_create_new_contact_under_account": "Create a new contact under an account based on email domain of attendee and existing contacts",
|
||||||
"salesforce_change_record_owner_on_booking":"Change record owner on booking",
|
"salesforce_change_record_owner_on_booking": "Change record owner on booking",
|
||||||
"mass_assign_attributes": "Mass assign attributes",
|
"mass_assign_attributes": "Mass assign attributes",
|
||||||
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
|
"reroute_preview_custom_message": "It results in showing custom message. Try changing the response to route to an event",
|
||||||
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
|
"reroute_preview_external_redirect": "It results in redirecting to {{externalUrl}}. Try changing the response to route to an event",
|
||||||
@@ -2888,6 +2892,7 @@
|
|||||||
"yes_reassign": "Yes, reassign",
|
"yes_reassign": "Yes, reassign",
|
||||||
"filter_by_attributes": "Filter by attributes",
|
"filter_by_attributes": "Filter by attributes",
|
||||||
"reassign_reason": "Reason for reassigning",
|
"reassign_reason": "Reason for reassigning",
|
||||||
|
"field_type_change_suggestion": "Changing the field type may cause data loss. Please create a new field instead.",
|
||||||
"reassigned_by": "Reassigned by",
|
"reassigned_by": "Reassigned by",
|
||||||
"row": "row",
|
"row": "row",
|
||||||
"rows": "rows",
|
"rows": "rows",
|
||||||
@@ -2933,7 +2938,7 @@
|
|||||||
"routing_form_insights_assignment_reason": "Assignment Reason",
|
"routing_form_insights_assignment_reason": "Assignment Reason",
|
||||||
"access_denied": "Access Denied",
|
"access_denied": "Access Denied",
|
||||||
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
"salesforce_route_to_owner": "Contact owner will be the Round Robin host if available",
|
||||||
"salesforce_add_attendees_as":"Add new attendees as",
|
"salesforce_add_attendees_as": "Add new attendees as",
|
||||||
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
"salesforce_do_not_route_to_owner": "Contact owner will not be forced (can still be host if it matches the attributes and Round Robin criteria)",
|
||||||
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
"salesforce_route_to_custom_lookup_field": "Route to a user that matches a lookup field on an account",
|
||||||
"salesforce_option": "Salesforce Option",
|
"salesforce_option": "Salesforce Option",
|
||||||
@@ -2996,7 +3001,7 @@
|
|||||||
"shortfall": "Shortfall",
|
"shortfall": "Shortfall",
|
||||||
"routing_form": "Routing Form",
|
"routing_form": "Routing Form",
|
||||||
"go_back_and_save": "Go back and save",
|
"go_back_and_save": "Go back and save",
|
||||||
"save_changes":"Save changes",
|
"save_changes": "Save changes",
|
||||||
"leave_without_saving": "Leave without saving",
|
"leave_without_saving": "Leave without saving",
|
||||||
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
"leave_without_saving_description": "Are you sure you want to leave without saving changes to your routing form?",
|
||||||
"something_unexpected_occurred": "Something unexpected occurred",
|
"something_unexpected_occurred": "Something unexpected occurred",
|
||||||
@@ -3031,9 +3036,9 @@
|
|||||||
"authorize": "Authorize",
|
"authorize": "Authorize",
|
||||||
"next": "Next",
|
"next": "Next",
|
||||||
"hide": "Hide",
|
"hide": "Hide",
|
||||||
"show":"Show",
|
"show": "Show",
|
||||||
"utm_params":"UTM parameters",
|
"utm_params": "UTM parameters",
|
||||||
"no_utm_params":"No UTM parameters available.",
|
"no_utm_params": "No UTM parameters available.",
|
||||||
"asc": "Asc",
|
"asc": "Asc",
|
||||||
"desc": "Desc",
|
"desc": "Desc",
|
||||||
"verify_email_change": "Verify email change",
|
"verify_email_change": "Verify email change",
|
||||||
@@ -3103,7 +3108,7 @@
|
|||||||
"user_has_no_team_yet": "You don't have a team yet",
|
"user_has_no_team_yet": "You don't have a team yet",
|
||||||
"no_team_members": "You don't have team members yet",
|
"no_team_members": "You don't have team members yet",
|
||||||
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
"recurring_event_doesnt_support_seats": "Recurring event doesn't support seats feature. Make it non-recurring to enable seats.",
|
||||||
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
"booking_limit_per_booker_doesnt_support_recurring": "Recurring event doesn't support booking limit per booker feature. Disable booking limit per booker to enable Recurring event.",
|
||||||
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
"seats_doesnt_support_recurring": "Seats feature doesn't support recurring event. Disable seats feature to make it recurring.",
|
||||||
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
"recurring_event_seats_error": "Recurring event doesn't support seats feature. Disable seats feature or make the event non-recurring.",
|
||||||
"segment": "Segment",
|
"segment": "Segment",
|
||||||
@@ -3181,8 +3186,8 @@
|
|||||||
"resend": "Resend",
|
"resend": "Resend",
|
||||||
"verification_email_sent": "Verification email sent",
|
"verification_email_sent": "Verification email sent",
|
||||||
"select_all_members": "Select all members",
|
"select_all_members": "Select all members",
|
||||||
"select_all_members_tooltip":"Selecting all members will add all future team members when they are added to the team.",
|
"select_all_members_tooltip": "Selecting all members will add all future team members when they are added to the team.",
|
||||||
"form_settings":"Form Settings",
|
"form_settings": "Form Settings",
|
||||||
"remove_whitelist_status": "Remove whitelist status",
|
"remove_whitelist_status": "Remove whitelist status",
|
||||||
"whitelist_user_workflows": "Whitelist user workflows",
|
"whitelist_user_workflows": "Whitelist user workflows",
|
||||||
"user_workflows_whitelisted": "User workflows whitelisted",
|
"user_workflows_whitelisted": "User workflows whitelisted",
|
||||||
@@ -3240,6 +3245,10 @@
|
|||||||
"requires_credits": "Requires credits",
|
"requires_credits": "Requires credits",
|
||||||
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
||||||
"available_credits": "Available credits",
|
"available_credits": "Available credits",
|
||||||
|
"choose_restriction_schedule": "Add restriction schedule",
|
||||||
|
"choose_restriction_schedule_description": "Enable this if you want to restrict the availability of hosts for this event type based on a restriction schedule. This only removes the slots that are not in the restriction schedule, it does not add any new slots.",
|
||||||
|
"use_booker_timezone": "Use booker's timezone",
|
||||||
|
"use_booker_timezone_info": "Apply the selected restriction schedule in the booker's timezone",
|
||||||
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
"load_balancing_warning": "This will disable Load Balancing on all your Round Robin event types",
|
||||||
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
"rr_load_balancing_disabled": "Load Balancing is only available when the timestamp basis is set to Booking Creation Time",
|
||||||
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
"round_robin_settings_description": "Customize the default round robin settings for this team",
|
||||||
@@ -3248,11 +3257,15 @@
|
|||||||
"meeting_start_time": "Meeting Start Time",
|
"meeting_start_time": "Meeting Start Time",
|
||||||
"members_affected_by_disabling_delegation_credential": "Affected members",
|
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
"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",
|
"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": "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?",
|
"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?",
|
||||||
"duration_min_error": "Duration must be at least {{min}} minute(s)",
|
"duration_min_error": "Duration must be at least {{min}} minute(s)",
|
||||||
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
|
"duration_max_error": "Duration cannot exceed {{max}} minutes (24 hours)",
|
||||||
|
"booking_not_allowed_by_restriction_schedule_error": "Booking outside restriction schedule availability.",
|
||||||
|
"restriction_schedule_not_found_error": "Restriction schedule not found",
|
||||||
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
"converted_image_size_limit_exceed": "Image size limit exceeded, please use a smaller image preferably in JPEG format",
|
||||||
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@
|
|||||||
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
|
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
|
||||||
"notes": "Jegyzetek",
|
"notes": "Jegyzetek",
|
||||||
"manage_my_bookings": "Foglalásaim kezelése",
|
"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": "Új eseményt foglaltak.",
|
||||||
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
|
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
|
||||||
"invitee_email": "Meghívó e-mail",
|
"invitee_email": "Meghívó e-mail",
|
||||||
@@ -240,7 +240,7 @@
|
|||||||
"copy_public_page_link": "Nyilvános oldal linkjének másolása",
|
"copy_public_page_link": "Nyilvános oldal linkjének másolása",
|
||||||
"copy_referral_link": "Ajánlói link másolása",
|
"copy_referral_link": "Ajánlói link másolása",
|
||||||
"sign_out": "Kijelentkezés",
|
"sign_out": "Kijelentkezés",
|
||||||
"add_another": "Adj hozzá egy másikat",
|
"add_another": "Másvalaki hozzáadása",
|
||||||
"install_another": "Telepítsen másikat",
|
"install_another": "Telepítsen másikat",
|
||||||
"until": "amíg",
|
"until": "amíg",
|
||||||
"powered_by": "működteti",
|
"powered_by": "működteti",
|
||||||
@@ -354,10 +354,10 @@
|
|||||||
"connect_caldav": "Csatlakozás a CalDav szerverhez",
|
"connect_caldav": "Csatlakozás a CalDav szerverhez",
|
||||||
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
|
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
|
||||||
"connect": "Csatlakozás",
|
"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",
|
"create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével",
|
||||||
"who": "Ki",
|
"who": "Ki",
|
||||||
"what": "Mit",
|
"what": "Mi",
|
||||||
"when": "Mikor",
|
"when": "Mikor",
|
||||||
"where": "Hol",
|
"where": "Hol",
|
||||||
"add_to_calendar": "Hozzáadás naptárhoz",
|
"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_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.",
|
"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.",
|
"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": "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ó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ó 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 a foglalásod véglegesítéséhez.",
|
"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 mindig vissza kell igazolnia vagy elutasítania a foglalást az ismétlődő találkozóra.",
|
"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": "Találkozó sikeresen lefoglalva",
|
||||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontod le lett foglalva",
|
"meeting_is_scheduled_recurring": "Az ismétlődő időpontja le lett foglalva",
|
||||||
"booking_submitted": "A foglalásod be lett küldve",
|
"booking_submitted": "Foglalását elküldte",
|
||||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldtük",
|
"booking_submitted_recurring": "Ismétlődő találkozóját elküldte",
|
||||||
"booking_confirmed": "A foglalásod vissza lett igazolva",
|
"booking_confirmed": "Foglalását visszaigazolták",
|
||||||
"booking_confirmed_recurring": "Ismétlődő találkozóját megerősítetté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_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",
|
"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.",
|
"enter_new_password": "Adja meg a fiókjához kívánt új jelszót.",
|
||||||
"reset_password": "Jelszó visszaállítása",
|
"reset_password": "Jelszó visszaállítása",
|
||||||
"change_your_password": "Változtasd meg a jelszavadat",
|
"change_your_password": "Változtassa meg a jelszavát",
|
||||||
"show_password": "Mutasd a jelszót",
|
"show_password": "Jelszó megjelenítése",
|
||||||
"hide_password": "Jelszó elrejté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.",
|
"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.",
|
"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á",
|
"whoops": "Hoppá",
|
||||||
"login": "Bejelentkezés",
|
"login": "Bejelentkezés",
|
||||||
"success": "Siker",
|
"success": "Siker",
|
||||||
"failed": "Nem sikerült",
|
"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",
|
"layout": "Elrendezés",
|
||||||
"bookerlayout_default_title": "Alapértelmezett nézet",
|
"bookerlayout_default_title": "Alapértelmezett nézet",
|
||||||
"bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.",
|
"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",
|
"additional_notes": "Egyéb megjegyzés",
|
||||||
"booking_fail": "Találkozó nem foglalható.",
|
"booking_fail": "Találkozó nem foglalható.",
|
||||||
"reschedule_fail": "Nem sikerült időpontot módisítani.",
|
"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.",
|
"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 a következővel: {{profileName}}",
|
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét vele: {{profileName}}",
|
||||||
"booking_reschedule_confirmation": "Módosítsd {{eventTypeTitle}} időpontod nála: {{profileName}}",
|
"booking_reschedule_confirmation": "Módosítsa {{eventTypeTitle}} időpontját nála: {{profileName}}",
|
||||||
"in_person_meeting": "Személyes találkozó",
|
"in_person_meeting": "Személyes találkozó",
|
||||||
"in_person": "Személyesen (Szervező címén)",
|
"in_person": "Személyesen (Szervező címén)",
|
||||||
"link_meeting": "Találkozó összekapcsolása",
|
"link_meeting": "Találkozó összekapcsolása",
|
||||||
"phone_number": "Telefonszám",
|
"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",
|
"organizer_phone_number": "Szervező telefonszáma",
|
||||||
"enter_phone_number": "Adja meg a telefonszámot",
|
"enter_phone_number": "Adja meg a telefonszámot",
|
||||||
"reschedule": "átütmezés",
|
"reschedule": "átütmezés",
|
||||||
"reschedule_this": "Inkább időpontmódosítás",
|
"reschedule_this": "Inkább időpontmódosítás",
|
||||||
"book_a_team_member": "Foglaljon helyette egy csapattagot",
|
"book_a_team_member": "Foglaljon inkáb egy másik csapattagnál",
|
||||||
"or": "VAGY",
|
"or": "vagy",
|
||||||
"go_back": "Vissza",
|
"go_back": "Vissza",
|
||||||
"email_or_username": "E-mail cím vagy felhasználónév",
|
"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",
|
"role": "Szerepkör",
|
||||||
"edit_role": "Szerepkör szerkesztése",
|
"edit_role": "Szerepkör szerkesztése",
|
||||||
"edit_team": "Csapat szerkesztése",
|
"edit_team": "Csapat szerkesztése",
|
||||||
"editing_user": "Felhasználó szerkesztése",
|
"editing_user": "Felhasználó szerkesztése",
|
||||||
"editing_org": "Szervezet 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>",
|
"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",
|
"reject_all": "Az összes elutasítása",
|
||||||
"accept": "Elfogad",
|
"accept": "Elfogadás",
|
||||||
"leave": "Elhagyás",
|
"leave": "Kilépés",
|
||||||
"profile": "Profil",
|
"profile": "Profil",
|
||||||
"my_team_url": "Csapatom URL-je",
|
"my_team_url": "Csapatom URL-je",
|
||||||
"my_teams": "A csapataim",
|
"my_teams": "A csapataim",
|
||||||
"team_name": "Csapat neve",
|
"team_name": "Csapat neve",
|
||||||
"your_team_name": "A csapatod neve",
|
"your_team_name": "A csapatod neve",
|
||||||
"team_updated_successfully": "A csapat sikeresen frissítve",
|
"team_updated_successfully": "A csapat sikeresen frissítve",
|
||||||
"your_team_updated_successfully": "Csapatát sikeresen frissítette.",
|
"your_team_updated_successfully": "Csapatod sikeresen frissítetted.",
|
||||||
"your_org_updated_successfully": "Szervezete sikeresen frissítve.",
|
"your_org_updated_successfully": "Szervezet sikeresen frissítve.",
|
||||||
"error_copying_to_clipboard": "Hiba a vágólapra másolás során",
|
"error_copying_to_clipboard": "Hiba a vágólapra másolás során",
|
||||||
"about": "Rólunk",
|
"about": "Rólunk",
|
||||||
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
|
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
|
||||||
|
|||||||
@@ -66,18 +66,18 @@
|
|||||||
"rejection_reason_title": "Odbij zahtev za rezervaciju?",
|
"rejection_reason_title": "Odbij zahtev za rezervaciju?",
|
||||||
"rejection_reason_description": "Da li ste sigurni da želite da odbijete rezervaciju? Obavestićemo osobu koja je pokušala da rezerviše. Možete navesti razlog ispod.",
|
"rejection_reason_description": "Da li ste sigurni da želite da odbijete rezervaciju? Obavestićemo osobu koja je pokušala da rezerviše. Možete navesti razlog ispod.",
|
||||||
"rejection_confirmation": "Odbij rezervaciju",
|
"rejection_confirmation": "Odbij rezervaciju",
|
||||||
"manage_this_event": "Upravljaj ovim dogadjajem",
|
"manage_this_event": "Upravljaj ovim događajem",
|
||||||
"invite_team_member": "Pozovite člana tima",
|
"invite_team_member": "Pozovite člana tima",
|
||||||
"invite_team_individual_segment": "Pozovite jednu osobu",
|
"invite_team_individual_segment": "Pozovite jednu osobu",
|
||||||
"invite_team_bulk_segment": "Pozovite grupno",
|
"invite_team_bulk_segment": "Pozovite grupno",
|
||||||
"invite_team_notifcation_badge": "Poz.",
|
"invite_team_notifcation_badge": "Poz.",
|
||||||
"your_event_has_been_scheduled": "Vaš dogadjaj je odložen",
|
"your_event_has_been_scheduled": "Vaš događaj je zakazan",
|
||||||
"your_event_has_been_scheduled_recurring": "Vaš ponavljajući događaj je zakazan",
|
"your_event_has_been_scheduled_recurring": "Vaš ponavljajući događaj je zakazan",
|
||||||
"accept_our_license": "Prihvatite našu dozvolu tako što ćete promeniti .env varijablu <1>NEXT_PUBLIC_LICENSE_CONSENT</1> u '{{agree}}'.",
|
"accept_our_license": "Prihvatite našu dozvolu tako što ćete promeniti .env varijablu <1>NEXT_PUBLIC_LICENSE_CONSENT</1> u '{{agree}}'.",
|
||||||
"remove_banner_instructions": "Da uklonite baner, molim vas da otvorite vaš .env file i promenite <1>NEXT_PUBLIC_LICENSE_CONSENT</1> varijablu u '{{agree}}'.",
|
"remove_banner_instructions": "Da uklonite baner, molim vas da otvorite vaš .env file i promenite <1>NEXT_PUBLIC_LICENSE_CONSENT</1> varijablu u '{{agree}}'.",
|
||||||
"error_message": "Poruka o grešci je bila: '{{errorMessage}}'",
|
"error_message": "Poruka o grešci je bila: '{{errorMessage}}'",
|
||||||
"refund_failed_subject": "Povraćaj novca neuspešan: {{name}} - {{date}} - {{eventType}}",
|
"refund_failed_subject": "Povraćaj novca neuspešan: {{name}} - {{date}} - {{eventType}}",
|
||||||
"refund_failed": "Povraćaj novca za dogadjaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
"refund_failed": "Povraćaj novca za događaj {{eventType}} sa {{userName}} datuma {{date}} je neuspešan.",
|
||||||
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
|
"check_with_provider_and_user": "Molimo vas proverite sa vašom uslugom plaćanja i {{user}} kako bi rešili ovo.",
|
||||||
"a_refund_failed": "Povraćaj novca neuspešan",
|
"a_refund_failed": "Povraćaj novca neuspešan",
|
||||||
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
|
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
|
||||||
@@ -115,23 +115,23 @@
|
|||||||
"open_env": "Otvorite .env i pristanite na našu Licencu",
|
"open_env": "Otvorite .env i pristanite na našu Licencu",
|
||||||
"env_changed": "Promenio/la sam moj .env",
|
"env_changed": "Promenio/la sam moj .env",
|
||||||
"accept_license": "Prihvati Licencu",
|
"accept_license": "Prihvati Licencu",
|
||||||
"still_waiting_for_approval": "Dogadjaj još čeka na odobrenje",
|
"still_waiting_for_approval": "Događaj još čeka na odobrenje",
|
||||||
"event_is_still_waiting": "Zahtev na dogadjaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
"event_is_still_waiting": "Zahtev na događaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
"no_result_found_for": "Nema rezultata za \"{{searchTerm}}\"",
|
"no_result_found_for": "Nema rezultata za \"{{searchTerm}}\"",
|
||||||
"no_more_results": "Nema više rezultata",
|
"no_more_results": "Nema više rezultata",
|
||||||
"no_results": "Nema rezultata",
|
"no_results": "Nema rezultata",
|
||||||
"load_more_results": "Učitaj više rezultata",
|
"load_more_results": "Učitaj više rezultata",
|
||||||
"integration_meeting_id": "{{integrationName}} ID sastanka: {{meetingId}}",
|
"integration_meeting_id": "{{integrationName}} ID sastanka: {{meetingId}}",
|
||||||
"confirmed_event_type_subject": "Potvrdjeno: {{eventType}} sa {{name}} datuma {{date}}",
|
"confirmed_event_type_subject": "Potvrdjeno: {{eventType}} sa {{name}} datuma {{date}}",
|
||||||
"new_event_request": "Novi zahtev na dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
"new_event_request": "Novi zahtev na događaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
"confirm_or_reject_request": "Potvrdite ili odbijte zahtev",
|
"confirm_or_reject_request": "Potvrdite ili odbijte zahtev",
|
||||||
"check_bookings_page_to_confirm_or_reject": "Proverite stranicu rezervacija da potvrdite ili odbijete rezervaciju.",
|
"check_bookings_page_to_confirm_or_reject": "Proverite stranicu rezervacija da potvrdite ili odbijete rezervaciju.",
|
||||||
"check_in_assistant": "Asistent za prijavu",
|
"check_in_assistant": "Asistent za prijavu",
|
||||||
"check_in_assistant_description": "Obavlja odlazni poziv da proveri da li zakazani termin odgovara ili želite da ga pomerite.",
|
"check_in_assistant_description": "Obavlja odlazni poziv da proveri da li zakazani termin odgovara ili želite da ga pomerite.",
|
||||||
"create_your_own_prompt": "Kreirajte sopstveni upit i koristite ga za odlazne pozive.",
|
"create_your_own_prompt": "Kreirajte sopstveni upit i koristite ga za odlazne pozive.",
|
||||||
"event_awaiting_approval": "Dogadjaj čeka vaše odobrenje",
|
"event_awaiting_approval": "Događaj čeka vaše odobrenje",
|
||||||
"event_awaiting_approval_recurring": "Ponavljajući događaj čeka na vaše odobrenje",
|
"event_awaiting_approval_recurring": "Ponavljajući događaj čeka na vaše odobrenje",
|
||||||
"someone_requested_an_event": "Neko zahteva da rezerviše dogadjaj na vašem kalendaru.",
|
"someone_requested_an_event": "Neko zahteva da rezerviše događaj na vašem kalendaru.",
|
||||||
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
|
"someone_requested_password_reset": "Neko zahteva link da promeni vašu lozinku.",
|
||||||
"password_reset_email_sent": "Ako ovaj imejl postoji našem sistemu, trebalo bi da primite imejl za resetovanje.",
|
"password_reset_email_sent": "Ako ovaj imejl postoji našem sistemu, trebalo bi da primite imejl za resetovanje.",
|
||||||
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
|
"password_reset_instructions": "Ako niste pokrenuli ovaj zahtev, slobodno ignorišite ovu e-poštu i vaša lozinka se neće promeniti.",
|
||||||
@@ -145,7 +145,7 @@
|
|||||||
"your_meeting_has_been_booked": "Sastanak je zakazan",
|
"your_meeting_has_been_booked": "Sastanak je zakazan",
|
||||||
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
|
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
|
||||||
"event_type_has_been_updated": "Vaš {{title}} je ažuriran.",
|
"event_type_has_been_updated": "Vaš {{title}} je ažuriran.",
|
||||||
"event_has_been_rescheduled": "Ažurirano - Vaš dogadjaj je odložen",
|
"event_has_been_rescheduled": "Ažurirano - Vaš događaj je odložen",
|
||||||
"event_has_been_updated": "Događaj je ažuriran",
|
"event_has_been_updated": "Događaj je ažuriran",
|
||||||
"your_event_has_been_rescheduled": "Vaš događaj je ponovo zakazan",
|
"your_event_has_been_rescheduled": "Vaš događaj je ponovo zakazan",
|
||||||
"request_reschedule_subtitle": "{{organizer}} je otkazao/la rezervaciju i tražio/la je da odaberete drugo vreme.",
|
"request_reschedule_subtitle": "{{organizer}} je otkazao/la rezervaciju i tražio/la je da odaberete drugo vreme.",
|
||||||
@@ -157,17 +157,17 @@
|
|||||||
"ics_event_title": "{{eventType}} sa {{name}}",
|
"ics_event_title": "{{eventType}} sa {{name}}",
|
||||||
"please_book_a_time_sometime_later": "Izgleda da su svi naši stručnjaci trenutno zauzeti. Molimo vas da zakažete poziv u vreme koje vam najviše odgovara i bićemo spremni da vam pomognemo.",
|
"please_book_a_time_sometime_later": "Izgleda da su svi naši stručnjaci trenutno zauzeti. Molimo vas da zakažete poziv u vreme koje vam najviše odgovara i bićemo spremni da vam pomognemo.",
|
||||||
"schedule_instead": "Zakažite umesto toga",
|
"schedule_instead": "Zakažite umesto toga",
|
||||||
"new_event_subject": "Novi dogadjaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
"new_event_subject": "Novi događaj: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||||
"join_by_entrypoint": "Pridruži se putem {{entryPoint}}",
|
"join_by_entrypoint": "Pridruži se putem {{entryPoint}}",
|
||||||
"notes": "Beleške",
|
"notes": "Beleške",
|
||||||
"manage_my_bookings": "Upravljaj svojim rezervacijama",
|
"manage_my_bookings": "Upravljaj svojim rezervacijama",
|
||||||
"need_to_make_a_change": "Trebate napraviti promenu?",
|
"need_to_make_a_change": "Trebate napraviti promenu?",
|
||||||
"new_event_scheduled": "Novi dogadjaj je zakazan.",
|
"new_event_scheduled": "Novi događaj je zakazan.",
|
||||||
"new_event_scheduled_recurring": "Novi ponavljajući događaj je zakazan.",
|
"new_event_scheduled_recurring": "Novi ponavljajući događaj je zakazan.",
|
||||||
"invitee_email": "E-pošta od pozvane osobe",
|
"invitee_email": "E-pošta od pozvane osobe",
|
||||||
"invitee_timezone": "Vremenska zona pozvane osobe",
|
"invitee_timezone": "Vremenska zona pozvane osobe",
|
||||||
"time_left": "Preostalo vreme",
|
"time_left": "Preostalo vreme",
|
||||||
"event_type": "Tip Dogadjaja",
|
"event_type": "Tip Događaja",
|
||||||
"duplicate_event_type": "Duplirani tip događaja",
|
"duplicate_event_type": "Duplirani tip događaja",
|
||||||
"enter_meeting": "Pridruži se sastanku",
|
"enter_meeting": "Pridruži se sastanku",
|
||||||
"video_call_provider": "Provajder video poziva",
|
"video_call_provider": "Provajder video poziva",
|
||||||
@@ -205,7 +205,7 @@
|
|||||||
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
||||||
"hey_there": "Zdravo",
|
"hey_there": "Zdravo",
|
||||||
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
|
"forgot_your_password_calcom": "Zaboravili ste lozinku? - {{appName}}",
|
||||||
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je dogadjaj zakazan ili otkazan.",
|
"delete_webhook_confirmation_message": "Da li ste sigurni da želite da obrišete ovaj webhook? Nećete više dobijati {{appName}} podatke o sastancima preko naznačenog URL, istovremeno kad je događaj zakazan ili otkazan.",
|
||||||
"confirm_delete_webhook": "Da, obriši webhook",
|
"confirm_delete_webhook": "Da, obriši webhook",
|
||||||
"edit_webhook": "Uredi Webhook",
|
"edit_webhook": "Uredi Webhook",
|
||||||
"delete_webhook": "Obriši Webhook",
|
"delete_webhook": "Obriši Webhook",
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
"add_to_homescreen": "Dodaj ovu aplikaciju na telefon za brži pristup i poboljšano korisničko iskustvo.",
|
"add_to_homescreen": "Dodaj ovu aplikaciju na telefon za brži pristup i poboljšano korisničko iskustvo.",
|
||||||
"upcoming": "Predstojeći",
|
"upcoming": "Predstojeći",
|
||||||
"recurring": "Ponavljajući",
|
"recurring": "Ponavljajući",
|
||||||
"past": "Prošli dogadjaji",
|
"past": "Prošli događaji",
|
||||||
"choose_a_file": "Odaberi datoteku...",
|
"choose_a_file": "Odaberi datoteku...",
|
||||||
"upload_image": "Otpremi sliku",
|
"upload_image": "Otpremi sliku",
|
||||||
"upload_target": "Prikači {{target}}",
|
"upload_target": "Prikači {{target}}",
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
"connect_your_calendar": "Povežite vaš kalendar",
|
"connect_your_calendar": "Povežite vaš kalendar",
|
||||||
"connect_your_video_app": "Povežite svoje video aplikacije",
|
"connect_your_video_app": "Povežite svoje video aplikacije",
|
||||||
"connect_your_video_app_instructions": "Povežite svoje video aplikacije da biste ih koristili u vašim tipovima događaja.",
|
"connect_your_video_app_instructions": "Povežite svoje video aplikacije da biste ih koristili u vašim tipovima događaja.",
|
||||||
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove dogadjaje kad se zakažu.",
|
"connect_your_calendar_instructions": "Povežite vaš kalendar da automatski radi proveru zauzetih vremena i vidi nove događaje kad se zakažu.",
|
||||||
"set_up_later": "Postavi kasnije",
|
"set_up_later": "Postavi kasnije",
|
||||||
"current_time": "Trenutno vreme",
|
"current_time": "Trenutno vreme",
|
||||||
"details": "Detalji",
|
"details": "Detalji",
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
"error_end_time_before_start_time": "Krajnje vreme ne može biti pre početnog vremena",
|
"error_end_time_before_start_time": "Krajnje vreme ne može biti pre početnog vremena",
|
||||||
"error_end_time_next_day": "Krajnje vreme ne može biti veće od 24 sata",
|
"error_end_time_next_day": "Krajnje vreme ne može biti veće od 24 sata",
|
||||||
"back_to_bookings": "Nazad na rezervacije",
|
"back_to_bookings": "Nazad na rezervacije",
|
||||||
"free_to_pick_another_event_type": "Slobodno izaberite drugi dogadjaj u bilo kom trenutku.",
|
"free_to_pick_another_event_type": "Slobodno izaberite drugi događaj u bilo kom trenutku.",
|
||||||
"cancelled": "Otkazano",
|
"cancelled": "Otkazano",
|
||||||
"no_booking": "Nema rezervacije",
|
"no_booking": "Nema rezervacije",
|
||||||
"awaiting_host": "Čeka se domaćin",
|
"awaiting_host": "Čeka se domaćin",
|
||||||
@@ -450,7 +450,7 @@
|
|||||||
"event_between_users": "{{eventName}} sa {{host}} i {{attendeeName}}",
|
"event_between_users": "{{eventName}} sa {{host}} i {{attendeeName}}",
|
||||||
"bookings": "Rezervacije",
|
"bookings": "Rezervacije",
|
||||||
"booking_not_found": "Rezervacija nije pronađena",
|
"booking_not_found": "Rezervacija nije pronađena",
|
||||||
"bookings_description": "Vidite nadolazeće i prošle dogadjaje rezervisane preko linkova vaših tipova događaja.",
|
"bookings_description": "Vidite nadolazeće i prošle događaje rezervisane preko linkova vaših tipova događaja.",
|
||||||
"upcoming_bookings": "Čim neko rezerviše termin sa vama to će se pokazati ovde.",
|
"upcoming_bookings": "Čim neko rezerviše termin sa vama to će se pokazati ovde.",
|
||||||
"recurring_bookings": "Čim neko zakaže ponavljajući sastanak sa vama, to će se pokazati ovde.",
|
"recurring_bookings": "Čim neko zakaže ponavljajući sastanak sa vama, to će se pokazati ovde.",
|
||||||
"past_bookings": "Vaše prethodne rezervacije će se pojaviti ovde.",
|
"past_bookings": "Vaše prethodne rezervacije će se pojaviti ovde.",
|
||||||
@@ -522,7 +522,7 @@
|
|||||||
"booking_paid": "Rezervacija je plaćena",
|
"booking_paid": "Rezervacija je plaćena",
|
||||||
"ooo_created": "Kreirano odsustvo iz kancelarije",
|
"ooo_created": "Kreirano odsustvo iz kancelarije",
|
||||||
"booking_no_show_updated": "Ažurirano odsustvo sa rezervacije",
|
"booking_no_show_updated": "Ažurirano odsustvo sa rezervacije",
|
||||||
"event_triggers": "Okidači Dogadjaja",
|
"event_triggers": "Okidači Događaja",
|
||||||
"subscriber_url": "URL pretplatnika",
|
"subscriber_url": "URL pretplatnika",
|
||||||
"create_new_webhook": "Napravite novi webhook",
|
"create_new_webhook": "Napravite novi webhook",
|
||||||
"webhooks": "Webhooks",
|
"webhooks": "Webhooks",
|
||||||
@@ -737,7 +737,7 @@
|
|||||||
"remove_member": "Ukloni člana",
|
"remove_member": "Ukloni člana",
|
||||||
"manage_your_team": "Upravljaj svojim timom",
|
"manage_your_team": "Upravljaj svojim timom",
|
||||||
"no_teams": "Još uvek nemate nijedan tim.",
|
"no_teams": "Još uvek nemate nijedan tim.",
|
||||||
"no_teams_description": "Timovi služe za rezervacije dogadjaja koje delite sa vašim kolegama.",
|
"no_teams_description": "Timovi služe za rezervacije događaja koje delite sa vašim kolegama.",
|
||||||
"submit": "Podnesi",
|
"submit": "Podnesi",
|
||||||
"confirm_remove_attribute": "Da, ukloni atribut",
|
"confirm_remove_attribute": "Da, ukloni atribut",
|
||||||
"remove_attribute": "Ukloni atribut",
|
"remove_attribute": "Ukloni atribut",
|
||||||
|
|||||||
@@ -0,0 +1,479 @@
|
|||||||
|
import prismock from "../../../../../tests/libs/__mocks__/prisma";
|
||||||
|
|
||||||
|
import {
|
||||||
|
createBookingScenario,
|
||||||
|
Timezones,
|
||||||
|
TestData,
|
||||||
|
type ScenarioData,
|
||||||
|
} from "../../utils/bookingScenario/bookingScenario";
|
||||||
|
|
||||||
|
import { describe, test, vi } from "vitest";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import type { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||||
|
import { getAvailableSlots as getSchedule } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||||
|
|
||||||
|
import { expect } from "./expects";
|
||||||
|
import { setupAndTeardown } from "./setupAndTeardown";
|
||||||
|
|
||||||
|
// Mock the FeaturesRepository to enable restriction-schedule feature
|
||||||
|
vi.mock("@calcom/features/flags/features.repository", () => ({
|
||||||
|
FeaturesRepository: vi.fn().mockImplementation(() => ({
|
||||||
|
checkIfTeamHasFeature: vi.fn().mockResolvedValue(true),
|
||||||
|
checkIfFeatureIsEnabledGlobally: vi.fn().mockResolvedValue(true),
|
||||||
|
getAllFeatures: vi.fn().mockResolvedValue([]),
|
||||||
|
getFeatureFlagMap: vi.fn().mockResolvedValue({}),
|
||||||
|
checkIfUserHasFeature: vi.fn().mockResolvedValue(true),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
type ScheduleScenario = {
|
||||||
|
eventTypes: ScenarioData["eventTypes"];
|
||||||
|
users: ScenarioData["users"];
|
||||||
|
apps: ScenarioData["apps"];
|
||||||
|
selectedSlots?: ScenarioData["selectedSlots"];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTestScheduleInput = ({
|
||||||
|
yesterdayDateString,
|
||||||
|
plus5DateString,
|
||||||
|
}: {
|
||||||
|
yesterdayDateString: string;
|
||||||
|
plus5DateString: string;
|
||||||
|
}): z.infer<typeof getScheduleSchema> => ({
|
||||||
|
eventTypeId: 1,
|
||||||
|
eventTypeSlug: "",
|
||||||
|
startTime: `${yesterdayDateString}T18:30:00.000Z`,
|
||||||
|
endTime: `${plus5DateString}T18:29:59.999Z`,
|
||||||
|
timeZone: Timezones["+5:30"],
|
||||||
|
isTeamEvent: true,
|
||||||
|
orgSlug: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const getBaseScenarioData = (): ScheduleScenario => ({
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
slotInterval: 60,
|
||||||
|
length: 60,
|
||||||
|
teamId: 1,
|
||||||
|
hosts: [{ userId: 101 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
...TestData.users.example,
|
||||||
|
id: 101,
|
||||||
|
schedules: [TestData.schedules.IstWorkHours],
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
membership: { role: "ADMIN", accepted: true },
|
||||||
|
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
apps: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to set up team for the tests
|
||||||
|
async function setupTeamAndFeatures() {
|
||||||
|
// Create the team (needed for event type configuration)
|
||||||
|
await prismock.team.create({
|
||||||
|
data: {
|
||||||
|
id: 1,
|
||||||
|
name: "Test Team",
|
||||||
|
slug: "test-team",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("getSchedule", () => {
|
||||||
|
setupAndTeardown();
|
||||||
|
|
||||||
|
describe("Restriction Schedule", () => {
|
||||||
|
test("should respect date override rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
|
||||||
|
await setupTeamAndFeatures();
|
||||||
|
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||||
|
|
||||||
|
const plus2DateString = "2025-06-02"; // Date with override
|
||||||
|
const plus5DateString = "2025-06-05";
|
||||||
|
|
||||||
|
const scenarioData: ScheduleScenario = {
|
||||||
|
...getBaseScenarioData(),
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
length: 60,
|
||||||
|
teamId: 1,
|
||||||
|
restrictionScheduleId: 50,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
hosts: [{ userId: 101 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
...TestData.users.example,
|
||||||
|
id: 101,
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Test Schedule",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
membership: { role: "ADMIN", accepted: true },
|
||||||
|
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarioData.users[0].schedules.push({
|
||||||
|
id: 50,
|
||||||
|
name: "Restriction Schedule - Only Date Override",
|
||||||
|
timeZone: "Asia/Dubai",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
date: "2025-06-02T00:00:00.000Z",
|
||||||
|
days: [],
|
||||||
|
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
|
const result = await getSchedule({
|
||||||
|
input: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
eventTypeSlug: "",
|
||||||
|
startTime: "2025-06-01T00:00:00.000Z",
|
||||||
|
endTime: `${plus5DateString}T23:59:59.999Z`,
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
isTeamEvent: true,
|
||||||
|
orgSlug: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expected slots based on intersection of:
|
||||||
|
// - User availability: 9 AM - 5 PM London
|
||||||
|
// - Restriction: 10 AM - 6 PM Dubai
|
||||||
|
const expectedUtcSlots = [
|
||||||
|
"2025-06-02T08:00:00.000Z", // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
|
||||||
|
"2025-06-02T09:00:00.000Z", // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
|
||||||
|
"2025-06-02T10:00:00.000Z", // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
|
||||||
|
"2025-06-02T11:00:00.000Z", // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
|
||||||
|
"2025-06-02T12:00:00.000Z", // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
|
||||||
|
"2025-06-02T13:00:00.000Z", // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
|
||||||
|
"2025-06-02T14:00:00.000Z", // 3 PM London, 6 PM Dubai, 7:30 PM Kolkata
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(result).toHaveTimeSlots(expectedUtcSlots, {
|
||||||
|
dateString: plus2DateString,
|
||||||
|
doExactMatch: false,
|
||||||
|
});
|
||||||
|
expect(result).toHaveDateDisabled({ dateString: "2025-06-03" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=false)", async () => {
|
||||||
|
await setupTeamAndFeatures();
|
||||||
|
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||||
|
|
||||||
|
const plus2DateString = "2025-06-02"; // Monday
|
||||||
|
const plus6DateString = "2025-06-06"; // Friday
|
||||||
|
|
||||||
|
const scenarioData: ScheduleScenario = {
|
||||||
|
...getBaseScenarioData(),
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
length: 60,
|
||||||
|
teamId: 1,
|
||||||
|
restrictionScheduleId: 50,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
hosts: [{ userId: 101 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
...TestData.users.example,
|
||||||
|
id: 101,
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Test Schedule",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
membership: { role: "ADMIN", accepted: true },
|
||||||
|
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarioData.users[0].schedules.push({
|
||||||
|
id: 50,
|
||||||
|
name: "Test Schedule",
|
||||||
|
timeZone: "Asia/Dubai",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
days: [1, 2, 3, 4, 5],
|
||||||
|
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
|
const result = await getSchedule({
|
||||||
|
input: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
eventTypeSlug: "",
|
||||||
|
startTime: "2025-06-01T00:00:00.000Z",
|
||||||
|
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
isTeamEvent: true,
|
||||||
|
orgSlug: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expected slots based on intersection of:
|
||||||
|
// - User availability: 9 AM - 5 PM London
|
||||||
|
// - Restriction: 10 AM - 6 PM Dubai, weekdays only
|
||||||
|
const getExpectedSlotsForDate = (dateString: string) => [
|
||||||
|
`${dateString}T08:00:00.000Z`, // 9 AM London, 12 PM Dubai, 1:30 PM Kolkata
|
||||||
|
`${dateString}T09:00:00.000Z`, // 10 AM London, 1 PM Dubai, 2:30 PM Kolkata
|
||||||
|
`${dateString}T10:00:00.000Z`, // 11 AM London, 2 PM Dubai, 3:30 PM Kolkata
|
||||||
|
`${dateString}T11:00:00.000Z`, // 12 PM London, 3 PM Dubai, 4:30 PM Kolkata
|
||||||
|
`${dateString}T12:00:00.000Z`, // 1 PM London, 4 PM Dubai, 5:30 PM Kolkata
|
||||||
|
`${dateString}T13:00:00.000Z`, // 2 PM London, 5 PM Dubai, 6:30 PM Kolkata
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify weekday slots
|
||||||
|
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||||
|
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||||
|
dateString,
|
||||||
|
doExactMatch: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify weekend is disabled
|
||||||
|
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
|
||||||
|
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should respect recurring rule in restrictionSchedule (Europe/London, useBookerTimezone=true)", async () => {
|
||||||
|
await setupTeamAndFeatures();
|
||||||
|
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||||
|
|
||||||
|
const plus2DateString = "2025-06-02"; // Monday
|
||||||
|
const plus6DateString = "2025-06-06"; // Friday
|
||||||
|
|
||||||
|
const scenarioData: ScheduleScenario = {
|
||||||
|
...getBaseScenarioData(),
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
length: 60,
|
||||||
|
teamId: 1,
|
||||||
|
restrictionScheduleId: 50,
|
||||||
|
useBookerTimezone: true,
|
||||||
|
hosts: [{ userId: 101 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
...TestData.users.example,
|
||||||
|
id: 101,
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Test Schedule",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
membership: { role: "ADMIN", accepted: true },
|
||||||
|
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
scenarioData.users[0].schedules.push({
|
||||||
|
id: 50,
|
||||||
|
name: "Test Schedule",
|
||||||
|
timeZone: "Asia/Dubai",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
days: [1, 2, 3, 4, 5],
|
||||||
|
startTime: new Date("1970-01-01T10:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T18:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
|
const result = await getSchedule({
|
||||||
|
input: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
eventTypeSlug: "",
|
||||||
|
startTime: "2025-06-01T00:00:00.000Z",
|
||||||
|
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
isTeamEvent: true,
|
||||||
|
orgSlug: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expected slots based on:
|
||||||
|
// - User availability: 9 AM - 5 PM London (1:30 PM - 9:30 PM Kolkata)
|
||||||
|
// - Restriction: 10 AM - 6 PM Kolkata
|
||||||
|
const getExpectedSlotsForDate = (dateString: string) => [
|
||||||
|
`${dateString}T08:00:00.000Z`, // 1:30 PM Kolkata, 9 AM London
|
||||||
|
`${dateString}T09:00:00.000Z`, // 2:30 PM Kolkata, 10 AM London
|
||||||
|
`${dateString}T10:00:00.000Z`, // 3:30 PM Kolkata, 11 AM London
|
||||||
|
`${dateString}T11:00:00.000Z`, // 4:30 PM Kolkata, 12 PM London
|
||||||
|
`${dateString}T12:00:00.000Z`, // 5:30 PM Kolkata, 1 PM London
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify weekday slots
|
||||||
|
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||||
|
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||||
|
dateString,
|
||||||
|
doExactMatch: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify weekend is disabled
|
||||||
|
expect(result).toHaveDateDisabled({ dateString: "2025-06-07" });
|
||||||
|
expect(result).toHaveDateDisabled({ dateString: "2025-06-08" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should return all slots when no restriction schedule is applied", async () => {
|
||||||
|
await setupTeamAndFeatures();
|
||||||
|
vi.setSystemTime("2025-06-01T23:30:00Z");
|
||||||
|
|
||||||
|
const plus2DateString = "2025-06-02"; // Monday
|
||||||
|
const plus6DateString = "2025-06-06"; // Friday
|
||||||
|
|
||||||
|
const scenarioData: ScheduleScenario = {
|
||||||
|
...getBaseScenarioData(),
|
||||||
|
eventTypes: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
length: 60,
|
||||||
|
teamId: 1,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
hosts: [{ userId: 101 }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{
|
||||||
|
...TestData.users.example,
|
||||||
|
id: 101,
|
||||||
|
schedules: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: "Test Schedule",
|
||||||
|
timeZone: "Europe/London",
|
||||||
|
availability: [
|
||||||
|
{
|
||||||
|
days: [0, 1, 2, 3, 4, 5, 6],
|
||||||
|
startTime: new Date("1970-01-01T09:00:00.000Z"),
|
||||||
|
endTime: new Date("1970-01-01T17:00:00.000Z"),
|
||||||
|
date: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
teams: [
|
||||||
|
{
|
||||||
|
membership: { role: "ADMIN", accepted: true },
|
||||||
|
team: { id: 1, name: "Test Team", slug: "test-team" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await createBookingScenario(scenarioData);
|
||||||
|
|
||||||
|
const result = await getSchedule({
|
||||||
|
input: {
|
||||||
|
eventTypeId: 1,
|
||||||
|
eventTypeSlug: "",
|
||||||
|
startTime: "2025-06-01T00:00:00.000Z",
|
||||||
|
endTime: `${plus6DateString}T23:59:59.999Z`,
|
||||||
|
timeZone: "Asia/Kolkata",
|
||||||
|
isTeamEvent: true,
|
||||||
|
orgSlug: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Expected slots based on user's availability: 9 AM - 5 PM London
|
||||||
|
const getExpectedSlotsForDate = (dateString: string) => [
|
||||||
|
`${dateString}T08:00:00.000Z`, // 9 AM London, 1:30 PM Kolkata
|
||||||
|
`${dateString}T09:00:00.000Z`, // 10 AM London, 2:30 PM Kolkata
|
||||||
|
`${dateString}T10:00:00.000Z`, // 11 AM London, 3:30 PM Kolkata
|
||||||
|
`${dateString}T11:00:00.000Z`, // 12 PM London, 4:30 PM Kolkata
|
||||||
|
`${dateString}T12:00:00.000Z`, // 1 PM London, 5:30 PM Kolkata
|
||||||
|
`${dateString}T13:00:00.000Z`, // 2 PM London, 6:30 PM Kolkata
|
||||||
|
`${dateString}T14:00:00.000Z`, // 3 PM London, 7:30 PM Kolkata
|
||||||
|
`${dateString}T15:00:00.000Z`, // 4 PM London, 8:30 PM Kolkata
|
||||||
|
`${dateString}T16:00:00.000Z`, // 5 PM London, 9:30 PM Kolkata
|
||||||
|
];
|
||||||
|
|
||||||
|
// Verify weekday slots
|
||||||
|
["2025-06-02", "2025-06-03", "2025-06-04", "2025-06-05", "2025-06-06"].forEach((dateString) => {
|
||||||
|
expect(result).toHaveTimeSlots(getExpectedSlotsForDate(dateString), {
|
||||||
|
dateString,
|
||||||
|
doExactMatch: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify a late evening slot (8:30 PM Kolkata, 4 PM London) is present
|
||||||
|
const lateEveningSlot = `${plus2DateString}T15:00:00.000Z`;
|
||||||
|
expect(result.slots[plus2DateString].map((s) => s.time)).toContain(lateEveningSlot);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -147,6 +147,8 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
userId: 4,
|
userId: 4,
|
||||||
rrSegmentQueryValue: undefined,
|
rrSegmentQueryValue: undefined,
|
||||||
assignRRMembersUsingSegment: false,
|
assignRRMembersUsingSegment: false,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
restrictionScheduleId: null,
|
||||||
allowReschedulingCancelledBookings: false,
|
allowReschedulingCancelledBookings: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -203,6 +205,8 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
scheduleId: null,
|
scheduleId: null,
|
||||||
lockTimeZoneToggleOnBookingPage: false,
|
lockTimeZoneToggleOnBookingPage: false,
|
||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
restrictionScheduleId: null,
|
||||||
hashedLink: {
|
hashedLink: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
},
|
},
|
||||||
@@ -317,6 +321,8 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
workflows: undefined,
|
workflows: undefined,
|
||||||
rrSegmentQueryValue: undefined,
|
rrSegmentQueryValue: undefined,
|
||||||
assignRRMembersUsingSegment: false,
|
assignRRMembersUsingSegment: false,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
restrictionScheduleId: null,
|
||||||
allowReschedulingCancelledBookings: false,
|
allowReschedulingCancelledBookings: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -372,6 +378,7 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
hashedLink: {
|
hashedLink: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
},
|
},
|
||||||
|
useBookerTimezone: false,
|
||||||
lockTimeZoneToggleOnBookingPage: false,
|
lockTimeZoneToggleOnBookingPage: false,
|
||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
allowReschedulingCancelledBookings: false,
|
allowReschedulingCancelledBookings: false,
|
||||||
@@ -469,6 +476,8 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
locations: [],
|
locations: [],
|
||||||
lockTimeZoneToggleOnBookingPage: false,
|
lockTimeZoneToggleOnBookingPage: false,
|
||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
restrictionScheduleId: null,
|
||||||
parentId: 1,
|
parentId: 1,
|
||||||
userId: 5,
|
userId: 5,
|
||||||
users: {
|
users: {
|
||||||
@@ -493,6 +502,8 @@ describe("handleChildrenEventTypes", () => {
|
|||||||
customReplyToEmail: null,
|
customReplyToEmail: null,
|
||||||
lockTimeZoneToggleOnBookingPage: false,
|
lockTimeZoneToggleOnBookingPage: false,
|
||||||
requiresBookerEmailVerification: false,
|
requiresBookerEmailVerification: false,
|
||||||
|
useBookerTimezone: false,
|
||||||
|
restrictionScheduleId: null,
|
||||||
hashedLink: {
|
hashedLink: {
|
||||||
deleteMany: {},
|
deleteMany: {},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -213,9 +213,6 @@ export type InputEventType = {
|
|||||||
userId?: number;
|
userId?: number;
|
||||||
minimumBookingNotice?: number;
|
minimumBookingNotice?: number;
|
||||||
useEventLevelSelectedCalendars?: boolean;
|
useEventLevelSelectedCalendars?: boolean;
|
||||||
/**
|
|
||||||
* These user ids are `ScenarioData["users"]["id"]`
|
|
||||||
*/
|
|
||||||
users?: { id: number }[];
|
users?: { id: number }[];
|
||||||
hosts?: InputHost[];
|
hosts?: InputHost[];
|
||||||
schedulingType?: SchedulingType;
|
schedulingType?: SchedulingType;
|
||||||
@@ -237,6 +234,25 @@ export type InputEventType = {
|
|||||||
owner?: number;
|
owner?: number;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
rescheduleWithSameRoundRobinHost?: boolean;
|
rescheduleWithSameRoundRobinHost?: boolean;
|
||||||
|
restrictionSchedule?: {
|
||||||
|
create: {
|
||||||
|
name: string;
|
||||||
|
timeZone: string;
|
||||||
|
availability: {
|
||||||
|
createMany: {
|
||||||
|
data: {
|
||||||
|
days: number[];
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date;
|
||||||
|
date: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
user: { connect: { id: number } };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
useBookerTimezone?: boolean;
|
||||||
|
restrictionScheduleId?: number;
|
||||||
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule" | "bookingLimits" | "durationLimits">>;
|
||||||
|
|
||||||
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||||
@@ -310,19 +326,31 @@ export async function addEventTypesToDb(
|
|||||||
"users" | "workflows" | "destinationCalendar" | "schedule"
|
"users" | "workflows" | "destinationCalendar" | "schedule"
|
||||||
> & {
|
> & {
|
||||||
id?: number;
|
id?: number;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
users?: any[];
|
users?: any[];
|
||||||
userId?: number;
|
userId?: number;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
hosts?: any[];
|
hosts?: any[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
workflows?: any[];
|
workflows?: any[];
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
destinationCalendar?: any;
|
destinationCalendar?: any;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
schedule?: any;
|
schedule?: any;
|
||||||
metadata?: any;
|
metadata?: any;
|
||||||
team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean };
|
team?: { id?: number | null; bookingLimits?: IntervalLimit; includeManagedEventsInLimits?: boolean };
|
||||||
|
restrictionSchedule?: {
|
||||||
|
create: {
|
||||||
|
name: string;
|
||||||
|
timeZone: string;
|
||||||
|
availability: {
|
||||||
|
createMany: {
|
||||||
|
data: {
|
||||||
|
days: number[];
|
||||||
|
startTime: Date;
|
||||||
|
endTime: Date;
|
||||||
|
date: string | null;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
user: { connect: { id: number } };
|
||||||
|
};
|
||||||
|
};
|
||||||
})[]
|
})[]
|
||||||
) {
|
) {
|
||||||
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
|
||||||
@@ -338,11 +366,6 @@ export async function addEventTypesToDb(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
|
||||||
* This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship.
|
|
||||||
* HACK STARTS
|
|
||||||
*/
|
|
||||||
log.silly("Fixed possible prismock bug by creating schedule separately");
|
|
||||||
for (let i = 0; i < eventTypes.length; i++) {
|
for (let i = 0; i < eventTypes.length; i++) {
|
||||||
const eventType = eventTypes[i];
|
const eventType = eventTypes[i];
|
||||||
const createdEventType = allEventTypes[i];
|
const createdEventType = allEventTypes[i];
|
||||||
@@ -350,8 +373,6 @@ export async function addEventTypesToDb(
|
|||||||
if (eventType.schedule) {
|
if (eventType.schedule) {
|
||||||
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
|
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
|
||||||
await prismock.schedule.create({
|
await prismock.schedule.create({
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
//@ts-ignore
|
|
||||||
data: {
|
data: {
|
||||||
...eventType.schedule.create,
|
...eventType.schedule.create,
|
||||||
eventType: {
|
eventType: {
|
||||||
@@ -363,6 +384,24 @@ export async function addEventTypesToDb(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle restrictionSchedule creation and connection
|
||||||
|
if (eventType.restrictionSchedule) {
|
||||||
|
const createdRestrictionSchedule = await prismock.schedule.create({
|
||||||
|
data: {
|
||||||
|
...eventType.restrictionSchedule.create,
|
||||||
|
eventType: {
|
||||||
|
connect: {
|
||||||
|
id: createdEventType.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await prismock.eventType.update({
|
||||||
|
where: { id: createdEventType.id },
|
||||||
|
data: { restrictionScheduleId: createdRestrictionSchedule.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (eventType.team?.id) {
|
if (eventType.team?.id) {
|
||||||
const createdTeam = await prismock.team.create({
|
const createdTeam = await prismock.team.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -379,9 +418,6 @@ export async function addEventTypesToDb(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/***
|
|
||||||
* HACK ENDS
|
|
||||||
*/
|
|
||||||
|
|
||||||
log.silly(
|
log.silly(
|
||||||
"TestData: All EventTypes in DB are",
|
"TestData: All EventTypes in DB are",
|
||||||
@@ -454,6 +490,22 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: eventType.schedule,
|
: eventType.schedule,
|
||||||
|
restrictionScheduleId: eventType.restrictionScheduleId ?? undefined,
|
||||||
|
useBookerTimezone: eventType.useBookerTimezone ?? false,
|
||||||
|
restrictionSchedule: eventType.restrictionSchedule
|
||||||
|
? {
|
||||||
|
create: {
|
||||||
|
name: eventType.restrictionSchedule.create.name,
|
||||||
|
timeZone: eventType.restrictionSchedule.create.timeZone,
|
||||||
|
availability: {
|
||||||
|
createMany: {
|
||||||
|
data: eventType.restrictionSchedule.create.availability.createMany.data,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { connect: { id: eventType.userId || (eventType.users && eventType.users[0]?.id) || 1 } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
|
owner: eventType.owner ? { connect: { id: eventType.owner } } : undefined,
|
||||||
schedulingType: eventType.schedulingType,
|
schedulingType: eventType.schedulingType,
|
||||||
parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined,
|
parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined,
|
||||||
|
|||||||
@@ -24102,7 +24102,7 @@
|
|||||||
},
|
},
|
||||||
"CalendarEventResponseStatus": {
|
"CalendarEventResponseStatus": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Attendee's response to the invitation",
|
"description": "Host's response to the invitation",
|
||||||
"enum": ["accepted", "pending", "declined", "needsAction"]
|
"enum": ["accepted", "pending", "declined", "needsAction"]
|
||||||
},
|
},
|
||||||
"CalendarEventAttendee": {
|
"CalendarEventAttendee": {
|
||||||
@@ -24150,6 +24150,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"description": "Display name of the event host"
|
"description": "Display name of the event host"
|
||||||
|
},
|
||||||
|
"responseStatus": {
|
||||||
|
"nullable": true,
|
||||||
|
"example": "accepted",
|
||||||
|
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["email"]
|
"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.
|
Use the **secret key** to create an HMAC, and update it with the webhook payload received to generate an SHA256 hash.
|
||||||
</Step>
|
</Step>
|
||||||
<Step title="Verify the payload authenticity">
|
<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>
|
</Step>
|
||||||
</Steps>
|
</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. |
|
| 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. |
|
| 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"] |
|
| 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
|
## 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. |
|
| 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. |
|
| 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
|
## Google Meet
|
||||||
|
|||||||
+3
-2
@@ -80,8 +80,9 @@
|
|||||||
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
"docker-build-api": "docker build -t cal-api -f ./infra/docker/api/Dockerfile .",
|
||||||
"docker-run-api": "docker run -p 80:80 cal-api",
|
"docker-run-api": "docker run -p 80:80 cal-api",
|
||||||
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
"docker-stop-api": "docker ps --filter 'ancestor=cal-api' -q | xargs docker stop",
|
||||||
|
"changesets-add": "yarn changeset add",
|
||||||
"changesets-version": "yarn changeset version",
|
"changesets-version": "yarn changeset version",
|
||||||
"changesets-release": "turbo run build && yarn changeset publish"
|
"changesets-release": "NODE_OPTIONS='--max_old_space_size=12288' turbo run build --filter=@calcom/atoms && yarn changeset publish"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@changesets/changelog-github": "^0.5.1",
|
"@changesets/changelog-github": "^0.5.1",
|
||||||
@@ -108,7 +109,7 @@
|
|||||||
"prismock": "^1.33.4",
|
"prismock": "^1.33.4",
|
||||||
"resize-observer-polyfill": "^1.5.1",
|
"resize-observer-polyfill": "^1.5.1",
|
||||||
"tsc-absolute": "^1.0.0",
|
"tsc-absolute": "^1.0.0",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.7.2",
|
||||||
"vitest": "^2.1.1",
|
"vitest": "^2.1.1",
|
||||||
"vitest-fetch-mock": "^0.3.0",
|
"vitest-fetch-mock": "^0.3.0",
|
||||||
"vitest-mock-extended": "^2.0.2"
|
"vitest-mock-extended": "^2.0.2"
|
||||||
|
|||||||
Binary file not shown.
@@ -31,6 +31,6 @@
|
|||||||
"eslint-plugin-react": "^7.30.1",
|
"eslint-plugin-react": "^7.30.1",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
"ts-node": "^10.9.1",
|
"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.
|
* Manages OAuth2.0 as well as JWT tokens(For JWT tokens, only Google Calendar use it at the moment) for an app and resourceOwner.
|
||||||
* It is aware of the credential sync endpoint and can sync the token from the third party source.
|
* What it does
|
||||||
* It is unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
* - It automatically refreshes the token if needed when making a request.
|
||||||
|
* - It is aware of the credential sync endpoint and can sync the token from the third party source.
|
||||||
|
* - It is kept unaware of Prisma and App logic. It is just a utility to manage OAuth2.0 tokens with life cycle methods
|
||||||
|
*
|
||||||
|
* What it doesn't do yet
|
||||||
|
* - It doesn't have a flow to re-send the request if the access-token had been communicated as invalid after making the request itself. It relies on the caller to make the next request in which it will actually refresh the token.
|
||||||
*
|
*
|
||||||
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
* For a recommended usage example, see Zoom VideoApiAdapter.ts
|
||||||
*/
|
*/
|
||||||
@@ -89,7 +94,13 @@ export class OAuthManager {
|
|||||||
currentTokenObject,
|
currentTokenObject,
|
||||||
fetchNewTokenObject,
|
fetchNewTokenObject,
|
||||||
updateTokenObject,
|
updateTokenObject,
|
||||||
|
/**
|
||||||
|
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||||
|
*/
|
||||||
isTokenObjectUnusable,
|
isTokenObjectUnusable,
|
||||||
|
/**
|
||||||
|
* The fn must not crash. It is the responsibility of the caller to handle any error and appropriately decide what to return
|
||||||
|
*/
|
||||||
isAccessTokenUnusable,
|
isAccessTokenUnusable,
|
||||||
invalidateTokenObject,
|
invalidateTokenObject,
|
||||||
expireAccessToken,
|
expireAccessToken,
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import { trpc } from "@calcom/trpc/react";
|
|
||||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||||
import type { App } from "@calcom/types/App";
|
import type { App } from "@calcom/types/App";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
|
||||||
import classNames from "@calcom/ui/classNames";
|
import classNames from "@calcom/ui/classNames";
|
||||||
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
|
||||||
import { InstallAppButtonMap } from "./apps.browser.generated";
|
import { InstallAppButtonMap } from "./apps.browser.generated";
|
||||||
import type { InstallAppButtonProps } from "./types";
|
import type { InstallAppButtonProps } from "./types";
|
||||||
@@ -59,44 +55,8 @@ export const InstallAppButton = (
|
|||||||
disableInstall?: boolean;
|
disableInstall?: boolean;
|
||||||
} & InstallAppButtonProps
|
} & 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 (
|
return (
|
||||||
<div ref={proProtectionElementRef} className={props.wrapperClassName}>
|
<div className={props.wrapperClassName}>
|
||||||
<InstallAppButtonWithoutPlanCheck {...props} />
|
<InstallAppButtonWithoutPlanCheck {...props} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,6 +42,35 @@ export interface DailyVideoCallData {
|
|||||||
url: string;
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Regions available to create DailyVideo Rooms in.
|
||||||
|
const REGION_CODES = [
|
||||||
|
"af-south-1",
|
||||||
|
"ap-northeast-2",
|
||||||
|
"ap-southeast-1",
|
||||||
|
"ap-southeast-2",
|
||||||
|
"ap-south-1",
|
||||||
|
"eu-central-1",
|
||||||
|
"eu-west-2",
|
||||||
|
"sa-east-1",
|
||||||
|
"us-east-1",
|
||||||
|
"us-west-2",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
type RoomGeo = (typeof REGION_CODES)[number];
|
||||||
|
|
||||||
|
function getDailyVideoRegionFromEnv(): RoomGeo | undefined {
|
||||||
|
if (!process?.env?.DAILY_VIDEO_REGION) return;
|
||||||
|
const isRoomGeo = (value: string): value is RoomGeo => REGION_CODES.includes(value as RoomGeo);
|
||||||
|
function assertIsDailyVideoRegion(value: string): asserts value is RoomGeo {
|
||||||
|
if (!isRoomGeo(value)) {
|
||||||
|
throw new Error(`Invalid region code: ${value}. Must be one of: ${REGION_CODES.join(", ")}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const region = process.env.DAILY_VIDEO_REGION;
|
||||||
|
assertIsDailyVideoRegion(region);
|
||||||
|
return region;
|
||||||
|
}
|
||||||
|
|
||||||
const isS3StorageEnabled =
|
const isS3StorageEnabled =
|
||||||
process.env.CAL_VIDEO_BUCKET_NAME &&
|
process.env.CAL_VIDEO_BUCKET_NAME &&
|
||||||
process.env.CAL_VIDEO_BUCKET_REGION &&
|
process.env.CAL_VIDEO_BUCKET_REGION &&
|
||||||
@@ -203,11 +232,15 @@ export const setEnableRecordingUIAndUserIdForOrganizer = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
||||||
async function createOrUpdateMeeting(endpoint: string, event: CalendarEvent): Promise<VideoCallData> {
|
async function createOrUpdateMeeting(
|
||||||
|
endpoint: string,
|
||||||
|
event: CalendarEvent,
|
||||||
|
region?: RoomGeo
|
||||||
|
): Promise<VideoCallData> {
|
||||||
if (!event.uid) {
|
if (!event.uid) {
|
||||||
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
throw new Error("We need need the booking uid to create the Daily reference in DB");
|
||||||
}
|
}
|
||||||
const body = await translateEvent(event);
|
const body = await translateEvent(event, region);
|
||||||
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
const dailyEvent = await postToDailyAPI(endpoint, body).then(dailyReturnTypeSchema.parse);
|
||||||
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
const meetingToken = await postToDailyAPI("/meeting-tokens", {
|
||||||
properties: {
|
properties: {
|
||||||
@@ -226,7 +259,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const translateEvent = async (event: CalendarEvent) => {
|
const translateEvent = async (event: CalendarEvent, region?: RoomGeo) => {
|
||||||
// Documentation at: https://docs.daily.co/reference#list-rooms
|
// Documentation at: https://docs.daily.co/reference#list-rooms
|
||||||
// Adds 14 days from the end of the booking as the expiration date
|
// Adds 14 days from the end of the booking as the expiration date
|
||||||
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
|
const exp = Math.round(new Date(event.endTime).getTime() / 1000) + 60 * 60 * 24 * 14;
|
||||||
@@ -248,6 +281,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
return {
|
return {
|
||||||
privacy: "public",
|
privacy: "public",
|
||||||
properties: {
|
properties: {
|
||||||
|
...(region ? { geo: region } : {}),
|
||||||
enable_prejoin_ui: true,
|
enable_prejoin_ui: true,
|
||||||
enable_knocking: true,
|
enable_knocking: true,
|
||||||
enable_screenshare: true,
|
enable_screenshare: true,
|
||||||
@@ -284,7 +318,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
async function createInstantMeeting(endTime: string) {
|
async function createInstantMeeting(endTime: string, region?: RoomGeo) {
|
||||||
// added a 1 hour buffer for room expiration
|
// added a 1 hour buffer for room expiration
|
||||||
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
const exp = Math.round(new Date(endTime).getTime() / 1000) + 60 * 60;
|
||||||
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
const { scale_plan: scalePlan } = await getDailyAppKeys();
|
||||||
@@ -296,6 +330,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
const body = {
|
const body = {
|
||||||
privacy: "public",
|
privacy: "public",
|
||||||
properties: {
|
properties: {
|
||||||
|
...(region ? { geo: region } : {}),
|
||||||
enable_prejoin_ui: true,
|
enable_prejoin_ui: true,
|
||||||
enable_knocking: true,
|
enable_knocking: true,
|
||||||
enable_screenshare: true,
|
enable_screenshare: true,
|
||||||
@@ -347,20 +382,22 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
url: dailyEvent.url,
|
url: dailyEvent.url,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Region on which the DailyVideo room is created can be controlled by ENV var
|
||||||
|
// undefined region leaves the choice to DailyVideo
|
||||||
|
const region = getDailyVideoRegionFromEnv();
|
||||||
return {
|
return {
|
||||||
/** Daily doesn't need to return busy times, so we return empty */
|
/** Daily doesn't need to return busy times, so we return empty */
|
||||||
getAvailability: () => {
|
getAvailability: () => {
|
||||||
return Promise.resolve([]);
|
return Promise.resolve([]);
|
||||||
},
|
},
|
||||||
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
createMeeting: async (event: CalendarEvent): Promise<VideoCallData> =>
|
||||||
createOrUpdateMeeting("/rooms", event),
|
createOrUpdateMeeting("/rooms", event, region),
|
||||||
deleteMeeting: async (uid: string): Promise<void> => {
|
deleteMeeting: async (uid: string): Promise<void> => {
|
||||||
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
await fetcher(`/rooms/${uid}`, { method: "DELETE" });
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
},
|
},
|
||||||
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
updateMeeting: (bookingRef: PartialReference, event: CalendarEvent): Promise<VideoCallData> =>
|
||||||
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event),
|
createOrUpdateMeeting(`/rooms/${bookingRef.uid}`, event, region),
|
||||||
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
getRecordings: async (roomName: string): Promise<GetRecordingsResponseSchema> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
const res = await fetcher(`/recordings?room_name=${roomName}`).then(
|
||||||
@@ -371,7 +408,7 @@ const DailyVideoApiAdapter = (): VideoApiAdapter => {
|
|||||||
throw new Error("Something went wrong! Unable to get recording");
|
throw new Error("Something went wrong! Unable to get recording");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime),
|
createInstantCalVideoRoom: (endTime: string) => createInstantMeeting(endTime, region),
|
||||||
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
getRecordingDownloadLink: async (recordingId: string): Promise<GetAccessLinkResponseSchema> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
const res = await fetcher(`/recordings/${recordingId}/access-link?valid_for_secs=43200`).then(
|
||||||
|
|||||||
@@ -2,15 +2,9 @@ import { calendar_v3 } from "@googleapis/calendar";
|
|||||||
import { OAuth2Client } from "googleapis-common";
|
import { OAuth2Client } from "googleapis-common";
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { updateProfilePhotoGoogle } from "@calcom/app-store/_utils/oauth/updateProfilePhotoGoogle";
|
|
||||||
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
import GoogleCalendarService from "@calcom/app-store/googlecalendar/lib/CalendarService";
|
||||||
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
import { renewSelectedCalendarCredentialId } from "@calcom/lib/connectedCalendar";
|
||||||
import {
|
import { GOOGLE_CALENDAR_SCOPES, WEBAPP_URL, WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
GOOGLE_CALENDAR_SCOPES,
|
|
||||||
SCOPE_USERINFO_PROFILE,
|
|
||||||
WEBAPP_URL,
|
|
||||||
WEBAPP_URL_FOR_OAUTH,
|
|
||||||
} from "@calcom/lib/constants";
|
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import { HttpError } from "@calcom/lib/http-error";
|
import { HttpError } from "@calcom/lib/http-error";
|
||||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
@@ -101,9 +95,10 @@ async function getHandler(req: NextApiRequest, res: NextApiResponse) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Only attempt to update the user's profile photo if the user has granted the required scope
|
// Only attempt to update the user's profile photo if the user has granted the required scope
|
||||||
if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
// TODO: Use the avatarUrl when setting the profile picture
|
||||||
await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
// if (grantedScopes.includes(SCOPE_USERINFO_PROFILE)) {
|
||||||
}
|
// await updateProfilePhotoGoogle(oAuth2Client, req.session.user.id);
|
||||||
|
// }
|
||||||
|
|
||||||
const selectedCalendarWhereUnique = {
|
const selectedCalendarWhereUnique = {
|
||||||
userId: req.session.user.id,
|
userId: req.session.user.id,
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/Calenda
|
|||||||
|
|
||||||
class MyGoogleOAuth2Client extends OAuth2Client {
|
class MyGoogleOAuth2Client extends OAuth2Client {
|
||||||
constructor(client_id: string, client_secret: string, redirect_uri: string) {
|
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() {
|
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[]> {
|
async listCalendars(): Promise<IntegrationCalendar[]> {
|
||||||
this.log.debug("Listing calendars");
|
this.log.debug("Listing calendars");
|
||||||
const calendar = await this.authedCalendar();
|
const calendar = await this.authedCalendar();
|
||||||
@@ -1009,39 +1000,16 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPrimaryCalendar(
|
async getPrimaryCalendar(_calendar?: calendar_v3.Calendar): Promise<calendar_v3.Schema$Calendar | null> {
|
||||||
calendar: calendar_v3.Calendar,
|
|
||||||
fields: string[] = ["id", "summary", "primary", "accessRole"]
|
|
||||||
): Promise<calendar_v3.Schema$CalendarListEntry | null> {
|
|
||||||
let pageToken: string | undefined;
|
|
||||||
let firstCalendar: calendar_v3.Schema$CalendarListEntry | undefined;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
do {
|
const calendar = _calendar ?? (await this.authedCalendar());
|
||||||
const response: any = await calendar.calendarList.list({
|
const response = await calendar.calendars.get({
|
||||||
fields: `items(${fields.join(",")}),nextPageToken`,
|
calendarId: "primary",
|
||||||
pageToken,
|
});
|
||||||
maxResults: 250, // 250 is max
|
return response.data;
|
||||||
});
|
|
||||||
|
|
||||||
const cals = response.data.items ?? [];
|
|
||||||
const primaryCal = cals.find((cal: calendar_v3.Schema$CalendarListEntry) => cal.primary);
|
|
||||||
if (primaryCal) {
|
|
||||||
return primaryCal;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store the first calendar in case no primary is found
|
|
||||||
if (cals.length > 0 && !firstCalendar) {
|
|
||||||
firstCalendar = cals[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
pageToken = response.data.nextPageToken;
|
|
||||||
} while (pageToken);
|
|
||||||
|
|
||||||
// should not be reached because Google Cal always has a primary cal
|
|
||||||
return firstCalendar ?? null;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error in `getPrimaryCalendar`", { error });
|
// should not be reached because Google Cal always has a primary cal
|
||||||
|
logger.error("Error getting primary calendar", { error });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { vi } from "vitest";
|
|||||||
const setCredentialsMock = vi.fn();
|
const setCredentialsMock = vi.fn();
|
||||||
const freebusyQueryMock = vi.fn();
|
const freebusyQueryMock = vi.fn();
|
||||||
const calendarListMock = vi.fn();
|
const calendarListMock = vi.fn();
|
||||||
|
const calendarsGetMock = vi.fn();
|
||||||
const calendarMockImplementation = {
|
const calendarMockImplementation = {
|
||||||
channels: {
|
channels: {
|
||||||
stop: vi.fn().mockResolvedValue(undefined),
|
stop: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -10,6 +11,9 @@ const calendarMockImplementation = {
|
|||||||
calendarList: {
|
calendarList: {
|
||||||
list: calendarListMock,
|
list: calendarListMock,
|
||||||
},
|
},
|
||||||
|
calendars: {
|
||||||
|
get: calendarsGetMock,
|
||||||
|
},
|
||||||
events: {
|
events: {
|
||||||
watch: vi.fn().mockResolvedValue({
|
watch: vi.fn().mockResolvedValue({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -885,3 +885,27 @@ describe("getAvailability", () => {
|
|||||||
expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]);
|
expect(availabilityWithAllCalendarsAsFallback).toEqual([...mockedBusyTimes1, ...mockedBusyTimes2]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("getPrimaryCalendar", () => {
|
||||||
|
test("should fetch primary calendar using 'primary' keyword", async () => {
|
||||||
|
const credential = await createCredentialForCalendarService();
|
||||||
|
const calendarService = new CalendarService(credential);
|
||||||
|
setFullMockOAuthManagerRequest();
|
||||||
|
const mockPrimaryCalendar = {
|
||||||
|
id: "user@example.com",
|
||||||
|
summary: "Primary Calendar",
|
||||||
|
description: "Primary calendar for user@example.com",
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
};
|
||||||
|
const calendarsGetMock = vi.fn().mockResolvedValue({
|
||||||
|
data: mockPrimaryCalendar,
|
||||||
|
});
|
||||||
|
calendarMock.calendar_v3.Calendar().calendars.get = calendarsGetMock;
|
||||||
|
const result = await calendarService.getPrimaryCalendar();
|
||||||
|
expect(calendarsGetMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(calendarsGetMock).toHaveBeenCalledWith({
|
||||||
|
calendarId: "primary",
|
||||||
|
});
|
||||||
|
expect(result).toEqual(mockPrimaryCalendar);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
import { CAL_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
|
|
||||||
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||||
|
|
||||||
@@ -20,13 +20,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
|||||||
isEmbed: false,
|
isEmbed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
it("should return CAL_URL for non-migrated user", () => {
|
it("should return WEBAPP_URL for non-migrated user", () => {
|
||||||
const result = getAbsoluteEventTypeRedirectUrl({
|
const result = getAbsoluteEventTypeRedirectUrl({
|
||||||
...defaultParams,
|
...defaultParams,
|
||||||
eventTypeRedirectUrl: "user/event",
|
eventTypeRedirectUrl: "user/event",
|
||||||
form: { ...defaultForm, nonOrgUsername: "user" },
|
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", () => {
|
it("should return user origin for migrated user", () => {
|
||||||
@@ -34,13 +34,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
|||||||
expect(result).toBe("https://user.cal.com/user/event?");
|
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({
|
const result = getAbsoluteEventTypeRedirectUrl({
|
||||||
...defaultParams,
|
...defaultParams,
|
||||||
eventTypeRedirectUrl: "team/team1/event",
|
eventTypeRedirectUrl: "team/team1/event",
|
||||||
form: { ...defaultForm, nonOrgTeamslug: "team1" },
|
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", () => {
|
it("should return team origin for migrated team", () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
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";
|
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||||
|
|
||||||
export const enrichFormWithMigrationData = <
|
export const enrichFormWithMigrationData = <
|
||||||
@@ -45,12 +45,12 @@ export const enrichFormWithMigrationData = <
|
|||||||
? getOrgFullOrigin(formOwnerOrgSlug, {
|
? getOrgFullOrigin(formOwnerOrgSlug, {
|
||||||
protocol: true,
|
protocol: true,
|
||||||
})
|
})
|
||||||
: CAL_URL,
|
: WEBAPP_URL,
|
||||||
teamOrigin: form.team?.parent?.slug
|
teamOrigin: form.team?.parent?.slug
|
||||||
? getOrgFullOrigin(form.team.parent.slug, {
|
? getOrgFullOrigin(form.team.parent.slug, {
|
||||||
protocol: true,
|
protocol: true,
|
||||||
})
|
})
|
||||||
: CAL_URL,
|
: WEBAPP_URL,
|
||||||
nonOrgUsername,
|
nonOrgUsername,
|
||||||
nonOrgTeamslug,
|
nonOrgTeamslug,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ describe("handleResponse", () => {
|
|||||||
it("should record form response when not in preview and not queued", async () => {
|
it("should record form response when not in preview and not queued", async () => {
|
||||||
const dbFormResponse = {
|
const dbFormResponse = {
|
||||||
id: 1,
|
id: 1,
|
||||||
|
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
|
||||||
formId: mockForm.id,
|
formId: mockForm.id,
|
||||||
response: mockResponse,
|
response: mockResponse,
|
||||||
chosenRouteId: null,
|
chosenRouteId: null,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
MultiOptionInput,
|
MultiOptionInput,
|
||||||
} from "@calcom/ui/components/form";
|
} from "@calcom/ui/components/form";
|
||||||
import { Icon } from "@calcom/ui/components/icon";
|
import { Icon } from "@calcom/ui/components/icon";
|
||||||
|
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||||
|
|
||||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ function Field({
|
|||||||
moveUp,
|
moveUp,
|
||||||
moveDown,
|
moveDown,
|
||||||
appUrl,
|
appUrl,
|
||||||
|
disableTypeChange,
|
||||||
}: {
|
}: {
|
||||||
fieldIndex: number;
|
fieldIndex: number;
|
||||||
hookForm: HookForm;
|
hookForm: HookForm;
|
||||||
@@ -53,6 +55,7 @@ function Field({
|
|||||||
fn: () => void;
|
fn: () => void;
|
||||||
};
|
};
|
||||||
appUrl: string;
|
appUrl: string;
|
||||||
|
disableTypeChange: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
|
|
||||||
@@ -123,32 +126,53 @@ function Field({
|
|||||||
defaultValue={routerField?.type}
|
defaultValue={routerField?.type}
|
||||||
render={({ field: { value, onChange } }) => {
|
render={({ field: { value, onChange } }) => {
|
||||||
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
const defaultValue = FieldTypes.find((fieldType) => fieldType.value === value);
|
||||||
return (
|
if (disableTypeChange) {
|
||||||
<SelectField
|
return (
|
||||||
maxMenuHeight={200}
|
<div className="data-testid-field-type">
|
||||||
styles={{
|
<Label htmlFor="field-type-button">{t("type")}</Label>
|
||||||
singleValue: (baseStyles) => ({
|
<Tooltip content={t("field_type_change_suggestion")}>
|
||||||
...baseStyles,
|
<Button
|
||||||
fontSize: "14px",
|
type="button"
|
||||||
}),
|
disabled
|
||||||
option: (baseStyles) => ({
|
color="secondary"
|
||||||
...baseStyles,
|
className={classNames(
|
||||||
fontSize: "14px",
|
"h-8 w-full justify-between text-left text-sm",
|
||||||
}),
|
!!router && "bg-subtle cursor-not-allowed"
|
||||||
}}
|
)}>
|
||||||
label="Type"
|
<span className="text-default">{defaultValue?.label || "Select field type"}</span>
|
||||||
isDisabled={!!router}
|
<Icon name="chevron-down" className="text-default h-4 w-4" />
|
||||||
containerClassName="data-testid-field-type"
|
</Button>
|
||||||
options={FieldTypes}
|
</Tooltip>
|
||||||
onChange={(option) => {
|
</div>
|
||||||
if (!option) {
|
);
|
||||||
return;
|
} else {
|
||||||
}
|
return (
|
||||||
onChange(option.value);
|
<SelectField
|
||||||
}}
|
maxMenuHeight={200}
|
||||||
defaultValue={defaultValue}
|
styles={{
|
||||||
/>
|
singleValue: (baseStyles) => ({
|
||||||
);
|
...baseStyles,
|
||||||
|
fontSize: "14px",
|
||||||
|
}),
|
||||||
|
option: (baseStyles) => ({
|
||||||
|
...baseStyles,
|
||||||
|
fontSize: "14px",
|
||||||
|
}),
|
||||||
|
}}
|
||||||
|
label="Type"
|
||||||
|
isDisabled={!!router}
|
||||||
|
containerClassName="data-testid-field-type"
|
||||||
|
options={FieldTypes}
|
||||||
|
onChange={(option) => {
|
||||||
|
if (!option) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onChange(option.value);
|
||||||
|
}}
|
||||||
|
defaultValue={defaultValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,6 +237,7 @@ const FormEdit = ({
|
|||||||
} = useFieldArray({
|
} = useFieldArray({
|
||||||
control: hookForm.control,
|
control: hookForm.control,
|
||||||
name: fieldsNamespace,
|
name: fieldsNamespace,
|
||||||
|
keyName: "_id",
|
||||||
});
|
});
|
||||||
|
|
||||||
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
const [animationRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
@@ -236,12 +261,15 @@ const FormEdit = ({
|
|||||||
<div className="w-full py-4 lg:py-8">
|
<div className="w-full py-4 lg:py-8">
|
||||||
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
<div ref={animationRef} className="flex w-full flex-col rounded-md">
|
||||||
{hookFormFields.map((field, key) => {
|
{hookFormFields.map((field, key) => {
|
||||||
|
const existingField = Boolean((form.fields || []).find((f) => f.id === field.id));
|
||||||
|
const hasFormResponses = (form._count?.responses ?? 0) > 0;
|
||||||
return (
|
return (
|
||||||
<Field
|
<Field
|
||||||
appUrl={appUrl}
|
appUrl={appUrl}
|
||||||
fieldIndex={key}
|
fieldIndex={key}
|
||||||
hookForm={hookForm}
|
hookForm={hookForm}
|
||||||
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
hookFieldNamespace={`${fieldsNamespace}.${key}`}
|
||||||
|
disableTypeChange={existingField && hasFormResponses}
|
||||||
deleteField={{
|
deleteField={{
|
||||||
check: () => hookFormFields.length > 1,
|
check: () => hookFormFields.length > 1,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Locator, Page } from "@playwright/test";
|
import type { Locator, Page } from "@playwright/test";
|
||||||
import { expect } from "@playwright/test";
|
import { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||||
import { prisma } from "@calcom/prisma";
|
import { prisma } from "@calcom/prisma";
|
||||||
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
import { AttributeType, MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||||
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
|
||||||
@@ -262,7 +263,7 @@ test.describe("Routing Forms", () => {
|
|||||||
option: 2,
|
option: 2,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||||
await saveCurrentForm(page);
|
await saveCurrentForm(page);
|
||||||
|
|
||||||
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
const { fields } = await addAllTypesOfFieldsAndSaveForm(formId, page, {
|
||||||
@@ -290,7 +291,7 @@ test.describe("Routing Forms", () => {
|
|||||||
|
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.hostname.includes("cal.com");
|
return url.pathname.endsWith("/pro");
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = new URL(page.url());
|
const url = new URL(page.url());
|
||||||
@@ -509,7 +510,7 @@ test.describe("Routing Forms", () => {
|
|||||||
|
|
||||||
await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
|
await page.goto(`/router?form=${routingForm.id}&Test field=external-redirect`);
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.hostname.includes("cal.com") && url.searchParams.get("Test field") === "external-redirect";
|
return url.pathname.endsWith("/pro") && url.searchParams.get("Test field") === "external-redirect";
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
await page.goto(`/router?form=${routingForm.id}&Test field=custom-page`);
|
||||||
@@ -576,7 +577,7 @@ test.describe("Routing Forms", () => {
|
|||||||
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
|
routingType = await page.locator('[data-testid="chosen-route-title"]').innerText();
|
||||||
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
route = await page.locator('[data-testid="test-routing-result"]').innerText();
|
||||||
expect(routingType).toBe("External Redirect");
|
expect(routingType).toBe("External Redirect");
|
||||||
expect(route).toBe("https://cal.com/peer");
|
expect(route).toBe(`${WEBAPP_URL}/pro`);
|
||||||
await page.click('[data-testid="close-results-button"]');
|
await page.click('[data-testid="close-results-button"]');
|
||||||
|
|
||||||
// Multiselect(Legacy)
|
// Multiselect(Legacy)
|
||||||
@@ -925,7 +926,7 @@ test.describe("Routing Forms", () => {
|
|||||||
option: 2,
|
option: 2,
|
||||||
page,
|
page,
|
||||||
});
|
});
|
||||||
await page.fill("[name=externalRedirectUrl]", "https://cal.com/peer");
|
await page.fill("[name=externalRedirectUrl]", `${WEBAPP_URL}/pro`);
|
||||||
await saveCurrentForm(page);
|
await saveCurrentForm(page);
|
||||||
return {
|
return {
|
||||||
formId,
|
formId,
|
||||||
@@ -978,7 +979,7 @@ test.describe("Routing Forms", () => {
|
|||||||
await page.fill('[data-testid="form-field-short-text"]', "test");
|
await page.fill('[data-testid="form-field-short-text"]', "test");
|
||||||
await page.click('button[type="submit"]');
|
await page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.hostname.includes("cal.com");
|
return url.pathname.endsWith("/pro");
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1059,7 +1060,7 @@ async function fillSeededForm(page: Page, routingFormId: string) {
|
|||||||
await fillAllOptionsBasedFields();
|
await fillAllOptionsBasedFields();
|
||||||
page.click('button[type="submit"]');
|
page.click('button[type="submit"]');
|
||||||
await page.waitForURL((url) => {
|
await page.waitForURL((url) => {
|
||||||
return url.hostname.includes("cal.com");
|
return url.pathname.endsWith("/pro");
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default async function getAttributesForTeamHandler({
|
|||||||
}: GetAttributesForTeamHandlerOptions) {
|
}: GetAttributesForTeamHandlerOptions) {
|
||||||
const { teamId } = input;
|
const { teamId } = input;
|
||||||
const { user } = ctx;
|
const { user } = ctx;
|
||||||
const isMemberOfTeam = await MembershipRepository.findFirstByUserIdAndTeamId({ userId: user.id, teamId });
|
const isMemberOfTeam = await MembershipRepository.findUniqueByUserIdAndTeamId({ userId: user.id, teamId });
|
||||||
|
|
||||||
if (!isMemberOfTeam) {
|
if (!isMemberOfTeam) {
|
||||||
throw new TRPCError({
|
throw new TRPCError({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { NextApiRequest } from "next";
|
import type { NextApiRequest } from "next";
|
||||||
import { stringify } from "querystring";
|
import { stringify } from "querystring";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
|
||||||
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
@@ -26,7 +26,7 @@ async function handler(req: NextApiRequest) {
|
|||||||
const params = {
|
const params = {
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
client_id,
|
client_id,
|
||||||
redirect_uri: `${WEBAPP_URL}/api/integrations/zoomvideo/callback`,
|
redirect_uri: `${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`,
|
||||||
state,
|
state,
|
||||||
};
|
};
|
||||||
const query = stringify(params);
|
const query = stringify(params);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
|
||||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
import { WEBAPP_URL_FOR_OAUTH } from "@calcom/lib/constants";
|
||||||
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
|
|||||||
const { code } = req.query;
|
const { code } = req.query;
|
||||||
const { client_id, client_secret } = await getZoomAppKeys();
|
const { client_id, client_secret } = await getZoomAppKeys();
|
||||||
|
|
||||||
const redirectUri = encodeURI(`${WEBAPP_URL}/api/integrations/zoomvideo/callback`);
|
const redirectUri = encodeURI(`${WEBAPP_URL_FOR_OAUTH}/api/integrations/zoomvideo/callback`);
|
||||||
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
const authHeader = `Basic ${Buffer.from(`${client_id}:${client_secret}`).toString("base64")}`;
|
||||||
const result = await fetch(
|
const result = await fetch(
|
||||||
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
`https://zoom.us/oauth/token?grant_type=authorization_code&code=${code}&redirect_uri=${redirectUri}`,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
CREDENTIAL_SYNC_SECRET_HEADER_NAME,
|
||||||
} from "@calcom/lib/constants";
|
} from "@calcom/lib/constants";
|
||||||
import logger from "@calcom/lib/logger";
|
import logger from "@calcom/lib/logger";
|
||||||
|
import { getPiiFreeCalendarEvent } from "@calcom/lib/piiFreeData";
|
||||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Frequency } from "@calcom/prisma/zod-utils";
|
import { Frequency } from "@calcom/prisma/zod-utils";
|
||||||
@@ -63,12 +64,16 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
|
|||||||
|
|
||||||
/** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */
|
/** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */
|
||||||
export const zoomUserSettingsSchema = z.object({
|
export const zoomUserSettingsSchema = z.object({
|
||||||
recording: z.object({
|
recording: z
|
||||||
auto_recording: z.string(),
|
.object({
|
||||||
}),
|
auto_recording: z.string().nullish(),
|
||||||
schedule_meeting: z.object({
|
})
|
||||||
default_password_for_scheduled_meetings: z.string(),
|
.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
|
// 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 fetchZoomApi = async (endpoint: string, options?: RequestInit) => {
|
||||||
const auth = new OAuthManager({
|
const auth = new OAuthManager({
|
||||||
credentialSyncVariables: {
|
credentialSyncVariables: {
|
||||||
@@ -216,9 +247,18 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
},
|
},
|
||||||
isTokenObjectUnusable: async function (response) {
|
isTokenObjectUnusable: async function (response) {
|
||||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isTokenObjectUnusable"] });
|
||||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
if (!response.ok) {
|
||||||
const responseBody = await response.json();
|
let responseBody;
|
||||||
|
const responseToUseInCaseOfError = response.clone();
|
||||||
|
try {
|
||||||
|
responseBody = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
return await handleZoomResponseJsonParseError({
|
||||||
|
error: e,
|
||||||
|
clonedResponse: responseToUseInCaseOfError,
|
||||||
|
});
|
||||||
|
}
|
||||||
myLog.debug(safeStringify({ responseBody }));
|
myLog.debug(safeStringify({ responseBody }));
|
||||||
|
|
||||||
if (responseBody.error === "invalid_grant") {
|
if (responseBody.error === "invalid_grant") {
|
||||||
@@ -229,11 +269,20 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
},
|
},
|
||||||
isAccessTokenUnusable: async function (response) {
|
isAccessTokenUnusable: async function (response) {
|
||||||
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
const myLog = logger.getSubLogger({ prefix: ["zoomvideo:isAccessTokenUnusable"] });
|
||||||
myLog.debug(safeStringify({ status: response.status, ok: response.ok }));
|
myLog.info(safeStringify({ status: response.status, ok: response.ok }));
|
||||||
if (!response.ok || (response.status < 200 && response.status >= 300)) {
|
if (!response.ok) {
|
||||||
const responseBody = await response.json();
|
let responseBody;
|
||||||
|
const responseToUseInCaseOfError = response.clone();
|
||||||
|
try {
|
||||||
|
responseBody = await response.json();
|
||||||
|
} catch (e) {
|
||||||
|
return await handleZoomResponseJsonParseError({
|
||||||
|
error: e,
|
||||||
|
clonedResponse: responseToUseInCaseOfError,
|
||||||
|
});
|
||||||
|
}
|
||||||
myLog.debug(safeStringify({ responseBody }));
|
myLog.debug(safeStringify({ responseBody }));
|
||||||
|
// 124 is the error code for invalid access token from Zoom API
|
||||||
if (responseBody.code === 124) {
|
if (responseBody.code === 124) {
|
||||||
return { reason: responseBody.message ?? "" };
|
return { reason: responseBody.message ?? "" };
|
||||||
}
|
}
|
||||||
@@ -280,7 +329,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
end: new Date(new Date(meeting.start_time).getTime() + meeting.duration * 60000).toISOString(),
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
log.error("Failed to get availability", safeStringify(err));
|
||||||
/* Prevents booking failure when Zoom Token is expired */
|
/* Prevents booking failure when Zoom Token is expired */
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -307,7 +356,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
}
|
}
|
||||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
log.error(
|
||||||
|
"Zoom meeting creation failed",
|
||||||
|
safeStringify({ error: safeStringify(err), event: getPiiFreeCalendarEvent(event) })
|
||||||
|
);
|
||||||
/* Prevents meeting creation failure when Zoom Token is expired */
|
/* Prevents meeting creation failure when Zoom Token is expired */
|
||||||
throw new Error("Unexpected error");
|
throw new Error("Unexpected error");
|
||||||
}
|
}
|
||||||
@@ -342,7 +394,10 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
|||||||
url: result.join_url,
|
url: result.join_url,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Failed to update meeting", safeStringify(err));
|
log.error(
|
||||||
|
"Failed to update meeting",
|
||||||
|
safeStringify({ error: err, event: getPiiFreeCalendarEvent(event) })
|
||||||
|
);
|
||||||
return Promise.reject(new Error("Failed to update meeting"));
|
return Promise.reject(new Error("Failed to update meeting"));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -33,6 +33,6 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||||
"tailwind-scrollbar": "^2.0.1",
|
"tailwind-scrollbar": "^2.0.1",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.7.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@
|
|||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"postcss": "^8.4.18",
|
"postcss": "^8.4.18",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^4.1.2",
|
"vite": "^4.1.2",
|
||||||
"vite-plugin-environment": "^1.1.3"
|
"vite-plugin-environment": "^1.1.3"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { Mock } from "vitest";
|
|||||||
|
|
||||||
import { EmbedElement } from "./EmbedElement";
|
import { EmbedElement } from "./EmbedElement";
|
||||||
import inlineHTML from "./Inline/inlineHtml";
|
import inlineHTML from "./Inline/inlineHtml";
|
||||||
|
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||||
import { getColorSchemeDarkQuery } from "./ui-utils";
|
import { getColorSchemeDarkQuery } from "./ui-utils";
|
||||||
|
|
||||||
(function defineEmbedTestElement() {
|
(function defineEmbedTestElement() {
|
||||||
@@ -269,8 +270,8 @@ describe("EmbedElement", () => {
|
|||||||
},
|
},
|
||||||
getSkeletonData: vi.fn(),
|
getSkeletonData: vi.fn(),
|
||||||
});
|
});
|
||||||
expect(element.classList.contains("dark")).toBe(true);
|
expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
|
||||||
expect(element.classList.contains("light")).toBe(false);
|
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", () => {
|
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 }));
|
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
|
||||||
|
|
||||||
expect(element.classList.contains("dark")).toBe(true);
|
expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
|
||||||
expect(element.classList.contains("light")).toBe(false);
|
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", () => {
|
it("should not update theme on system color scheme change when embed theme is set", () => {
|
||||||
@@ -291,7 +292,7 @@ describe("EmbedElement", () => {
|
|||||||
getSkeletonData: vi.fn(),
|
getSkeletonData: vi.fn(),
|
||||||
});
|
});
|
||||||
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
|
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 type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types";
|
||||||
import {
|
import {
|
||||||
getThemeClassForEmbed,
|
getThemeClassForEmbed,
|
||||||
@@ -157,9 +158,7 @@ export class EmbedElement extends HTMLElement {
|
|||||||
this.isModal = data.isModal;
|
this.isModal = data.isModal;
|
||||||
this.layout = this.getLayout();
|
this.layout = this.getLayout();
|
||||||
this.theme = this.dataset.theme as EmbedThemeConfig | null;
|
this.theme = this.dataset.theme as EmbedThemeConfig | null;
|
||||||
this.themeClass = getThemeClassForEmbed({ theme: this.theme });
|
this.setTheme(this.theme);
|
||||||
this.classList.add(this.themeClass);
|
|
||||||
|
|
||||||
this.getSkeletonData = data.getSkeletonData;
|
this.getSkeletonData = data.getSkeletonData;
|
||||||
this.boundResizeHandler = this.resizeHandler.bind(this);
|
this.boundResizeHandler = this.resizeHandler.bind(this);
|
||||||
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
|
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
|
||||||
@@ -197,20 +196,26 @@ export class EmbedElement extends HTMLElement {
|
|||||||
skeletonEl.innerHTML = skeletonContent;
|
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) {
|
public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) {
|
||||||
const isDarkPreferred = e.matches;
|
const isDarkPreferred = e.matches;
|
||||||
const allPossibleThemeClasses = ["dark", "light"];
|
|
||||||
if (isThemePreferenceProvided(this.theme)) {
|
if (isThemePreferenceProvided(this.theme)) {
|
||||||
// User has provided a theme preference, so we stick to that and don't react to system theme change
|
// User has provided a theme preference, so we stick to that and don't react to system theme change
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const newThemeClass = getThemeClassForEmbed({
|
this.setTheme(isDarkPreferred ? "dark" : "light");
|
||||||
theme: isDarkPreferred ? "dark" : "light",
|
|
||||||
});
|
|
||||||
if (newThemeClass !== this.themeClass) {
|
|
||||||
this.classList.remove(...allPossibleThemeClasses);
|
|
||||||
this.classList.add(newThemeClass);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Make sure to show the loader initially.
|
// Make sure to show the loader initially.
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ type fakeCurrentDocumentUrlParams = {
|
|||||||
params?: Record<string, string>;
|
params?: Record<string, string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Ensure that we have it globally so that unexpected errors like 'document is not defined' don't happen due to timer being fired when test is shutting down
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
function fakeCurrentDocumentUrl({
|
function fakeCurrentDocumentUrl({
|
||||||
origin = "https://example.com",
|
origin = "https://example.com",
|
||||||
path = "",
|
path = "",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { EMBED_DARK_THEME_CLASS } from "./constants";
|
||||||
|
|
||||||
export const addAppCssVars = () => {
|
export const addAppCssVars = () => {
|
||||||
const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"];
|
const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"];
|
||||||
try {
|
try {
|
||||||
@@ -148,7 +150,7 @@ export const addAppCssVars = () => {
|
|||||||
--cal-brand-text: hsla(0, 0%, 100%, 1); /* white */
|
--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 */
|
/* Background Standard */
|
||||||
--cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */
|
--cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */
|
||||||
--cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */
|
--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"
|
"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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const willSlotsBeFetched = url.searchParams.get("cal.skipSlotsFetch") !== "true";
|
||||||
|
log(`Slots will ${willSlotsBeFetched ? "" : "NOT "}be fetched`);
|
||||||
|
|
||||||
window.addEventListener("message", (e) => {
|
window.addEventListener("message", (e) => {
|
||||||
const data: Message = e.data;
|
const data: Message = e.data;
|
||||||
if (!data) {
|
if (!data) {
|
||||||
@@ -631,7 +634,7 @@ function main() {
|
|||||||
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
|
||||||
initializeAndSetupEmbed();
|
initializeAndSetupEmbed();
|
||||||
} else {
|
} else {
|
||||||
log(`Preloaded scenario - Skipping initialization and setup`);
|
log(`Preloaded scenario - Skipping initialization and setup as only assets need to be loaded`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -679,6 +682,7 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply configurations to the preloaded page and then ask parent to show the embed
|
* Apply configurations to the preloaded page and then ask parent to show the embed
|
||||||
|
* If there is a need to fetch the slots, then the slots would be fetched and then only this function call would complete
|
||||||
* url has the config as params
|
* url has the config as params
|
||||||
*/
|
*/
|
||||||
async function connectPreloadedEmbed({
|
async function connectPreloadedEmbed({
|
||||||
|
|||||||
@@ -1215,26 +1215,26 @@ describe("Cal", () => {
|
|||||||
expect(result).toBe("noAction");
|
expect(result).toBe("noAction");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return connect-no-slots-fetch when reuseFully is true and slots are not stale", () => {
|
it("should return connect-no-slots-fetch when backgroundSlotsFetch is true and slots are not stale", () => {
|
||||||
const result = calInstance.getNextActionForModal({
|
const result = calInstance.getNextActionForModal({
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
stateData: {
|
stateData: {
|
||||||
...baseArgs.stateData,
|
...baseArgs.stateData,
|
||||||
prerenderOptions: {
|
prerenderOptions: {
|
||||||
reuseFully: true,
|
backgroundSlotsFetch: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(result).toBe("connect-no-slots-fetch");
|
expect(result).toBe("connect-no-slots-fetch");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return connect when reuseFully is true but slots are stale", () => {
|
it("should return connect when backgroundSlotsFetch is true but slots are stale", () => {
|
||||||
const result = calInstance.getNextActionForModal({
|
const result = calInstance.getNextActionForModal({
|
||||||
...baseArgs,
|
...baseArgs,
|
||||||
stateData: {
|
stateData: {
|
||||||
...baseArgs.stateData,
|
...baseArgs.stateData,
|
||||||
prerenderOptions: {
|
prerenderOptions: {
|
||||||
reuseFully: true,
|
backgroundSlotsFetch: true,
|
||||||
},
|
},
|
||||||
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
previousEmbedRenderStartTime: Date.now() - EMBED_MODAL_IFRAME_SLOT_STALE_TIME - 1,
|
||||||
embedRenderStartTime: Date.now(),
|
embedRenderStartTime: Date.now(),
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ type ModalPrerenderOptions = {
|
|||||||
slotsStaleTimeMs?: number;
|
slotsStaleTimeMs?: number;
|
||||||
iframeForceReloadThresholdMs?: number;
|
iframeForceReloadThresholdMs?: number;
|
||||||
reuseFully?: boolean;
|
reuseFully?: boolean;
|
||||||
|
// Default value is true right now but it would change soon
|
||||||
|
backgroundSlotsFetch?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModalStateData = {
|
type ModalStateData = {
|
||||||
@@ -598,7 +600,7 @@ export class Cal {
|
|||||||
return "fullReload";
|
return "fullReload";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (prerenderOptions?.reuseFully && !areSlotsStale) {
|
if (prerenderOptions?.backgroundSlotsFetch && !areSlotsStale) {
|
||||||
return "connect-no-slots-fetch";
|
return "connect-no-slots-fetch";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,6 +988,7 @@ class CalApi {
|
|||||||
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
|
||||||
|
|
||||||
if (__prerender && this.cal.modalBox) {
|
if (__prerender && this.cal.modalBox) {
|
||||||
|
log("Destroying previous prerendered modalbox");
|
||||||
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
// If we are re-prerendering, we destroy the previous modalbox, allowing user to prerender as many times as they want
|
||||||
this.cal.modalBox.remove();
|
this.cal.modalBox.remove();
|
||||||
}
|
}
|
||||||
@@ -999,9 +1002,18 @@ class CalApi {
|
|||||||
const containerEl = document.body;
|
const containerEl = document.body;
|
||||||
this.cal.isPrerendering = !!__prerender;
|
this.cal.isPrerendering = !!__prerender;
|
||||||
if (__prerender) {
|
if (__prerender) {
|
||||||
// TODO: Make `reuseFully` a configurable param as well later.
|
// While prerendering headless router path, we by default want to fetch slots in background(for the time being)
|
||||||
// If someone's preloading the headless router path, we must reuse the iframe fully as is as Router would redirect to a Booking Page and that should be shown as is
|
// For other prerenderings, we don't want to fetch slots in background - Keeping the behaviour same as before
|
||||||
this.prerenderOptions = { ...prerenderOptions, reuseFully: isHeadlessRouterPath };
|
const DEFAULT_BACKGROUND_SLOTS_FETCH = isHeadlessRouterPath ? true : false;
|
||||||
|
const backgroundSlotsFetch =
|
||||||
|
typeof prerenderOptions.backgroundSlotsFetch === "undefined"
|
||||||
|
? DEFAULT_BACKGROUND_SLOTS_FETCH
|
||||||
|
: prerenderOptions.backgroundSlotsFetch;
|
||||||
|
|
||||||
|
this.prerenderOptions = {
|
||||||
|
...prerenderOptions,
|
||||||
|
backgroundSlotsFetch,
|
||||||
|
};
|
||||||
// Add prerender query param
|
// Add prerender query param
|
||||||
config.prerender = "true";
|
config.prerender = "true";
|
||||||
|
|
||||||
@@ -1010,7 +1022,7 @@ class CalApi {
|
|||||||
config["cal.queueFormResponse"] = "true";
|
config["cal.queueFormResponse"] = "true";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.prerenderOptions?.reuseFully) {
|
if (!backgroundSlotsFetch) {
|
||||||
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
|
||||||
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
|
||||||
config["cal.skipSlotsFetch"] = "true";
|
config["cal.skipSlotsFetch"] = "true";
|
||||||
@@ -1045,9 +1057,11 @@ class CalApi {
|
|||||||
isConnectionInitiated,
|
isConnectionInitiated,
|
||||||
prerenderOptions: this.prerenderOptions ?? null,
|
prerenderOptions: this.prerenderOptions ?? null,
|
||||||
};
|
};
|
||||||
|
const lastLoadedUrlObject = this.cal.getLastLoadedLinkInframe();
|
||||||
|
const lastLoadedPathIsRouter = lastLoadedUrlObject?.pathname?.includes("/router");
|
||||||
|
|
||||||
// If we want to reuse fully then we shouldn't submit response again
|
// TODO: This branch needs to be removed as we don't want to support this use case anymore.
|
||||||
if (isHeadlessRouterPath && !this.prerenderOptions?.reuseFully) {
|
if (isHeadlessRouterPath && !lastLoadedPathIsRouter) {
|
||||||
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
|
||||||
existingModalEl.setAttribute("state", "loading");
|
existingModalEl.setAttribute("state", "loading");
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||||
import type { AllPossibleLayouts, EmbedThemeConfig } from "./types";
|
import type { AllPossibleLayouts, EmbedThemeConfig } from "./types";
|
||||||
|
|
||||||
export function getMaxHeightForModal() {
|
export function getMaxHeightForModal() {
|
||||||
@@ -32,7 +33,7 @@ function detectColorScheme() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) {
|
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) {
|
export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) {
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
"@vitejs/plugin-react": "^2.2.0",
|
"@vitejs/plugin-react": "^2.2.0",
|
||||||
"eslint": "^8.34.0",
|
"eslint": "^8.34.0",
|
||||||
"npm-run-all": "^4.1.5",
|
"npm-run-all": "^4.1.5",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^4.5.2"
|
"vite": "^4.5.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
],
|
],
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.7.2",
|
||||||
"vite": "^4.1.2"
|
"vite": "^4.1.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"@typescript-eslint/parser": "^5.52.0",
|
"@typescript-eslint/parser": "^5.52.0",
|
||||||
"@typescript-eslint/utils": "^5.52.0",
|
"@typescript-eslint/utils": "^5.52.0",
|
||||||
"ts-node": "^10.9.1",
|
"ts-node": "^10.9.1",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.7.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/eslint": "^8.4.5"
|
"@types/eslint": "^8.4.5"
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ export const getOptions = ({
|
|||||||
encode: async ({ token, maxAge, secret }) => {
|
encode: async ({ token, maxAge, secret }) => {
|
||||||
log.debug("jwt:encode", safeStringify({ token, maxAge }));
|
log.debug("jwt:encode", safeStringify({ token, maxAge }));
|
||||||
if (token?.sub && isNumber(token.sub)) {
|
if (token?.sub && isNumber(token.sub)) {
|
||||||
const user = await prisma.user.findUnique({
|
const user = await prisma.user.findFirst({
|
||||||
where: { id: Number(token.sub) },
|
where: { id: Number(token.sub) },
|
||||||
select: { metadata: true },
|
select: { metadata: true },
|
||||||
});
|
});
|
||||||
@@ -489,7 +489,7 @@ export const getOptions = ({
|
|||||||
} as JWT;
|
} as JWT;
|
||||||
}
|
}
|
||||||
const autoMergeIdentities = async () => {
|
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
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
where: { email: token.email! },
|
where: { email: token.email! },
|
||||||
select: {
|
select: {
|
||||||
@@ -855,7 +855,7 @@ export const getOptions = ({
|
|||||||
// If the email address doesn't match, check if an account already exists
|
// 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
|
// 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.
|
// 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 },
|
where: { email: user.email },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -875,9 +875,12 @@ export const getOptions = ({
|
|||||||
// a new account. If an account already exists with the incoming email
|
// a new account. If an account already exists with the incoming email
|
||||||
// address return an error for now.
|
// address return an error for now.
|
||||||
|
|
||||||
const existingUserWithEmail = await prisma.user.findUnique({
|
const existingUserWithEmail = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
email: user.email,
|
email: {
|
||||||
|
equals: user.email,
|
||||||
|
mode: "insensitive",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
password: true,
|
password: true,
|
||||||
|
|||||||
@@ -168,7 +168,9 @@ export const EventMeta = ({
|
|||||||
{translatedTitle ?? event?.title}
|
{translatedTitle ?? event?.title}
|
||||||
</EventTitle>
|
</EventTitle>
|
||||||
{(event.description || translatedDescription) && (
|
{(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
|
<div
|
||||||
// eslint-disable-next-line react/no-danger
|
// eslint-disable-next-line react/no-danger
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user