Compare commits
107
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 | ||
|
|
3bea03f4b1 | ||
|
|
19dbde8a3c | ||
|
|
a5fe10189f | ||
|
|
7075384313 | ||
|
|
25bf43cddd | ||
|
|
2880a681a8 | ||
|
|
bbcd0b7e78 | ||
|
|
916ed13bea | ||
|
|
11a06324be | ||
|
|
bcb340d97e | ||
|
|
aabef47ff1 | ||
|
|
e27de47d9f | ||
|
|
7900be3a30 | ||
|
|
ddc5838c7b | ||
|
|
6a13d7c5c6 |
@@ -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
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)
|
||||||
|
|||||||
@@ -54,6 +54,23 @@ import {
|
|||||||
} from "@calcom/platform-constants";
|
} from "@calcom/platform-constants";
|
||||||
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
import { ApiResponse, CalendarBusyTimesInput, CreateCalendarCredentialsInput } from "@calcom/platform-types";
|
||||||
|
|
||||||
|
export interface CalendarState {
|
||||||
|
accessToken: string;
|
||||||
|
origin: string;
|
||||||
|
redir?: string;
|
||||||
|
isDryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const calendarStateSchema = z.object({
|
||||||
|
accessToken: z.string(),
|
||||||
|
origin: z.string(),
|
||||||
|
redir: z.string().optional(),
|
||||||
|
isDryRun: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.transform((val) => val === "true"),
|
||||||
|
});
|
||||||
|
|
||||||
@Controller({
|
@Controller({
|
||||||
path: "/v2/calendars",
|
path: "/v2/calendars",
|
||||||
version: API_VERSIONS_VALUES,
|
version: API_VERSIONS_VALUES,
|
||||||
@@ -181,32 +198,31 @@ export class CalendarsController {
|
|||||||
@Query("code") code: string,
|
@Query("code") code: string,
|
||||||
@Param("calendar") calendar: string
|
@Param("calendar") calendar: string
|
||||||
): Promise<{ url: string }> {
|
): Promise<{ url: string }> {
|
||||||
// state params contains our user access token
|
let stateObj: CalendarState;
|
||||||
const stateParams = new URLSearchParams(state);
|
|
||||||
const { accessToken, origin, redir, isDryRun } = z
|
try {
|
||||||
.object({
|
// First try to parse as JSON
|
||||||
accessToken: z.string(),
|
stateObj = JSON.parse(state) as CalendarState;
|
||||||
origin: z.string(),
|
} catch (e) {
|
||||||
redir: z.string().nullish().optional(),
|
// If JSON parsing fails, try URL params
|
||||||
isDryRun: z.string().nullish().optional(),
|
const stateParams = new URLSearchParams(state);
|
||||||
})
|
|
||||||
.parse({
|
const parsedState = calendarStateSchema.parse({
|
||||||
accessToken: stateParams.get("accessToken"),
|
accessToken: stateParams.get("accessToken"),
|
||||||
origin: stateParams.get("origin"),
|
origin: stateParams.get("origin"),
|
||||||
redir: stateParams.get("redir"),
|
redir: stateParams.get("redir") || undefined,
|
||||||
isDryRun: stateParams.get("isDryRun"),
|
isDryRun: stateParams.get("isDryRun"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
stateObj = parsedState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { accessToken, origin, redir, isDryRun } = stateObj;
|
||||||
switch (calendar) {
|
switch (calendar) {
|
||||||
case OFFICE_365_CALENDAR:
|
case OFFICE_365_CALENDAR:
|
||||||
return await this.outlookService.save(code, accessToken, origin, redir ?? "", isDryRun === "true");
|
return await this.outlookService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||||
case GOOGLE_CALENDAR:
|
case GOOGLE_CALENDAR:
|
||||||
return await this.googleCalendarService.save(
|
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||||
code,
|
|
||||||
accessToken,
|
|
||||||
origin,
|
|
||||||
redir ?? "",
|
|
||||||
isDryRun === "true"
|
|
||||||
);
|
|
||||||
default:
|
default:
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
"Invalid calendar type, available calendars are: ",
|
"Invalid calendar type, available calendars are: ",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||||
|
import type { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { AppsRepository } from "@/modules/apps/apps.repository";
|
import { AppsRepository } from "@/modules/apps/apps.repository";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
@@ -65,12 +66,18 @@ export class GoogleCalendarService implements OAuthCalendarApp {
|
|||||||
|
|
||||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||||
|
const state: CalendarState = {
|
||||||
|
accessToken,
|
||||||
|
origin,
|
||||||
|
redir,
|
||||||
|
isDryRun,
|
||||||
|
};
|
||||||
|
|
||||||
const authUrl = oAuth2Client.generateAuthUrl({
|
const authUrl = oAuth2Client.generateAuthUrl({
|
||||||
access_type: "offline",
|
access_type: "offline",
|
||||||
scope: CALENDAR_SCOPES,
|
scope: CALENDAR_SCOPES,
|
||||||
prompt: "consent",
|
prompt: "consent",
|
||||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
state: JSON.stringify(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
return authUrl;
|
return authUrl;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||||
|
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||||
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
import { CredentialsRepository } from "@/modules/credentials/credentials.repository";
|
||||||
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
import { SelectedCalendarsRepository } from "@/modules/selected-calendars/selected-calendars.repository";
|
||||||
@@ -60,6 +61,13 @@ export class OutlookService implements OAuthCalendarApp {
|
|||||||
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
async getCalendarRedirectUrl(accessToken: string, origin: string, redir?: string, isDryRun?: boolean) {
|
||||||
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
const { client_id } = await this.calendarsService.getAppKeys(OFFICE_365_CALENDAR_ID);
|
||||||
|
|
||||||
|
const state: CalendarState = {
|
||||||
|
accessToken,
|
||||||
|
origin,
|
||||||
|
redir,
|
||||||
|
isDryRun,
|
||||||
|
};
|
||||||
|
|
||||||
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
const scopes = ["User.Read", "Calendars.Read", "Calendars.ReadWrite", "offline_access"];
|
||||||
const params = {
|
const params = {
|
||||||
response_type: "code",
|
response_type: "code",
|
||||||
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
|
|||||||
client_id,
|
client_id,
|
||||||
prompt: "select_account",
|
prompt: "select_account",
|
||||||
redirect_uri: this.redirectUri,
|
redirect_uri: this.redirectUri,
|
||||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
state: JSON.stringify(state),
|
||||||
};
|
};
|
||||||
|
|
||||||
const query = stringify(params);
|
const query = stringify(params);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ type CalVideoSettings = {
|
|||||||
disableRecordingForGuests: boolean;
|
disableRecordingForGuests: boolean;
|
||||||
disableRecordingForOrganizer: boolean;
|
disableRecordingForOrganizer: boolean;
|
||||||
enableAutomaticTranscription: boolean;
|
enableAutomaticTranscription: boolean;
|
||||||
|
disableTranscriptionForGuests: boolean;
|
||||||
|
disableTranscriptionForOrganizer: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldEnableRecordButton = ({
|
const shouldEnableRecordButton = ({
|
||||||
@@ -57,6 +59,25 @@ const shouldEnableAutomaticTranscription = ({
|
|||||||
return !!calVideoSettings.enableAutomaticTranscription;
|
return !!calVideoSettings.enableAutomaticTranscription;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const shouldEnableTranscriptionButton = ({
|
||||||
|
hasTeamPlan,
|
||||||
|
calVideoSettings,
|
||||||
|
isOrganizer,
|
||||||
|
}: {
|
||||||
|
hasTeamPlan: boolean;
|
||||||
|
calVideoSettings?: CalVideoSettings | null;
|
||||||
|
isOrganizer: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!hasTeamPlan) return false;
|
||||||
|
if (!calVideoSettings) return true;
|
||||||
|
|
||||||
|
if (isOrganizer) {
|
||||||
|
return !calVideoSettings.disableTranscriptionForOrganizer;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !calVideoSettings.disableTranscriptionForGuests;
|
||||||
|
};
|
||||||
|
|
||||||
const checkIfUserIsHost = async ({
|
const checkIfUserIsHost = async ({
|
||||||
booking,
|
booking,
|
||||||
sessionUserId,
|
sessionUserId,
|
||||||
@@ -222,6 +243,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
hasTeamPlan: !!hasTeamPlan,
|
hasTeamPlan: !!hasTeamPlan,
|
||||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||||
});
|
});
|
||||||
|
const showTranscriptionButton = shouldEnableTranscriptionButton({
|
||||||
|
hasTeamPlan: !!hasTeamPlan,
|
||||||
|
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||||
|
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
@@ -245,6 +271,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||||
showRecordingButton,
|
showRecordingButton,
|
||||||
enableAutomaticTranscription,
|
enableAutomaticTranscription,
|
||||||
|
showTranscriptionButton,
|
||||||
rediectAttendeeToOnExit: isOrganizer
|
rediectAttendeeToOnExit: isOrganizer
|
||||||
? undefined
|
? undefined
|
||||||
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
|||||||
eventTypeId: eventType.id,
|
eventTypeId: eventType.id,
|
||||||
team: eventType.team,
|
team: eventType.team,
|
||||||
owner: eventType.users[0] ?? null,
|
owner: eventType.users[0] ?? null,
|
||||||
orgSlug: currentOrgDomain,
|
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||||
}),
|
}),
|
||||||
profile,
|
profile,
|
||||||
eventType,
|
eventType,
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -653,7 +653,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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -73,9 +73,11 @@ export interface DailyCustomTrayButton {
|
|||||||
export const CalAiTranscribe = ({
|
export const CalAiTranscribe = ({
|
||||||
showRecordingButton,
|
showRecordingButton,
|
||||||
enableAutomaticTranscription,
|
enableAutomaticTranscription,
|
||||||
|
showTranscriptionButton,
|
||||||
}: {
|
}: {
|
||||||
showRecordingButton: boolean;
|
showRecordingButton: boolean;
|
||||||
enableAutomaticTranscription: boolean;
|
enableAutomaticTranscription: boolean;
|
||||||
|
showTranscriptionButton: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const daily = useDaily();
|
const daily = useDaily();
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
@@ -101,7 +103,11 @@ export const CalAiTranscribe = ({
|
|||||||
recording,
|
recording,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
transcription,
|
...(showTranscriptionButton
|
||||||
|
? {
|
||||||
|
transcription,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
|
|||||||
overrideName,
|
overrideName,
|
||||||
showRecordingButton,
|
showRecordingButton,
|
||||||
enableAutomaticTranscription,
|
enableAutomaticTranscription,
|
||||||
|
showTranscriptionButton,
|
||||||
rediectAttendeeToOnExit,
|
rediectAttendeeToOnExit,
|
||||||
} = props;
|
} = props;
|
||||||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||||||
@@ -80,12 +81,16 @@ export default function JoinCall(props: PageProps) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
transcription: {
|
...(showTranscriptionButton
|
||||||
label: "Cal.ai",
|
? {
|
||||||
tooltip: "Transcription powered by AI",
|
transcription: {
|
||||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
label: "Cal.ai",
|
||||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
tooltip: "Transcription powered by AI",
|
||||||
},
|
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||||
|
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
@@ -114,6 +119,7 @@ export default function JoinCall(props: PageProps) {
|
|||||||
<CalAiTranscribe
|
<CalAiTranscribe
|
||||||
showRecordingButton={showRecordingButton}
|
showRecordingButton={showRecordingButton}
|
||||||
enableAutomaticTranscription={enableAutomaticTranscription}
|
enableAutomaticTranscription={enableAutomaticTranscription}
|
||||||
|
showTranscriptionButton={showTranscriptionButton}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ zIndex: 2, position: "relative" }}>
|
<div style={{ zIndex: 2, position: "relative" }}>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@calcom/web",
|
"name": "@calcom/web",
|
||||||
"version": "5.4.2",
|
"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 {
|
||||||
|
|||||||
@@ -49,6 +49,8 @@
|
|||||||
"disable_recording_for_guests": "Disable recording for guests",
|
"disable_recording_for_guests": "Disable recording for guests",
|
||||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||||
"disable_recording_for_organizer": "Disable recording for organizer",
|
"disable_recording_for_organizer": "Disable recording for organizer",
|
||||||
|
"disable_transcription_for_guests": "Disable transcription for guests",
|
||||||
|
"disable_transcription_for_organizer": "Disable transcription for organizer",
|
||||||
"event_request_declined_recurring": "Your recurring event request has been declined",
|
"event_request_declined_recurring": "Your recurring event request has been declined",
|
||||||
"event_request_cancelled": "Your scheduled event was canceled",
|
"event_request_cancelled": "Your scheduled event was canceled",
|
||||||
"event_request_reassigned": "Your scheduled event was reassigned",
|
"event_request_reassigned": "Your scheduled event was reassigned",
|
||||||
@@ -265,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.",
|
||||||
@@ -1708,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",
|
||||||
@@ -2833,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",
|
||||||
@@ -2886,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",
|
||||||
@@ -2931,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",
|
||||||
@@ -2994,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",
|
||||||
@@ -3029,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",
|
||||||
@@ -3101,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",
|
||||||
@@ -3179,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",
|
||||||
@@ -3238,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",
|
||||||
@@ -3246,10 +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",
|
||||||
"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() {
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ export default class GoogleCalendarService implements Calendar {
|
|||||||
organizer: true,
|
organizer: true,
|
||||||
// Tried changing the display name to the user but GCal will not let you do that. It will only display the name of the external calendar. Leaving this in just incase it works in the future.
|
// Tried changing the display name to the user but GCal will not let you do that. It will only display the name of the external calendar. Leaving this in just incase it works in the future.
|
||||||
displayName: event.organizer.name,
|
displayName: event.organizer.name,
|
||||||
email: hostExternalCalendarId ?? selectedHostDestinationCalendar?.externalId ?? event.organizer.email,
|
// We use || instead of ?? here to handle empty strings
|
||||||
|
email: hostExternalCalendarId || selectedHostDestinationCalendar?.externalId || event.organizer.email,
|
||||||
},
|
},
|
||||||
...eventAttendees,
|
...eventAttendees,
|
||||||
];
|
];
|
||||||
@@ -677,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();
|
||||||
@@ -1008,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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import "@calcom/lib/__mocks__/logger";
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { z } from "zod";
|
||||||
|
|
||||||
|
import { findTeamMembersMatchingAttributeLogic } from "@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic";
|
||||||
|
import { RoutingFormResponseRepository } from "@calcom/lib/server/repository/formResponse";
|
||||||
|
import type { ZResponseInputSchema } from "@calcom/trpc/server/routers/viewer/routing-forms/response.schema";
|
||||||
|
|
||||||
|
import isRouter from "../lib/isRouter";
|
||||||
|
import routerGetCrmContactOwnerEmail from "./crmRouting/routerGetCrmContactOwnerEmail";
|
||||||
|
import type { TargetRoutingFormForResponse } from "./formSubmissionUtils";
|
||||||
|
import { onSubmissionOfFormResponse } from "./formSubmissionUtils";
|
||||||
|
import { handleResponse } from "./handleResponse";
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/raqb/findTeamMembersMatchingAttributeLogic", () => ({
|
||||||
|
findTeamMembersMatchingAttributeLogic: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/server/repository/formResponse", () => ({
|
||||||
|
RoutingFormResponseRepository: {
|
||||||
|
recordQueuedFormResponse: vi.fn(),
|
||||||
|
recordFormResponse: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./crmRouting/routerGetCrmContactOwnerEmail", () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./formSubmissionUtils", () => ({
|
||||||
|
onSubmissionOfFormResponse: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../lib/isRouter", () => ({
|
||||||
|
default: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@calcom/lib/sentryWrapper", () => ({
|
||||||
|
withReporting: (fn: unknown) => fn,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockForm: TargetRoutingFormForResponse = {
|
||||||
|
id: "form-id",
|
||||||
|
name: "Test Form",
|
||||||
|
fields: [
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
label: "Name",
|
||||||
|
type: "text" as const,
|
||||||
|
required: true,
|
||||||
|
identifier: "name",
|
||||||
|
placeholder: undefined,
|
||||||
|
selectText: undefined,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "email",
|
||||||
|
label: "Email",
|
||||||
|
type: "email" as const,
|
||||||
|
required: true,
|
||||||
|
identifier: "email",
|
||||||
|
placeholder: undefined,
|
||||||
|
selectText: undefined,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "guests",
|
||||||
|
label: "Guests",
|
||||||
|
type: "text" as const,
|
||||||
|
required: false,
|
||||||
|
identifier: "guests",
|
||||||
|
placeholder: undefined,
|
||||||
|
selectText: undefined,
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
routes: [],
|
||||||
|
userId: 1,
|
||||||
|
teamId: 1,
|
||||||
|
user: {
|
||||||
|
id: 1,
|
||||||
|
email: "test@example.com",
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
parentId: 2,
|
||||||
|
},
|
||||||
|
createdAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2023-01-01T00:00:00.000Z",
|
||||||
|
description: null,
|
||||||
|
disabled: false,
|
||||||
|
settings: {
|
||||||
|
emailOwnerOnSubmission: false,
|
||||||
|
},
|
||||||
|
connectedForms: [],
|
||||||
|
routers: [],
|
||||||
|
teamMembers: [],
|
||||||
|
position: 0,
|
||||||
|
updatedById: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse: z.infer<typeof ZResponseInputSchema>["response"] = {
|
||||||
|
name: { value: "John Doe", label: "Name" },
|
||||||
|
email: { value: "john.doe@example.com", label: "Email" },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("handleResponse", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw a TRPCError for missing required fields", async () => {
|
||||||
|
await expect(
|
||||||
|
handleResponse({
|
||||||
|
response: { email: { value: "test@test.com", label: "Email" } }, // Name is missing
|
||||||
|
form: mockForm,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: null,
|
||||||
|
isPreview: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Missing required fields Name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw a TRPCError for invalid email", async () => {
|
||||||
|
await expect(
|
||||||
|
handleResponse({
|
||||||
|
response: {
|
||||||
|
name: { value: "John Doe", label: "Name" },
|
||||||
|
email: { value: "invalid-email", label: "Email" },
|
||||||
|
},
|
||||||
|
form: mockForm,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: null,
|
||||||
|
isPreview: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Invalid value for fields 'Email' with value 'invalid-email' should be valid email/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should record form response when not in preview and not queued", async () => {
|
||||||
|
const dbFormResponse = {
|
||||||
|
id: 1,
|
||||||
|
uuid: "d8b4b7d2-3f45-4f67-9aa1-98c4b49cf283",
|
||||||
|
formId: mockForm.id,
|
||||||
|
response: mockResponse,
|
||||||
|
chosenRouteId: null,
|
||||||
|
formFillerId: "user1",
|
||||||
|
routedToBookingUid: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
vi.mocked(RoutingFormResponseRepository.recordFormResponse).mockResolvedValue(dbFormResponse);
|
||||||
|
|
||||||
|
const result = await handleResponse({
|
||||||
|
response: mockResponse,
|
||||||
|
form: mockForm,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: null,
|
||||||
|
isPreview: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(RoutingFormResponseRepository.recordFormResponse).toHaveBeenCalledWith({
|
||||||
|
formId: mockForm.id,
|
||||||
|
response: mockResponse,
|
||||||
|
chosenRouteId: null,
|
||||||
|
});
|
||||||
|
expect(onSubmissionOfFormResponse).toHaveBeenCalledWith({
|
||||||
|
form: mockForm,
|
||||||
|
formResponseInDb: dbFormResponse,
|
||||||
|
chosenRouteAction: null,
|
||||||
|
});
|
||||||
|
expect(result.formResponse).toEqual(dbFormResponse);
|
||||||
|
expect(result.queuedFormResponse).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should queue form response when queueFormResponse is true", async () => {
|
||||||
|
const queuedResponse = {
|
||||||
|
id: "queued-id",
|
||||||
|
formId: mockForm.id,
|
||||||
|
response: mockResponse,
|
||||||
|
chosenRouteId: null,
|
||||||
|
actualResponseId: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
vi.mocked(RoutingFormResponseRepository.recordQueuedFormResponse).mockResolvedValue(queuedResponse);
|
||||||
|
|
||||||
|
const result = await handleResponse({
|
||||||
|
response: mockResponse,
|
||||||
|
form: mockForm,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: null,
|
||||||
|
isPreview: false,
|
||||||
|
queueFormResponse: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(RoutingFormResponseRepository.recordQueuedFormResponse).toHaveBeenCalledWith({
|
||||||
|
formId: mockForm.id,
|
||||||
|
response: mockResponse,
|
||||||
|
chosenRouteId: null,
|
||||||
|
});
|
||||||
|
expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
|
||||||
|
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
|
||||||
|
expect(result.queuedFormResponse).toEqual(queuedResponse);
|
||||||
|
expect(result.formResponse).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Preview mode", () => {
|
||||||
|
it("should send formResponse with id=0 and quueueFormResponse isn't true", async () => {
|
||||||
|
const result = await handleResponse({
|
||||||
|
response: mockResponse,
|
||||||
|
form: mockForm,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: null,
|
||||||
|
isPreview: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(RoutingFormResponseRepository.recordFormResponse).not.toHaveBeenCalled();
|
||||||
|
expect(onSubmissionOfFormResponse).not.toHaveBeenCalled();
|
||||||
|
expect(result.isPreview).toBe(true);
|
||||||
|
expect(result.formResponse).toBeDefined();
|
||||||
|
expect(result.formResponse?.id).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send queuedFormResponse with id=00000000-0000-0000-0000-000000000000 when queueFormResponse is true", async () => {
|
||||||
|
const result = await handleResponse({
|
||||||
|
response: mockResponse,
|
||||||
|
form: mockForm,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: null,
|
||||||
|
isPreview: true,
|
||||||
|
queueFormResponse: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(RoutingFormResponseRepository.recordQueuedFormResponse).not.toHaveBeenCalled();
|
||||||
|
expect(result.isPreview).toBe(true);
|
||||||
|
expect(result.queuedFormResponse).toBeDefined();
|
||||||
|
expect(result.queuedFormResponse?.id).toBe("00000000-0000-0000-0000-000000000000");
|
||||||
|
expect(result.formResponse).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle chosen route and call routing logic", async () => {
|
||||||
|
const chosenRoute = {
|
||||||
|
id: "route1",
|
||||||
|
queryValue: { type: "group", children1: {} } as any,
|
||||||
|
action: {
|
||||||
|
type: "customPageMessage" as const,
|
||||||
|
value: "Thank you for your submission!",
|
||||||
|
},
|
||||||
|
attributeRoutingConfig: null,
|
||||||
|
attributesQueryValue: { type: "group", children1: {} } as any,
|
||||||
|
fallbackAttributesQueryValue: { type: "group", children1: {} } as any,
|
||||||
|
isFallback: false,
|
||||||
|
};
|
||||||
|
const formWithRoute: TargetRoutingFormForResponse = { ...mockForm, routes: [chosenRoute as any] };
|
||||||
|
|
||||||
|
vi.mocked(routerGetCrmContactOwnerEmail).mockResolvedValue({
|
||||||
|
email: "owner@example.com",
|
||||||
|
recordType: "contact",
|
||||||
|
crmAppSlug: "hubspot",
|
||||||
|
});
|
||||||
|
vi.mocked(findTeamMembersMatchingAttributeLogic).mockResolvedValue({
|
||||||
|
teamMembersMatchingAttributeLogic: [{ userId: 123, result: "MATCH" as any }],
|
||||||
|
checkedFallback: false,
|
||||||
|
fallbackAttributeLogicBuildingWarnings: [],
|
||||||
|
mainAttributeLogicBuildingWarnings: [],
|
||||||
|
timeTaken: {
|
||||||
|
ttGetAttributesForLogic: 50,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await handleResponse({
|
||||||
|
response: mockResponse,
|
||||||
|
form: formWithRoute,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: "route1",
|
||||||
|
isPreview: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(routerGetCrmContactOwnerEmail).toHaveBeenCalled();
|
||||||
|
expect(findTeamMembersMatchingAttributeLogic).toHaveBeenCalled();
|
||||||
|
expect(result.crmContactOwnerEmail).toBe("owner@example.com");
|
||||||
|
expect(result.teamMembersMatchingAttributeLogic).toEqual([123]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should throw error if chosen route is a router", async () => {
|
||||||
|
const chosenRoute = {
|
||||||
|
id: "route1",
|
||||||
|
name: "A router",
|
||||||
|
description: "Router description",
|
||||||
|
isRouter: true as const,
|
||||||
|
routes: [],
|
||||||
|
};
|
||||||
|
vi.mocked(isRouter).mockReturnValue(true);
|
||||||
|
const formWithRoute: TargetRoutingFormForResponse = {
|
||||||
|
...mockForm,
|
||||||
|
routes: [chosenRoute as any],
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
handleResponse({
|
||||||
|
response: mockResponse,
|
||||||
|
form: formWithRoute,
|
||||||
|
formFillerId: "user1",
|
||||||
|
chosenRouteId: "route1",
|
||||||
|
isPreview: false,
|
||||||
|
})
|
||||||
|
).rejects.toThrow(/Chosen route is a router/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,6 +3,15 @@ import slugify from "@calcom/lib/slugify";
|
|||||||
import type { FormResponse, NonRouterRoute, Field } from "../types/types";
|
import type { FormResponse, NonRouterRoute, Field } from "../types/types";
|
||||||
import getFieldIdentifier from "./getFieldIdentifier";
|
import getFieldIdentifier from "./getFieldIdentifier";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitues variables in the target URL identified by routeValue with values from response
|
||||||
|
* e.g. {firstName} is replaced with value of the field with identifier firstName
|
||||||
|
*
|
||||||
|
* @param routeValue - The target URL with variables to be substituted
|
||||||
|
* @param response - The form response containing the values to be substituted
|
||||||
|
* @param fields - The fields of the form
|
||||||
|
* @returns The URL with variables substituted
|
||||||
|
*/
|
||||||
export const substituteVariables = (
|
export const substituteVariables = (
|
||||||
routeValue: NonRouterRoute["action"]["value"],
|
routeValue: NonRouterRoute["action"]["value"],
|
||||||
response: FormResponse,
|
response: FormResponse,
|
||||||
|
|||||||
@@ -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: () => {
|
||||||
|
|||||||
+22
@@ -328,4 +328,26 @@ describe("getUrlSearchParamsToForward", () => {
|
|||||||
});
|
});
|
||||||
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Dry Run", () => {
|
||||||
|
it("should add cal.routingFormResponseId=0 when formResponseId is 0", () => {
|
||||||
|
const searchParams = new URLSearchParams("?query1=value1&query2=value2");
|
||||||
|
const expectedParams = {
|
||||||
|
"cal.routingFormResponseId": "0",
|
||||||
|
query1: "value1",
|
||||||
|
query2: "value2",
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getUrlSearchParamsToForward({
|
||||||
|
formResponse: {},
|
||||||
|
fields: [],
|
||||||
|
searchParams,
|
||||||
|
teamMembersMatchingAttributeLogic: null,
|
||||||
|
formResponseId: 0,
|
||||||
|
queuedFormResponseId: null,
|
||||||
|
attributeRoutingConfig: null,
|
||||||
|
});
|
||||||
|
expect(fromEntriesWithDuplicateKeys(result.entries())).toEqual(expectedParams);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,7 +124,9 @@ export function getUrlSearchParamsToForward({
|
|||||||
...(teamMembersMatchingAttributeLogic
|
...(teamMembersMatchingAttributeLogic
|
||||||
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
|
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
|
||||||
: null),
|
: null),
|
||||||
...(formResponseId ? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) } : null),
|
...(typeof formResponseId === "number"
|
||||||
|
? { [ROUTING_FORM_RESPONSE_ID_QUERY_STRING]: String(formResponseId) }
|
||||||
|
: null),
|
||||||
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
|
...(queuedFormResponseId ? { ["cal.queuedFormResponseId"]: queuedFormResponseId } : null),
|
||||||
...attributeRoutingConfigParams,
|
...attributeRoutingConfigParams,
|
||||||
...(reroutingFormResponses
|
...(reroutingFormResponses
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import "@calcom/lib/__mocks__/logger";
|
||||||
|
|
||||||
|
import { describe, it, vi, expect, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
|
import getWebhooks from "@calcom/features/webhooks/lib/getWebhooks";
|
||||||
|
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
|
||||||
|
import { WebhookTriggerEvents } from "@calcom/prisma/client";
|
||||||
|
|
||||||
|
import { _onFormSubmission } from "./utils";
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
vi.mock("@calcom/lib/getOrgIdFromMemberOrTeamId", () => ({
|
||||||
|
default: vi.fn(() => Promise.resolve(1)),
|
||||||
|
}));
|
||||||
|
vi.mock("@calcom/features/webhooks/lib/getWebhooks", () => ({
|
||||||
|
default: vi.fn(() => Promise.resolve([])),
|
||||||
|
}));
|
||||||
|
vi.mock("@calcom/features/webhooks/lib/sendPayload", () => ({
|
||||||
|
sendGenericWebhookPayload: vi.fn(() => Promise.resolve()),
|
||||||
|
}));
|
||||||
|
vi.mock("@calcom/features/tasker", () => {
|
||||||
|
const tasker = {
|
||||||
|
create: vi.fn(() => Promise.resolve()),
|
||||||
|
};
|
||||||
|
return { default: Promise.resolve(tasker) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSendEmail = vi.fn(() => Promise.resolve());
|
||||||
|
const mockResponseEmailConstructor = vi.fn();
|
||||||
|
vi.mock("../emails/templates/response-email", () => ({
|
||||||
|
default: class MockResponseEmail {
|
||||||
|
sendEmail = mockSendEmail;
|
||||||
|
constructor(...args: unknown[]) {
|
||||||
|
mockResponseEmailConstructor(...args);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("_onFormSubmission", () => {
|
||||||
|
const mockForm = {
|
||||||
|
id: "form-1",
|
||||||
|
name: "Test Form",
|
||||||
|
fields: [
|
||||||
|
{ id: "field-1", identifier: "email", label: "Email", type: "email" },
|
||||||
|
{ id: "field-2", identifier: "name", label: "Name", type: "text" },
|
||||||
|
],
|
||||||
|
user: { id: 1, email: "test@example.com" },
|
||||||
|
teamId: null,
|
||||||
|
settings: { emailOwnerOnSubmission: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockResponse = {
|
||||||
|
"field-1": { label: "Email", value: "test@response.com" },
|
||||||
|
"field-2": { label: "Name", value: "Test Name" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const responseId = 123;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Webhooks", () => {
|
||||||
|
it("should call FORM_SUBMITTED webhooks", async () => {
|
||||||
|
vi.mocked(getWebhooks).mockResolvedValueOnce([{ id: "wh-1", secret: "secret" } as any]);
|
||||||
|
|
||||||
|
await _onFormSubmission(mockForm as any, mockResponse, responseId);
|
||||||
|
|
||||||
|
expect(getWebhooks).toHaveBeenCalledWith({
|
||||||
|
userId: 1,
|
||||||
|
teamId: null,
|
||||||
|
orgId: 1,
|
||||||
|
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED,
|
||||||
|
});
|
||||||
|
expect(sendGenericWebhookPayload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should schedule FORM_SUBMITTED_NO_EVENT webhooks via tasker", async () => {
|
||||||
|
const tasker = await (await import("@calcom/features/tasker")).default;
|
||||||
|
const mockWebhook = { id: "wh-no-event-1", secret: "secret" };
|
||||||
|
const chosenAction = { type: "customPageMessage" as const, value: "test" };
|
||||||
|
|
||||||
|
vi.mocked(getWebhooks).mockImplementation(async (options) => {
|
||||||
|
if (options.triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT) {
|
||||||
|
return [mockWebhook as any];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
await _onFormSubmission(mockForm as any, mockResponse, responseId, chosenAction);
|
||||||
|
|
||||||
|
expect(getWebhooks).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
triggerEvent: WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(tasker.create).toHaveBeenCalledWith(
|
||||||
|
"triggerFormSubmittedNoEventWebhook",
|
||||||
|
{
|
||||||
|
responseId,
|
||||||
|
form: mockForm,
|
||||||
|
responses: {
|
||||||
|
email: {
|
||||||
|
value: "test@response.com",
|
||||||
|
response: "test@response.com",
|
||||||
|
},
|
||||||
|
name: { value: "Test Name", response: "Test Name" },
|
||||||
|
},
|
||||||
|
redirect: chosenAction,
|
||||||
|
webhook: mockWebhook,
|
||||||
|
},
|
||||||
|
{ scheduledAt: expect.any(Date) }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Response Email", () => {
|
||||||
|
it("should send response email to team members for a team form", async () => {
|
||||||
|
const teamForm = {
|
||||||
|
...mockForm,
|
||||||
|
teamId: 1,
|
||||||
|
userWithEmails: ["team-member1@example.com", "team-member2@example.com"],
|
||||||
|
user: { id: 1, email: "test@example.com" },
|
||||||
|
};
|
||||||
|
|
||||||
|
await _onFormSubmission(teamForm as any, mockResponse, responseId);
|
||||||
|
|
||||||
|
expect(mockResponseEmailConstructor).toHaveBeenCalledWith({
|
||||||
|
form: teamForm,
|
||||||
|
toAddresses: ["team-member1@example.com", "team-member2@example.com"],
|
||||||
|
orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]],
|
||||||
|
});
|
||||||
|
expect(mockSendEmail).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should send response email to owner when enabled", async () => {
|
||||||
|
const ownerForm = {
|
||||||
|
...mockForm,
|
||||||
|
settings: { emailOwnerOnSubmission: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
await _onFormSubmission(ownerForm as any, mockResponse, responseId);
|
||||||
|
|
||||||
|
expect(mockResponseEmailConstructor).toHaveBeenCalledWith({
|
||||||
|
form: ownerForm,
|
||||||
|
toAddresses: [ownerForm.user.email],
|
||||||
|
orderedResponses: [mockResponse["field-1"], mockResponse["field-2"]],
|
||||||
|
});
|
||||||
|
expect(mockSendEmail).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not send response email to owner when disabled", async () => {
|
||||||
|
const ownerForm = {
|
||||||
|
...mockForm,
|
||||||
|
settings: { emailOwnerOnSubmission: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
await _onFormSubmission(ownerForm as any, mockResponse, responseId);
|
||||||
|
|
||||||
|
expect(mockResponseEmailConstructor).not.toHaveBeenCalled();
|
||||||
|
expect(mockSendEmail).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -85,7 +85,7 @@ export function getFieldResponse({
|
|||||||
* Not called in preview mode or dry run mode
|
* Not called in preview mode or dry run mode
|
||||||
* It takes care of sending webhooks and emails for form submissions
|
* It takes care of sending webhooks and emails for form submissions
|
||||||
*/
|
*/
|
||||||
async function _onFormSubmission(
|
export async function _onFormSubmission(
|
||||||
form: Ensure<
|
form: Ensure<
|
||||||
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
|
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
|
||||||
"fields"
|
"fields"
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,11 @@ Cal.config = Cal.config || {};
|
|||||||
Cal.config.forwardQueryParams=true
|
Cal.config.forwardQueryParams=true
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Enabling Logging
|
||||||
|
You can enable logging for the embed by adding the `cal.embed.logging=1` query parameter to the URL of the page where the embed is placed. This is useful for debugging issues with the embed. It will log all important things in parent as well as in the iframe(i.e. child)
|
||||||
|
|
||||||
|
For example, if your page is `https.example.com/contact`, you can enable logging by visiting `https.example.com/contact?cal.embed.logging=1`.
|
||||||
|
|
||||||
### Advanced Features
|
### Advanced Features
|
||||||
|
|
||||||
#### Routing Prerendering System
|
#### Routing Prerendering System
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user