Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
undecided:
|
||||
- "@calcom/prisma"
|
||||
Vendored
Vendored
Vendored
@@ -38,7 +38,7 @@
|
||||
"next-axiom": "^0.17.0",
|
||||
"next-swagger-doc": "^0.3.6",
|
||||
"next-validations": "^0.2.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"tzdata": "^1.0.30",
|
||||
"uuid": "^8.3.2",
|
||||
"zod": "^3.22.4"
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
"ts-loader": "^9.4.3",
|
||||
"ts-node": "^10.9.1",
|
||||
"tsconfig-paths": "^4.1.0",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"prisma": {
|
||||
"schema": "../../../packages/prisma/schema.prisma"
|
||||
|
||||
@@ -54,6 +54,23 @@ import {
|
||||
} from "@calcom/platform-constants";
|
||||
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({
|
||||
path: "/v2/calendars",
|
||||
version: API_VERSIONS_VALUES,
|
||||
@@ -181,32 +198,31 @@ export class CalendarsController {
|
||||
@Query("code") code: string,
|
||||
@Param("calendar") calendar: string
|
||||
): Promise<{ url: string }> {
|
||||
// state params contains our user access token
|
||||
const stateParams = new URLSearchParams(state);
|
||||
const { accessToken, origin, redir, isDryRun } = z
|
||||
.object({
|
||||
accessToken: z.string(),
|
||||
origin: z.string(),
|
||||
redir: z.string().nullish().optional(),
|
||||
isDryRun: z.string().nullish().optional(),
|
||||
})
|
||||
.parse({
|
||||
let stateObj: CalendarState;
|
||||
|
||||
try {
|
||||
// First try to parse as JSON
|
||||
stateObj = JSON.parse(state) as CalendarState;
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, try URL params
|
||||
const stateParams = new URLSearchParams(state);
|
||||
|
||||
const parsedState = calendarStateSchema.parse({
|
||||
accessToken: stateParams.get("accessToken"),
|
||||
origin: stateParams.get("origin"),
|
||||
redir: stateParams.get("redir"),
|
||||
redir: stateParams.get("redir") || undefined,
|
||||
isDryRun: stateParams.get("isDryRun"),
|
||||
});
|
||||
|
||||
stateObj = parsedState;
|
||||
}
|
||||
|
||||
const { accessToken, origin, redir, isDryRun } = stateObj;
|
||||
switch (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:
|
||||
return await this.googleCalendarService.save(
|
||||
code,
|
||||
accessToken,
|
||||
origin,
|
||||
redir ?? "",
|
||||
isDryRun === "true"
|
||||
);
|
||||
return await this.googleCalendarService.save(code, accessToken, origin, redir ?? "", !!isDryRun);
|
||||
default:
|
||||
throw new BadRequestException(
|
||||
"Invalid calendar type, available calendars are: ",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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 { AppsRepository } from "@/modules/apps/apps.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) {
|
||||
const oAuth2Client = await this.getOAuthClient(this.redirectUri);
|
||||
const state: CalendarState = {
|
||||
accessToken,
|
||||
origin,
|
||||
redir,
|
||||
isDryRun,
|
||||
};
|
||||
|
||||
const authUrl = oAuth2Client.generateAuthUrl({
|
||||
access_type: "offline",
|
||||
scope: CALENDAR_SCOPES,
|
||||
prompt: "consent",
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
state: JSON.stringify(state),
|
||||
});
|
||||
|
||||
return authUrl;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { OAuthCalendarApp } from "@/ee/calendars/calendars.interface";
|
||||
import { CalendarState } from "@/ee/calendars/controllers/calendars.controller";
|
||||
import { CalendarsService } from "@/ee/calendars/services/calendars.service";
|
||||
import { CredentialsRepository } from "@/modules/credentials/credentials.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) {
|
||||
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 params = {
|
||||
response_type: "code",
|
||||
@@ -67,7 +75,7 @@ export class OutlookService implements OAuthCalendarApp {
|
||||
client_id,
|
||||
prompt: "select_account",
|
||||
redirect_uri: this.redirectUri,
|
||||
state: `accessToken=${accessToken}&origin=${origin}&redir=${redir ?? ""}&isDryRun=${isDryRun}`,
|
||||
state: JSON.stringify(state),
|
||||
};
|
||||
|
||||
const query = stringify(params);
|
||||
|
||||
@@ -6,6 +6,100 @@ import { ERROR_STATUS } from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { Response } from "@calcom/platform-types";
|
||||
|
||||
export type TRPCErrorCode = TRPCError["code"];
|
||||
|
||||
export interface ErrorDefinition {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Define the specific TRPC error codes
|
||||
export const TRPC_ERROR_CODE = {
|
||||
UNAUTHORIZED: "UNAUTHORIZED",
|
||||
FORBIDDEN: "FORBIDDEN",
|
||||
NOT_FOUND: "NOT_FOUND",
|
||||
INTERNAL_SERVER_ERROR: "INTERNAL_SERVER_ERROR",
|
||||
BAD_REQUEST: "BAD_REQUEST",
|
||||
CONFLICT: "CONFLICT",
|
||||
TOO_MANY_REQUESTS: "TOO_MANY_REQUESTS",
|
||||
PAYLOAD_TOO_LARGE: "PAYLOAD_TOO_LARGE",
|
||||
CLIENT_CLOSED_REQUEST: "CLIENT_CLOSED_REQUEST",
|
||||
METHOD_NOT_SUPPORTED: "METHOD_NOT_SUPPORTED",
|
||||
NOT_IMPLEMENTED: "NOT_IMPLEMENTED",
|
||||
PRECONDITION_FAILED: "PRECONDITION_FAILED",
|
||||
TIMEOUT: "TIMEOUT",
|
||||
UNPROCESSABLE_CONTENT: "UNPROCESSABLE_CONTENT",
|
||||
PARSE_ERROR: "PARSE_ERROR",
|
||||
} as const; // `as const` ensures literal types, improving type inference
|
||||
|
||||
// Map TRPC error codes to their HTTP status codes and default messages
|
||||
export const TRPC_ERROR_MAP: Record<(typeof TRPC_ERROR_CODE)[keyof typeof TRPC_ERROR_CODE], ErrorDefinition> =
|
||||
{
|
||||
[TRPC_ERROR_CODE.UNAUTHORIZED]: {
|
||||
statusCode: 401,
|
||||
message: "You are not authorized to access this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.FORBIDDEN]: {
|
||||
statusCode: 403,
|
||||
message: "You don't have necessary permissions to access this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.NOT_FOUND]: {
|
||||
statusCode: 404,
|
||||
message: "The requested resource was not found",
|
||||
},
|
||||
[TRPC_ERROR_CODE.INTERNAL_SERVER_ERROR]: {
|
||||
statusCode: 500,
|
||||
message: "An unexpected error occurred on the server. Please try again later",
|
||||
},
|
||||
[TRPC_ERROR_CODE.BAD_REQUEST]: {
|
||||
statusCode: 400,
|
||||
message: "Bad request: Please check your request data and try again",
|
||||
},
|
||||
[TRPC_ERROR_CODE.CONFLICT]: {
|
||||
statusCode: 409,
|
||||
message: "Could not process the request due to a resource conflict with the current state",
|
||||
},
|
||||
[TRPC_ERROR_CODE.TOO_MANY_REQUESTS]: {
|
||||
statusCode: 429,
|
||||
message: "You have exceeded the allowed number of requests",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PAYLOAD_TOO_LARGE]: {
|
||||
statusCode: 413,
|
||||
message: "The request payload is too large",
|
||||
},
|
||||
[TRPC_ERROR_CODE.CLIENT_CLOSED_REQUEST]: {
|
||||
statusCode: 499,
|
||||
message: "The client closed the connection before the server could finish processing the request",
|
||||
},
|
||||
[TRPC_ERROR_CODE.METHOD_NOT_SUPPORTED]: {
|
||||
statusCode: 405,
|
||||
message: "The requested method is not supported for this resource",
|
||||
},
|
||||
[TRPC_ERROR_CODE.NOT_IMPLEMENTED]: {
|
||||
statusCode: 501,
|
||||
message: "The requested method is not implemented on the server",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PRECONDITION_FAILED]: {
|
||||
statusCode: 412,
|
||||
message: "The server does not meet one of the preconditions that the requester put on the request",
|
||||
},
|
||||
[TRPC_ERROR_CODE.TIMEOUT]: {
|
||||
statusCode: 408,
|
||||
message: "The request took too long to complete. Please try again later",
|
||||
},
|
||||
[TRPC_ERROR_CODE.UNPROCESSABLE_CONTENT]: {
|
||||
statusCode: 422,
|
||||
message:
|
||||
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect",
|
||||
},
|
||||
[TRPC_ERROR_CODE.PARSE_ERROR]: {
|
||||
statusCode: 400,
|
||||
message: "The request could not be parsed due to invalid syntax.",
|
||||
},
|
||||
};
|
||||
|
||||
// --- TRPCExceptionFilter (updated) ---
|
||||
|
||||
@Catch(TRPCError)
|
||||
export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger("TRPCExceptionFilter");
|
||||
@@ -15,77 +109,13 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest<Request>();
|
||||
|
||||
let statusCode = 500;
|
||||
let errorMessage = exception.message;
|
||||
const errorDefinition = TRPC_ERROR_MAP[exception.code as keyof typeof TRPC_ERROR_CODE] || {
|
||||
statusCode: 500,
|
||||
message: exception.message,
|
||||
};
|
||||
|
||||
switch (exception.code) {
|
||||
case "UNAUTHORIZED":
|
||||
statusCode = 401;
|
||||
errorMessage = "You are not authorized to access this resource";
|
||||
break;
|
||||
case "FORBIDDEN":
|
||||
statusCode = 403;
|
||||
errorMessage = "You don't have necessary permissions to access this resource";
|
||||
break;
|
||||
case "NOT_FOUND":
|
||||
statusCode = 404;
|
||||
errorMessage = "The requested resource was not found";
|
||||
break;
|
||||
case "INTERNAL_SERVER_ERROR":
|
||||
statusCode = 500;
|
||||
errorMessage = "An unexpected error occurred on the server. Please try again later";
|
||||
break;
|
||||
case "BAD_REQUEST":
|
||||
statusCode = 400;
|
||||
errorMessage = "Bad request: Please check your request data and try again";
|
||||
break;
|
||||
case "CONFLICT":
|
||||
statusCode = 409;
|
||||
errorMessage = "Could not process the request due to a resource conflict with the current state";
|
||||
break;
|
||||
case "TOO_MANY_REQUESTS":
|
||||
statusCode = 429;
|
||||
errorMessage = "You have exceeded the allowed number of requests";
|
||||
break;
|
||||
case "PAYLOAD_TOO_LARGE":
|
||||
statusCode = 413;
|
||||
errorMessage = "The request payload is too large";
|
||||
break;
|
||||
case "CLIENT_CLOSED_REQUEST":
|
||||
statusCode = 499;
|
||||
errorMessage =
|
||||
"The client closed the connection before the server could finish processing the request";
|
||||
break;
|
||||
case "METHOD_NOT_SUPPORTED":
|
||||
statusCode = 405;
|
||||
errorMessage = "The requested method is not supported for this resource";
|
||||
case "NOT_IMPLEMENTED":
|
||||
statusCode = 501;
|
||||
errorMessage = "The requested method is not implemented on the server";
|
||||
break;
|
||||
case "PRECONDITION_FAILED":
|
||||
statusCode = 412;
|
||||
errorMessage =
|
||||
"The server does not meet one of the preconditions that the requester put on the request";
|
||||
break;
|
||||
case "TIMEOUT":
|
||||
statusCode = 408;
|
||||
errorMessage = "The request took too long to complete. Please try again later";
|
||||
break;
|
||||
case "UNPROCESSABLE_CONTENT":
|
||||
statusCode = 422;
|
||||
errorMessage =
|
||||
"The server was unable to process the request because the request payload is semantically incorrect or because the request is syntactically incorrect";
|
||||
break;
|
||||
case "PARSE_ERROR":
|
||||
statusCode = 400;
|
||||
errorMessage = "The request could not be parsed due to invalid syntax.";
|
||||
break;
|
||||
default:
|
||||
statusCode = 500;
|
||||
errorMessage = exception.message;
|
||||
break;
|
||||
}
|
||||
const statusCode = errorDefinition.statusCode;
|
||||
const errorMessage = errorDefinition.message;
|
||||
|
||||
const requestId = request.headers["X-Request-Id"] ?? "unknown-request-id";
|
||||
response.setHeader("X-Request-Id", requestId.toString());
|
||||
@@ -103,7 +133,7 @@ export class TRPCExceptionFilter implements ExceptionFilter {
|
||||
status: ERROR_STATUS,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: request.url,
|
||||
error: { code: exception.name, message: errorMessage },
|
||||
error: { code: exception.name, message: errorMessage }, // exception.name is usually "TRPCError"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +211,18 @@ export class CalendarEventHost {
|
||||
description: "Display name of the event host",
|
||||
})
|
||||
name?: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
@IsEnum(CalendarEventResponseStatus)
|
||||
@ApiPropertyOptional({
|
||||
enum: CalendarEventResponseStatus,
|
||||
nullable: true,
|
||||
enumName: "CalendarEventResponseStatus",
|
||||
description: "Host's response to the invitation",
|
||||
example: CalendarEventResponseStatus.ACCEPTED,
|
||||
})
|
||||
responseStatus!: CalendarEventResponseStatus | null;
|
||||
}
|
||||
|
||||
export class CalendarEventAttendee {
|
||||
|
||||
+20
-20
@@ -159,28 +159,28 @@ export class GoogleCalendarEventOutputPipe
|
||||
}
|
||||
}
|
||||
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse): Array<{ email: string; name?: string }> {
|
||||
if (googleEvent.organizer) {
|
||||
return [
|
||||
{
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
},
|
||||
];
|
||||
} else if (googleEvent.attendees) {
|
||||
// If no explicit organizer, find the first attendee with organizer flag
|
||||
const organizerAttendee = googleEvent.attendees.find((attendee) => attendee.organizer);
|
||||
if (organizerAttendee) {
|
||||
return [
|
||||
{
|
||||
email: organizerAttendee.email,
|
||||
name: organizerAttendee.displayName,
|
||||
},
|
||||
];
|
||||
}
|
||||
private transformHosts(googleEvent: GoogleCalendarEventResponse) {
|
||||
const hosts: Array<{ email: string; name?: string; responseStatus: CalendarEventResponseStatus | null }> =
|
||||
[];
|
||||
|
||||
const organizerAttendees = googleEvent?.attendees?.filter((attendee) => attendee.organizer);
|
||||
if (organizerAttendees?.length) {
|
||||
organizerAttendees.forEach((organizer) => {
|
||||
hosts.push({
|
||||
email: organizer.email,
|
||||
name: organizer.displayName,
|
||||
responseStatus: this.transformAttendeeResponseStatus(organizer.responseStatus),
|
||||
});
|
||||
});
|
||||
} else {
|
||||
hosts.push({
|
||||
email: googleEvent.organizer.email,
|
||||
name: googleEvent.organizer.displayName,
|
||||
responseStatus: null,
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
return hosts;
|
||||
}
|
||||
|
||||
private transformLocations(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
|
||||
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { Prisma } from "@prisma/client";
|
||||
|
||||
import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
|
||||
@@ -8,12 +9,26 @@ import { GOOGLE_MEET_TYPE } from "@calcom/platform-constants";
|
||||
export class ConferencingRepository {
|
||||
constructor(private readonly dbRead: PrismaReadService, private readonly dbWrite: PrismaWriteService) {}
|
||||
|
||||
private readonly credentialSelect = Prisma.validator<Prisma.CredentialSelect>()({
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
teamId: true,
|
||||
appId: true,
|
||||
subscriptionId: true,
|
||||
paymentStatus: true,
|
||||
billingCycleStart: true,
|
||||
invalid: true,
|
||||
delegationCredentialId: true,
|
||||
});
|
||||
|
||||
async findConferencingApps(userId: number) {
|
||||
return this.dbRead.prisma.credential.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -23,24 +38,28 @@ export class ConferencingRepository {
|
||||
teamId,
|
||||
type: { endsWith: "_video" },
|
||||
},
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findGoogleMeet(userId: number) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, type: GOOGLE_MEET_TYPE },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findConferencingApp(userId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { userId, appId: app },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
|
||||
async findTeamConferencingApp(teamId: number, app: string) {
|
||||
return this.dbRead.prisma.credential.findFirst({
|
||||
where: { teamId, appId: app },
|
||||
select: this.credentialSelect,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { TRPC_ERROR_CODE, TRPC_ERROR_MAP, TRPCErrorCode } from "@/filters/trpc-exception.filter";
|
||||
import { SlotsOutputService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import type { RangeSlots, TimeSlots } from "@/modules/slots/slots-2024-04-15/services/slots-output.service";
|
||||
import { SlotsWorkerService_2024_04_15 } from "@/modules/slots/slots-2024-04-15/services/slots-worker.service";
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
VERSION_2024_06_11,
|
||||
VERSION_2024_08_13,
|
||||
} from "@calcom/platform-constants";
|
||||
import { TRPCError } from "@calcom/platform-libraries";
|
||||
import { getAvailableSlots } from "@calcom/platform-libraries/slots";
|
||||
import { RemoveSelectedSlotInput_2024_04_15, ReserveSlotInput_2024_04_15 } from "@calcom/platform-types";
|
||||
import { ApiResponse, GetAvailableSlotsInput_2024_04_15 } from "@calcom/platform-types";
|
||||
@@ -209,7 +211,12 @@ export class SlotsController_2024_04_15 {
|
||||
"Invalid time range given - check the 'startTime' and 'endTime' query parameters."
|
||||
);
|
||||
}
|
||||
|
||||
if (TRPC_ERROR_MAP[error.message as keyof typeof TRPC_ERROR_CODE]) {
|
||||
throw new TRPCError({ code: error.message as TRPCErrorCode });
|
||||
}
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26400,7 +26400,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": [
|
||||
"accepted",
|
||||
"pending",
|
||||
@@ -26460,6 +26460,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -2,6 +2,8 @@ import { withAppDirSsr } from "app/WithAppDirSsr";
|
||||
import type { PageProps as ServerPageProps } from "app/_types";
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { buildLegacyCtx } from "@lib/buildLegacyCtx";
|
||||
|
||||
@@ -21,11 +23,18 @@ export const generateMetadata = async () => {
|
||||
};
|
||||
|
||||
const getData = withAppDirSsr<ClientPageProps>(getServerSideProps);
|
||||
const stepSchema = z.enum(["1", "2", "3", "4"]);
|
||||
|
||||
const ServerPage = async ({ params, searchParams }: ServerPageProps) => {
|
||||
const props = await getData(
|
||||
buildLegacyCtx(await headers(), await cookies(), await params, await searchParams)
|
||||
);
|
||||
const ServerPage = async ({ params, searchParams: _searchParams }: ServerPageProps) => {
|
||||
const searchParams = await _searchParams;
|
||||
const stepResult = stepSchema.safeParse(searchParams?.step);
|
||||
|
||||
// If step parameter is invalid, redirect to step 1
|
||||
if (!stepResult.success) {
|
||||
return redirect(`/auth/setup?step=1`);
|
||||
}
|
||||
|
||||
const props = await getData(buildLegacyCtx(await headers(), await cookies(), await params, searchParams));
|
||||
return <Setup {...props} />;
|
||||
};
|
||||
|
||||
|
||||
@@ -46,13 +46,13 @@ async function postHandler(req: NextRequest) {
|
||||
if (!log.smsSid) return;
|
||||
|
||||
try {
|
||||
const price = await twilio.getPriceForSMS(log.smsSid);
|
||||
const { price, numSegments } = await twilio.getMessageInfo(log.smsSid);
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
if (!credits) return;
|
||||
|
||||
const updatedLog = await prisma.creditExpenseLog.update({
|
||||
where: { id: log.id },
|
||||
data: { credits },
|
||||
data: { credits, smsSegments: numSegments },
|
||||
select: {
|
||||
creditBalance: {
|
||||
select: {
|
||||
|
||||
@@ -105,7 +105,7 @@ const CustomEmailTextField = ({
|
||||
<DropdownItem
|
||||
StartIcon="trash"
|
||||
color="destructive"
|
||||
className="disabled:opacity-40"
|
||||
className="rounded-t-none disabled:opacity-40"
|
||||
onClick={handleItemDelete}
|
||||
disabled={emailPrimary}
|
||||
data-testid="secondary-email-delete-button">
|
||||
|
||||
@@ -23,6 +23,8 @@ type CalVideoSettings = {
|
||||
disableRecordingForGuests: boolean;
|
||||
disableRecordingForOrganizer: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
disableTranscriptionForGuests: boolean;
|
||||
disableTranscriptionForOrganizer: boolean;
|
||||
};
|
||||
|
||||
const shouldEnableRecordButton = ({
|
||||
@@ -57,6 +59,25 @@ const shouldEnableAutomaticTranscription = ({
|
||||
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 ({
|
||||
booking,
|
||||
sessionUserId,
|
||||
@@ -222,6 +243,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
});
|
||||
const showTranscriptionButton = shouldEnableTranscriptionButton({
|
||||
hasTeamPlan: !!hasTeamPlan,
|
||||
calVideoSettings: bookingObj.eventType?.calVideoSettings,
|
||||
isOrganizer: sessionUserId === bookingObj.user?.id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
@@ -245,6 +271,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit: isOrganizer
|
||||
? undefined
|
||||
: bookingObj.eventType?.calVideoSettings?.redirectUrlOnExit,
|
||||
|
||||
@@ -234,7 +234,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
orgSlug: currentOrgDomain,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
profile,
|
||||
eventType,
|
||||
|
||||
@@ -315,17 +315,20 @@ export default function Success(props: PageProps) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
if (isAttendee) {
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
return t(`${titlePrefix}emailed_host_and_attendee${titleSuffix}`, {
|
||||
host,
|
||||
attendee,
|
||||
interpolation: { escapeValue: false },
|
||||
});
|
||||
}
|
||||
return t(`emailed_host_and_attendee${titleSuffix}`);
|
||||
@@ -546,7 +549,7 @@ export default function Success(props: PageProps) {
|
||||
</h4>
|
||||
)}
|
||||
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 border-t pt-8 text-left rtl:text-right">
|
||||
<div className="border-subtle text-default mt-8 grid grid-cols-3 gap-x-4 border-t pt-8 text-left rtl:text-right sm:gap-x-0">
|
||||
{(isCancelled || reschedule) && cancellationReason && (
|
||||
<>
|
||||
<div className="font-medium">
|
||||
|
||||
@@ -653,7 +653,7 @@ export const InfiniteEventTypeList = ({
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
className="w-full rounded-t-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
@@ -750,7 +750,7 @@ export const InfiniteEventTypeList = ({
|
||||
setDeleteDialogSchedulingType(type.schedulingType);
|
||||
}}
|
||||
StartIcon="trash"
|
||||
className="w-full rounded-none">
|
||||
className="w-full rounded-t-none">
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -3,22 +3,60 @@
|
||||
import { ProgressBar } from "@tremor/react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import ServerTrans from "@calcom/lib/components/ServerTrans";
|
||||
import { IS_SMS_CREDITS_ENABLED } from "@calcom/lib/constants";
|
||||
import { downloadAsCsv } from "@calcom/lib/csvUtils";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Select } from "@calcom/ui/components/form";
|
||||
import { TextField, Label, InputError } from "@calcom/ui/components/form";
|
||||
import { showToast } from "@calcom/ui/toast";
|
||||
|
||||
import { BillingCreditsSkeleton } from "./BillingCreditsSkeleton";
|
||||
|
||||
type MonthOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
};
|
||||
|
||||
// returns the last 12 months starting from May 2025 (when credits were introduced)
|
||||
const getMonthOptions = (): MonthOption[] => {
|
||||
const options: MonthOption[] = [];
|
||||
const minDate = dayjs.utc("2025-05-01");
|
||||
|
||||
let date = dayjs.utc();
|
||||
let count = 0;
|
||||
while ((date.isAfter(minDate) || date.isSame(minDate, "month")) && count < 12) {
|
||||
const startDate = date.startOf("month");
|
||||
const endDate = date.endOf("month");
|
||||
options.push({
|
||||
value: date.format("MMMM YYYY"),
|
||||
label: date.format("MMMM YYYY"),
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
});
|
||||
date = date.subtract(1, "month");
|
||||
count++;
|
||||
}
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
export default function BillingCredits() {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const monthOptions = useMemo(() => getMonthOptions(), []);
|
||||
const [selectedMonth, setSelectedMonth] = useState<MonthOption>(monthOptions[0]);
|
||||
const [isDownloading, setIsDownloading] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -28,8 +66,8 @@ export default function BillingCredits() {
|
||||
} = useForm<{ quantity: number }>({ defaultValues: { quantity: 50 } });
|
||||
|
||||
const params = useParamsWithFallback();
|
||||
|
||||
const teamId = params.id ? Number(params.id) : undefined;
|
||||
|
||||
const { data: creditsData, isLoading } = trpc.viewer.credits.getAllCredits.useQuery({ teamId });
|
||||
|
||||
const buyCreditsMutation = trpc.viewer.credits.buyCredits.useMutation({
|
||||
@@ -43,6 +81,27 @@ export default function BillingCredits() {
|
||||
},
|
||||
});
|
||||
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const result = await utils.viewer.credits.downloadExpenseLog.fetch({
|
||||
teamId,
|
||||
startDate: selectedMonth.startDate,
|
||||
endDate: selectedMonth.endDate,
|
||||
});
|
||||
if (result?.csvData) {
|
||||
const filename = `credit-expense-log-${selectedMonth.value.toLowerCase().replace(" ", "-")}.csv`;
|
||||
downloadAsCsv(result.csvData, filename);
|
||||
} else {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(t("error_downloading_expense_log"), "error");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!IS_SMS_CREDITS_ENABLED) {
|
||||
return null;
|
||||
}
|
||||
@@ -112,8 +171,8 @@ export default function BillingCredits() {
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="flex">
|
||||
<div className="mr-auto">
|
||||
<div className="mb-2 font-semibold">{t("buy_additional_credits")}</div>
|
||||
<div className="-mb-1 mr-auto">
|
||||
<Label>{t("buy_additional_credits")}</Label>
|
||||
<div className="flex flex-col">
|
||||
<TextField
|
||||
required
|
||||
@@ -132,12 +191,32 @@ export default function BillingCredits() {
|
||||
{errors.quantity && <InputError message={errors.quantity.message ?? t("invalid_input")} />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 mt-auto">
|
||||
<div className="mt-auto">
|
||||
<Button color="primary" target="_blank" EndIcon="external-link" type="submit">
|
||||
{t("buy_credits")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="mr-auto ">
|
||||
<Label className="mb-4">{t("download_expense_log")}</Label>
|
||||
<div className="mt-2 flex flex-col">
|
||||
<Select
|
||||
options={monthOptions}
|
||||
value={selectedMonth}
|
||||
onChange={(option) => option && setSelectedMonth(option)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-auto">
|
||||
<Button onClick={handleDownload} loading={isDownloading} StartIcon="file-down">
|
||||
{t("download")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,7 @@ export function BillingCreditsSkeleton() {
|
||||
<div className="flex">
|
||||
<div className="mr-auto">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 mt-1 h-5 w-[268px]" /> {/* Buy credits label */}
|
||||
<SkeletonText className="mb-2 h-4 w-[268px]" /> {/* Buy credits label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[240px] rounded-md" /> {/* Input field */}
|
||||
@@ -56,6 +56,24 @@ export function BillingCreditsSkeleton() {
|
||||
<SkeletonButton className="h-9 w-[120px] rounded-md" /> {/* Buy button */}
|
||||
</div>
|
||||
</div>
|
||||
<div className="-mx-6 mb-6 mt-6">
|
||||
<hr className="border-subtle mb-3 mt-3" />
|
||||
</div>
|
||||
{/* Download expense log */}
|
||||
<div className="flex">
|
||||
<div className="mr-auto mt-1">
|
||||
<div>
|
||||
<SkeletonText className="mb-2 h-4 w-[156px]" /> {/* Label */}
|
||||
</div>
|
||||
<div>
|
||||
<SkeletonText className="mt-2 h-8 w-[156px] rounded-md" /> {/* Select */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto ">
|
||||
<SkeletonButton className="h-9 w-[109px] rounded-md" /> {/* Download button */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SkeletonContainer>
|
||||
|
||||
@@ -73,9 +73,11 @@ export interface DailyCustomTrayButton {
|
||||
export const CalAiTranscribe = ({
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
}: {
|
||||
showRecordingButton: boolean;
|
||||
enableAutomaticTranscription: boolean;
|
||||
showTranscriptionButton: boolean;
|
||||
}) => {
|
||||
const daily = useDaily();
|
||||
const { t } = useLocale();
|
||||
@@ -101,7 +103,11 @@ export const CalAiTranscribe = ({
|
||||
recording,
|
||||
}
|
||||
: {}),
|
||||
transcription,
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default function JoinCall(props: PageProps) {
|
||||
overrideName,
|
||||
showRecordingButton,
|
||||
enableAutomaticTranscription,
|
||||
showTranscriptionButton,
|
||||
rediectAttendeeToOnExit,
|
||||
} = props;
|
||||
const [daily, setDaily] = useState<DailyCall | null>(null);
|
||||
@@ -80,12 +81,16 @@ export default function JoinCall(props: PageProps) {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
...(showTranscriptionButton
|
||||
? {
|
||||
transcription: {
|
||||
label: "Cal.ai",
|
||||
tooltip: "Transcription powered by AI",
|
||||
iconPath: TRANSCRIPTION_STOPPED_ICON,
|
||||
iconPathDarkMode: TRANSCRIPTION_STOPPED_ICON,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -114,6 +119,7 @@ export default function JoinCall(props: PageProps) {
|
||||
<CalAiTranscribe
|
||||
showRecordingButton={showRecordingButton}
|
||||
enableAutomaticTranscription={enableAutomaticTranscription}
|
||||
showTranscriptionButton={showTranscriptionButton}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ zIndex: 2, position: "relative" }}>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "5.4.2",
|
||||
"version": "5.4.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -194,7 +194,7 @@
|
||||
"tailwindcss": "^3.3.3",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"nextBundleAnalysis": {
|
||||
"budget": 358400,
|
||||
|
||||
@@ -121,7 +121,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
if (!orgId) {
|
||||
orgId = await getOrgIdFromMemberOrTeamId({
|
||||
memberId: parsedUserId,
|
||||
...(!parsedTeamId ? { memberId: parsedUserId } : {}),
|
||||
teamId: parsedTeamId,
|
||||
});
|
||||
}
|
||||
@@ -137,7 +137,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
return res.status(200).send(`SMS are free for organizations. Credits set to 0`);
|
||||
}
|
||||
|
||||
const price = await twilio.getPriceForSMS(smsSid);
|
||||
const { price, numSegments } = await twilio.getMessageInfo(smsSid);
|
||||
|
||||
const credits = price ? creditService.calculateCreditsFromPrice(price) : null;
|
||||
|
||||
@@ -147,6 +147,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
userId: parsedUserId,
|
||||
smsSid,
|
||||
bookingUid: parsedBookingUid,
|
||||
smsSegments: numSegments ?? undefined,
|
||||
});
|
||||
|
||||
if (chargedUserOrTeamId) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { randomString } from "@calcom/lib/random";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
bookTimeSlot,
|
||||
createNewEventType,
|
||||
createNewUserEventType,
|
||||
gotoBookingPage,
|
||||
gotoFirstEventType,
|
||||
saveEventType,
|
||||
@@ -55,7 +55,7 @@ test.describe("Event Types tests", () => {
|
||||
test("can add new event type", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await expect(page.locator(`text='${eventTitle}'`)).toBeVisible();
|
||||
});
|
||||
@@ -63,7 +63,7 @@ test.describe("Event Types tests", () => {
|
||||
test("new event type appears first in the list", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `hello ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
const firstEvent = page.locator("[data-testid=event-types] > li a").first();
|
||||
const firstEventTitle = await firstEvent.getAttribute("title");
|
||||
@@ -73,7 +73,7 @@ test.describe("Event Types tests", () => {
|
||||
test("enabling recurring event comes with default options", async ({ page }) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `my recurring event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
|
||||
// fix the race condition
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
@@ -393,7 +393,7 @@ test.describe("Event Types tests", () => {
|
||||
}) => {
|
||||
const nonce = randomString(3);
|
||||
const eventTitle = `Conflict event ${nonce}`;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.goto("/event-types");
|
||||
await page.click(`text=${eventTitle}`);
|
||||
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { loginUser } from "playwright/fixtures/regularBookings";
|
||||
|
||||
import { MembershipRole } from "@calcom/prisma/enums";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
test.describe("AI Translation - Event Type", () => {
|
||||
test("Toggle should not be clickable for free plan members", async ({ page, users, bookingPage }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("Toggle should not be clickable for team plan members", async ({ page, users, bookingPage }) => {
|
||||
const user = await users.create(
|
||||
{ name: "testuser" },
|
||||
{
|
||||
hasTeam: true,
|
||||
teamRole: MembershipRole.MEMBER,
|
||||
}
|
||||
);
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(true);
|
||||
});
|
||||
|
||||
test("Toggle should be clickable for org members", async ({ page, orgs, users, bookingPage }) => {
|
||||
const org = await orgs.create({
|
||||
name: "acme",
|
||||
});
|
||||
const user = await users.create({
|
||||
organizationId: org.id,
|
||||
roleInOrganization: MembershipRole.MEMBER,
|
||||
});
|
||||
await user.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
expect(await bookingPage.getAITranslationToggleDisabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -198,5 +198,19 @@ export function createBookingPageFixture(page: Page) {
|
||||
checkTimeSlotsCount: async (eventTypePage: Page, count: number) => {
|
||||
await expect(eventTypePage.getByTestId("time")).toHaveCount(count);
|
||||
},
|
||||
getAITranslationToggleDisabled: async () => {
|
||||
await page.waitForSelector('[data-testid="ai_translation_toggle"]', {
|
||||
timeout: 5000,
|
||||
state: "attached",
|
||||
});
|
||||
const toggle = page.getByTestId("ai_translation_toggle");
|
||||
return (await toggle.getAttribute("disabled")) !== null;
|
||||
},
|
||||
toggleAITranslation: async () => {
|
||||
await page.getByTestId("ai_translation_toggle").click();
|
||||
},
|
||||
updateEventTypeDescription: async (description: string) => {
|
||||
await page.getByTestId("editor-input").fill(description);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -7,12 +7,14 @@ import { createServer } from "http";
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import type { Messages } from "mailhog";
|
||||
import { totp } from "otplib";
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import type { IntervalLimit } from "@calcom/lib/intervalLimits/intervalLimitSchema";
|
||||
import type { Prisma } from "@calcom/prisma/client";
|
||||
import { BookingStatus, SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import type { CreateUsersFixture } from "../fixtures/users";
|
||||
import type { Fixtures } from "./fixtures";
|
||||
|
||||
type Request = IncomingMessage & { body?: unknown };
|
||||
@@ -177,10 +179,12 @@ export async function expectSlotNotAllowedToBook(page: Page) {
|
||||
await expect(page.locator("[data-testid=slot-not-allowed-to-book]")).toBeVisible();
|
||||
}
|
||||
|
||||
export const createNewEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
export const createNewUserEventType = async (page: Page, args: { eventTitle: string; username?: string }) => {
|
||||
await page.click("[data-testid=new-event-type]");
|
||||
const eventTitle = args.eventTitle;
|
||||
await page.fill("[name=title]", eventTitle);
|
||||
if (args.username) {
|
||||
await page.getByRole("button", { name: args.username }).click();
|
||||
}
|
||||
await page.fill("[name=title]", args.eventTitle);
|
||||
await page.fill("[name=length]", "10");
|
||||
await page.click("[type=submit]");
|
||||
|
||||
@@ -216,7 +220,7 @@ export async function setupManagedEvent({
|
||||
|
||||
export const createNewSeatedEventType = async (page: Page, args: { eventTitle: string }) => {
|
||||
const eventTitle = args.eventTitle;
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
"aria-current",
|
||||
@@ -563,3 +567,26 @@ export async function expectPageToBeNotFound({ page, url }: { page: Page; url: s
|
||||
await page.goto(`${url}`);
|
||||
await expect(page.getByTestId(`404-page`)).toBeVisible();
|
||||
}
|
||||
|
||||
export async function setupOrgMember(users: CreateUsersFixture) {
|
||||
const orgRequestedSlug = `example-${uuid()}`;
|
||||
|
||||
const orgMember = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
orgRequestedSlug,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team: org } = await orgMember.getOrgMembership();
|
||||
const { team } = await orgMember.getFirstTeamMembership();
|
||||
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
|
||||
const userEvent = orgMember.eventTypes[0];
|
||||
|
||||
await orgMember.apiLogin();
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import type { CalendarEvent } from "@calcom/types/Calendar";
|
||||
import { test } from "./lib/fixtures";
|
||||
import {
|
||||
createHttpServer,
|
||||
createNewEventType,
|
||||
createNewUserEventType,
|
||||
selectFirstAvailableTimeSlotNextMonth,
|
||||
submitAndWaitForResponse,
|
||||
} from "./lib/testUtils";
|
||||
@@ -752,7 +752,7 @@ test.describe("Text area min and max characters text", () => {
|
||||
|
||||
// We wait until loading is finished
|
||||
await page.waitForSelector('[data-testid="event-types"]');
|
||||
await createNewEventType(page, { eventTitle });
|
||||
await createNewUserEventType(page, { eventTitle });
|
||||
await page.waitForSelector('[data-testid="event-title"]');
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
|
||||
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
|
||||
|
||||
@@ -1,36 +1,9 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { doOnOrgDomain } from "playwright/lib/testUtils";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import { doOnOrgDomain, setupOrgMember } from "playwright/lib/testUtils";
|
||||
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
|
||||
import type { CreateUsersFixture } from "../fixtures/users";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
async function setupOrgMember(users: CreateUsersFixture) {
|
||||
const orgRequestedSlug = `example-${uuid()}`;
|
||||
|
||||
const orgMember = await users.create(undefined, {
|
||||
hasTeam: true,
|
||||
isOrg: true,
|
||||
hasSubteam: true,
|
||||
isOrgVerified: true,
|
||||
isDnsSetup: true,
|
||||
orgRequestedSlug,
|
||||
schedulingType: SchedulingType.ROUND_ROBIN,
|
||||
});
|
||||
|
||||
const { team: org } = await orgMember.getOrgMembership();
|
||||
const { team } = await orgMember.getFirstTeamMembership();
|
||||
const teamEvent = await orgMember.getFirstTeamEvent(team.id);
|
||||
const userEvent = orgMember.eventTypes[0];
|
||||
|
||||
await orgMember.apiLogin();
|
||||
|
||||
return { orgMember, org, team, teamEvent, userEvent };
|
||||
}
|
||||
|
||||
type TestContext = Awaited<ReturnType<typeof setupOrgMember>>;
|
||||
|
||||
interface ToggleSeoSwitchParams {
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
"disable_recording_for_guests": "Disable recording for guests",
|
||||
"enable_automatic_transcription": "Enable automatic transcription after joining the meeting",
|
||||
"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_cancelled": "Your scheduled event was canceled",
|
||||
"event_request_reassigned": "Your scheduled event was reassigned",
|
||||
@@ -265,6 +267,8 @@
|
||||
"guests": "Guests",
|
||||
"guest": "Guest",
|
||||
"web_conferencing_details_to_follow": "Web conferencing details to follow in the confirmation email.",
|
||||
"confirmation":"Confirmation",
|
||||
"what_booker_should_provide": "What your booker should provide to receive confirmations",
|
||||
"404_the_user": "The username",
|
||||
"username": "Username",
|
||||
"is_still_available": "is still available.",
|
||||
@@ -1708,6 +1712,8 @@
|
||||
"show_available_seats_count": "Show the number of available seats",
|
||||
"how_booking_questions_as_variables": "How to use booking questions as variables?",
|
||||
"format": "Format",
|
||||
"questions": "Questions",
|
||||
"all_info_your_booker_provide": "All the info your booker should provide before booking with you.",
|
||||
"uppercase_for_letters": "Use uppercase for all letters",
|
||||
"replace_whitespaces_underscores": "Replace whitespaces with underscores",
|
||||
"platform_members": "Platform members",
|
||||
@@ -3238,6 +3244,10 @@
|
||||
"requires_credits": "Requires credits",
|
||||
"requires_credits_tooltip": "You need enough credits in your account to use this feature",
|
||||
"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",
|
||||
"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",
|
||||
@@ -3246,10 +3256,15 @@
|
||||
"meeting_start_time": "Meeting Start Time",
|
||||
"members_affected_by_disabling_delegation_credential": "Affected members",
|
||||
"no_members_affected_by_disabling_delegation_credential": "No members affected by disabling delegation credential",
|
||||
"download_expense_log": "Download Expense Log",
|
||||
"error_downloading_expense_log": "Error downloading expense log",
|
||||
"offer_to_reschedule_last_booking": "Offer to reschedule last active booking to chosen time slot",
|
||||
"booker_limit_exceeded_error": "Booker maximum active booking limit exceeded",
|
||||
"booker_limit_exceeded_error_reschedule": "You already have a booking for this event on {{date}}. Would you like to reschedule to the new selected time?",
|
||||
"duration_min_error": "Duration must be at least {{min}} minute(s)",
|
||||
"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 ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
|
||||
}
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
"join_by_entrypoint": "Csatlakozás a következővel: {{entryPoint}}",
|
||||
"notes": "Jegyzetek",
|
||||
"manage_my_bookings": "Foglalásaim kezelése",
|
||||
"need_to_make_a_change": "Változtatnál valamit?",
|
||||
"need_to_make_a_change": "Változtatna valamin?",
|
||||
"new_event_scheduled": "Új eseményt foglaltak.",
|
||||
"new_event_scheduled_recurring": "Egy új ismétlődő időpont le lett foglalva.",
|
||||
"invitee_email": "Meghívó e-mail",
|
||||
@@ -240,7 +240,7 @@
|
||||
"copy_public_page_link": "Nyilvános oldal linkjének másolása",
|
||||
"copy_referral_link": "Ajánlói link másolása",
|
||||
"sign_out": "Kijelentkezés",
|
||||
"add_another": "Adj hozzá egy másikat",
|
||||
"add_another": "Másvalaki hozzáadása",
|
||||
"install_another": "Telepítsen másikat",
|
||||
"until": "amíg",
|
||||
"powered_by": "működteti",
|
||||
@@ -354,10 +354,10 @@
|
||||
"connect_caldav": "Csatlakozás a CalDav szerverhez",
|
||||
"connect_ics_feed": "Csatlakozás egy ICS feedhez",
|
||||
"connect": "Csatlakozás",
|
||||
"try_for_free": "Próbáld ki ingyen",
|
||||
"try_for_free": "Próbálja ki ingyen",
|
||||
"create_booking_link_with_calcom": "Hozzon létre saját foglalási linket a {{appName}} segítségével",
|
||||
"who": "Ki",
|
||||
"what": "Mit",
|
||||
"what": "Mi",
|
||||
"when": "Mikor",
|
||||
"where": "Hol",
|
||||
"add_to_calendar": "Hozzáadás naptárhoz",
|
||||
@@ -373,32 +373,32 @@
|
||||
"round_robin_emailed_you_and_attendees_recurring": "Ön találkozik {{user}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
|
||||
"round_robin_emailed_host_and_attendee_recurring": "{{host}} találkozik {{attendee}} résztvevővel. Mindenkinek küldtünk egy e-mailt a naptári meghívóval és a részletekkel az ismétlődő események első alkalmára.",
|
||||
"emailed_you_and_any_other_attendees": "Küldtünk egy e-mail-t mindenkinek ezekről az információkról.",
|
||||
"needs_to_be_confirmed_or_rejected": "Foglalását még meg kell erősíteni vagy el kell utasítani.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozót még meg kell erősíteni vagy el kell utasítani.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges a foglalásod véglegesítéséhez.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még mindig vissza kell igazolnia vagy elutasítania a foglalást az ismétlődő találkozóra.",
|
||||
"needs_to_be_confirmed_or_rejected": "Foglalását még megerősítésre vagy lemondásra vár.",
|
||||
"needs_to_be_confirmed_or_rejected_recurring": "Az ismétlődő találkozó még megerősítésre vagy lemondásra vár.",
|
||||
"user_needs_to_confirm_or_reject_booking": "{{user}} jóváhagyása szükséges foglalása véglegesítéséhez.",
|
||||
"user_needs_to_confirm_or_reject_booking_recurring": "{{user}} résztvevőnek még vissza kell igazolnia vagy el kell utasítania a foglalást az ismétlődő találkozóra.",
|
||||
"meeting_is_scheduled": "Találkozó sikeresen lefoglalva",
|
||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontod le lett foglalva",
|
||||
"booking_submitted": "A foglalásod be lett küldve",
|
||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldtük",
|
||||
"booking_confirmed": "A foglalásod vissza lett igazolva",
|
||||
"meeting_is_scheduled_recurring": "Az ismétlődő időpontja le lett foglalva",
|
||||
"booking_submitted": "Foglalását elküldte",
|
||||
"booking_submitted_recurring": "Ismétlődő találkozóját elküldte",
|
||||
"booking_confirmed": "Foglalását visszaigazolták",
|
||||
"booking_confirmed_recurring": "Ismétlődő találkozóját megerősítették",
|
||||
"warning_recurring_event_payment": "A fizetések még nem támogatottak ismétlődő események esetén",
|
||||
"warning_payment_recurring_event": "Az ismétlődő eseményeket a fizetés még nincs támogatva",
|
||||
"enter_new_password": "Adja meg a fiókjához kívánt új jelszót.",
|
||||
"reset_password": "Jelszó visszaállítása",
|
||||
"change_your_password": "Változtasd meg a jelszavadat",
|
||||
"show_password": "Mutasd a jelszót",
|
||||
"change_your_password": "Változtassa meg a jelszavát",
|
||||
"show_password": "Jelszó megjelenítése",
|
||||
"hide_password": "Jelszó elrejtése",
|
||||
"try_again": "Próbáld újra",
|
||||
"try_again": "Próbálja újra",
|
||||
"request_is_expired": "Ez a kérelem lejárt.",
|
||||
"reset_instructions": "Adja meg a fiókjához társított e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
|
||||
"reset_instructions": "Adja meg a fiókjához tartozó e-mail címet, és küldünk egy linket a jelszó visszaállításához.",
|
||||
"request_is_expired_instructions": "Ez a kérelem lejárt. Menjen vissza, és adja meg a fiókjához tartozó e-mail-címet, és küldünk egy újabb linket a jelszó visszaállításához.",
|
||||
"whoops": "Hoppá",
|
||||
"login": "Bejelentkezés",
|
||||
"success": "Siker",
|
||||
"failed": "Nem sikerült",
|
||||
"password_has_been_reset_login": "A jelszavad vissza lett állítva. Most már bejelentkezhet az újonnan létrehozott jelszavával.",
|
||||
"password_has_been_reset_login": "Jelszavad visszaállítottuk. Most már bejelentkezhetsz az újonnan létrehozott jelszavaddal.",
|
||||
"layout": "Elrendezés",
|
||||
"bookerlayout_default_title": "Alapértelmezett nézet",
|
||||
"bookerlayout_description": "Többet is kiválaszthat, és a foglalók nézetet válthatnak.",
|
||||
@@ -641,41 +641,41 @@
|
||||
"additional_notes": "Egyéb megjegyzés",
|
||||
"booking_fail": "Találkozó nem foglalható.",
|
||||
"reschedule_fail": "Nem sikerült időpontot módisítani.",
|
||||
"share_additional_notes": "Kérlek ossz meg velünk bármit, ami segíthet felkészülni a találkozóra.",
|
||||
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét a következővel: {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Módosítsd {{eventTypeTitle}} időpontod nála: {{profileName}}",
|
||||
"share_additional_notes": "Kérjük osszon meg velünk bármit, ami segíthet felkészülni a találkozóra.",
|
||||
"booking_confirmation": "Erősítse meg {{eventTypeTitle}} eseményét vele: {{profileName}}",
|
||||
"booking_reschedule_confirmation": "Módosítsa {{eventTypeTitle}} időpontját nála: {{profileName}}",
|
||||
"in_person_meeting": "Személyes találkozó",
|
||||
"in_person": "Személyesen (Szervező címén)",
|
||||
"link_meeting": "Találkozó összekapcsolása",
|
||||
"phone_number": "Telefonszám",
|
||||
"attendee_phone_number": "A résztvevő telefonszáma",
|
||||
"attendee_phone_number": "Résztvevő telefonszáma",
|
||||
"organizer_phone_number": "Szervező telefonszáma",
|
||||
"enter_phone_number": "Adja meg a telefonszámot",
|
||||
"reschedule": "átütmezés",
|
||||
"reschedule_this": "Inkább időpontmódosítás",
|
||||
"book_a_team_member": "Foglaljon helyette egy csapattagot",
|
||||
"or": "VAGY",
|
||||
"book_a_team_member": "Foglaljon inkáb egy másik csapattagnál",
|
||||
"or": "vagy",
|
||||
"go_back": "Vissza",
|
||||
"email_or_username": "E-mail cím vagy felhasználónév",
|
||||
"send_invite_email": "Küldj meghívó e-mailt",
|
||||
"send_invite_email": "Meghívó e-mail küldése",
|
||||
"role": "Szerepkör",
|
||||
"edit_role": "Szerepkör szerkesztése",
|
||||
"edit_team": "Csapat szerkesztése",
|
||||
"editing_user": "Felhasználó szerkesztése",
|
||||
"editing_org": "Szervezet szerkesztése",
|
||||
"edit_or_manage_webhooks": "Ha szeretnéd szerkeszteni vagy kezelni a webhookjaidat, látogass el a <0>webhook beállításokhoz</0>",
|
||||
"reject": "Elutasít",
|
||||
"reject": "Elutasítás",
|
||||
"reject_all": "Az összes elutasítása",
|
||||
"accept": "Elfogad",
|
||||
"leave": "Elhagyás",
|
||||
"accept": "Elfogadás",
|
||||
"leave": "Kilépés",
|
||||
"profile": "Profil",
|
||||
"my_team_url": "Csapatom URL-je",
|
||||
"my_teams": "A csapataim",
|
||||
"team_name": "Csapat neve",
|
||||
"your_team_name": "A csapatod neve",
|
||||
"team_updated_successfully": "A csapat sikeresen frissítve",
|
||||
"your_team_updated_successfully": "Csapatát sikeresen frissítette.",
|
||||
"your_org_updated_successfully": "Szervezete sikeresen frissítve.",
|
||||
"your_team_updated_successfully": "Csapatod sikeresen frissítetted.",
|
||||
"your_org_updated_successfully": "Szervezet sikeresen frissítve.",
|
||||
"error_copying_to_clipboard": "Hiba a vágólapra másolás során",
|
||||
"about": "Rólunk",
|
||||
"team_description": "Néhány mondat a csapatodról. Ez megjelenik a csapata URL-oldalán.",
|
||||
|
||||
@@ -66,18 +66,18 @@
|
||||
"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_confirmation": "Odbij rezervaciju",
|
||||
"manage_this_event": "Upravljaj ovim dogadjajem",
|
||||
"manage_this_event": "Upravljaj ovim događajem",
|
||||
"invite_team_member": "Pozovite člana tima",
|
||||
"invite_team_individual_segment": "Pozovite jednu osobu",
|
||||
"invite_team_bulk_segment": "Pozovite grupno",
|
||||
"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",
|
||||
"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}}'.",
|
||||
"error_message": "Poruka o grešci je bila: '{{errorMessage}}'",
|
||||
"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.",
|
||||
"a_refund_failed": "Povraćaj novca neuspešan",
|
||||
"awaiting_payment_subject": "Čekanje na uplatu: {{title}} datuma {{date}}",
|
||||
@@ -115,23 +115,23 @@
|
||||
"open_env": "Otvorite .env i pristanite na našu Licencu",
|
||||
"env_changed": "Promenio/la sam moj .env",
|
||||
"accept_license": "Prihvati Licencu",
|
||||
"still_waiting_for_approval": "Dogadjaj još čeka na odobrenje",
|
||||
"event_is_still_waiting": "Zahtev na dogadjaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"still_waiting_for_approval": "Događaj još čeka na odobrenje",
|
||||
"event_is_still_waiting": "Zahtev na događaj još čeka: {{attendeeName}} - {{date}} - {{eventType}}",
|
||||
"no_result_found_for": "Nema rezultata za \"{{searchTerm}}\"",
|
||||
"no_more_results": "Nema više rezultata",
|
||||
"no_results": "Nema rezultata",
|
||||
"load_more_results": "Učitaj više rezultata",
|
||||
"integration_meeting_id": "{{integrationName}} ID sastanka: {{meetingId}}",
|
||||
"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",
|
||||
"check_bookings_page_to_confirm_or_reject": "Proverite stranicu rezervacija da potvrdite ili odbijete rezervaciju.",
|
||||
"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.",
|
||||
"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",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -145,7 +145,7 @@
|
||||
"your_meeting_has_been_booked": "Sastanak je zakazan",
|
||||
"event_type_has_been_rescheduled_on_time_date": "Ваш {{титле}} је померен за {{дате}}.",
|
||||
"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",
|
||||
"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.",
|
||||
@@ -157,17 +157,17 @@
|
||||
"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.",
|
||||
"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}}",
|
||||
"notes": "Beleške",
|
||||
"manage_my_bookings": "Upravljaj svojim rezervacijama",
|
||||
"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.",
|
||||
"invitee_email": "E-pošta od pozvane osobe",
|
||||
"invitee_timezone": "Vremenska zona pozvane osobe",
|
||||
"time_left": "Preostalo vreme",
|
||||
"event_type": "Tip Dogadjaja",
|
||||
"event_type": "Tip Događaja",
|
||||
"duplicate_event_type": "Duplirani tip događaja",
|
||||
"enter_meeting": "Pridruži se sastanku",
|
||||
"video_call_provider": "Provajder video poziva",
|
||||
@@ -205,7 +205,7 @@
|
||||
"use_link_to_reset_password": "Resetujte lozinku koristeći link ispod",
|
||||
"hey_there": "Zdravo",
|
||||
"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",
|
||||
"edit_webhook": "Uredi 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.",
|
||||
"upcoming": "Predstojeći",
|
||||
"recurring": "Ponavljajući",
|
||||
"past": "Prošli dogadjaji",
|
||||
"past": "Prošli događaji",
|
||||
"choose_a_file": "Odaberi datoteku...",
|
||||
"upload_image": "Otpremi sliku",
|
||||
"upload_target": "Prikači {{target}}",
|
||||
@@ -343,7 +343,7 @@
|
||||
"connect_your_calendar": "Povežite vaš kalendar",
|
||||
"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_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",
|
||||
"current_time": "Trenutno vreme",
|
||||
"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_next_day": "Krajnje vreme ne može biti veće od 24 sata",
|
||||
"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",
|
||||
"no_booking": "Nema rezervacije",
|
||||
"awaiting_host": "Čeka se domaćin",
|
||||
@@ -450,7 +450,7 @@
|
||||
"event_between_users": "{{eventName}} sa {{host}} i {{attendeeName}}",
|
||||
"bookings": "Rezervacije",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -522,7 +522,7 @@
|
||||
"booking_paid": "Rezervacija je plaćena",
|
||||
"ooo_created": "Kreirano odsustvo iz kancelarije",
|
||||
"booking_no_show_updated": "Ažurirano odsustvo sa rezervacije",
|
||||
"event_triggers": "Okidači Dogadjaja",
|
||||
"event_triggers": "Okidači Događaja",
|
||||
"subscriber_url": "URL pretplatnika",
|
||||
"create_new_webhook": "Napravite novi webhook",
|
||||
"webhooks": "Webhooks",
|
||||
@@ -737,7 +737,7 @@
|
||||
"remove_member": "Ukloni člana",
|
||||
"manage_your_team": "Upravljaj svojim timom",
|
||||
"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",
|
||||
"confirm_remove_attribute": "Da, 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,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -203,6 +205,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
scheduleId: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
@@ -317,6 +321,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
workflows: undefined,
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
},
|
||||
});
|
||||
@@ -372,6 +378,7 @@ describe("handleChildrenEventTypes", () => {
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
useBookerTimezone: false,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
allowReschedulingCancelledBookings: false,
|
||||
@@ -469,6 +476,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
locations: [],
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
parentId: 1,
|
||||
userId: 5,
|
||||
users: {
|
||||
@@ -493,6 +502,8 @@ describe("handleChildrenEventTypes", () => {
|
||||
customReplyToEmail: null,
|
||||
lockTimeZoneToggleOnBookingPage: false,
|
||||
requiresBookerEmailVerification: false,
|
||||
useBookerTimezone: false,
|
||||
restrictionScheduleId: null,
|
||||
hashedLink: {
|
||||
deleteMany: {},
|
||||
},
|
||||
|
||||
@@ -213,9 +213,6 @@ export type InputEventType = {
|
||||
userId?: number;
|
||||
minimumBookingNotice?: number;
|
||||
useEventLevelSelectedCalendars?: boolean;
|
||||
/**
|
||||
* These user ids are `ScenarioData["users"]["id"]`
|
||||
*/
|
||||
users?: { id: number }[];
|
||||
hosts?: InputHost[];
|
||||
schedulingType?: SchedulingType;
|
||||
@@ -237,6 +234,25 @@ export type InputEventType = {
|
||||
owner?: number;
|
||||
metadata?: any;
|
||||
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">>;
|
||||
|
||||
type AttendeeBookingSeatInput = Pick<Prisma.BookingSeatCreateInput, "referenceUid" | "data">;
|
||||
@@ -310,19 +326,31 @@ export async function addEventTypesToDb(
|
||||
"users" | "workflows" | "destinationCalendar" | "schedule"
|
||||
> & {
|
||||
id?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
users?: any[];
|
||||
userId?: number;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hosts?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
workflows?: any[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
destinationCalendar?: any;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
schedule?: any;
|
||||
metadata?: any;
|
||||
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));
|
||||
@@ -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++) {
|
||||
const eventType = eventTypes[i];
|
||||
const createdEventType = allEventTypes[i];
|
||||
@@ -350,8 +373,6 @@ export async function addEventTypesToDb(
|
||||
if (eventType.schedule) {
|
||||
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
|
||||
await prismock.schedule.create({
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
data: {
|
||||
...eventType.schedule.create,
|
||||
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) {
|
||||
const createdTeam = await prismock.team.create({
|
||||
data: {
|
||||
@@ -379,9 +418,6 @@ export async function addEventTypesToDb(
|
||||
});
|
||||
}
|
||||
}
|
||||
/***
|
||||
* HACK ENDS
|
||||
*/
|
||||
|
||||
log.silly(
|
||||
"TestData: All EventTypes in DB are",
|
||||
@@ -454,6 +490,22 @@ export async function addEventTypes(eventTypes: InputEventType[], usersStore: In
|
||||
},
|
||||
}
|
||||
: 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,
|
||||
schedulingType: eventType.schedulingType,
|
||||
parent: eventType.parent ? { connect: { id: eventType.parent.id } } : undefined,
|
||||
|
||||
@@ -24102,7 +24102,7 @@
|
||||
},
|
||||
"CalendarEventResponseStatus": {
|
||||
"type": "string",
|
||||
"description": "Attendee's response to the invitation",
|
||||
"description": "Host's response to the invitation",
|
||||
"enum": ["accepted", "pending", "declined", "needsAction"]
|
||||
},
|
||||
"CalendarEventAttendee": {
|
||||
@@ -24150,6 +24150,11 @@
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"description": "Display name of the event host"
|
||||
},
|
||||
"responseStatus": {
|
||||
"nullable": true,
|
||||
"example": "accepted",
|
||||
"$ref": "#/components/schemas/CalendarEventResponseStatus"
|
||||
}
|
||||
},
|
||||
"required": ["email"]
|
||||
|
||||
@@ -156,7 +156,7 @@ To create a new webhook subscription, visit `/settings/developer/webhooks` and p
|
||||
Use the **secret key** to create an HMAC, and update it with the webhook payload received to generate an SHA256 hash.
|
||||
</Step>
|
||||
<Step title="Verify the payload authenticity">
|
||||
Compare the hash received in the header of the webhook `(X-Cal-Signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
|
||||
Compare the hash received in the header of the webhook `(x-cal-signature-256)` with the one you created using the **secret key** and the body of the payload. If they don't match, the received payload has been adulterated and cannot be trusted.
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
|
||||
@@ -124,6 +124,7 @@ Below is a list of props that can be passed to the booker atom.
|
||||
| allowUpdatingUrlParams | No | Boolean indicating if the URL parameters should be updated, defaults to false. |
|
||||
| confirmButtonDisabled | No | Boolean indicating if the submit button should be disabled, defaults to false. |
|
||||
| timeZones | No | Array of valid IANA timezones to be used in the booker. Eg. ["Asia/Kolkata", "Europe/London"] |
|
||||
| onTimeslotsLoaded | No | Callback function triggered once the available timeslots have been fetched. |
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
@@ -31,7 +31,9 @@ Below is a list of props that can be passed to the Conferencing Apps Atom
|
||||
| :------------------ | :------- | :-------------------------------------------------------------------------- |
|
||||
| returnTo | No | The URL of the page to redirect to after a successful installation. |
|
||||
| onErrorReturnTo | No | The URL of the page to redirect to in case an error occurs. |
|
||||
| disableToasts | No | boolean value to disable toast notifications in the atom. |
|
||||
| disableToasts | No | Boolean value to disable toast notifications in the atom. |
|
||||
| apps | No | Array of conferencing app slugs to display in the dropdown. If provided, only these apps will be shown (if not already installed). Valid values are `'google-meet'`, `'zoom'`, and `'msteams'`. |
|
||||
| disableBulkUpdateEventTypes | No | A Boolean flag that prevents the bulk update of event types modal from appearing when the default conferencing app is changed. Defaults to false |
|
||||
|
||||
|
||||
## Google Meet
|
||||
|
||||
+1
-1
@@ -108,7 +108,7 @@
|
||||
"prismock": "^1.33.4",
|
||||
"resize-observer-polyfill": "^1.5.1",
|
||||
"tsc-absolute": "^1.0.0",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.1",
|
||||
"vitest-fetch-mock": "^0.3.0",
|
||||
"vitest-mock-extended": "^2.0.2"
|
||||
|
||||
@@ -31,6 +31,6 @@
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import type { UseAddAppMutationOptions } from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { deriveAppDictKeyFromType } from "@calcom/lib/deriveAppDictKeyFromType";
|
||||
import { useHasTeamPlan } from "@calcom/lib/hooks/useHasPaidPlan";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { App } from "@calcom/types/App";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
import { InstallAppButtonMap } from "./apps.browser.generated";
|
||||
import type { InstallAppButtonProps } from "./types";
|
||||
@@ -59,44 +55,8 @@ export const InstallAppButton = (
|
||||
disableInstall?: boolean;
|
||||
} & InstallAppButtonProps
|
||||
) => {
|
||||
const { isPending: isUserLoading, data: user } = trpc.viewer.me.get.useQuery();
|
||||
const router = useRouter();
|
||||
const proProtectionElementRef = useRef<HTMLDivElement | null>(null);
|
||||
const { isPending: isTeamPlanStatusLoading, hasTeamPlan } = useHasTeamPlan();
|
||||
|
||||
useEffect(() => {
|
||||
const el = proProtectionElementRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
el.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!user) {
|
||||
router.push(
|
||||
`${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL + location.pathname + location.search}`
|
||||
);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.teamsPlanRequired && !hasTeamPlan) {
|
||||
// TODO: I think we should show the UpgradeTip in a Dialog here. This would solve the problem of no way to go back to the App page from the UpgradeTip page(except browser's back button)
|
||||
router.push(props.teamsPlanRequired.upgradeUrl);
|
||||
e.stopPropagation();
|
||||
return;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
}, [isUserLoading, user, router, hasTeamPlan, props.teamsPlanRequired]);
|
||||
|
||||
if (isUserLoading || isTeamPlanStatusLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={proProtectionElementRef} className={props.wrapperClassName}>
|
||||
<div className={props.wrapperClassName}>
|
||||
<InstallAppButtonWithoutPlanCheck {...props} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,7 +29,14 @@ const log = logger.getSubLogger({ prefix: ["app-store/googlecalendar/lib/Calenda
|
||||
|
||||
class MyGoogleOAuth2Client extends OAuth2Client {
|
||||
constructor(client_id: string, client_secret: string, redirect_uri: string) {
|
||||
super(client_id, client_secret, redirect_uri);
|
||||
super({
|
||||
clientId: client_id,
|
||||
clientSecret: client_secret,
|
||||
redirectUri: redirect_uri,
|
||||
// default: 5 * 60 * 1000, 5 minutes
|
||||
// tho, fn will never run in excess of 60 seconds
|
||||
eagerRefreshThresholdMillis: 60000,
|
||||
});
|
||||
}
|
||||
|
||||
isTokenExpiring() {
|
||||
|
||||
@@ -98,7 +98,8 @@ export default class GoogleCalendarService implements Calendar {
|
||||
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.
|
||||
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,
|
||||
];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { getAbsoluteEventTypeRedirectUrl } from "../getEventTypeRedirectUrl";
|
||||
|
||||
@@ -20,13 +20,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
isEmbed: false,
|
||||
};
|
||||
|
||||
it("should return CAL_URL for non-migrated user", () => {
|
||||
it("should return WEBAPP_URL for non-migrated user", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "user/event",
|
||||
form: { ...defaultForm, nonOrgUsername: "user" },
|
||||
});
|
||||
expect(result).toBe(`${CAL_URL}/user/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/user/event?`);
|
||||
});
|
||||
|
||||
it("should return user origin for migrated user", () => {
|
||||
@@ -34,13 +34,13 @@ describe("getAbsoluteEventTypeRedirectUrl", () => {
|
||||
expect(result).toBe("https://user.cal.com/user/event?");
|
||||
});
|
||||
|
||||
it("should return CAL_URL for non-migrated team", () => {
|
||||
it("should return WEBAPP_URL for non-migrated team", () => {
|
||||
const result = getAbsoluteEventTypeRedirectUrl({
|
||||
...defaultParams,
|
||||
eventTypeRedirectUrl: "team/team1/event",
|
||||
form: { ...defaultForm, nonOrgTeamslug: "team1" },
|
||||
});
|
||||
expect(result).toBe(`${CAL_URL}/team/team1/event?`);
|
||||
expect(result).toBe(`${WEBAPP_URL}/team/team1/event?`);
|
||||
});
|
||||
|
||||
it("should return team origin for migrated team", () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { teamMetadataSchema, userMetadata } from "@calcom/prisma/zod-utils";
|
||||
|
||||
export const enrichFormWithMigrationData = <
|
||||
@@ -45,12 +45,12 @@ export const enrichFormWithMigrationData = <
|
||||
? getOrgFullOrigin(formOwnerOrgSlug, {
|
||||
protocol: true,
|
||||
})
|
||||
: CAL_URL,
|
||||
: WEBAPP_URL,
|
||||
teamOrigin: form.team?.parent?.slug
|
||||
? getOrgFullOrigin(form.team.parent.slug, {
|
||||
protocol: true,
|
||||
})
|
||||
: CAL_URL,
|
||||
: WEBAPP_URL,
|
||||
nonOrgUsername,
|
||||
nonOrgTeamslug,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
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,
|
||||
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 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 = (
|
||||
routeValue: NonRouterRoute["action"]["value"],
|
||||
response: FormResponse,
|
||||
|
||||
+22
@@ -328,4 +328,26 @@ describe("getUrlSearchParamsToForward", () => {
|
||||
});
|
||||
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
|
||||
? { ["cal.routedTeamMemberIds"]: teamMembersMatchingAttributeLogic.join(",") }
|
||||
: 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),
|
||||
...attributeRoutingConfigParams,
|
||||
...(reroutingFormResponses
|
||||
|
||||
@@ -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
|
||||
* It takes care of sending webhooks and emails for form submissions
|
||||
*/
|
||||
async function _onFormSubmission(
|
||||
export async function _onFormSubmission(
|
||||
form: Ensure<
|
||||
SerializableForm<App_RoutingForms_Form> & { user: Pick<User, "id" | "email">; userWithEmails?: string[] },
|
||||
"fields"
|
||||
|
||||
@@ -63,12 +63,16 @@ export type ZoomUserSettings = z.infer<typeof zoomUserSettingsSchema>;
|
||||
|
||||
/** @link https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings */
|
||||
export const zoomUserSettingsSchema = z.object({
|
||||
recording: z.object({
|
||||
auto_recording: z.string(),
|
||||
}),
|
||||
schedule_meeting: z.object({
|
||||
default_password_for_scheduled_meetings: z.string(),
|
||||
}),
|
||||
recording: z
|
||||
.object({
|
||||
auto_recording: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
schedule_meeting: z
|
||||
.object({
|
||||
default_password_for_scheduled_meetings: z.string(),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
// https://developers.zoom.us/docs/api/rest/reference/user/methods/#operation/userSettings
|
||||
@@ -308,6 +312,7 @@ const ZoomVideoApiAdapter = (credential: CredentialPayload): VideoApiAdapter =>
|
||||
throw new Error(`Failed to create meeting. Response is ${JSON.stringify(result)}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
log.error("Zoom meeting creation failed", safeStringify({ error: err, event }));
|
||||
/* Prevents meeting creation failure when Zoom Token is expired */
|
||||
throw new Error("Unexpected error");
|
||||
}
|
||||
|
||||
@@ -33,6 +33,6 @@
|
||||
"prettier-plugin-tailwindcss": "^0.2.5",
|
||||
"tailwind-scrollbar": "^2.0.1",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,6 +156,11 @@ Cal.config = Cal.config || {};
|
||||
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
|
||||
|
||||
#### Routing Prerendering System
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"npm-run-all": "^4.1.5",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^4.1.2",
|
||||
"vite-plugin-environment": "^1.1.3"
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Mock } from "vitest";
|
||||
|
||||
import { EmbedElement } from "./EmbedElement";
|
||||
import inlineHTML from "./Inline/inlineHtml";
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import { getColorSchemeDarkQuery } from "./ui-utils";
|
||||
|
||||
(function defineEmbedTestElement() {
|
||||
@@ -269,8 +270,8 @@ describe("EmbedElement", () => {
|
||||
},
|
||||
getSkeletonData: vi.fn(),
|
||||
});
|
||||
expect(element.classList.contains("dark")).toBe(true);
|
||||
expect(element.classList.contains("light")).toBe(false);
|
||||
expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
|
||||
expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should update theme class when system preference changes as long as embed theme is not set", () => {
|
||||
@@ -279,8 +280,8 @@ describe("EmbedElement", () => {
|
||||
});
|
||||
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
|
||||
|
||||
expect(element.classList.contains("dark")).toBe(true);
|
||||
expect(element.classList.contains("light")).toBe(false);
|
||||
expect(element.classList.contains(EMBED_DARK_THEME_CLASS)).toBe(true);
|
||||
expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not update theme on system color scheme change when embed theme is set", () => {
|
||||
@@ -291,7 +292,7 @@ describe("EmbedElement", () => {
|
||||
getSkeletonData: vi.fn(),
|
||||
});
|
||||
getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true }));
|
||||
expect(element.classList.contains("light")).toBe(true);
|
||||
expect(element.classList.contains(EMBED_LIGHT_THEME_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types";
|
||||
import {
|
||||
getThemeClassForEmbed,
|
||||
@@ -157,9 +158,7 @@ export class EmbedElement extends HTMLElement {
|
||||
this.isModal = data.isModal;
|
||||
this.layout = this.getLayout();
|
||||
this.theme = this.dataset.theme as EmbedThemeConfig | null;
|
||||
this.themeClass = getThemeClassForEmbed({ theme: this.theme });
|
||||
this.classList.add(this.themeClass);
|
||||
|
||||
this.setTheme(this.theme);
|
||||
this.getSkeletonData = data.getSkeletonData;
|
||||
this.boundResizeHandler = this.resizeHandler.bind(this);
|
||||
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
|
||||
@@ -197,20 +196,26 @@ export class EmbedElement extends HTMLElement {
|
||||
skeletonEl.innerHTML = skeletonContent;
|
||||
}
|
||||
|
||||
public setTheme(theme: EmbedThemeConfig | null) {
|
||||
const allPossibleThemeClasses = [EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS];
|
||||
|
||||
const newThemeClass = getThemeClassForEmbed({ theme });
|
||||
|
||||
if (newThemeClass === this.themeClass) {
|
||||
return;
|
||||
}
|
||||
this.themeClass = newThemeClass;
|
||||
this.classList.remove(...allPossibleThemeClasses);
|
||||
this.classList.add(this.themeClass);
|
||||
}
|
||||
|
||||
public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) {
|
||||
const isDarkPreferred = e.matches;
|
||||
const allPossibleThemeClasses = ["dark", "light"];
|
||||
if (isThemePreferenceProvided(this.theme)) {
|
||||
// User has provided a theme preference, so we stick to that and don't react to system theme change
|
||||
return;
|
||||
}
|
||||
const newThemeClass = getThemeClassForEmbed({
|
||||
theme: isDarkPreferred ? "dark" : "light",
|
||||
});
|
||||
if (newThemeClass !== this.themeClass) {
|
||||
this.classList.remove(...allPossibleThemeClasses);
|
||||
this.classList.add(newThemeClass);
|
||||
}
|
||||
this.setTheme(isDarkPreferred ? "dark" : "light");
|
||||
}
|
||||
connectedCallback() {
|
||||
// Make sure to show the loader initially.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { EMBED_DARK_THEME_CLASS } from "./constants";
|
||||
|
||||
export const addAppCssVars = () => {
|
||||
const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"];
|
||||
try {
|
||||
@@ -148,7 +150,7 @@ export const addAppCssVars = () => {
|
||||
--cal-brand-text: hsla(0, 0%, 100%, 1); /* white */
|
||||
}
|
||||
|
||||
${calElements.map((element) => `${element}.dark`).join(",")} {
|
||||
${calElements.map((element) => `${element}.${EMBED_DARK_THEME_CLASS}`).join(",")} {
|
||||
/* Background Standard */
|
||||
--cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */
|
||||
--cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */
|
||||
|
||||
@@ -10,3 +10,8 @@ if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHO
|
||||
"EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS"
|
||||
);
|
||||
}
|
||||
|
||||
// These classes are applied to Embed Elements and thus are in the same scope as the embedding webpage.
|
||||
// So, these classes need to be unique to us, to avoid accidental override
|
||||
export const EMBED_LIGHT_THEME_CLASS = "cal-element-embed-light";
|
||||
export const EMBED_DARK_THEME_CLASS = "cal-element-embed-dark";
|
||||
|
||||
@@ -349,8 +349,10 @@ export class Cal {
|
||||
|
||||
urlInstance.searchParams.set("embed", this.namespace);
|
||||
|
||||
if (calConfig.debug) {
|
||||
urlInstance.searchParams.set("debug", `${calConfig.debug}`);
|
||||
const pageParams = this.getQueryParamsFromPage();
|
||||
// cal.embed.logging=1 enabled logging in parent and by setting debug=true in iframe, it enables logging in iframe(child) as well
|
||||
if (calConfig.debug || pageParams["cal.embed.logging"] === "1") {
|
||||
urlInstance.searchParams.set("debug", "true");
|
||||
}
|
||||
|
||||
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import type { AllPossibleLayouts, EmbedThemeConfig } from "./types";
|
||||
|
||||
export function getMaxHeightForModal() {
|
||||
@@ -32,7 +33,7 @@ function detectColorScheme() {
|
||||
}
|
||||
|
||||
function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) {
|
||||
return theme ?? "light";
|
||||
return theme === "dark" ? EMBED_DARK_THEME_CLASS : EMBED_LIGHT_THEME_CLASS;
|
||||
}
|
||||
|
||||
export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) {
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
"@vitejs/plugin-react": "^2.2.0",
|
||||
"eslint": "^8.34.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^4.5.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"types": "./dist/index.d.ts",
|
||||
"devDependencies": {
|
||||
"typescript": "^5.8.3",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^4.1.2"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"@typescript-eslint/parser": "^5.52.0",
|
||||
"@typescript-eslint/utils": "^5.52.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/eslint": "^8.4.5"
|
||||
|
||||
@@ -438,7 +438,7 @@ export const getOptions = ({
|
||||
encode: async ({ token, maxAge, secret }) => {
|
||||
log.debug("jwt:encode", safeStringify({ token, maxAge }));
|
||||
if (token?.sub && isNumber(token.sub)) {
|
||||
const user = await prisma.user.findUnique({
|
||||
const user = await prisma.user.findFirst({
|
||||
where: { id: Number(token.sub) },
|
||||
select: { metadata: true },
|
||||
});
|
||||
@@ -489,7 +489,7 @@ export const getOptions = ({
|
||||
} as JWT;
|
||||
}
|
||||
const autoMergeIdentities = async () => {
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
const existingUser = await prisma.user.findFirst({
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
where: { email: token.email! },
|
||||
select: {
|
||||
@@ -855,7 +855,7 @@ export const getOptions = ({
|
||||
// If the email address doesn't match, check if an account already exists
|
||||
// with the new email address. If it does, for now we return an error. If
|
||||
// not, update the email of their account and log them in.
|
||||
const userWithNewEmail = await prisma.user.findUnique({
|
||||
const userWithNewEmail = await prisma.user.findFirst({
|
||||
where: { email: user.email },
|
||||
});
|
||||
|
||||
@@ -875,9 +875,12 @@ export const getOptions = ({
|
||||
// a new account. If an account already exists with the incoming email
|
||||
// address return an error for now.
|
||||
|
||||
const existingUserWithEmail = await prisma.user.findUnique({
|
||||
const existingUserWithEmail = await prisma.user.findFirst({
|
||||
where: {
|
||||
email: user.email,
|
||||
email: {
|
||||
equals: user.email,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
password: true,
|
||||
|
||||
@@ -168,7 +168,9 @@ export const EventMeta = ({
|
||||
{translatedTitle ?? event?.title}
|
||||
</EventTitle>
|
||||
{(event.description || translatedDescription) && (
|
||||
<EventMetaBlock contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
|
||||
<EventMetaBlock
|
||||
data-testid="event-meta-description"
|
||||
contentClassName="mb-8 break-words max-w-full max-h-[180px] scroll-bar pr-4">
|
||||
<div
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
|
||||
@@ -43,16 +43,14 @@ type EventDetailCustomBlock = {
|
||||
|
||||
type EventDetailsProps = EventDetailsPropsBase & (EventDetailDefaultBlock | EventDetailCustomBlock);
|
||||
|
||||
interface EventMetaProps {
|
||||
interface EventMetaProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
customIcon?: React.ReactNode;
|
||||
icon?: IconName;
|
||||
iconUrl?: string;
|
||||
children: React.ReactNode;
|
||||
// Emphasises the text in the block. For now only
|
||||
// applying in dark mode.
|
||||
highlight?: boolean;
|
||||
contentClassName?: string;
|
||||
className?: string;
|
||||
isDark?: boolean;
|
||||
}
|
||||
|
||||
@@ -80,6 +78,7 @@ export const EventMetaBlock = ({
|
||||
contentClassName,
|
||||
className,
|
||||
isDark,
|
||||
...rest
|
||||
}: EventMetaProps) => {
|
||||
if (!React.Children.count(children)) return null;
|
||||
|
||||
@@ -89,7 +88,8 @@ export const EventMetaBlock = ({
|
||||
"flex items-start justify-start text-sm",
|
||||
highlight ? "text-emphasis" : "text-text",
|
||||
className
|
||||
)}>
|
||||
)}
|
||||
{...rest}>
|
||||
{iconUrl ? (
|
||||
<img
|
||||
src={iconUrl}
|
||||
|
||||
@@ -160,7 +160,22 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
type: "email",
|
||||
name: "email",
|
||||
required: !isEmailFieldOptional,
|
||||
editable: isOrgTeamEvent ? "system-but-optional" : "system",
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
@@ -169,7 +184,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
defaultLabel: "location",
|
||||
type: "radioInput",
|
||||
@@ -204,23 +218,6 @@ export const ensureBookingInputsHaveSystemFields = ({
|
||||
],
|
||||
},
|
||||
];
|
||||
if (isOrgTeamEvent) {
|
||||
systemBeforeFields.splice(2, 0, {
|
||||
defaultLabel: "phone_number",
|
||||
type: "phone",
|
||||
name: "attendeePhoneNumber",
|
||||
required: false,
|
||||
hidden: true,
|
||||
editable: "system-but-optional",
|
||||
sources: [
|
||||
{
|
||||
label: "Default",
|
||||
id: "default",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// These fields should be added after other user fields
|
||||
const systemAfterFields: typeof bookingFields = [
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Logger } from "tslog";
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { Dayjs } from "@calcom/dayjs";
|
||||
import { checkForConflicts } from "@calcom/features/bookings/lib/conflictChecker/checkForConflicts";
|
||||
import { buildDateRanges } from "@calcom/lib/date-ranges";
|
||||
import { ErrorCode } from "@calcom/lib/errorCodes";
|
||||
import { getBusyTimesForLimitChecks } from "@calcom/lib/getBusyTimes";
|
||||
import { getUsersAvailability } from "@calcom/lib/getUserAvailability";
|
||||
@@ -11,6 +12,7 @@ import { parseDurationLimit } from "@calcom/lib/intervalLimits/isDurationLimits"
|
||||
import { getPiiFreeUser } from "@calcom/lib/piiFreeData";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import { withReporting } from "@calcom/lib/sentryWrapper";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import type { getEventTypeResponse } from "./getEventTypesFromDB";
|
||||
import type { BookingType } from "./originalRescheduledBookingUtils";
|
||||
@@ -121,6 +123,93 @@ const _ensureAvailableUsers = async (
|
||||
},
|
||||
});
|
||||
|
||||
if (eventType.restrictionScheduleId) {
|
||||
try {
|
||||
const restrictionSchedule = await prisma.schedule.findUnique({
|
||||
where: { id: eventType.restrictionScheduleId },
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
userId: true,
|
||||
availability: {
|
||||
select: {
|
||||
days: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
date: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
defaultScheduleId: true,
|
||||
travelSchedules: {
|
||||
select: {
|
||||
id: true,
|
||||
timeZone: true,
|
||||
startDate: true,
|
||||
endDate: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!restrictionSchedule) {
|
||||
loggerWithEventDetails.error(`Restriction schedule ${eventType.restrictionScheduleId} not found`);
|
||||
throw new Error(ErrorCode.RestrictionScheduleNotFound);
|
||||
}
|
||||
|
||||
const restrictionTimezone = eventType.useBookerTimezone
|
||||
? input.timeZone
|
||||
: restrictionSchedule.timeZone!;
|
||||
|
||||
if (!eventType.useBookerTimezone && !restrictionSchedule.timeZone) {
|
||||
loggerWithEventDetails.error(
|
||||
`No timezone is set for the restriction schedule and useBookerTimezone is false`
|
||||
);
|
||||
throw new Error(ErrorCode.BookingNotAllowedByRestrictionSchedule);
|
||||
}
|
||||
|
||||
const restrictionAvailability = restrictionSchedule.availability.map((rule) => ({
|
||||
days: rule.days,
|
||||
startTime: rule.startTime,
|
||||
endTime: rule.endTime,
|
||||
date: rule.date,
|
||||
}));
|
||||
|
||||
const isDefaultSchedule = restrictionSchedule.user.defaultScheduleId === restrictionSchedule.id;
|
||||
const travelSchedules =
|
||||
isDefaultSchedule && !eventType.useBookerTimezone
|
||||
? restrictionSchedule.user.travelSchedules.map((schedule) => ({
|
||||
startDate: dayjs(schedule.startDate),
|
||||
endDate: schedule.endDate ? dayjs(schedule.endDate) : undefined,
|
||||
timeZone: schedule.timeZone,
|
||||
}))
|
||||
: [];
|
||||
|
||||
const { dateRanges: restrictionRanges } = buildDateRanges({
|
||||
availability: restrictionAvailability,
|
||||
timeZone: restrictionTimezone,
|
||||
dateFrom: startDateTimeUtc,
|
||||
dateTo: endDateTimeUtc,
|
||||
travelSchedules,
|
||||
});
|
||||
|
||||
if (!hasDateRangeForBooking(restrictionRanges, startDateTimeUtc, endDateTimeUtc)) {
|
||||
loggerWithEventDetails.error(
|
||||
`Booking outside restriction schedule availability.`,
|
||||
piiFreeInputDataForLogging
|
||||
);
|
||||
throw new Error(ErrorCode.BookingNotAllowedByRestrictionSchedule);
|
||||
}
|
||||
} catch (error) {
|
||||
loggerWithEventDetails.error(`Error checking restriction schedule.`, piiFreeInputDataForLogging);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
usersAvailability.forEach(({ oooExcludedDateRanges: dateRanges, busy: bufferedBusyTimes }, index) => {
|
||||
const user = eventType.users[index];
|
||||
|
||||
@@ -161,6 +250,7 @@ const _ensureAvailableUsers = async (
|
||||
loggerWithEventDetails.error(`No available users found.`, piiFreeInputDataForLogging);
|
||||
throw new Error(ErrorCode.NoAvailableUsersFound);
|
||||
}
|
||||
|
||||
// make sure TypeScript understands availableUsers is at least one.
|
||||
return availableUsers.length === 1 ? [availableUsers[0]] : [availableUsers[0], ...availableUsers.slice(1)];
|
||||
};
|
||||
|
||||
@@ -21,6 +21,8 @@ export const getEventTypesFromDB = async (eventTypeId: number) => {
|
||||
id: true,
|
||||
customInputs: true,
|
||||
disableGuests: true,
|
||||
restrictionScheduleId: true,
|
||||
useBookerTimezone: true,
|
||||
users: {
|
||||
select: {
|
||||
credentials: {
|
||||
|
||||
@@ -191,6 +191,7 @@ describe("CreditService", () => {
|
||||
teamId: 1,
|
||||
availableCredits: 0,
|
||||
creditType: CreditType.ADDITIONAL,
|
||||
limitReached: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -58,12 +58,14 @@ export class CreditService {
|
||||
credits,
|
||||
bookingUid,
|
||||
smsSid,
|
||||
smsSegments,
|
||||
}: {
|
||||
userId?: number;
|
||||
teamId?: number;
|
||||
credits: number | null;
|
||||
bookingUid?: string;
|
||||
smsSid?: string;
|
||||
smsSegments?: number;
|
||||
}) {
|
||||
return await prisma
|
||||
.$transaction(async (tx) => {
|
||||
@@ -96,6 +98,7 @@ export class CreditService {
|
||||
userId: userIdToCharge,
|
||||
credits,
|
||||
creditType,
|
||||
smsSegments,
|
||||
tx,
|
||||
});
|
||||
|
||||
@@ -127,6 +130,9 @@ export class CreditService {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
also returns true if team has no available credits but limitReachedAt is not yet set
|
||||
*/
|
||||
async hasAvailableCredits({ userId, teamId }: { userId?: number | null; teamId?: number | null }) {
|
||||
return await prisma.$transaction(async (tx) => {
|
||||
if (!IS_SMS_CREDITS_ENABLED) return true;
|
||||
@@ -157,12 +163,14 @@ export class CreditService {
|
||||
);
|
||||
return true;
|
||||
}
|
||||
// limtReachedAt is set and still no available credits
|
||||
return false;
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
const teamWithAvailableCredits = await this._getTeamWithAvailableCredits({ userId, tx });
|
||||
|
||||
if (teamWithAvailableCredits && teamWithAvailableCredits?.availableCredits > 0) return true;
|
||||
if (teamWithAvailableCredits && !teamWithAvailableCredits.limitReached) return true;
|
||||
|
||||
const userCredits = await this._getAllCredits({ userId, tx });
|
||||
|
||||
@@ -179,6 +187,9 @@ export class CreditService {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
If user has memberships, it always returns a team, even if all have limit reached. In that case, limitReached: true is returned
|
||||
*/
|
||||
protected async _getTeamWithAvailableCredits({ userId, tx }: { userId: number; tx: PrismaTransaction }) {
|
||||
const memberships = await MembershipRepository.findAllAcceptedMemberships(userId, tx);
|
||||
|
||||
@@ -223,6 +234,7 @@ export class CreditService {
|
||||
teamId: memberships[0].teamId,
|
||||
availableCredits: 0,
|
||||
creditType: CreditType.ADDITIONAL,
|
||||
limitReached: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -289,9 +301,10 @@ export class CreditService {
|
||||
userId?: number;
|
||||
credits: number | null;
|
||||
creditType: CreditType;
|
||||
smsSegments?: number;
|
||||
tx: PrismaTransaction;
|
||||
}) {
|
||||
const { credits, creditType, bookingUid, smsSid, teamId, userId, tx } = props;
|
||||
const { credits, creditType, bookingUid, smsSid, teamId, userId, smsSegments, tx } = props;
|
||||
let creditBalance: { id: string; additionalCredits: number } | null | undefined =
|
||||
await CreditsRepository.findCreditBalance({ teamId, userId }, tx);
|
||||
|
||||
@@ -331,6 +344,7 @@ export class CreditService {
|
||||
date: new Date(),
|
||||
bookingUid,
|
||||
smsSid,
|
||||
smsSegments,
|
||||
},
|
||||
tx
|
||||
);
|
||||
@@ -592,7 +606,10 @@ export class CreditService {
|
||||
}
|
||||
|
||||
protected async _getAllCreditsForTeam({ teamId, tx }: { teamId: number; tx: PrismaTransaction }) {
|
||||
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs({ teamId }, tx);
|
||||
const creditBalance = await CreditsRepository.findCreditBalanceWithExpenseLogs(
|
||||
{ teamId, creditType: CreditType.MONTHLY },
|
||||
tx
|
||||
);
|
||||
|
||||
const totalMonthlyCredits = await this.getMonthlyCredits(teamId);
|
||||
const totalMonthlyCreditsUsed =
|
||||
|
||||
@@ -160,6 +160,7 @@ export default async function handleChildrenEventTypes({
|
||||
newUserIds.map((userId) => {
|
||||
return prisma.eventType.create({
|
||||
data: {
|
||||
instantMeetingScheduleId: eventType.instantMeetingScheduleId ?? undefined,
|
||||
profileId: profileId ?? null,
|
||||
...managedEventTypeValues,
|
||||
...unlockedEventTypeValues,
|
||||
@@ -187,6 +188,8 @@ export default async function handleChildrenEventTypes({
|
||||
rrSegmentQueryValue: undefined,
|
||||
assignRRMembersUsingSegment: false,
|
||||
useEventLevelSelectedCalendars: false,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
allowReschedulingCancelledBookings:
|
||||
managedEventTypeValues.allowReschedulingCancelledBookings ?? false,
|
||||
},
|
||||
@@ -255,6 +258,8 @@ export default async function handleChildrenEventTypes({
|
||||
...updatePayloadFiltered,
|
||||
hidden: children?.find((ch) => ch.owner.id === userId)?.hidden ?? false,
|
||||
scheduleId: eventType.scheduleId || null,
|
||||
restrictionScheduleId: null,
|
||||
useBookerTimezone: false,
|
||||
hashedLink:
|
||||
"multiplePrivateLinks" in unlockedFieldProps
|
||||
? undefined
|
||||
|
||||
@@ -197,6 +197,9 @@ async function createOrganizationWithNonExistentUserAsOwner({
|
||||
);
|
||||
const owner = await findUserToBeOrgOwner(email);
|
||||
if (!owner) {
|
||||
// Can happen when the organization was created earlier and the webhook had failed and when the webhook got fired again in next subscription update, then the email was deleted already
|
||||
// The fix would be to change the email in Onboarding record to new owner of the organization
|
||||
// TODO: Identify the owner of the organization from Membership table and use that email instead here.
|
||||
throw new Error(`Org exists but owner could not be found for email: ${email}`);
|
||||
}
|
||||
return { organization, owner };
|
||||
|
||||
@@ -128,6 +128,7 @@ export default function OtherTeamListItem(props: Props) {
|
||||
color="destructive"
|
||||
type="button"
|
||||
StartIcon="trash"
|
||||
className="rounded-t-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
|
||||
@@ -112,6 +112,7 @@ function AttributeItem({
|
||||
type="button"
|
||||
StartIcon="trash-2"
|
||||
color="destructive"
|
||||
className="rounded-t-none"
|
||||
onClick={() => setAttributeToDelete(attribute)}>
|
||||
{t("delete")}
|
||||
</DropdownItem>
|
||||
|
||||
@@ -183,6 +183,11 @@ const OrgProfileForm = ({ defaultValues }: { defaultValues: FormValues }) => {
|
||||
|
||||
const mutation = trpc.viewer.organizations.update.useMutation({
|
||||
onError: (err) => {
|
||||
// Handle JSON parsing errors from body size limit exceeded
|
||||
if (err.message.includes("Unexpected token") && err.message.includes("Body excee")) {
|
||||
showToast(t("converted_image_size_limit_exceed"), "error");
|
||||
return;
|
||||
}
|
||||
showToast(err.message, "error");
|
||||
},
|
||||
onSuccess: async (res) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { getClientSecretFromPayment } from "@calcom/features/ee/payments/pages/getClientSecretFromPayment";
|
||||
import { shouldHideBrandingForEvent } from "@calcom/lib/hideBranding";
|
||||
import prisma from "@calcom/prisma";
|
||||
@@ -18,7 +18,7 @@ const querySchema = z.object({
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const { uid } = querySchema.parse(context.query);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
const session = await getServerSession({ req: context.req });
|
||||
|
||||
const rawPayment = await prisma.payment.findUnique({
|
||||
where: {
|
||||
@@ -59,7 +59,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
eventTypeId: eventType.id,
|
||||
team: eventType.team,
|
||||
owner: eventType.users[0] ?? null,
|
||||
orgSlug: currentOrgDomain,
|
||||
organizationId: session?.user?.profile?.organizationId ?? session?.user?.org?.id ?? null,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -272,6 +272,7 @@ export default function TeamListItem(props: Props) {
|
||||
color="destructive"
|
||||
type="button"
|
||||
StartIcon="trash"
|
||||
className="rounded-t-none"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}>
|
||||
|
||||
@@ -29,25 +29,6 @@ export async function handler(req: NextRequest) {
|
||||
return NextResponse.json({ message: "Not authenticated" }, { status: 401 });
|
||||
}
|
||||
|
||||
//delete all scheduled sms reminders where scheduled date is past current date
|
||||
await prisma.workflowReminder.deleteMany({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
method: WorkflowMethods.SMS,
|
||||
scheduledDate: {
|
||||
lte: dayjs().toISOString(),
|
||||
},
|
||||
},
|
||||
{
|
||||
retryCount: {
|
||||
gt: 1,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
//find all unscheduled SMS reminders
|
||||
const unscheduledReminders = (await prisma.workflowReminder.findMany({
|
||||
where: {
|
||||
|
||||
@@ -284,11 +284,14 @@ export async function getCountryCodeForNumber(phoneNumber: string) {
|
||||
return countryCode;
|
||||
}
|
||||
|
||||
export async function getPriceForSMS(smsSid: string) {
|
||||
export async function getMessageInfo(smsSid: string) {
|
||||
const twilio = createTwilioClient();
|
||||
const message = await twilio.messages(smsSid).fetch();
|
||||
if (message.price == null || message.price === "null") return null;
|
||||
return Math.abs(parseFloat(message.price));
|
||||
const price = message.price ? Math.abs(parseFloat(message.price)) : null;
|
||||
|
||||
const numSegments = message.numSegments ? parseInt(message.numSegments) : null;
|
||||
|
||||
return { price, numSegments };
|
||||
}
|
||||
|
||||
export async function validateWebhookRequest({
|
||||
|
||||
@@ -11,6 +11,7 @@ vi.mock("@calcom/lib/constants", async () => {
|
||||
vi.mock("../reminders/providers/twilioProvider", () => ({
|
||||
validateWebhookRequest: vi.fn().mockResolvedValue(true),
|
||||
getCountryCodeForNumber: vi.fn().mockResolvedValue("US"),
|
||||
getMessageInfo: vi.fn().mockResolvedValue({ price: null, numSegments: null }),
|
||||
}));
|
||||
|
||||
const mockChargeCredits = vi.fn().mockResolvedValue({ teamId: 1 });
|
||||
@@ -120,12 +121,6 @@ describe("Twilio Webhook Handler", () => {
|
||||
default: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
vi.mock("../reminders/providers/twilioProvider", () => ({
|
||||
validateWebhookRequest: vi.fn().mockResolvedValue(true),
|
||||
getCountryCodeForNumber: vi.fn().mockResolvedValue("US"),
|
||||
getPriceForSMS: vi.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
const mockRequest = {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { forwardRef } from "react";
|
||||
|
||||
import type { BookerLayout } from "@calcom/features/bookings/Booker/types";
|
||||
import { APP_NAME } from "@calcom/lib/constants";
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useEmbedBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { TextArea } from "@calcom/ui/components/form";
|
||||
|
||||
@@ -188,7 +188,7 @@ ${getEmbedTypeSpecificString({
|
||||
HTMLIFrameElement | HTMLTextAreaElement | null,
|
||||
{ calLink: string; embedType: EmbedType; previewState: PreviewState; namespace: string }
|
||||
>(function Preview({ calLink, embedType }, ref) {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const bookerUrl = useEmbedBookerUrl();
|
||||
const iframeSrc = `${EMBED_PREVIEW_HTML_URL}?embedType=${embedType}&calLink=${calLink}&embedLibUrl=${embedLibUrl}&bookerUrl=${bookerUrl}`;
|
||||
if (ref instanceof Function || !ref) {
|
||||
return null;
|
||||
@@ -329,7 +329,7 @@ const getInstructionString = ({
|
||||
};
|
||||
|
||||
function useGetEmbedSnippetString(namespace: string | null) {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const bookerUrl = useEmbedBookerUrl();
|
||||
// TODO: Import this string from @calcom/embed-snippet
|
||||
// Right now the problem is that embed-snippet export is not minified and has comments which makes it unsuitable for giving it to users.
|
||||
// If we can minify that during build time and then import the built code here, that could work
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useEmbedBookerUrl } from "@calcom/lib/hooks/useBookerUrl";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
|
||||
export const useEmbedTypes = () => {
|
||||
@@ -319,6 +319,6 @@ export const useEmbedTypes = () => {
|
||||
};
|
||||
|
||||
export const useEmbedCalOrigin = () => {
|
||||
const bookerUrl = useBookerUrl();
|
||||
const bookerUrl = useEmbedBookerUrl();
|
||||
return bookerUrl;
|
||||
};
|
||||
|
||||
@@ -388,6 +388,39 @@ const Locations: React.FC<LocationsProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.disableTranscriptionForGuests"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForGuests}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_transcription_for_guests")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!isPlatform && (
|
||||
<Controller
|
||||
name="calVideoSettings.disableTranscriptionForOrganizer"
|
||||
defaultValue={!!eventType.calVideoSettings?.disableTranscriptionForOrganizer}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<SettingsToggle
|
||||
title={t("disable_transcription_for_organizer")}
|
||||
labelClassName="text-sm"
|
||||
checked={value}
|
||||
onCheckedChange={onChange}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<TextField
|
||||
label={t("enter_redirect_url_on_exit_description")}
|
||||
defaultValue={eventType.calVideoSettings?.redirectUrlOnExit || ""}
|
||||
|
||||
@@ -576,27 +576,39 @@ export const EventAdvancedTab = ({
|
||||
isUserLoading={isUserLoading}
|
||||
/>
|
||||
)}
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<FormBuilder
|
||||
title={t("booking_questions_title")}
|
||||
description={t("booking_questions_description")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: {
|
||||
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
|
||||
source: { label: "Location" },
|
||||
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
|
||||
<div className="border-subtle bg-muted rounded-lg border p-1">
|
||||
<div className="p-5">
|
||||
<div className="text-default text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
|
||||
{t("booking_questions_title")}
|
||||
</div>
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("booking_questions_description")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="border-subtle bg-default rounded-lg border p-5">
|
||||
<FormBuilder
|
||||
showPhoneAndEmailToggle
|
||||
title={t("confirmation")}
|
||||
description={t("what_booker_should_provide")}
|
||||
addFieldLabel={t("add_a_booking_question")}
|
||||
formProp="bookingFields"
|
||||
{...shouldLockDisableProps("bookingFields")}
|
||||
dataStore={{
|
||||
options: {
|
||||
locations: {
|
||||
// FormBuilder doesn't handle plural for non-english languages. So, use english(Location) only. This is similar to 'Workflow'
|
||||
source: { label: "Location" },
|
||||
value: getLocationsOptionsForSelect(formMethods.getValues("locations") ?? [], t),
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
shouldConsiderRequired={(field: BookingField) => {
|
||||
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
|
||||
return field.name === "location" ? true : field.required;
|
||||
}}
|
||||
/>
|
||||
}}
|
||||
shouldConsiderRequired={(field: BookingField) => {
|
||||
// Location field has a default value at backend so API can send no location but we don't allow it in UI and thus we want to show it as required to user
|
||||
return field.name === "location" ? true : field.required;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<RequiresConfirmationController
|
||||
eventType={eventType}
|
||||
|
||||
+309
-40
@@ -18,11 +18,13 @@ import type {
|
||||
Host,
|
||||
SelectClassNames,
|
||||
} from "@calcom/features/eventtypes/lib/types";
|
||||
import CheckboxField from "@calcom/features/form/components/CheckboxField";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { weekdayNames } from "@calcom/lib/weekday";
|
||||
import { weekStartNum } from "@calcom/lib/weekstart";
|
||||
import { SchedulingType } from "@calcom/prisma/enums";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
@@ -113,6 +115,12 @@ type EventTypeScheduleProps = {
|
||||
eventType: EventTypeSetup;
|
||||
teamMembers: TeamMember[];
|
||||
customClassNames?: UserAvailabilityCustomClassNames;
|
||||
fieldName?: "schedule" | "restrictionSchedule";
|
||||
scheduleQueryData?: ScheduleQueryData;
|
||||
restrictionScheduleQueryData?: ScheduleQueryData;
|
||||
isSchedulePending?: boolean;
|
||||
isRestrictionSchedulePending?: boolean;
|
||||
restrictionScheduleRedirectUrl?: string;
|
||||
} & Omit<EventTypeScheduleDetailsProps, "customClassNames"> &
|
||||
Omit<EventTypeTeamScheduleProps, "customClassNames">;
|
||||
|
||||
@@ -120,7 +128,7 @@ export type EventAvailabilityTabBaserProps = {
|
||||
isTeamEvent: boolean;
|
||||
};
|
||||
|
||||
type UseCommonScheduleSettingsToggle = Omit<EventTypeScheduleProps, "customClassNames"> & {
|
||||
type UseTeamEventScheduleSettingsToggle = Omit<EventTypeScheduleProps, "customClassNames"> & {
|
||||
customClassNames?: EventAvailabilityTabCustomClassNames;
|
||||
};
|
||||
|
||||
@@ -252,10 +260,12 @@ const EventTypeScheduleDetails = memo(
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<div className="flex flex-col gap-2">
|
||||
<span className="text-default flex items-center justify-center text-sm sm:justify-start">
|
||||
<Icon name="globe" className="h-3.5 w-3.5 ltr:mr-2 rtl:ml-2" />
|
||||
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
</div>
|
||||
{!!scheduleQueryData?.id &&
|
||||
!scheduleQueryData.isManaged &&
|
||||
!scheduleQueryData.readOnly &&
|
||||
@@ -278,11 +288,161 @@ const EventTypeScheduleDetails = memo(
|
||||
|
||||
EventTypeScheduleDetails.displayName = "EventTypeScheduleDetails";
|
||||
|
||||
type EventTypeRestrictionScheduleDetailsProps = {
|
||||
scheduleQueryData?: Pick<ScheduleQueryData, "timeZone" | "id" | "isManaged" | "readOnly"> & {
|
||||
schedule: Array<Pick<ScheduleQueryData["schedule"][number], "days" | "startTime" | "endTime">>;
|
||||
};
|
||||
isSchedulePending?: boolean;
|
||||
user?: Pick<RouterOutputs["viewer"]["me"]["get"], "timeFormat" | "weekStart">;
|
||||
restrictionScheduleRedirectUrl?: string;
|
||||
customClassNames?: AvailabilityTableCustomClassNames;
|
||||
useBookerTimezone?: boolean;
|
||||
};
|
||||
|
||||
const EventTypeRestrictionScheduleDetails = memo(
|
||||
({
|
||||
scheduleQueryData,
|
||||
isSchedulePending,
|
||||
user,
|
||||
restrictionScheduleRedirectUrl,
|
||||
customClassNames,
|
||||
useBookerTimezone: initialUseBookerTimezone,
|
||||
}: EventTypeRestrictionScheduleDetailsProps) => {
|
||||
const timeFormat = user?.timeFormat;
|
||||
const { t, i18n } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { setValue, watch } = formMethods;
|
||||
|
||||
const weekStart = weekStartNum(user?.weekStart);
|
||||
|
||||
const filterDays = (dayNum: number) =>
|
||||
scheduleQueryData?.schedule?.filter((item) => item.days.includes((dayNum + weekStart) % 7)) || [];
|
||||
|
||||
const useBookerTimezone = watch("useBookerTimezone");
|
||||
|
||||
useEffect(() => {
|
||||
if (useBookerTimezone === undefined) {
|
||||
setValue("useBookerTimezone", initialUseBookerTimezone || false, { shouldDirty: false });
|
||||
}
|
||||
}, [useBookerTimezone, setValue, initialUseBookerTimezone]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={classNames("border-subtle space-y-4 border-x p-6", customClassNames?.tableContainer)}>
|
||||
<ol className={classNames("table border-collapse text-sm", customClassNames?.table)}>
|
||||
{weekdayNames(i18n.language, weekStart, "long").map((day, index) => {
|
||||
const isAvailable = !!filterDays(index).length;
|
||||
return (
|
||||
<li
|
||||
key={day}
|
||||
className={classNames(
|
||||
"my-6 flex border-transparent last:mb-2",
|
||||
customClassNames?.tableRow
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
"w-20 font-medium sm:w-32 ",
|
||||
!isAvailable ? "text-subtle line-through" : "text-default",
|
||||
customClassNames?.day
|
||||
)}>
|
||||
{day}
|
||||
</span>
|
||||
{isSchedulePending ? (
|
||||
<SkeletonText className="block h-5 w-60" />
|
||||
) : isAvailable ? (
|
||||
<div className="space-y-3 text-right">
|
||||
{filterDays(index).map((dayRange, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={classNames(
|
||||
"text-default flex items-center leading-4",
|
||||
customClassNames?.dayAvailabilityContainer
|
||||
)}>
|
||||
<span
|
||||
className={classNames(
|
||||
"w-16 sm:w-28 sm:text-left",
|
||||
customClassNames?.dayAvailabilityFrom
|
||||
)}>
|
||||
{format(dayRange.startTime, timeFormat === 12)}
|
||||
</span>
|
||||
<span className={classNames("ms-4", customClassNames?.dayAvailabilitySeperator)}>
|
||||
-
|
||||
</span>
|
||||
<div className={classNames("ml-6 sm:w-28", customClassNames?.dayAvailabilityTo)}>
|
||||
{format(dayRange.endTime, timeFormat === 12)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span
|
||||
className={classNames("text-subtle ml-6 sm:ml-0", customClassNames?.dayUnavailable)}>
|
||||
{t("unavailable")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="bg-muted border-subtle flex flex-col justify-center gap-2 rounded-b-md border p-6 sm:flex-row sm:justify-between">
|
||||
<div className="flex flex-col gap-2">
|
||||
<span
|
||||
className={classNames(
|
||||
"text-default flex items-center justify-center text-sm sm:justify-start",
|
||||
useBookerTimezone && "text-muted line-through"
|
||||
)}>
|
||||
<Icon
|
||||
name="globe"
|
||||
className={classNames("h-3.5 w-3.5 ltr:mr-2 rtl:ml-2", useBookerTimezone && "text-muted")}
|
||||
/>
|
||||
{scheduleQueryData?.timeZone || <SkeletonText className="block h-5 w-32" />}
|
||||
</span>
|
||||
<div className="ltr:mr-2 rtl:ml-2">
|
||||
<CheckboxField
|
||||
checked={useBookerTimezone}
|
||||
disabled={isSchedulePending}
|
||||
description={t("use_booker_timezone")}
|
||||
informationIconText={t("use_booker_timezone_info")}
|
||||
onChange={(e) => {
|
||||
setValue("useBookerTimezone", e.target.checked, { shouldDirty: true });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!!scheduleQueryData?.id &&
|
||||
!scheduleQueryData.isManaged &&
|
||||
!scheduleQueryData.readOnly &&
|
||||
!!restrictionScheduleRedirectUrl && (
|
||||
<Button
|
||||
href={restrictionScheduleRedirectUrl}
|
||||
disabled={isSchedulePending}
|
||||
color="minimal"
|
||||
EndIcon="external-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
{t("edit_availability")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
EventTypeRestrictionScheduleDetails.displayName = "EventTypeRestrictionScheduleDetails";
|
||||
|
||||
const EventTypeSchedule = ({
|
||||
eventType,
|
||||
schedulesQueryData,
|
||||
isSchedulesPending,
|
||||
customClassNames,
|
||||
fieldName = "schedule",
|
||||
scheduleQueryData,
|
||||
restrictionScheduleQueryData,
|
||||
isSchedulePending,
|
||||
isRestrictionSchedulePending,
|
||||
restrictionScheduleRedirectUrl,
|
||||
...rest
|
||||
}: EventTypeScheduleProps) => {
|
||||
const { t } = useLocale();
|
||||
@@ -291,14 +451,21 @@ const EventTypeSchedule = ({
|
||||
useLockedFieldsManager({ eventType, translate: t, formMethods });
|
||||
const { watch, setValue } = formMethods;
|
||||
|
||||
const scheduleId = watch("schedule");
|
||||
// Map the fieldName to the actual form field name
|
||||
const formFieldName = fieldName === "restrictionSchedule" ? "restrictionScheduleId" : fieldName;
|
||||
const scheduleId = watch(formFieldName);
|
||||
|
||||
useEffect(() => {
|
||||
// after data is loaded.
|
||||
if (schedulesQueryData && scheduleId !== 0 && !scheduleId) {
|
||||
const newValue = isManagedEventType ? 0 : schedulesQueryData.find((schedule) => schedule.isDefault)?.id;
|
||||
let newValue;
|
||||
if (fieldName === "restrictionSchedule") {
|
||||
newValue =
|
||||
eventType.restrictionScheduleId || schedulesQueryData.find((schedule) => schedule.isDefault)?.id;
|
||||
} else {
|
||||
newValue = isManagedEventType ? 0 : schedulesQueryData.find((schedule) => schedule.isDefault)?.id;
|
||||
}
|
||||
if (!newValue && newValue !== 0) return;
|
||||
setValue("schedule", newValue, {
|
||||
setValue(formFieldName, newValue, {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
@@ -349,6 +516,29 @@ const EventTypeSchedule = ({
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
fieldName === "restrictionSchedule" &&
|
||||
eventType.restrictionScheduleId &&
|
||||
!schedulesQueryData.find((schedule) => schedule.id === eventType.restrictionScheduleId)
|
||||
) {
|
||||
options.push({
|
||||
value: eventType.restrictionScheduleId,
|
||||
label: eventType.restrictionScheduleName ?? t("default_schedule_name"),
|
||||
isDefault: false,
|
||||
isManaged: false,
|
||||
});
|
||||
}
|
||||
|
||||
const filteredOptions =
|
||||
fieldName === "restrictionSchedule"
|
||||
? options.filter((option) => option.value !== eventType.schedule)
|
||||
: options;
|
||||
|
||||
const currentScheduleQueryData =
|
||||
fieldName === "schedule" ? scheduleQueryData : restrictionScheduleQueryData;
|
||||
const isCurrentSchedulePending =
|
||||
fieldName === "schedule" ? isSchedulePending : isRestrictionSchedulePending;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
@@ -363,22 +553,27 @@ const EventTypeSchedule = ({
|
||||
customClassNames?.availabilitySelect?.label
|
||||
)}>
|
||||
{t("availability")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator("schedule")}
|
||||
{(isManagedEventType || isChildrenManagedEventType) && shouldLockIndicator(formFieldName)}
|
||||
</label>
|
||||
<Controller
|
||||
name="schedule"
|
||||
name={formFieldName}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const optionValue: AvailabilityOption | undefined = options.find(
|
||||
const optionValue: AvailabilityOption | undefined = filteredOptions.find(
|
||||
(option) => option.value === value
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
placeholder={t("select")}
|
||||
options={options}
|
||||
isDisabled={shouldLockDisableProps("schedule").disabled}
|
||||
options={filteredOptions}
|
||||
isDisabled={shouldLockDisableProps(formFieldName).disabled}
|
||||
isSearchable={false}
|
||||
onChange={(selected) => {
|
||||
if (selected) onChange(selected.value);
|
||||
if (selected) {
|
||||
onChange(selected.value);
|
||||
if (fieldName === "restrictionSchedule" && selected.value) {
|
||||
setValue("restrictionScheduleId", selected.value, { shouldDirty: true });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"block w-full min-w-0 flex-1 rounded-sm text-sm",
|
||||
@@ -393,16 +588,34 @@ const EventTypeSchedule = ({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{scheduleId !== 0 ? (
|
||||
<EventTypeScheduleDetails {...rest} customClassNames={customClassNames?.availabilityTable} />
|
||||
{scheduleId !== 0 && (fieldName === "schedule" || fieldName === "restrictionSchedule") ? (
|
||||
fieldName === "restrictionSchedule" ? (
|
||||
<EventTypeRestrictionScheduleDetails
|
||||
{...rest}
|
||||
scheduleQueryData={currentScheduleQueryData}
|
||||
isSchedulePending={isCurrentSchedulePending}
|
||||
customClassNames={customClassNames?.availabilityTable}
|
||||
useBookerTimezone={eventType.useBookerTimezone}
|
||||
restrictionScheduleRedirectUrl={restrictionScheduleRedirectUrl}
|
||||
/>
|
||||
) : (
|
||||
<EventTypeScheduleDetails
|
||||
{...rest}
|
||||
scheduleQueryData={currentScheduleQueryData}
|
||||
isSchedulePending={isCurrentSchedulePending}
|
||||
customClassNames={customClassNames?.availabilityTable}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
isManagedEventType && (
|
||||
isManagedEventType &&
|
||||
fieldName === "schedule" && (
|
||||
<p className="!mt-2 ml-1 text-sm text-gray-600">{t("members_default_schedule_description")}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamMemberSchedule = ({
|
||||
host,
|
||||
index,
|
||||
@@ -575,35 +788,91 @@ const useCommonScheduleState = (initialScheduleId: number | null) => {
|
||||
};
|
||||
};
|
||||
|
||||
const UseCommonScheduleSettingsToggle = ({
|
||||
const useRestrictionScheduleState = (initialRestrictionScheduleId: number | null) => {
|
||||
const { setValue } = useFormContext<FormValues>();
|
||||
const [restrictScheduleForHosts, setRestrictScheduleForHosts] = useState(!!initialRestrictionScheduleId);
|
||||
|
||||
// Toggle function for restriction schedule
|
||||
const toggleRestrictScheduleState = (checked: boolean) => {
|
||||
setRestrictScheduleForHosts(checked);
|
||||
if (!checked) {
|
||||
setValue("restrictionScheduleId", null, { shouldDirty: true });
|
||||
setValue("useBookerTimezone", false, { shouldDirty: true });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
restrictScheduleForHosts,
|
||||
toggleRestrictScheduleState,
|
||||
};
|
||||
};
|
||||
|
||||
const UseTeamEventScheduleSettingsToggle = ({
|
||||
eventType,
|
||||
customClassNames,
|
||||
...rest
|
||||
}: UseCommonScheduleSettingsToggle) => {
|
||||
}: UseTeamEventScheduleSettingsToggle) => {
|
||||
const { t } = useLocale();
|
||||
const isPlatform = useIsPlatform();
|
||||
const { useHostSchedulesForTeamEvent, toggleScheduleState } = useCommonScheduleState(eventType.schedule);
|
||||
const { restrictScheduleForHosts, toggleRestrictScheduleState } = useRestrictionScheduleState(
|
||||
eventType.restrictionScheduleId
|
||||
);
|
||||
|
||||
// Check if team has restriction schedule feature enabled
|
||||
const { data: isRestrictionScheduleEnabled = false } = trpc.viewer.features.checkTeamFeature.useQuery(
|
||||
{
|
||||
teamId: eventType.team?.id || 0,
|
||||
feature: "restriction-schedule",
|
||||
},
|
||||
{
|
||||
enabled: !!eventType.team?.id,
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<SettingsToggle
|
||||
checked={!useHostSchedulesForTeamEvent}
|
||||
onCheckedChange={toggleScheduleState}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
{/* handles the state for which 'schedule' ID is set, as it's unknown until the Select dropdown is loaded */}
|
||||
<EventTypeSchedule
|
||||
customClassNames={customClassNames?.userAvailability}
|
||||
eventType={eventType}
|
||||
{...rest}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
{useHostSchedulesForTeamEvent && (
|
||||
<div className="lg:ml-14">
|
||||
<TeamAvailability
|
||||
teamMembers={rest.teamMembers}
|
||||
hostSchedulesQuery={rest.hostSchedulesQuery}
|
||||
customClassNames={customClassNames?.teamAvailability}
|
||||
<div className="space-y-4">
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<SettingsToggle
|
||||
checked={!useHostSchedulesForTeamEvent}
|
||||
onCheckedChange={toggleScheduleState}
|
||||
title={t("choose_common_schedule_team_event")}
|
||||
description={t("choose_common_schedule_team_event_description")}>
|
||||
<EventTypeSchedule
|
||||
customClassNames={customClassNames?.userAvailability}
|
||||
eventType={eventType}
|
||||
fieldName="schedule"
|
||||
{...rest}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
{useHostSchedulesForTeamEvent && (
|
||||
<div className="lg:ml-14">
|
||||
<TeamAvailability
|
||||
teamMembers={rest.teamMembers}
|
||||
hostSchedulesQuery={rest.hostSchedulesQuery}
|
||||
customClassNames={customClassNames?.teamAvailability}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isPlatform && isRestrictionScheduleEnabled ? (
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<SettingsToggle
|
||||
checked={restrictScheduleForHosts}
|
||||
onCheckedChange={toggleRestrictScheduleState}
|
||||
title={t("choose_restriction_schedule")}
|
||||
description={t("choose_restriction_schedule_description")}>
|
||||
<EventTypeSchedule
|
||||
customClassNames={customClassNames?.userAvailability}
|
||||
eventType={eventType}
|
||||
fieldName="restrictionSchedule"
|
||||
{...rest}
|
||||
/>
|
||||
</SettingsToggle>
|
||||
</div>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -611,7 +880,7 @@ const UseCommonScheduleSettingsToggle = ({
|
||||
|
||||
export const EventAvailabilityTab = ({ eventType, isTeamEvent, ...rest }: EventAvailabilityTabProps) => {
|
||||
return isTeamEvent && eventType.schedulingType !== SchedulingType.MANAGED ? (
|
||||
<UseCommonScheduleSettingsToggle eventType={eventType} {...rest} />
|
||||
<UseTeamEventScheduleSettingsToggle eventType={eventType} {...rest} />
|
||||
) : (
|
||||
<EventTypeSchedule
|
||||
eventType={eventType}
|
||||
|
||||
@@ -170,6 +170,7 @@ export const EventSetupTab = (
|
||||
}}
|
||||
disabled={!orgId}
|
||||
tooltip={!orgId ? t("orgs_upgrade_to_enable_feature") : undefined}
|
||||
data-testid="ai_translation_toggle"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -152,10 +152,15 @@ export type FormValues = {
|
||||
secondaryEmailId?: number;
|
||||
isRRWeightsEnabled: boolean;
|
||||
maxLeadThreshold?: number;
|
||||
restrictionScheduleId: number | null;
|
||||
useBookerTimezone: boolean;
|
||||
restrictionScheduleName: string | null;
|
||||
calVideoSettings?: {
|
||||
disableRecordingForOrganizer?: boolean;
|
||||
disableRecordingForGuests?: boolean;
|
||||
enableAutomaticTranscription?: boolean;
|
||||
disableTranscriptionForGuests?: boolean;
|
||||
disableTranscriptionForOrganizer?: boolean;
|
||||
redirectUrlOnExit?: string;
|
||||
};
|
||||
maxActiveBookingPerBookerOfferReschedule: boolean;
|
||||
|
||||
@@ -21,4 +21,5 @@ export type AppFlags = {
|
||||
"workflow-smtp-emails": boolean;
|
||||
"cal-video-log-in-overlay": boolean;
|
||||
"use-api-v2-for-team-slots": boolean;
|
||||
"restriction-schedule": boolean;
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const initialData: AppFlags = {
|
||||
"workflow-smtp-emails": false,
|
||||
"cal-video-log-in-overlay": false,
|
||||
"use-api-v2-for-team-slots": false,
|
||||
"restriction-schedule": false,
|
||||
};
|
||||
|
||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AppFlags } from "@calcom/features/flags/config";
|
||||
import publicProcedure from "@calcom/trpc/server/procedures/publicProcedure";
|
||||
import { router } from "@calcom/trpc/server/trpc";
|
||||
|
||||
@@ -9,5 +12,16 @@ export const featureFlagRouter = router({
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
return featuresRepository.getAllFeatures();
|
||||
}),
|
||||
checkTeamFeature: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
teamId: z.number(),
|
||||
feature: z.string(),
|
||||
})
|
||||
)
|
||||
.query(async ({ input }) => {
|
||||
const featuresRepository = new FeaturesRepository();
|
||||
return featuresRepository.checkIfTeamHasFeature(input.teamId, input.feature as keyof AppFlags);
|
||||
}),
|
||||
map,
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { DialogContent, DialogFooter, DialogHeader, DialogClose } from "@calcom/ui/components/dialog";
|
||||
import { Editor } from "@calcom/ui/components/editor";
|
||||
import { ToggleGroup } from "@calcom/ui/components/form";
|
||||
import {
|
||||
Switch,
|
||||
CheckboxField,
|
||||
@@ -59,6 +60,7 @@ export const FormBuilder = function FormBuilder({
|
||||
LockedIcon,
|
||||
dataStore,
|
||||
shouldConsiderRequired,
|
||||
showPhoneAndEmailToggle = false,
|
||||
}: {
|
||||
formProp: string;
|
||||
title: string;
|
||||
@@ -66,6 +68,7 @@ export const FormBuilder = function FormBuilder({
|
||||
addFieldLabel: string;
|
||||
disabled: boolean;
|
||||
LockedIcon: false | JSX.Element;
|
||||
showPhoneAndEmailToggle?: boolean;
|
||||
/**
|
||||
* A readonly dataStore that is used to lookup the options for the fields. It works in conjunction with the field.getOptionAt property which acts as the key in options
|
||||
*/
|
||||
@@ -129,7 +132,68 @@ export const FormBuilder = function FormBuilder({
|
||||
{title}
|
||||
{LockedIcon}
|
||||
</div>
|
||||
<p className="text-subtle mt-0.5 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
||||
<div className="flex items-start justify-between">
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">{description}</p>
|
||||
{showPhoneAndEmailToggle && (
|
||||
<ToggleGroup
|
||||
value={(() => {
|
||||
const phoneField = fields.find((field) => field.name === "attendeePhoneNumber");
|
||||
const emailField = fields.find((field) => field.name === "email");
|
||||
|
||||
if (phoneField && !phoneField.hidden && phoneField.required && !emailField?.required) {
|
||||
return "phone";
|
||||
}
|
||||
|
||||
return "email";
|
||||
})()}
|
||||
options={[
|
||||
{
|
||||
value: "email",
|
||||
label: "Email",
|
||||
iconLeft: <Icon name="mail" className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
value: "phone",
|
||||
label: "Phone",
|
||||
iconLeft: <Icon name="phone" className="h-4 w-4" />,
|
||||
},
|
||||
]}
|
||||
onValueChange={(value) => {
|
||||
const phoneFieldIndex = fields.findIndex((field) => field.name === "attendeePhoneNumber");
|
||||
const emailFieldIndex = fields.findIndex((field) => field.name === "email");
|
||||
if (value === "email") {
|
||||
update(emailFieldIndex, {
|
||||
...fields[emailFieldIndex],
|
||||
hidden: false,
|
||||
required: true,
|
||||
});
|
||||
update(phoneFieldIndex, {
|
||||
...fields[phoneFieldIndex],
|
||||
hidden: true,
|
||||
required: false,
|
||||
});
|
||||
} else if (value === "phone") {
|
||||
update(emailFieldIndex, {
|
||||
...fields[emailFieldIndex],
|
||||
hidden: true,
|
||||
required: false,
|
||||
});
|
||||
update(phoneFieldIndex, {
|
||||
...fields[phoneFieldIndex],
|
||||
hidden: false,
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-default mt-5 text-sm font-semibold leading-none ltr:mr-1 rtl:ml-1">
|
||||
{t("questions")}
|
||||
</p>
|
||||
<p className="text-subtle mt-1 max-w-[280px] break-words text-sm sm:max-w-[500px]">
|
||||
{t("all_info_your_booker_provide")}
|
||||
</p>
|
||||
<ul ref={parent} className="border-subtle divide-subtle mt-4 divide-y rounded-md border">
|
||||
{fields.map((field, index) => {
|
||||
let options = field.options ?? null;
|
||||
@@ -480,6 +544,7 @@ function FieldEditDialog({
|
||||
const variantsConfig = fieldForm.watch("variantsConfig");
|
||||
|
||||
const fieldTypes = Object.values(fieldTypesConfigMap);
|
||||
const fieldName = fieldForm.getValues("name");
|
||||
|
||||
return (
|
||||
<Dialog open={dialog.isOpen} onOpenChange={onOpenChange} modal={false}>
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, beforeEach, afterEach, it } from "vitest";
|
||||
|
||||
import prisma from "@calcom/prisma";
|
||||
import { BookingStatus } from "@calcom/prisma/enums";
|
||||
|
||||
describe("RoutingFormResponseField", () => {
|
||||
let userId: number;
|
||||
let formId: string;
|
||||
let eventTypeId: number;
|
||||
let bookingId: number;
|
||||
let responseId: number;
|
||||
const randomId = Math.floor(Math.random() * 1000000);
|
||||
const email = `routing-form-response-field-${randomId}@example.com`;
|
||||
|
||||
beforeEach(async () => {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
username: `routing-form-response-field-testuser-${randomId}`,
|
||||
name: "Test User",
|
||||
},
|
||||
});
|
||||
userId = user.id;
|
||||
|
||||
const eventType = await prisma.eventType.create({
|
||||
data: {
|
||||
title: "Test Event",
|
||||
slug: "test-event",
|
||||
length: 60,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
eventTypeId = eventType.id;
|
||||
|
||||
const form = await prisma.app_RoutingForms_Form.create({
|
||||
data: {
|
||||
name: "Test Form",
|
||||
userId: user.id,
|
||||
fields: [
|
||||
{
|
||||
id: "text-field-id",
|
||||
type: "text",
|
||||
label: "Name",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "number-field-id",
|
||||
type: "number",
|
||||
label: "Rating",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "multiselect-field-id",
|
||||
type: "multiselect",
|
||||
label: "Skills",
|
||||
required: false,
|
||||
options: [
|
||||
{
|
||||
id: "skill-1",
|
||||
label: "JavaScript",
|
||||
},
|
||||
{
|
||||
id: "skill-2",
|
||||
label: "TypeScript",
|
||||
},
|
||||
{
|
||||
id: "skill-3",
|
||||
label: "React",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "email-field-id",
|
||||
type: "email",
|
||||
label: "Email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
formId = form.id;
|
||||
|
||||
const booking = await prisma.booking.create({
|
||||
data: {
|
||||
uid: `routing-form-response-field-${randomId}`,
|
||||
title: "Test Booking",
|
||||
startTime: new Date(),
|
||||
endTime: new Date(Date.now() + 60 * 60 * 1000),
|
||||
userId: user.id,
|
||||
eventTypeId: eventType.id,
|
||||
status: BookingStatus.PENDING,
|
||||
},
|
||||
});
|
||||
bookingId = booking.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (responseId) {
|
||||
await prisma.routingFormResponseField.deleteMany({
|
||||
where: { responseId },
|
||||
});
|
||||
await prisma.routingFormResponseDenormalized.deleteMany({
|
||||
where: { id: responseId },
|
||||
});
|
||||
await prisma.app_RoutingForms_FormResponse.deleteMany({
|
||||
where: { id: responseId },
|
||||
});
|
||||
}
|
||||
if (bookingId) {
|
||||
await prisma.booking.deleteMany({
|
||||
where: { id: bookingId },
|
||||
});
|
||||
}
|
||||
if (formId) {
|
||||
await prisma.app_RoutingForms_Form.deleteMany({
|
||||
where: { id: formId },
|
||||
});
|
||||
}
|
||||
if (eventTypeId) {
|
||||
await prisma.eventType.deleteMany({
|
||||
where: { id: eventTypeId },
|
||||
});
|
||||
}
|
||||
if (userId) {
|
||||
await prisma.user.deleteMany({
|
||||
where: { id: userId },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("INSERT Trigger Tests", () => {
|
||||
it("should create field entries when form response is created", async () => {
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-all-fields",
|
||||
formId: formId,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "John Doe",
|
||||
},
|
||||
"number-field-id": {
|
||||
label: "Rating",
|
||||
value: 5,
|
||||
},
|
||||
"multiselect-field-id": {
|
||||
label: "Skills",
|
||||
value: ["skill-1", "skill-3"],
|
||||
},
|
||||
"email-field-id": {
|
||||
label: "Email",
|
||||
value: "john.doe@example.com",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: null,
|
||||
},
|
||||
});
|
||||
responseId = formResponse.id;
|
||||
|
||||
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(denormalizedResponse).not.toBeNull();
|
||||
expect(denormalizedResponse?.fields).toBeDefined();
|
||||
expect(denormalizedResponse?.fields).toHaveLength(4);
|
||||
|
||||
const textField = denormalizedResponse?.fields.find((field) => field.fieldId === "text-field-id");
|
||||
expect(textField).toBeDefined();
|
||||
expect(textField?.valueString).toBe("John Doe");
|
||||
expect(textField?.valueNumber).toBeNull();
|
||||
expect(textField?.valueStringArray).toEqual([]);
|
||||
|
||||
const numberField = denormalizedResponse?.fields.find((field) => field.fieldId === "number-field-id");
|
||||
expect(numberField).toBeDefined();
|
||||
expect(numberField?.valueString).toBeNull();
|
||||
expect(numberField?.valueNumber?.equals(new Prisma.Decimal(5))).toBe(true);
|
||||
expect(numberField?.valueStringArray).toEqual([]);
|
||||
|
||||
const multiselectField = denormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "multiselect-field-id"
|
||||
);
|
||||
expect(multiselectField).toBeDefined();
|
||||
expect(multiselectField?.valueString).toBeNull();
|
||||
expect(multiselectField?.valueNumber).toBeNull();
|
||||
expect(multiselectField?.valueStringArray).toContain("skill-1");
|
||||
expect(multiselectField?.valueStringArray).toContain("skill-3");
|
||||
expect(multiselectField?.valueStringArray).toHaveLength(2);
|
||||
|
||||
const emailField = denormalizedResponse?.fields.find((field) => field.fieldId === "email-field-id");
|
||||
expect(emailField).toBeDefined();
|
||||
expect(emailField?.valueString).toBe("john.doe@example.com");
|
||||
expect(emailField?.valueNumber).toBeNull();
|
||||
expect(emailField?.valueStringArray).toEqual([]);
|
||||
});
|
||||
|
||||
it("should handle null and undefined field values", async () => {
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-null-values",
|
||||
formId: formId,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: null,
|
||||
},
|
||||
"number-field-id": {
|
||||
label: "Rating",
|
||||
value: undefined,
|
||||
},
|
||||
"multiselect-field-id": {
|
||||
label: "Skills",
|
||||
value: [],
|
||||
},
|
||||
"email-field-id": {
|
||||
label: "Email",
|
||||
value: "",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: null,
|
||||
},
|
||||
});
|
||||
responseId = formResponse.id;
|
||||
|
||||
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(denormalizedResponse).not.toBeNull();
|
||||
expect(denormalizedResponse?.fields).toBeDefined();
|
||||
expect(denormalizedResponse?.fields.length).toBeLessThanOrEqual(2);
|
||||
|
||||
const emailField = denormalizedResponse?.fields.find((field) => field.fieldId === "email-field-id");
|
||||
if (emailField) {
|
||||
expect(emailField.valueString).toBe("");
|
||||
}
|
||||
|
||||
const multiselectField = denormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "multiselect-field-id"
|
||||
);
|
||||
if (multiselectField) {
|
||||
expect(multiselectField.valueStringArray).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle invalid data types", async () => {
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-invalid-types",
|
||||
formId: formId,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: 123, // Number instead of string
|
||||
},
|
||||
"number-field-id": {
|
||||
label: "Rating",
|
||||
value: "five", // String instead of number
|
||||
},
|
||||
"multiselect-field-id": {
|
||||
label: "Skills",
|
||||
value: "skill-1", // String instead of array
|
||||
},
|
||||
},
|
||||
routedToBookingUid: null,
|
||||
},
|
||||
});
|
||||
responseId = formResponse.id;
|
||||
|
||||
const denormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(denormalizedResponse).not.toBeNull();
|
||||
expect(denormalizedResponse?.fields).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("UPDATE Trigger Tests", () => {
|
||||
it("should update field entries when form response is updated", async () => {
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-update",
|
||||
formId: formId,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Original Name",
|
||||
},
|
||||
"number-field-id": {
|
||||
label: "Rating",
|
||||
value: 3,
|
||||
},
|
||||
"multiselect-field-id": {
|
||||
label: "Skills",
|
||||
value: ["skill-1"],
|
||||
},
|
||||
},
|
||||
routedToBookingUid: null,
|
||||
},
|
||||
});
|
||||
responseId = formResponse.id;
|
||||
|
||||
const initialDenormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(initialDenormalizedResponse).not.toBeNull();
|
||||
expect(initialDenormalizedResponse?.fields).toBeDefined();
|
||||
expect(initialDenormalizedResponse?.fields).toHaveLength(3);
|
||||
|
||||
const initialTextField = initialDenormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "text-field-id"
|
||||
);
|
||||
expect(initialTextField?.valueString).toBe("Original Name");
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.update({
|
||||
where: { id: responseId },
|
||||
data: {
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Updated Name",
|
||||
},
|
||||
"number-field-id": {
|
||||
label: "Rating",
|
||||
value: 5,
|
||||
},
|
||||
"multiselect-field-id": {
|
||||
label: "Skills",
|
||||
value: ["skill-1", "skill-2"],
|
||||
},
|
||||
"email-field-id": {
|
||||
label: "Email",
|
||||
value: "new@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updatedDenormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(updatedDenormalizedResponse).not.toBeNull();
|
||||
expect(updatedDenormalizedResponse?.fields).toBeDefined();
|
||||
expect(updatedDenormalizedResponse?.fields).toHaveLength(4); // Now we have 4 fields
|
||||
|
||||
const updatedTextField = updatedDenormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "text-field-id"
|
||||
);
|
||||
expect(updatedTextField).toBeDefined();
|
||||
expect(updatedTextField?.valueString).toBe("Updated Name");
|
||||
|
||||
const updatedNumberField = updatedDenormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "number-field-id"
|
||||
);
|
||||
expect(updatedNumberField).toBeDefined();
|
||||
expect(updatedNumberField?.valueNumber?.equals(new Prisma.Decimal(5))).toBe(true);
|
||||
|
||||
const updatedMultiselectField = updatedDenormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "multiselect-field-id"
|
||||
);
|
||||
expect(updatedMultiselectField).toBeDefined();
|
||||
expect(updatedMultiselectField?.valueStringArray).toContain("skill-1");
|
||||
expect(updatedMultiselectField?.valueStringArray).toContain("skill-2");
|
||||
expect(updatedMultiselectField?.valueStringArray).toHaveLength(2);
|
||||
|
||||
const newEmailField = updatedDenormalizedResponse?.fields.find(
|
||||
(field) => field.fieldId === "email-field-id"
|
||||
);
|
||||
expect(newEmailField).toBeDefined();
|
||||
expect(newEmailField?.valueString).toBe("new@example.com");
|
||||
});
|
||||
|
||||
it("should remove field entries when fields are removed from response", async () => {
|
||||
const formResponse = await prisma.app_RoutingForms_FormResponse.create({
|
||||
data: {
|
||||
formFillerId: "test-filler-remove-fields",
|
||||
formId: formId,
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Test Name",
|
||||
},
|
||||
"number-field-id": {
|
||||
label: "Rating",
|
||||
value: 4,
|
||||
},
|
||||
"multiselect-field-id": {
|
||||
label: "Skills",
|
||||
value: ["skill-1", "skill-2"],
|
||||
},
|
||||
"email-field-id": {
|
||||
label: "Email",
|
||||
value: "test@example.com",
|
||||
},
|
||||
},
|
||||
routedToBookingUid: null,
|
||||
},
|
||||
});
|
||||
responseId = formResponse.id;
|
||||
|
||||
const initialDenormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(initialDenormalizedResponse).not.toBeNull();
|
||||
expect(initialDenormalizedResponse?.fields).toBeDefined();
|
||||
expect(initialDenormalizedResponse?.fields).toHaveLength(4);
|
||||
|
||||
await prisma.app_RoutingForms_FormResponse.update({
|
||||
where: { id: responseId },
|
||||
data: {
|
||||
response: {
|
||||
"text-field-id": {
|
||||
label: "Name",
|
||||
value: "Test Name",
|
||||
},
|
||||
"email-field-id": {
|
||||
label: "Email",
|
||||
value: "test@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const updatedDenormalizedResponse = await prisma.routingFormResponseDenormalized.findUnique({
|
||||
where: { id: responseId },
|
||||
include: { fields: true },
|
||||
});
|
||||
|
||||
expect(updatedDenormalizedResponse).not.toBeNull();
|
||||
expect(updatedDenormalizedResponse?.fields).toBeDefined();
|
||||
expect(updatedDenormalizedResponse?.fields).toHaveLength(2); // Now we have only 2 fields
|
||||
|
||||
const fieldIds = updatedDenormalizedResponse?.fields.map((field) => field.fieldId);
|
||||
expect(fieldIds).toContain("text-field-id");
|
||||
expect(fieldIds).toContain("email-field-id");
|
||||
expect(fieldIds).not.toContain("number-field-id");
|
||||
expect(fieldIds).not.toContain("multiselect-field-id");
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user